diff --git a/.env.sample b/.env.sample index f7462ec128..b2f1ca0dc7 100644 --- a/.env.sample +++ b/.env.sample @@ -203,7 +203,7 @@ RATE_LIMITER_DURATION_WINDOW=60 # ––––––––––– INTEGRATIONS ––––––––––– # –––––––––––––––––––––––––––––––––––––– -# The GitHub integration allows previewing issue and pull request links +# GitHub integration allows previewing issue and pull request links # DOCS: https://docs.getoutline.com/s/hosting/doc/github-GchT3NNxI9 GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= @@ -212,7 +212,7 @@ GITHUB_APP_NAME= GITHUB_APP_ID= GITHUB_APP_PRIVATE_KEY= -# The Linear integration allows previewing issue links as rich mentions +# Linear integration allows previewing issue links as rich mentions LINEAR_CLIENT_ID= LINEAR_CLIENT_SECRET= @@ -223,6 +223,10 @@ SLACK_VERIFICATION_TOKEN=your_token SLACK_APP_ID=A0XXXXXXX SLACK_MESSAGE_ACTIONS=true +# Figma integration allows previewing design files as rich mentions +FIGMA_CLIENT_ID= +FIGMA_CLIENT_SECRET= + # For Dropbox integration, follow these instructions to get the key https://www.dropbox.com/developers/embedder#setup # and do not forget to whitelist your domain name in the app settings DROPBOX_APP_KEY= diff --git a/app/editor/components/PasteMenu.tsx b/app/editor/components/PasteMenu.tsx index d8a8b83d86..c99b16c421 100644 --- a/app/editor/components/PasteMenu.tsx +++ b/app/editor/components/PasteMenu.tsx @@ -1,6 +1,6 @@ import { observer } from "mobx-react"; import { v4 as uuidv4 } from "uuid"; -import { EmailIcon, LinkIcon } from "outline-icons"; +import { BrowserIcon, EmailIcon, LinkIcon } from "outline-icons"; import React, { useCallback } from "react"; import { useTranslation } from "react-i18next"; import type { EmbedDescriptor } from "@shared/editor/embeds"; @@ -14,6 +14,7 @@ import { determineMentionType, isURLMentionable } from "~/utils/mention"; import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu"; import SuggestionsMenu from "./SuggestionsMenu"; import SuggestionsMenuItem from "./SuggestionsMenuItem"; +import { getMatchingEmbed } from "@shared/editor/lib/embeds"; type Props = Omit< SuggestionsMenuProps, @@ -57,18 +58,6 @@ function useItems({ const { integrations } = useStores(); const user = useCurrentUser({ rejectOnEmpty: false }); - const embed = React.useMemo(() => { - if (typeof pastedText === "string") { - for (const e of embeds) { - const matches = e.matcher(pastedText); - if (matches) { - return e; - } - } - } - return; - }, [embeds, pastedText]); - // single item is pasted. if (typeof pastedText === "string") { let mentionType: MentionType | undefined; @@ -84,6 +73,8 @@ function useItems({ : MentionType.URL; } + const embed = getMatchingEmbed(embeds, pastedText)?.embed; + return [ { name: "noop", @@ -108,14 +99,17 @@ function useItems({ { name: "embed", title: t("Embed"), + visible: !!embed, icon: embed?.icon, keywords: embed?.keywords, }, ]; } - const linksToMentionType: Record = {}; // list is pasted. + + // Check if the links can be converted to mentions. + const linksToMentionType: Record = {}; const convertibleToMentionList = pastedText.every((text) => { if (!isUrl(text)) { return false; @@ -128,7 +122,7 @@ function useItems({ const mentionType = integration ? determineMentionType({ url, integration }) - : undefined; + : MentionType.URL; if (mentionType) { linksToMentionType[text] = mentionType; @@ -137,8 +131,29 @@ function useItems({ return !!mentionType; }); - // don't render the menu when it can't be converted to mention. - if (!convertibleToMentionList) { + // Check if the links can be converted to embeds. + let embedType: string | undefined = undefined; + + const convertibleToEmbedList = pastedText.every((text) => { + const embed = getMatchingEmbed(embeds, text)?.embed; + + if (!embed) { + return false; + } + + embedType = !embedType || embedType === embed.title ? embed.title : "mixed"; + return true; + }); + + const embedIcon = + embedType === "mixed" ? ( + + ) : ( + embeds.find((e) => e.title === embedType)?.icon + ); + + // don't render the menu when it can't be converted to other types. + if (!convertibleToMentionList && !convertibleToEmbedList) { return; } @@ -151,8 +166,16 @@ function useItems({ { name: "mention_list", title: t("Mention"), + visible: !!convertibleToMentionList, icon: , attrs: { actorId: user?.id, ...linksToMentionType }, }, + { + name: "embed_list", + title: t("Embed"), + visible: !!convertibleToEmbedList, + icon: embedIcon, + attrs: { actorId: user?.id }, + }, ]; } diff --git a/app/editor/extensions/PasteHandler.tsx b/app/editor/extensions/PasteHandler.tsx index 25d6a5630b..2eb5019a02 100644 --- a/app/editor/extensions/PasteHandler.tsx +++ b/app/editor/extensions/PasteHandler.tsx @@ -447,6 +447,25 @@ export default class PasteHandler extends Extension { } }; + // Not a list of embeds technically, but inserts many embeds at once. + private insertEmbedList = () => { + const { view } = this.editor; + const { state } = view; + const result = this.findPlaceholder(state, this.placeholderId()); + + // Remove just the placeholder here. + // Embed list will be created by SuggestionsMenu. + if (result) { + const tr = state.tr.setMeta(this.key, { + remove: { id: this.placeholderId() }, + }); + + view.dispatch( + tr.setSelection(TextSelection.near(tr.doc.resolve(result[0]))) + ); + } + }; + private handleList(listNode: Node) { const { view, schema } = this.editor; const { state } = view; @@ -547,6 +566,11 @@ export default class PasteHandler extends Extension { this.insertMentionList(); break; } + case "embed_list": { + this.hidePasteMenu(); + this.insertEmbedList(); + break; + } default: break; } diff --git a/plugins/figma/client/Icon.tsx b/plugins/figma/client/Icon.tsx new file mode 100644 index 0000000000..dd1b2559e3 --- /dev/null +++ b/plugins/figma/client/Icon.tsx @@ -0,0 +1,26 @@ +import * as React from "react"; + +type Props = { + /** The size of the icon, 24px is default to match standard icons */ + size?: number; + /** The color of the icon, defaults to the current text color */ + fill?: string; +}; + +export default function Icon({ size = 24, fill = "currentColor" }: Props) { + return ( + + + + ); +} diff --git a/plugins/figma/client/Settings.tsx b/plugins/figma/client/Settings.tsx new file mode 100644 index 0000000000..81f43dd706 --- /dev/null +++ b/plugins/figma/client/Settings.tsx @@ -0,0 +1,125 @@ +import { observer } from "mobx-react"; +import * as React from "react"; +import { useTranslation, Trans } from "react-i18next"; +import { ConnectedButton } from "~/scenes/Settings/components/ConnectedButton"; +import { IntegrationScene } from "~/scenes/Settings/components/IntegrationScene"; +import { AvatarSize } from "~/components/Avatar"; +import Heading from "~/components/Heading"; +import List from "~/components/List"; +import ListItem from "~/components/List/Item"; +import Notice from "~/components/Notice"; +import TeamLogo from "~/components/TeamLogo"; +import Text from "~/components/Text"; +import env from "~/env"; +import useQuery from "~/hooks/useQuery"; +import useStores from "~/hooks/useStores"; +import FigmaIcon from "./Icon"; +import { FigmaConnectButton } from "./components/FigmaButton"; +import { IntegrationService, IntegrationType } from "@shared/types"; +import type Integration from "~/models/Integration"; +import Time from "~/components/Time"; + +function Figma() { + const { integrations } = useStores(); + const { t } = useTranslation(); + const query = useQuery(); + const error = query.get("error"); + const appName = env.APP_NAME; + + const linkedAccountIntegration = integrations.find({ + type: IntegrationType.LinkedAccount, + service: IntegrationService.Figma, + }) as Integration | undefined; + + const figmaAccount = linkedAccountIntegration?.settings?.figma?.account; + + return ( + }> + Figma + + {error === "access_denied" && ( + + + Whoops, you need to accept the permissions in Figma to connect{" "} + {{ appName }} to your workspace. Try again? + + + )} + {error === "unauthenticated" && ( + + + Something went wrong while authenticating your request. Please try + logging in again. + + + )} + {error === "unknown" && ( + + + Something went wrong while processing your request. Please try + again. + + + )} + {env.FIGMA_CLIENT_ID ? ( + <> + + + Link your {{ appName }} account to Figma to enable previews of + design files you have access to, directly within documents. + + + {linkedAccountIntegration ? ( + + + Enabled on{" "} + + ) : ( +

