mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
fix: Do not show embed option for unembeddable links (#11323)
* fix: Do not show embed option for unembeddable links * test
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { BrowserIcon, EmailIcon, LinkIcon } from "outline-icons";
|
||||
import React, { useCallback } from "react";
|
||||
import { EmailIcon, LinkIcon } from "outline-icons";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
@@ -10,6 +10,7 @@ import { isUrl } from "@shared/utils/urls";
|
||||
import type Integration from "~/models/Integration";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import { determineMentionType, isURLMentionable } from "~/utils/mention";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
@@ -24,13 +25,16 @@ type Props = Omit<
|
||||
embeds: EmbedDescriptor[];
|
||||
};
|
||||
|
||||
interface EmbedCheckState {
|
||||
loading: boolean;
|
||||
embeddable?: boolean;
|
||||
}
|
||||
|
||||
export const PasteMenu = observer(({ pastedText, embeds, ...props }: Props) => {
|
||||
const items = useItems({ pastedText, embeds });
|
||||
|
||||
const renderMenuItem = useCallback(
|
||||
(item, _index, options) => (
|
||||
<SuggestionsMenuItem {...options} title={item.title} icon={item.icon} />
|
||||
),
|
||||
(item, _index, options) => <SuggestionsMenuItem {...options} {...item} />,
|
||||
[]
|
||||
);
|
||||
|
||||
@@ -57,6 +61,44 @@ function useItems({
|
||||
const { t } = useTranslation();
|
||||
const { integrations } = useStores();
|
||||
const user = useCurrentUser({ rejectOnEmpty: false });
|
||||
const [embedCheck, setEmbedCheck] = useState<EmbedCheckState>({
|
||||
loading: false,
|
||||
});
|
||||
|
||||
const singleUrl =
|
||||
typeof pastedText === "string" && isUrl(pastedText) ? pastedText : null;
|
||||
const embed = singleUrl ? getMatchingEmbed(embeds, singleUrl)?.embed : null;
|
||||
|
||||
// Check embeddability for single URL
|
||||
useEffect(() => {
|
||||
if (!singleUrl || !embed) {
|
||||
setEmbedCheck({ loading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setEmbedCheck({ loading: true });
|
||||
|
||||
client
|
||||
.post<{ embeddable: boolean; reason?: string }>("/urls.checkEmbed", {
|
||||
url: singleUrl,
|
||||
})
|
||||
.then((res) => {
|
||||
if (!cancelled) {
|
||||
setEmbedCheck({ loading: false, embeddable: res.embeddable });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
// Optimistic on error - allow embedding attempt
|
||||
setEmbedCheck({ loading: false, embeddable: true });
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [singleUrl, embed]);
|
||||
|
||||
// single item is pasted.
|
||||
if (typeof pastedText === "string") {
|
||||
@@ -73,8 +115,6 @@ function useItems({
|
||||
: MentionType.URL;
|
||||
}
|
||||
|
||||
const embed = getMatchingEmbed(embeds, pastedText)?.embed;
|
||||
|
||||
return [
|
||||
{
|
||||
name: "noop",
|
||||
@@ -99,7 +139,9 @@ function useItems({
|
||||
{
|
||||
name: "embed",
|
||||
title: t("Embed"),
|
||||
visible: !!embed,
|
||||
subtitle:
|
||||
embedCheck.embeddable === false ? t("Not supported") : undefined,
|
||||
disabled: embedCheck.loading || !embedCheck.embeddable,
|
||||
icon: embed?.icon,
|
||||
keywords: embed?.keywords,
|
||||
},
|
||||
@@ -131,29 +173,8 @@ function useItems({
|
||||
return !!mentionType;
|
||||
});
|
||||
|
||||
// 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" ? (
|
||||
<BrowserIcon />
|
||||
) : (
|
||||
embeds.find((e) => e.title === embedType)?.icon
|
||||
);
|
||||
|
||||
// don't render the menu when it can't be converted to other types.
|
||||
if (!convertibleToMentionList && !convertibleToEmbedList) {
|
||||
// don't render the menu when it can't be converted to mentions.
|
||||
if (!convertibleToMentionList) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -170,12 +191,5 @@ function useItems({
|
||||
icon: <EmailIcon />,
|
||||
attrs: { actorId: user?.id, ...linksToMentionType },
|
||||
},
|
||||
{
|
||||
name: "embed_list",
|
||||
title: t("Embed"),
|
||||
visible: !!convertibleToEmbedList,
|
||||
icon: embedIcon,
|
||||
attrs: { actorId: user?.id },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -292,6 +292,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const handleClickItem = React.useCallback(
|
||||
(item) => {
|
||||
if (item.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
props.onSelect?.(item);
|
||||
|
||||
switch (item.name) {
|
||||
@@ -578,12 +582,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
event.stopPropagation();
|
||||
|
||||
if (filtered.length) {
|
||||
const prevIndex = selectedIndex - 1;
|
||||
const prev = filtered[prevIndex];
|
||||
|
||||
setSelectedIndex(
|
||||
Math.max(0, prev?.name === "separator" ? prevIndex - 1 : prevIndex)
|
||||
);
|
||||
let prevIndex = selectedIndex - 1;
|
||||
while (prevIndex >= 0) {
|
||||
const item = filtered[prevIndex];
|
||||
if (
|
||||
item?.name !== "separator" &&
|
||||
!("disabled" in item && item.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
prevIndex--;
|
||||
}
|
||||
if (prevIndex >= 0) {
|
||||
setSelectedIndex(prevIndex);
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
@@ -599,15 +611,20 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
if (filtered.length) {
|
||||
const total = filtered.length - 1;
|
||||
const nextIndex = selectedIndex + 1;
|
||||
const next = filtered[nextIndex];
|
||||
|
||||
setSelectedIndex(
|
||||
Math.min(
|
||||
next?.name === "separator" ? nextIndex + 1 : nextIndex,
|
||||
total
|
||||
)
|
||||
);
|
||||
let nextIndex = selectedIndex + 1;
|
||||
while (nextIndex <= total) {
|
||||
const item = filtered[nextIndex];
|
||||
if (
|
||||
item?.name !== "separator" &&
|
||||
!("disabled" in item && item.disabled)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
nextIndex++;
|
||||
}
|
||||
if (nextIndex <= total) {
|
||||
setSelectedIndex(nextIndex);
|
||||
}
|
||||
} else {
|
||||
close();
|
||||
}
|
||||
@@ -678,6 +695,7 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
|
||||
const handlePointerMove = (ev: React.PointerEvent) => {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
selectedIndex !== index &&
|
||||
// Safari triggers pointermove with identical coordinates when the pointer has not moved.
|
||||
// This causes the menu selection to flicker when the pointer is over the menu but not moving.
|
||||
@@ -693,7 +711,10 @@ function SuggestionsMenu<T extends MenuItem>(props: Props<T>) {
|
||||
};
|
||||
|
||||
const handlePointerDown = () => {
|
||||
if (selectedIndex !== index) {
|
||||
if (
|
||||
!("disabled" in item && item.disabled) &&
|
||||
selectedIndex !== index
|
||||
) {
|
||||
setSelectedIndex(index);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -82,7 +82,7 @@ import FileStorage from "@server/storage/files";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import ZipHelper from "@server/utils/ZipHelper";
|
||||
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embedHelper";
|
||||
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embeds";
|
||||
import { getTeamFromContext } from "@server/utils/passport";
|
||||
import { assertPresent } from "@server/validation";
|
||||
import pagination from "../middlewares/pagination";
|
||||
|
||||
@@ -53,3 +53,14 @@ export const UrlsCheckCnameSchema = BaseSchema.extend({
|
||||
});
|
||||
|
||||
export type UrlsCheckCnameReq = z.infer<typeof UrlsCheckCnameSchema>;
|
||||
|
||||
export const UrlsCheckEmbedSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
url: z
|
||||
.string()
|
||||
.url()
|
||||
.refine((val) => isUrl(val), { message: ValidateURL.message }),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UrlsCheckEmbedReq = z.infer<typeof UrlsCheckEmbedSchema>;
|
||||
|
||||
@@ -233,6 +233,49 @@ describe("#urls.unfurl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("#urls.checkEmbed", () => {
|
||||
let user: User;
|
||||
beforeEach(async () => {
|
||||
user = await buildUser();
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request when url is missing", async () => {
|
||||
const res = await server.post("/api/urls.checkEmbed", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should fail with status 400 bad request when url is not a valid URL", async () => {
|
||||
const res = await server.post("/api/urls.checkEmbed", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
url: "not-a-url",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should return a result for valid URLs", async () => {
|
||||
// Use a YouTube URL which matches a known embed pattern
|
||||
const res = await server.post("/api/urls.checkEmbed", {
|
||||
body: {
|
||||
token: user.getJwtToken(),
|
||||
url: "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
},
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
expect(res.status).toEqual(200);
|
||||
// Result depends on actual HTTP response from YouTube (or network error)
|
||||
expect(body).toHaveProperty("embeddable");
|
||||
});
|
||||
});
|
||||
|
||||
describe("#urls.validateCustomDomain", () => {
|
||||
it("should succeed with custom domain pointing at server", async () => {
|
||||
const user = await buildUser();
|
||||
|
||||
@@ -17,8 +17,13 @@ import type { APIContext, Unfurl } from "@server/types";
|
||||
import { CacheHelper, type CacheResult } from "@server/utils/CacheHelper";
|
||||
import { Hook, PluginManager } from "@server/utils/PluginManager";
|
||||
import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import {
|
||||
checkEmbeddability,
|
||||
type EmbedCheckResult,
|
||||
} from "@server/utils/embeds";
|
||||
import * as T from "./schema";
|
||||
import { MAX_AVATAR_DISPLAY } from "@shared/constants";
|
||||
import { Day } from "@shared/utils/time";
|
||||
|
||||
const router = new Router();
|
||||
const plugins = PluginManager.getHooks(Hook.UnfurlProvider);
|
||||
@@ -172,6 +177,26 @@ router.post(
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"urls.checkEmbed",
|
||||
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
|
||||
auth(),
|
||||
validate(T.UrlsCheckEmbedSchema),
|
||||
async (ctx: APIContext<T.UrlsCheckEmbedReq>) => {
|
||||
const { url } = ctx.input.body;
|
||||
|
||||
const result = await CacheHelper.getDataOrSet<EmbedCheckResult>(
|
||||
CacheHelper.getEmbedCheckKey(url),
|
||||
() => checkEmbeddability(url),
|
||||
Day.seconds
|
||||
);
|
||||
|
||||
ctx.body = result
|
||||
? { embeddable: result.embeddable, reason: result.reason }
|
||||
: { embeddable: false, reason: "error" };
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
"urls.validateCustomDomain",
|
||||
rateLimiter(RateLimiterStrategy.OneHundredPerHour),
|
||||
|
||||
@@ -157,4 +157,15 @@ export class CacheHelper {
|
||||
public static getCollectionDocumentsKey(collectionId: string) {
|
||||
return `cd:${collectionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets key for caching embed check results. This is a global cache key
|
||||
* (not team-specific) since embed headers are the same for all users.
|
||||
*
|
||||
* @param url The URL to generate a cache key for.
|
||||
* @returns the cache key string.
|
||||
*/
|
||||
public static getEmbedCheckKey(url: string) {
|
||||
return `embed:${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,4 +56,8 @@ export class CacheHelper {
|
||||
public static getCollectionDocumentsKey(collectionId: string) {
|
||||
return `cd:${collectionId}`;
|
||||
}
|
||||
|
||||
public static getEmbedCheckKey(url: string) {
|
||||
return `embed:${url}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { convertBareUrlsToEmbedMarkdown } from "./embedHelper";
|
||||
|
||||
describe("embedHelper", () => {
|
||||
describe("convertBareUrlsToEmbedMarkdown", () => {
|
||||
it("should convert bare YouTube URL to embed format", () => {
|
||||
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
const expected =
|
||||
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert bare Vimeo URL to embed format", () => {
|
||||
const input = "https://vimeo.com/123456789";
|
||||
const expected =
|
||||
"[https://vimeo.com/123456789](https://vimeo.com/123456789)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert bare youtu.be URL to embed format", () => {
|
||||
const input = "https://youtu.be/dQw4w9WgXcQ";
|
||||
const expected =
|
||||
"[https://youtu.be/dQw4w9WgXcQ](https://youtu.be/dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should not convert URLs that do not match embed patterns", () => {
|
||||
const input = "https://example.com/some-page";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should not convert URLs that are already in markdown link format", () => {
|
||||
const input =
|
||||
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should not convert URLs that have link text", () => {
|
||||
const input =
|
||||
"[Watch this video](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should not convert URLs that are part of other text on the same line", () => {
|
||||
const input =
|
||||
"Check out https://www.youtube.com/watch?v=dQw4w9WgXcQ video";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should handle multiple lines with mixed content", () => {
|
||||
const input = `Here is some text.
|
||||
|
||||
https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
||||
|
||||
And some more text.
|
||||
|
||||
https://example.com/not-an-embed
|
||||
|
||||
https://vimeo.com/987654321`;
|
||||
|
||||
const expected = `Here is some text.
|
||||
|
||||
[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)
|
||||
|
||||
And some more text.
|
||||
|
||||
https://example.com/not-an-embed
|
||||
|
||||
[https://vimeo.com/987654321](https://vimeo.com/987654321)`;
|
||||
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should preserve leading whitespace", () => {
|
||||
const input = " https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
const expected =
|
||||
" [https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(convertBareUrlsToEmbedMarkdown("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle text with no URLs", () => {
|
||||
const input = "This is just regular text with no URLs.";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should convert Spotify URLs", () => {
|
||||
const input = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT";
|
||||
const expected =
|
||||
"[https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT](https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert Loom URLs", () => {
|
||||
const input = "https://www.loom.com/share/abc123def456";
|
||||
const expected =
|
||||
"[https://www.loom.com/share/abc123def456](https://www.loom.com/share/abc123def456)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert Figma URLs", () => {
|
||||
// Figma regex requires 22-128 character file IDs
|
||||
const input =
|
||||
"https://www.figma.com/file/abcdefghij1234567890AB/Design-File";
|
||||
const expected =
|
||||
"[https://www.figma.com/file/abcdefghij1234567890AB/Design-File](https://www.figma.com/file/abcdefghij1234567890AB/Design-File)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle trailing whitespace on lines", () => {
|
||||
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ ";
|
||||
// Trailing whitespace is trimmed, so the URL still gets converted
|
||||
const expected =
|
||||
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should not convert URLs with text before them", () => {
|
||||
const input = "Video: https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,75 +0,0 @@
|
||||
import type { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
|
||||
/**
|
||||
* Checks if a URL matches any of the embed patterns.
|
||||
*
|
||||
* @param url - The URL to check.
|
||||
* @param embedDescriptors - The list of embed descriptors to check against.
|
||||
* @returns True if the URL matches an embed pattern with `matchOnInput` enabled.
|
||||
*/
|
||||
function isEmbedUrl(url: string, embedDescriptors: EmbedDescriptor[]): boolean {
|
||||
for (const embed of embedDescriptors) {
|
||||
if (!embed.matchOnInput) {
|
||||
continue;
|
||||
}
|
||||
if (embed.matcher(url)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A regex pattern that matches URLs at the beginning of a line or as standalone content.
|
||||
* Matches http:// and https:// URLs.
|
||||
*/
|
||||
const bareUrlPattern = /^(https?:\/\/[^\s]+)$/;
|
||||
|
||||
/**
|
||||
* Converts bare URLs in markdown text to the embed-friendly link format `[url](url)`.
|
||||
* This allows the markdown parser to recognize them as embeds when they match
|
||||
* supported embed patterns (YouTube, Vimeo, etc.).
|
||||
*
|
||||
* Only URLs that match a known embed pattern with `matchOnInput` enabled will be converted.
|
||||
*
|
||||
* @param text - The markdown text to process.
|
||||
* @param embedDescriptors - Optional custom list of embed descriptors. Defaults to built-in embeds.
|
||||
* @returns The processed text with bare embed URLs converted to link format.
|
||||
*
|
||||
* @example
|
||||
* // Input:
|
||||
* "Check out this video:\n\nhttps://www.youtube.com/watch?v=dQw4w9WgXcQ\n\nPretty cool!"
|
||||
*
|
||||
* // Output:
|
||||
* "Check out this video:\n\n[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)\n\nPretty cool!"
|
||||
*/
|
||||
export function convertBareUrlsToEmbedMarkdown(
|
||||
text: string,
|
||||
embedDescriptors: EmbedDescriptor[] = embeds
|
||||
): string {
|
||||
const lines = text.split("\n");
|
||||
|
||||
return lines
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check if the line is a bare URL
|
||||
const match = trimmed.match(bareUrlPattern);
|
||||
if (!match) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const url = match[1];
|
||||
|
||||
// Only convert if the URL matches a known embed pattern
|
||||
if (isEmbedUrl(url, embedDescriptors)) {
|
||||
// Preserve leading whitespace from the original line
|
||||
const leadingWhitespace = line.match(/^(\s*)/)?.[1] ?? "";
|
||||
return `${leadingWhitespace}[${url}](${url})`;
|
||||
}
|
||||
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
import fetchMock from "jest-fetch-mock";
|
||||
import { checkEmbeddability, convertBareUrlsToEmbedMarkdown } from "./embeds";
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.resetMocks();
|
||||
});
|
||||
|
||||
describe("checkEmbeddability", () => {
|
||||
describe("when URL doesn't match any embed pattern", () => {
|
||||
it("should return embeddable: false with reason: no-match for non-http URLs", async () => {
|
||||
// The generic embed only matches http/https URLs
|
||||
const result = await checkEmbeddability("file:///local/path");
|
||||
expect(result).toEqual({ embeddable: false, reason: "no-match" });
|
||||
});
|
||||
|
||||
it("should return embeddable: false with reason: no-match for invalid URLs", async () => {
|
||||
const result = await checkEmbeddability("not-a-valid-url");
|
||||
expect(result).toEqual({ embeddable: false, reason: "no-match" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("when URL matches an embed pattern", () => {
|
||||
it("should return embeddable: true when no restrictive headers", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: true });
|
||||
});
|
||||
|
||||
it("should return embeddable: false when X-Frame-Options: DENY", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: { "X-Frame-Options": "DENY" },
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
|
||||
});
|
||||
|
||||
it("should return embeddable: false when X-Frame-Options: SAMEORIGIN", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: { "X-Frame-Options": "SAMEORIGIN" },
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
|
||||
});
|
||||
|
||||
it("should return embeddable: false when X-Frame-Options: ALLOW-FROM", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: { "X-Frame-Options": "ALLOW-FROM https://example.com" },
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: false, reason: "x-frame-options" });
|
||||
});
|
||||
|
||||
it("should return embeddable: false when CSP frame-ancestors is 'none'", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Security-Policy":
|
||||
"default-src 'self'; frame-ancestors 'none'",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({
|
||||
embeddable: false,
|
||||
reason: "csp-frame-ancestors",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return embeddable: false when CSP frame-ancestors is 'self'", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({
|
||||
embeddable: false,
|
||||
reason: "csp-frame-ancestors",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return embeddable: true when CSP frame-ancestors is *", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Security-Policy": "frame-ancestors *",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: true });
|
||||
});
|
||||
|
||||
it("should return embeddable: false when CSP frame-ancestors has specific origins", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Security-Policy": "frame-ancestors https://allowed-site.com",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({
|
||||
embeddable: false,
|
||||
reason: "csp-frame-ancestors",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return embeddable: false when COEP is require-corp", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: { "Cross-Origin-Embedder-Policy": "require-corp" },
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: false, reason: "coep" });
|
||||
});
|
||||
|
||||
it("should return embeddable: true when COEP is unsafe-none", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 200,
|
||||
headers: { "Cross-Origin-Embedder-Policy": "unsafe-none" },
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: true });
|
||||
});
|
||||
|
||||
it("should return embeddable: false when server returns 403", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 403,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability(
|
||||
"https://www.example.com/forbiddenpage"
|
||||
);
|
||||
expect(result).toEqual({ embeddable: false, reason: "http-error" });
|
||||
});
|
||||
|
||||
it("should return embeddable: false when server returns 404", async () => {
|
||||
fetchMock.mockResponseOnce("", {
|
||||
status: 404,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const result = await checkEmbeddability(
|
||||
"https://www.example.com/nonexistentpage"
|
||||
);
|
||||
expect(result).toEqual({ embeddable: false, reason: "http-error" });
|
||||
});
|
||||
|
||||
it("should return embeddable: true on timeout (optimistic)", async () => {
|
||||
fetchMock.mockAbortOnce();
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: true, reason: "timeout" });
|
||||
});
|
||||
|
||||
it("should return embeddable: true on network error (optimistic)", async () => {
|
||||
fetchMock.mockRejectOnce(new Error("Network error"));
|
||||
|
||||
const result = await checkEmbeddability("https://www.example.com/embed");
|
||||
expect(result).toEqual({ embeddable: true, reason: "timeout" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertBareUrlsToEmbedMarkdown", () => {
|
||||
it("should convert bare YouTube URL to embed format", () => {
|
||||
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
const expected =
|
||||
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert bare Vimeo URL to embed format", () => {
|
||||
const input = "https://vimeo.com/123456789";
|
||||
const expected =
|
||||
"[https://vimeo.com/123456789](https://vimeo.com/123456789)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert bare youtu.be URL to embed format", () => {
|
||||
const input = "https://youtu.be/dQw4w9WgXcQ";
|
||||
const expected =
|
||||
"[https://youtu.be/dQw4w9WgXcQ](https://youtu.be/dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should not convert URLs that do not match embed patterns", () => {
|
||||
const input = "https://example.com/some-page";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should not convert URLs that are already in markdown link format", () => {
|
||||
const input =
|
||||
"[https://www.example.com/embed](https://www.example.com/embed)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should not convert URLs that have link text", () => {
|
||||
const input = "[Watch this video](https://www.example.com/embed)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should not convert URLs that are part of other text on the same line", () => {
|
||||
const input = "Check out https://www.example.com/embed video";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should handle multiple lines with mixed content", () => {
|
||||
const input = `Here is some text.
|
||||
|
||||
https://www.youtube.com/watch?v=dQw4w9WgXcQ
|
||||
|
||||
And some more text.
|
||||
|
||||
https://example.com/not-an-embed
|
||||
|
||||
https://vimeo.com/987654321`;
|
||||
|
||||
const expected = `Here is some text.
|
||||
|
||||
[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)
|
||||
|
||||
And some more text.
|
||||
|
||||
https://example.com/not-an-embed
|
||||
|
||||
[https://vimeo.com/987654321](https://vimeo.com/987654321)`;
|
||||
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should preserve leading whitespace", () => {
|
||||
const input = " https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
const expected =
|
||||
" [https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(convertBareUrlsToEmbedMarkdown("")).toBe("");
|
||||
});
|
||||
|
||||
it("should handle text with no URLs", () => {
|
||||
const input = "This is just regular text with no URLs.";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("should convert Spotify URLs", () => {
|
||||
const input = "https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT";
|
||||
const expected =
|
||||
"[https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT](https://open.spotify.com/track/4cOdK2wGLETKBW3PvgPWqT)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert Loom URLs", () => {
|
||||
const input = "https://www.loom.com/share/abc123def456";
|
||||
const expected =
|
||||
"[https://www.loom.com/share/abc123def456](https://www.loom.com/share/abc123def456)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should convert Figma URLs", () => {
|
||||
// Figma regex requires 22-128 character file IDs
|
||||
const input =
|
||||
"https://www.figma.com/file/abcdefghij1234567890AB/Design-File";
|
||||
const expected =
|
||||
"[https://www.figma.com/file/abcdefghij1234567890AB/Design-File](https://www.figma.com/file/abcdefghij1234567890AB/Design-File)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should handle trailing whitespace on lines", () => {
|
||||
const input = "https://www.youtube.com/watch?v=dQw4w9WgXcQ ";
|
||||
// Trailing whitespace is trimmed, so the URL still gets converted
|
||||
const expected =
|
||||
"[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("should not convert URLs with text before them", () => {
|
||||
const input = "Video: https://www.youtube.com/watch?v=dQw4w9WgXcQ";
|
||||
expect(convertBareUrlsToEmbedMarkdown(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import type { EmbedDescriptor } from "@shared/editor/embeds";
|
||||
import { getMatchingEmbed } from "@shared/editor/lib/embeds";
|
||||
import embeds from "@shared/editor/embeds";
|
||||
import fetch, { chromeUserAgent } from "./fetch";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
/**
|
||||
* Result of an embed check operation.
|
||||
*/
|
||||
export interface EmbedCheckResult {
|
||||
/** Whether the URL can be embedded in an iframe. */
|
||||
embeddable: boolean;
|
||||
/** The reason why the URL cannot be embedded, if applicable. */
|
||||
reason?:
|
||||
| "x-frame-options"
|
||||
| "csp-frame-ancestors"
|
||||
| "no-match"
|
||||
| "coep"
|
||||
| "http-error"
|
||||
| "error"
|
||||
| "timeout";
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses X-Frame-Options header and determines if embedding is allowed.
|
||||
*
|
||||
* @param value The X-Frame-Options header value.
|
||||
* @returns true if embedding is blocked, false otherwise.
|
||||
*/
|
||||
function isBlockedByXFrameOptions(value: string | null): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.toUpperCase().trim();
|
||||
|
||||
// DENY - Cannot be embedded anywhere
|
||||
// SAMEORIGIN - Can only be embedded on same origin (blocks us)
|
||||
// ALLOW-FROM - Deprecated but treat as blocked
|
||||
return (
|
||||
normalized === "DENY" ||
|
||||
normalized === "SAMEORIGIN" ||
|
||||
normalized.startsWith("ALLOW-FROM")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Content-Security-Policy header and checks if frame-ancestors blocks embedding.
|
||||
*
|
||||
* @param value The Content-Security-Policy header value.
|
||||
* @returns true if embedding is blocked, false otherwise.
|
||||
*/
|
||||
function isBlockedByCSP(value: string | null): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Parse the CSP header to find frame-ancestors directive
|
||||
const directives = value.split(";").map((d) => d.trim());
|
||||
|
||||
for (const directive of directives) {
|
||||
const parts = directive.split(/\s+/);
|
||||
if (parts[0]?.toLowerCase() === "frame-ancestors") {
|
||||
const sources = parts.slice(1);
|
||||
|
||||
// 'none' - Cannot be embedded anywhere
|
||||
if (sources.length === 1 && sources[0] === "'none'") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 'self' only - Same origin only (blocks us)
|
||||
if (sources.length === 1 && sources[0] === "'self'") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If there are specific origins listed (not * or 'self'), we're probably not in the list
|
||||
// Allow if * is present anywhere in the list
|
||||
if (sources.includes("*")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If specific origins are listed without *, treat as blocked (we're probably not in the list)
|
||||
if (
|
||||
sources.length > 0 &&
|
||||
!sources.every((s) => s === "'self'" || s === "'none'")
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks Cross-Origin-Embedder-Policy header for embedding restrictions.
|
||||
*
|
||||
* @param value The Cross-Origin-Embedder-Policy header value.
|
||||
* @returns true if embedding is blocked, false otherwise.
|
||||
*/
|
||||
function isBlockedByCOEP(value: string | null): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = value.toLowerCase().trim();
|
||||
|
||||
// unsafe-none means no restrictions, anything else blocks cross-origin embedding
|
||||
return normalized !== "unsafe-none";
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL can be embedded in an iframe by verifying:
|
||||
* 1. The URL matches a known embed pattern
|
||||
* 2. The URL's response headers don't block iframe embedding
|
||||
*
|
||||
* @param url The URL to check for embeddability.
|
||||
* @returns a promise resolving to the embed check result.
|
||||
*/
|
||||
export async function checkEmbeddability(
|
||||
url: string
|
||||
): Promise<EmbedCheckResult> {
|
||||
const match = getMatchingEmbed(embeds, url);
|
||||
if (!match) {
|
||||
return { embeddable: false, reason: "no-match" };
|
||||
}
|
||||
|
||||
if (match.embed.title !== "Embed") {
|
||||
// Known safe embed type
|
||||
return { embeddable: true };
|
||||
}
|
||||
|
||||
// Make GET request to check headers (HEAD is unreliable for many servers)
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), Second.ms * 3);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
redirect: "follow",
|
||||
headers: {
|
||||
"User-Agent": chromeUserAgent,
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Get headers then immediately close the connection - we don't need the body
|
||||
const status = response.status;
|
||||
const xFrameOptions = response.headers.get("x-frame-options");
|
||||
const csp = response.headers.get("content-security-policy");
|
||||
const coep = response.headers.get("cross-origin-embedder-policy");
|
||||
controller.abort();
|
||||
|
||||
// Check for HTTP errors - if the server rejects the request, embedding won't work
|
||||
if (status >= 400) {
|
||||
return { embeddable: false, reason: "http-error" };
|
||||
}
|
||||
|
||||
// Check X-Frame-Options header
|
||||
if (isBlockedByXFrameOptions(xFrameOptions)) {
|
||||
return { embeddable: false, reason: "x-frame-options" };
|
||||
}
|
||||
|
||||
// Check Content-Security-Policy for frame-ancestors
|
||||
if (isBlockedByCSP(csp)) {
|
||||
return { embeddable: false, reason: "csp-frame-ancestors" };
|
||||
}
|
||||
|
||||
// Check Cross-Origin-Embedder-Policy
|
||||
if (isBlockedByCOEP(coep)) {
|
||||
return { embeddable: false, reason: "coep" };
|
||||
}
|
||||
|
||||
return { embeddable: true };
|
||||
} catch {
|
||||
// On timeout or network error, be optimistic and allow embedding
|
||||
return { embeddable: true, reason: "timeout" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a URL matches any of the embed patterns.
|
||||
*
|
||||
* @param url - The URL to check.
|
||||
* @param embedDescriptors - The list of embed descriptors to check against.
|
||||
* @returns True if the URL matches an embed pattern with `matchOnInput` enabled.
|
||||
*/
|
||||
function isEmbedUrl(url: string, embedDescriptors: EmbedDescriptor[]): boolean {
|
||||
for (const embed of embedDescriptors) {
|
||||
if (!embed.matchOnInput) {
|
||||
continue;
|
||||
}
|
||||
if (embed.matcher(url)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* A regex pattern that matches URLs at the beginning of a line or as standalone content.
|
||||
* Matches http:// and https:// URLs.
|
||||
*/
|
||||
const bareUrlPattern = /^(https?:\/\/[^\s]+)$/;
|
||||
|
||||
/**
|
||||
* Converts bare URLs in markdown text to the embed-friendly link format `[url](url)`.
|
||||
* This allows the markdown parser to recognize them as embeds when they match
|
||||
* supported embed patterns (YouTube, Vimeo, etc.).
|
||||
*
|
||||
* Only URLs that match a known embed pattern with `matchOnInput` enabled will be converted.
|
||||
*
|
||||
* @param text - The markdown text to process.
|
||||
* @param embedDescriptors - Optional custom list of embed descriptors. Defaults to built-in embeds.
|
||||
* @returns The processed text with bare embed URLs converted to link format.
|
||||
*
|
||||
* @example
|
||||
* // Input:
|
||||
* "Check out this video:\n\nhttps://www.youtube.com/watch?v=dQw4w9WgXcQ\n\nPretty cool!"
|
||||
*
|
||||
* // Output:
|
||||
* "Check out this video:\n\n[https://www.youtube.com/watch?v=dQw4w9WgXcQ](https://www.youtube.com/watch?v=dQw4w9WgXcQ)\n\nPretty cool!"
|
||||
*/
|
||||
export function convertBareUrlsToEmbedMarkdown(
|
||||
text: string,
|
||||
embedDescriptors: EmbedDescriptor[] = embeds
|
||||
): string {
|
||||
const lines = text.split("\n");
|
||||
|
||||
return lines
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// Check if the line is a bare URL
|
||||
const match = trimmed.match(bareUrlPattern);
|
||||
if (!match) {
|
||||
return line;
|
||||
}
|
||||
|
||||
const url = match[1];
|
||||
|
||||
// Only convert if the URL matches a known embed pattern
|
||||
if (isEmbedUrl(url, embedDescriptors)) {
|
||||
// Preserve leading whitespace from the original line
|
||||
const leadingWhitespace = line.match(/^(\s*)/)?.[1] ?? "";
|
||||
return `${leadingWhitespace}[${url}](${url})`;
|
||||
}
|
||||
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
@@ -534,6 +534,7 @@
|
||||
"Keep as link": "Keep as link",
|
||||
"Mention": "Mention",
|
||||
"Embed": "Embed",
|
||||
"Not supported": "Not supported",
|
||||
"More options": "More options",
|
||||
"Rename": "Rename",
|
||||
"Insert after": "Insert after",
|
||||
|
||||
Reference in New Issue
Block a user