+ } /> +

+ )} + + ) : ( + + + The Figma integration is currently disabled. Please set the + associated environment variables and restart the server to enable + the integration. + + + )} +
+ ); +} + +export default observer(Figma); diff --git a/plugins/figma/client/components/FigmaButton.tsx b/plugins/figma/client/components/FigmaButton.tsx new file mode 100644 index 0000000000..254c6138b0 --- /dev/null +++ b/plugins/figma/client/components/FigmaButton.tsx @@ -0,0 +1,23 @@ +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Button, { type Props } from "~/components/Button"; +import useCurrentTeam from "~/hooks/useCurrentTeam"; +import { redirectTo } from "~/utils/urls"; +import { FigmaUtils } from "../../shared/FigmaUtils"; + +export function FigmaConnectButton(props: Props) { + const { t } = useTranslation(); + const team = useCurrentTeam(); + + return ( + + ); +} diff --git a/plugins/figma/client/index.tsx b/plugins/figma/client/index.tsx new file mode 100644 index 0000000000..88d459efb1 --- /dev/null +++ b/plugins/figma/client/index.tsx @@ -0,0 +1,18 @@ +import { Hook, PluginManager } from "~/utils/PluginManager"; +import config from "../plugin.json"; +import Icon from "./Icon"; +import { createLazyComponent } from "~/components/LazyLoad"; + +PluginManager.add([ + { + ...config, + type: Hook.Settings, + value: { + group: "Integrations", + icon: Icon, + description: + "Connect your Figma account to Outline to enable rich design file previews inside documents.", + component: createLazyComponent(() => import("./Settings")), + }, + }, +]); diff --git a/plugins/figma/plugin.json b/plugins/figma/plugin.json new file mode 100644 index 0000000000..5152b575e5 --- /dev/null +++ b/plugins/figma/plugin.json @@ -0,0 +1,7 @@ +{ + "id": "figma", + "name": "Figma", + "priority": 15, + "description": "Adds a Figma integration for link unfurling and converting links to mentions.", + "after": "linear" +} diff --git a/plugins/figma/server/api/figma.ts b/plugins/figma/server/api/figma.ts new file mode 100644 index 0000000000..6e09b66cbf --- /dev/null +++ b/plugins/figma/server/api/figma.ts @@ -0,0 +1,99 @@ +import auth from "@server/middlewares/authentication"; +import Router from "koa-router"; +import * as T from "./schema"; +import apexAuthRedirect from "@server/middlewares/apexAuthRedirect"; +import type { APIContext } from "@server/types"; +import validate from "@server/middlewares/validate"; +import { FigmaUtils } from "plugins/figma/shared/FigmaUtils"; +import { transaction } from "@server/middlewares/transaction"; +import Logger from "@server/logging/Logger"; +import { IntegrationService, IntegrationType } from "@shared/types"; +import { Integration, IntegrationAuthentication } from "@server/models"; +import { addSeconds } from "date-fns"; +import { Figma } from "../figma"; +import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask"; + +const router = new Router(); + +router.get( + "figma.callback", + auth({ optional: true }), + validate(T.FigmaCallbackSchema), + apexAuthRedirect({ + getTeamId: (ctx) => FigmaUtils.parseState(ctx.input.query.state)?.teamId, + getRedirectPath: (ctx, team) => + FigmaUtils.callbackUrl({ + baseUrl: team.url, + params: ctx.request.querystring, + }), + getErrorPath: () => FigmaUtils.errorUrl("unauthenticated"), + }), + transaction(), + async (ctx: APIContext) => { + const { code, error } = ctx.input.query; + + // Check error after any sub-domain redirection. Otherwise, the user will be redirected to the root domain. + if (error) { + ctx.redirect(FigmaUtils.errorUrl(error)); + return; + } + + const { user } = ctx.state.auth; + const { transaction } = ctx.state; + + try { + // validation middleware ensures that code is non-null at this point. + const oauth = await Figma.oauthAccess(code!); + const figmaAccount = await Figma.getInstalledAccount(oauth.access_token); + + const authentication = await IntegrationAuthentication.create( + { + service: IntegrationService.Figma, + userId: user.id, + teamId: user.teamId, + token: oauth.access_token, + refreshToken: oauth.refresh_token, + expiresAt: addSeconds(Date.now(), oauth.expires_in), + scopes: FigmaUtils.oauthScopes, + }, + { transaction } + ); + const integration = await Integration.create< + Integration + >( + { + service: IntegrationService.Figma, + type: IntegrationType.LinkedAccount, + userId: user.id, + teamId: user.teamId, + authenticationId: authentication.id, + settings: { + figma: { + account: { + id: figmaAccount.id, + name: figmaAccount.handle, + email: figmaAccount.email, + avatarUrl: figmaAccount.img_url, + }, + }, + }, + }, + { transaction } + ); + + transaction.afterCommit(async () => { + await new UploadIntegrationLogoTask().schedule({ + integrationId: integration.id, + logoUrl: figmaAccount.img_url, + }); + }); + + ctx.redirect(FigmaUtils.successUrl()); + } catch (err) { + Logger.error("Encountered error during Figma OAuth callback", err); + ctx.redirect(FigmaUtils.errorUrl("unknown")); + } + } +); + +export default router; diff --git a/plugins/figma/server/api/schema.ts b/plugins/figma/server/api/schema.ts new file mode 100644 index 0000000000..cec86235db --- /dev/null +++ b/plugins/figma/server/api/schema.ts @@ -0,0 +1,20 @@ +import { BaseSchema } from "@server/routes/api/schema"; +import isEmpty from "lodash/isEmpty"; +import { z } from "zod"; + +export const FigmaCallbackSchema = BaseSchema.extend({ + query: z + .object({ + code: z.string().nullish(), + state: z.string(), + error: z.string().nullish(), + }) + .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { + message: "one of code or error is required", + }) + .refine((req) => isEmpty(req.code) || isEmpty(req.error), { + message: "code and error cannot both be present", + }), +}); + +export type FigmaCallbackReq = z.infer; diff --git a/plugins/figma/server/env.ts b/plugins/figma/server/env.ts new file mode 100644 index 0000000000..09a8e0ab2f --- /dev/null +++ b/plugins/figma/server/env.ts @@ -0,0 +1,25 @@ +import { Environment } from "@server/env"; +import { Public } from "@server/utils/decorators/Public"; +import environment from "@server/utils/environment"; +import { CannotUseWithout } from "@server/utils/validators"; +import { IsOptional } from "class-validator"; + +class FigmaPluginEnvironment extends Environment { + /** + * Figma OAuth2 app client id. To enable integration with Figma. + */ + @Public + @IsOptional() + public FIGMA_CLIENT_ID = this.toOptionalString(environment.FIGMA_CLIENT_ID); + + /** + * Figma OAuth2 app client secret. To enable integration with Figma. + */ + @IsOptional() + @CannotUseWithout("FIGMA_CLIENT_ID") + public FIGMA_CLIENT_SECRET = this.toOptionalString( + environment.FIGMA_CLIENT_SECRET + ); +} + +export default new FigmaPluginEnvironment(); diff --git a/plugins/figma/server/figma.ts b/plugins/figma/server/figma.ts new file mode 100644 index 0000000000..77bf85e583 --- /dev/null +++ b/plugins/figma/server/figma.ts @@ -0,0 +1,208 @@ +import { z } from "zod"; +import env from "./env"; +import { FigmaUtils } from "../shared/FigmaUtils"; +import type { UnfurlSignature } from "@server/types"; +import isEmpty from "lodash/isEmpty"; +import type { User } from "@server/models"; +import { Integration } from "@server/models"; +import { IntegrationType } from "@shared/types"; +import { IntegrationService, UnfurlResourceType } from "@shared/types"; +import { cdnPath } from "@shared/utils/urls"; +import Logger from "@server/logging/Logger"; +import { Minute } from "@shared/utils/time"; + +const Credentials = Buffer.from( + `${env.FIGMA_CLIENT_ID}:${env.FIGMA_CLIENT_SECRET}` +).toString("base64"); + +const AccessTokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_in: z.number(), +}); + +const RefreshTokenResponseSchema = z.object({ + access_token: z.string(), + expires_in: z.number(), +}); + +const AccountResponseSchema = z.object({ + id: z.string(), + handle: z.string(), + email: z.string(), + img_url: z.string(), +}); + +export class Figma { + private static supportedHosts = ["www.figma.com", "figma.com"]; + private static supportedFileTypes = [ + "design", // Design files + "board", // Figjam + "slides", + "buzz", + "site", + "make", + ]; + /** + * Exchange an OAuth code for an access token + * + * @param code OAuth code to exchange for an access token + * @returns An object containing the access token and refresh token + */ + static async oauthAccess(code: string) { + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${Credentials}`, + }; + + const body = new URLSearchParams(); + body.set("code", code); + body.set("redirect_uri", FigmaUtils.callbackUrl()); + body.set("grant_type", "authorization_code"); + + const res = await fetch(FigmaUtils.tokenUrl, { + method: "POST", + headers, + body, + }); + + if (res.status !== 200) { + throw new Error( + `Error exchanging Figma OAuth code; status: ${res.status}, ${await res.text()}` + ); + } + + return AccessTokenResponseSchema.parse(await res.json()); + } + + static async refreshToken(refreshToken: string) { + const headers = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + Authorization: `Basic ${Credentials}`, + }; + + const body = new URLSearchParams(); + body.set("refresh_token", refreshToken); + + const res = await fetch(FigmaUtils.refreshUrl, { + method: "POST", + headers, + body, + }); + + if (res.status !== 200) { + throw new Error( + `Error while refreshing access token from Figma; status: ${res.status}, ${await res.text()}` + ); + } + + return RefreshTokenResponseSchema.parse(await res.json()); + } + + static async getInstalledAccount(accessToken: string) { + const res = await fetch(FigmaUtils.accountUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (res.status !== 200) { + throw new Error( + `Error getting Figma current account; status: ${res.status}, ${await res.text()}` + ); + } + + return AccountResponseSchema.parse(await res.json()); + } + + static unfurl: UnfurlSignature = async (url: string, actor?: User) => { + const resource = Figma.parseUrl(url); + if (!resource || !actor) { + return; + } + + const integrations = (await Integration.scope("withAuthentication").findAll( + { + where: { + type: IntegrationType.LinkedAccount, + service: IntegrationService.Figma, + userId: actor.id, + teamId: actor.teamId, + }, + } + )) as Integration[]; + + if (integrations.length === 0) { + return; + } + + // Try to unfurl with any of the linked accounts + // Note: We support only one figma account per team for now. + for (const integration of integrations) { + try { + const accessToken = + await integration.authentication.refreshTokenIfNeeded( + async (refreshToken: string) => Figma.refreshToken(refreshToken), + 5 * Minute.ms + ); + + const res = await fetch(Figma.fileMetadataUrl(resource.key), { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + + // This connected account has access to the file. + if (res.status === 200) { + const data = await res.json(); + return { + type: UnfurlResourceType.URL, + url, + title: data.file.name, + description: `Created by ${data.file.creator.handle}`, + thumbnailUrl: data.file.thumbnail_url, + faviconUrl: cdnPath("/images/figma.png"), + transformedUnfurl: true, + }; + } + } catch (err) { + Logger.error( + `Error fetching Figma file metadata for integration ${integration.id}`, + err + ); + } + } + + // Either no linked accounts have access to the file, or we faced an error. + // Fallback to iframely unfurl either way. + return; + }; + + private static parseUrl(url: string) { + const { hostname, pathname } = new URL(url); + if (!Figma.supportedHosts.includes(hostname)) { + return; + } + + const parts = pathname.split("/"); + const type = parts[1]; + const key = parts[2]; + + if (!Figma.supportedFileTypes.includes(type) || isEmpty(key)) { + return; + } + + return { + type, + key, + }; + } + + private static fileMetadataUrl(key: string) { + return `https://api.figma.com/v1/files/${key}/meta`; + } +} diff --git a/plugins/figma/server/index.ts b/plugins/figma/server/index.ts new file mode 100644 index 0000000000..07dc9bf8ef --- /dev/null +++ b/plugins/figma/server/index.ts @@ -0,0 +1,22 @@ +import { Hook, PluginManager } from "@server/utils/PluginManager"; +import config from "../plugin.json"; +import router from "./api/figma"; +import env from "./env"; +import { Figma } from "./figma"; +import { Minute } from "@shared/utils/time"; + +const enabled = !!env.FIGMA_CLIENT_ID && !!env.FIGMA_CLIENT_SECRET; + +if (enabled) { + PluginManager.add([ + { + ...config, + type: Hook.API, + value: router, + }, + { + type: Hook.UnfurlProvider, + value: { unfurl: Figma.unfurl, cacheExpiry: 10 * Minute.seconds }, + }, + ]); +} diff --git a/plugins/figma/shared/FigmaUtils.ts b/plugins/figma/shared/FigmaUtils.ts new file mode 100644 index 0000000000..1c6da86508 --- /dev/null +++ b/plugins/figma/shared/FigmaUtils.ts @@ -0,0 +1,52 @@ +import queryString from "query-string"; +import env from "@shared/env"; +import { integrationSettingsPath } from "@shared/utils/routeHelpers"; + +export type OAuthState = { + teamId: string; +}; + +export class FigmaUtils { + public static oauthScopes = ["current_user:read", "file_metadata:read"]; + + public static accountUrl = "https://api.figma.com/v1/me"; + public static tokenUrl = "https://api.figma.com/v1/oauth/token"; + public static refreshUrl = "https://api.figma.com/v1/oauth/refresh"; + private static authBaseUrl = "https://www.figma.com/oauth"; + + private static settingsUrl = integrationSettingsPath("figma"); + + static parseState(state: string): OAuthState { + return JSON.parse(state); + } + + static successUrl() { + return this.settingsUrl; + } + + static errorUrl(error: string) { + return `${this.settingsUrl}?error=${error}`; + } + + static callbackUrl( + { baseUrl, params }: { baseUrl: string; params?: string } = { + baseUrl: env.URL, + params: undefined, + } + ) { + return params + ? `${baseUrl}/api/figma.callback?${params}` + : `${baseUrl}/api/figma.callback`; + } + + static authUrl({ state }: { state: OAuthState }) { + const params = { + client_id: env.FIGMA_CLIENT_ID, + redirect_uri: this.callbackUrl(), + state: JSON.stringify(state), + scope: this.oauthScopes.join(","), + response_type: "code", + }; + return `${this.authBaseUrl}?${queryString.stringify(params)}`; + } +} diff --git a/plugins/github/server/api/schema.ts b/plugins/github/server/api/schema.ts index d537c239a3..6abb4b842d 100644 --- a/plugins/github/server/api/schema.ts +++ b/plugins/github/server/api/schema.ts @@ -20,6 +20,9 @@ export const GitHubCallbackSchema = BaseSchema.extend({ .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { message: "one of code or error is required", }) + .refine((req) => isEmpty(req.code) || isEmpty(req.error), { + message: "code and error cannot both be present", + }) .refine( (req) => !( diff --git a/plugins/github/server/github.ts b/plugins/github/server/github.ts index 5f33f0649c..52ae87c368 100644 --- a/plugins/github/server/github.ts +++ b/plugins/github/server/github.ts @@ -226,11 +226,10 @@ export class GitHub { * @param actor User attempting to unfurl resource url * @returns An object containing resource details e.g, a GitHub Pull Request details */ - public static unfurl: UnfurlSignature = async (url: string, actor: User) => { - // Early return if URL doesn't match GitHub pattern (before any DB queries) + public static unfurl: UnfurlSignature = async (url: string, actor?: User) => { const resource = GitHub.parseUrl(url); - if (!resource) { + if (!resource || !actor) { return; } diff --git a/plugins/iframely/server/iframely.ts b/plugins/iframely/server/iframely.ts index 947ba9af1c..2b62c325f8 100644 --- a/plugins/iframely/server/iframely.ts +++ b/plugins/iframely/server/iframely.ts @@ -4,6 +4,7 @@ import Logger from "@server/logging/Logger"; import type { UnfurlError, UnfurlSignature } from "@server/types"; import fetch from "@server/utils/fetch"; import env from "./env"; +import { cdnPath } from "@shared/utils/urls"; class Iframely { public static defaultUrl = "https://iframe.ly"; @@ -40,9 +41,25 @@ class Iframely { */ public static unfurl: UnfurlSignature = async (url: string) => { const data = await Iframely.requestResource(url); - return "error" in data // In addition to our custom UnfurlError, sometimes iframely returns error in the response body. - ? ({ error: data.error } as UnfurlError) - : { ...data, type: UnfurlResourceType.URL }; + + if ("error" in data) { + return { error: data.error } as UnfurlError; // In addition to our custom UnfurlError, sometimes iframely returns error in the response body. + } + + const parsedData = data as Record; + + return { + type: UnfurlResourceType.URL, + url: parsedData.url, + title: parsedData.meta.title, + description: parsedData.meta.description, + thumbnailUrl: (parsedData.links.thumbnail ?? [])[0]?.href ?? "", + faviconUrl: + parsedData.meta.site === "Figma" + ? cdnPath("/images/figma.png") + : ((parsedData.links.icon ?? [])[0]?.href ?? ""), + transformedUnfurl: true, + }; }; } diff --git a/plugins/linear/client/Settings.tsx b/plugins/linear/client/Settings.tsx index f4ebe2a418..d77bd1a3a4 100644 --- a/plugins/linear/client/Settings.tsx +++ b/plugins/linear/client/Settings.tsx @@ -68,7 +68,7 @@ function Linear() { Enable previews of Linear issues in documents by connecting a - Linear workspace to {appName}. + Linear workspace to {{ appName }}. {integrations.linear.length ? ( diff --git a/plugins/linear/server/api/linear.ts b/plugins/linear/server/api/linear.ts index 95af8d32f8..2e2f16c685 100644 --- a/plugins/linear/server/api/linear.ts +++ b/plugins/linear/server/api/linear.ts @@ -8,7 +8,7 @@ import validate from "@server/middlewares/validate"; import { IntegrationAuthentication, Integration } from "@server/models"; import type { APIContext } from "@server/types"; import { Linear } from "../linear"; -import UploadLinearWorkspaceLogoTask from "../tasks/UploadLinearWorkspaceLogoTask"; +import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask"; import * as T from "./schema"; import { LinearUtils } from "plugins/linear/shared/LinearUtils"; import { addSeconds } from "date-fns"; @@ -86,7 +86,7 @@ router.get( transaction.afterCommit(async () => { if (workspace.logoUrl) { - await new UploadLinearWorkspaceLogoTask().schedule({ + await new UploadIntegrationLogoTask().schedule({ integrationId: integration.id, logoUrl: workspace.logoUrl, }); diff --git a/plugins/linear/server/api/schema.ts b/plugins/linear/server/api/schema.ts index 73abca1b8a..bac1855a6a 100644 --- a/plugins/linear/server/api/schema.ts +++ b/plugins/linear/server/api/schema.ts @@ -11,6 +11,9 @@ export const LinearCallbackSchema = BaseSchema.extend({ }) .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { message: "one of code or error is required", + }) + .refine((req) => isEmpty(req.code) || isEmpty(req.error), { + message: "code and error cannot both be present", }), }); diff --git a/plugins/linear/server/index.ts b/plugins/linear/server/index.ts index 32d06ce409..9b533dcbaa 100644 --- a/plugins/linear/server/index.ts +++ b/plugins/linear/server/index.ts @@ -4,7 +4,7 @@ import config from "../plugin.json"; import router from "./api/linear"; import env from "./env"; import { Linear } from "./linear"; -import UploadLinearWorkspaceLogoTask from "./tasks/UploadLinearWorkspaceLogoTask"; +import UploadIntegrationLogoTask from "@server/queues/tasks/UploadIntegrationLogoTask"; import { uninstall } from "./uninstall"; const enabled = !!env.LINEAR_CLIENT_ID && !!env.LINEAR_CLIENT_SECRET; @@ -18,7 +18,7 @@ if (enabled) { }, { type: Hook.Task, - value: UploadLinearWorkspaceLogoTask, + value: UploadIntegrationLogoTask, }, { type: Hook.UnfurlProvider, diff --git a/plugins/linear/server/linear.ts b/plugins/linear/server/linear.ts index efbe16a93a..aaee91a106 100644 --- a/plugins/linear/server/linear.ts +++ b/plugins/linear/server/linear.ts @@ -104,11 +104,10 @@ export class Linear { * @param actor User attempting to unfurl resource url * @returns An object containing resource details e.g, a Linear issue details */ - static unfurl: UnfurlSignature = async (url: string, actor: User) => { - // Early return if URL doesn't match Linear pattern (before any DB queries) + static unfurl: UnfurlSignature = async (url: string, actor?: User) => { const resource = Linear.parseUrl(url); - if (!resource) { + if (!resource || !actor) { return; } diff --git a/plugins/notion/server/api/schema.ts b/plugins/notion/server/api/schema.ts index 28ce5c7ebc..ee8005e2cf 100644 --- a/plugins/notion/server/api/schema.ts +++ b/plugins/notion/server/api/schema.ts @@ -11,6 +11,9 @@ export const NotionCallbackSchema = BaseSchema.extend({ }) .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { message: "one of code or error is required", + }) + .refine((req) => isEmpty(req.code) || isEmpty(req.error), { + message: "code and error cannot both be present", }), }); diff --git a/plugins/slack/server/auth/schema.ts b/plugins/slack/server/auth/schema.ts index 1a30edc08f..d98176b624 100644 --- a/plugins/slack/server/auth/schema.ts +++ b/plugins/slack/server/auth/schema.ts @@ -11,6 +11,9 @@ export const SlackPostSchema = BaseSchema.extend({ }) .refine((req) => !(isEmpty(req.code) && isEmpty(req.error)), { message: "one of code or error is required", + }) + .refine((req) => isEmpty(req.code) || isEmpty(req.error), { + message: "code and error cannot both be present", }), }); diff --git a/plugins/slack/server/auth/slack.ts b/plugins/slack/server/auth/slack.ts index f7ce02569a..222714c654 100644 --- a/plugins/slack/server/auth/slack.ts +++ b/plugins/slack/server/auth/slack.ts @@ -188,7 +188,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { }, { transaction } ); - await Integration.create( + await Integration.create>( { service: IntegrationService.Slack, type: IntegrationType.Post, @@ -226,7 +226,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { }, { transaction } ); - await Integration.create( + await Integration.create>( { service: IntegrationService.Slack, type: IntegrationType.Command, @@ -246,7 +246,7 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) { case IntegrationType.LinkedAccount: { // validation middleware ensures that code is non-null at this point const data = await Slack.oauthAccess(code!, SlackUtils.connectUrl()); - await Integration.create({ + await Integration.create>({ service: IntegrationService.Slack, type: IntegrationType.LinkedAccount, userId: user.id, diff --git a/server/presenters/unfurl.ts b/server/presenters/unfurl.ts index c10dd4213a..4fb5cb8ca0 100644 --- a/server/presenters/unfurl.ts +++ b/server/presenters/unfurl.ts @@ -29,14 +29,22 @@ async function presentUnfurl( const presentURL = ( data: Record -): UnfurlResponse[UnfurlResourceType.URL] => ({ - type: UnfurlResourceType.URL, - url: data.url, - title: data.meta.title, - description: data.meta.description, - thumbnailUrl: (data.links.thumbnail ?? [])[0]?.href ?? "", - faviconUrl: (data.links.icon ?? [])[0]?.href ?? "", -}); +): UnfurlResponse[UnfurlResourceType.URL] => { + // TODO: For backwards compatibility, remove once cache has expired in next release. + if (data.transformedUnfurl) { + delete data.transformedUnfurl; + return data as UnfurlResponse[UnfurlResourceType.URL]; // this would have been transformed by the unfurl plugin. + } + + return { + type: UnfurlResourceType.URL, + url: data.url, + title: data.meta.title, + description: data.meta.description, + thumbnailUrl: (data.links.thumbnail ?? [])[0]?.href ?? "", + faviconUrl: (data.links.icon ?? [])[0]?.href ?? "", + }; +}; const presentMention = async ( data: Record, diff --git a/plugins/linear/server/tasks/UploadLinearWorkspaceLogoTask.ts b/server/queues/tasks/UploadIntegrationLogoTask.ts similarity index 52% rename from plugins/linear/server/tasks/UploadLinearWorkspaceLogoTask.ts rename to server/queues/tasks/UploadIntegrationLogoTask.ts index 28d491ac0f..04a35892ba 100644 --- a/plugins/linear/server/tasks/UploadLinearWorkspaceLogoTask.ts +++ b/server/queues/tasks/UploadIntegrationLogoTask.ts @@ -5,23 +5,28 @@ import { createContext } from "@server/context"; import { Integration, User } from "@server/models"; import { BaseTask, TaskPriority } from "@server/queues/tasks/base/BaseTask"; +const SupportedIntegrations = [ + IntegrationService.Linear, + IntegrationService.Figma, +]; + type Props = { /** The integrationId to operate on */ integrationId: string; - /** The original logoUrl from Linear */ + /** The original logoUrl from third-party service */ logoUrl: string; }; /** * A task that uploads the provided logoUrl to storage and updates the - * Linear integration record with the new url. + * associated integration record with the new url. */ -export default class UploadLinearWorkspaceLogoTask extends BaseTask { +export default class UploadIntegrationLogoTask extends BaseTask { public async perform(props: Props) { - const integration = await Integration.scope("withAuthentication").findByPk< - Integration - >(props.integrationId); - if (!integration || integration.service !== IntegrationService.Linear) { + const integration = await Integration.scope("withAuthentication").findByPk( + props.integrationId + ); + if (!integration || !SupportedIntegrations.includes(integration.service)) { return; } @@ -43,11 +48,29 @@ export default class UploadLinearWorkspaceLogoTask extends BaseTask { }, }); - if (attachment) { - integration.settings.linear!.workspace.logoUrl = attachment.url; - integration.changed("settings", true); - await integration.save(); + if (!attachment) { + return; } + + switch (integration.service) { + case IntegrationService.Linear: + ( + integration as Integration + ).settings.linear!.workspace.logoUrl = attachment.url; + break; + case IntegrationService.Figma: + ( + integration as Integration + ).settings.figma!.account.avatarUrl = attachment.url; + break; + default: + throw new Error( + `Unsupported integration service: ${integration.service}` + ); // This should never happen + } + + integration.changed("settings", true); + await integration.save(); } public get options() { diff --git a/server/types.ts b/server/types.ts index 0afa9a398d..31630b040b 100644 --- a/server/types.ts +++ b/server/types.ts @@ -567,12 +567,19 @@ export type UnfurlIssueOrPR = | UnfurlResponse[UnfurlResourceType.Issue] | UnfurlResponse[UnfurlResourceType.PR]; +export type UnfurlURL = UnfurlResponse[UnfurlResourceType.URL] & { + transformedUnfurl: true; +}; + export type Unfurl = | UnfurlIssueOrPR + | UnfurlURL | { type: Exclude< UnfurlResourceType, - UnfurlResourceType.Issue | UnfurlResourceType.PR + | UnfurlResourceType.Issue + | UnfurlResourceType.PR + | UnfurlResourceType.URL >; [x: string]: JSONValue; }; diff --git a/shared/editor/components/Mentions.tsx b/shared/editor/components/Mentions.tsx index d56962b700..d166fd2542 100644 --- a/shared/editor/components/Mentions.tsx +++ b/shared/editor/components/Mentions.tsx @@ -193,7 +193,7 @@ export const MentionURL = (props: IssueUrlProps) => { } = getAttributesFromNode(node); const url = String(attrs.href); - const unfurl = unfurls.get(attrs.href)?.data ?? unfurlAttr; + const unfurl = unfurls.get(url)?.data ?? unfurlAttr; React.useEffect(() => { const fetchUnfurl = async () => { @@ -204,31 +204,37 @@ export const MentionURL = (props: IssueUrlProps) => { return; } + // We got a result back from the server, so update the unfurl in the node attributes. if (unfurlModel) { onChangeUnfurl( unfurlModel.data satisfies UnfurlResponse[UnfurlResourceType.URL] ); - } else { - // If we didn't get a result back, we still want to add a basic unfurl - // to avoid refetching again in future. This will just show the URL - // with a generic link icon. - unfurls.add({ - id: url, - type: UnfurlResourceType.URL, - fetchedAt: new Date().toISOString(), - data: { + return; + } + + const attrs = getAttributesFromNode(node); + // If we have a unfurl attribute, use that. + // Otherwise, set a basic unfurl to avoid refetching again in future. + // This will just show the URL with a generic link icon. + const data = attrs.unfurl + ? attrs.unfurl + : { title: toDisplayUrl(url), faviconUrl: cdnPath("/images/link.png"), - }, - }); - } + }; + unfurls.add({ + id: url, + type: UnfurlResourceType.URL, + fetchedAt: new Date().toISOString(), + data, + }); } finally { setLoaded(true); } }; void fetchUnfurl(); - }, [unfurls, attrs.href, isMounted]); + }, [unfurls, url, node, isMounted]); if (!unfurl) { return !loaded ? ( @@ -244,7 +250,7 @@ export const MentionURL = (props: IssueUrlProps) => { className={cn(className, { "ProseMirror-selectednode": isSelected, })} - href={attrs.href as string} + href={url} target="_blank" rel="noopener noreferrer nofollow" > diff --git a/shared/editor/lib/embeds.ts b/shared/editor/lib/embeds.ts index 5ed7811e95..09f068955d 100644 --- a/shared/editor/lib/embeds.ts +++ b/shared/editor/lib/embeds.ts @@ -1,4 +1,6 @@ +import type { Node, Schema } from "prosemirror-model"; import type { EmbedDescriptor } from "../embeds"; +import { isList } from "../queries/isList"; export function getMatchingEmbed( embeds: EmbedDescriptor[], @@ -13,3 +15,28 @@ export function getMatchingEmbed( return undefined; } + +export function transformListToEmbeds(listNode: Node, schema: Schema): Node[] { + const nodes: Node[] = []; + + listNode.forEach((node) => { + nodes.push(...transformListItemToEmbeds(node, schema)); + }); + + return nodes; +} + +function transformListItemToEmbeds(listItemNode: Node, schema: Schema): Node[] { + const nodes: Node[] = []; + + listItemNode.forEach((node) => { + if (node.type.name === "paragraph") { + const url = node.textContent; + nodes.push(schema.nodes.embed.create({ href: url })); + } else if (isList(node, schema)) { + nodes.push(...transformListToEmbeds(node, schema)); + } + }); + + return nodes; +} diff --git a/shared/editor/nodes/Embed.tsx b/shared/editor/nodes/Embed.tsx index 3483b86bfb..071f03ecd7 100644 --- a/shared/editor/nodes/Embed.tsx +++ b/shared/editor/nodes/Embed.tsx @@ -1,21 +1,25 @@ import type { Token } from "markdown-it"; -import type { - NodeSpec, - NodeType, - Node as ProsemirrorNode, +import { + Fragment, + Slice, + type NodeSpec, + type NodeType, + type Node as ProsemirrorNode, } from "prosemirror-model"; import type { Command } from "prosemirror-state"; -import { NodeSelection } from "prosemirror-state"; +import { NodeSelection, TextSelection } from "prosemirror-state"; import * as React from "react"; import type { Primitive } from "utility-types"; import { sanitizeUrl } from "../../utils/urls"; import EmbedComponent from "../components/Embed"; import defaultEmbeds from "../embeds"; -import { getMatchingEmbed } from "../lib/embeds"; +import { getMatchingEmbed, transformListToEmbeds } from "../lib/embeds"; import type { MarkdownSerializerState } from "../lib/markdown/serializer"; -import embedsRule from "../rules/embeds"; import type { ComponentProps } from "../types"; import Node from "./Node"; +import { isInList } from "../queries/isInList"; +import { findParentNodeClosestToPos } from "../queries/findParentNode"; +import { isList } from "../queries/isList"; export default class Embed extends Node { get name() { @@ -96,10 +100,6 @@ export default class Embed extends Node { }; } - get rulePlugins() { - return [embedsRule(this.options.embeds)]; - } - handleChangeSize = ({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) => ({ width, height }: { width: number; height?: number }) => { @@ -132,13 +132,55 @@ export default class Embed extends Node { }; commands({ type }: { type: NodeType }) { - return (attrs: Record): Command => - (state, dispatch) => { - dispatch?.( - state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() - ); - return true; - }; + return { + embed: + (attrs: Record): Command => + (state, dispatch) => { + dispatch?.( + state.tr.replaceSelectionWith(type.create(attrs)).scrollIntoView() + ); + return true; + }, + embed_list: + (_attrs: Record): Command => + (state, dispatch) => { + const { selection } = state; + const position = + selection instanceof TextSelection + ? selection.$cursor?.pos + : selection.$to.pos; + + if (position === undefined || !isInList(state)) { + return false; + } + + const resolvedPos = state.tr.doc.resolve(position); + const nodeWithPos = findParentNodeClosestToPos(resolvedPos, (node) => + isList(node, this.editor.schema) + ); + + if (!nodeWithPos) { + return false; + } + + const listNode = nodeWithPos.node, + from = nodeWithPos.pos, + to = from + listNode.nodeSize; + + const nodes = transformListToEmbeds(listNode, this.editor.schema); + const slice = new Slice(Fragment.fromArray(nodes), 0, 0); + + const tr = state.tr.deleteRange(from, to); + dispatch?.( + tr + .setSelection(TextSelection.near(tr.doc.resolve(from))) + .replaceSelection(slice) + .scrollIntoView() + ); + + return true; + }, + }; } toMarkdown(state: MarkdownSerializerState, node: ProsemirrorNode) { diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 9714bf6bdf..bfdcf276d9 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -1374,8 +1374,14 @@ "Sorry, an answer could not be found in the workspace, try widening your search.": "Sorry, an answer could not be found in the workspace, try widening your search.", "Looking for answers": "Looking for answers", "Answer to \"{{ query }}\"": "Answer to \"{{ query }}\"", - "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?", + "Whoops, you need to accept the permissions in Figma to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Figma to connect {{appName}} to your workspace. Try again?", "Something went wrong while authenticating your request. Please try logging in again.": "Something went wrong while authenticating your request. Please try logging in again.", + "Something went wrong while processing your request. Please try again.": "Something went wrong while processing your request. Please try again.", + "Link your {{appName}} account to Figma to enable previews of design files you have access to, directly within documents.": "Link your {{appName}} account to Figma to enable previews of design files you have access to, directly within documents.", + "Enabled on": "Enabled on", + "Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?": "Disconnecting will prevent previewing Figma design files from this account in documents. Are you sure?", + "The Figma integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Figma integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.", + "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in GitHub to connect {{appName}} to your workspace. Try again?", "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.": "The owner of GitHub account has been requested to install the {{githubAppName}} GitHub app. Once approved, previews will be shown for respective links.", "Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.": "Enable previews of GitHub issues and pull requests in documents by connecting a GitHub organization or specific repositories to {appName}.", "Enabled by {{integrationCreatedBy}}": "Enabled by {{integrationCreatedBy}}", @@ -1386,8 +1392,7 @@ "Measurement ID": "Measurement ID", "Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.": "Create a \"Web\" stream in your Google Analytics admin dashboard and copy the measurement ID from the generated code snippet to install.", "Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?": "Whoops, you need to accept the permissions in Linear to connect {{appName}} to your workspace. Try again?", - "Something went wrong while processing your request. Please try again.": "Something went wrong while processing your request. Please try again.", - "Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.": "Enable previews of Linear issues in documents by connecting a Linear workspace to {appName}.", + "Enable previews of Linear issues in documents by connecting a Linear workspace to {{appName}}.": "Enable previews of Linear issues in documents by connecting a Linear workspace to {{appName}}.", "Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?": "Disconnecting will prevent previewing Linear links from this workspace in documents. Are you sure?", "The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.": "The Linear integration is currently disabled. Please set the associated environment variables and restart the server to enable the integration.", "Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.": "Configure a Matomo installation to send views and analytics from the workspace to your own Matomo instance.", diff --git a/shared/types.ts b/shared/types.ts index b9cfbf28cb..3aa49871fc 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -129,6 +129,7 @@ export enum IntegrationService { Umami = "umami", GitHub = "github", Linear = "linear", + Figma = "figma", Notion = "notion", } @@ -211,28 +212,38 @@ export type IntegrationSettings = T extends IntegrationType.Embed ? { externalWorkspace: { id: string; name: string; iconUrl?: string }; } - : - | { url: string } - | { - github?: { - installation: { - id: number; - account: { - id?: number; - name: string; - avatarUrl?: string; + : T extends IntegrationType.LinkedAccount + ? { + slack?: { serviceTeamId: string; serviceUserId: string }; + figma?: { + account: { + id: string; + name: string; + email: string; + avatarUrl: string; + }; + }; + } + : + | { url: string } + | { + github?: { + installation: { + id: number; + account: { + id?: number; + name: string; + avatarUrl?: string; + }; }; }; - }; - diagrams?: { - url: string; - }; - } - | { url: string; channel: string; channelId: string } - | { serviceTeamId: string } - | { measurementId: string } - | { slack: { serviceTeamId: string; serviceUserId: string } } - | undefined; + diagrams?: { + url: string; + }; + } + | { serviceTeamId: string } + | { measurementId: string } + | undefined; export enum UserPreference { /** Whether reopening the app should redirect to the last viewed document. */