Compare commits

...

9 Commits

Author SHA1 Message Date
Tom Moor ecd4e9371f fix: Overflow on math blocks 2025-04-21 16:18:43 -03:00
Tom Moor 2b07f412e2 fix: Image caption is not correctly centered on full-width image (#9013) 2025-04-18 19:31:36 -04:00
Hemachandar 65bb3b11f3 fix: Parse emoji and url only as workspace icon (#9009)
* fix: Parse emoji and url only as workspace icon

* scope emoji regex to transform function
2025-04-18 10:45:17 -04:00
Tom Moor e1e334dd5f fix: Deleted users appear in mention menu before search query (#9003) 2025-04-17 22:57:43 -04:00
codegen-sh[bot] 6e9092bcaf #8962: Remove "Self hosted" integrations page (#9001)
* #8962: Remove "Self hosted" integrations page

* Remove unused BuildingBlocksIcon import

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-04-17 08:34:08 -04:00
Tom Moor 09a4b76aae fix: Users subscribed to document and collection may be notified twice (#8997)
fix: Create notifications in transaction
2025-04-17 08:08:09 -04:00
Hemachandar 5789d65bf5 Ensure iframely fallback is not executed for connected unfurl integration (#8995)
* Ensure iframely fallback is not executed for connected unfurl integration

* tsc
2025-04-16 18:22:51 -04:00
Tom Moor 03a0f54236 fix: Cannot drag-select text while editing document title in sidebar (#8991)
* fix: Cannot drag-select text while editing document title in sidebar

* Clarify isEditing parameter description
2025-04-16 18:22:43 -04:00
Tom Moor 1e7244c737 fix: Infinite loop loading page with vbnet code embed (#8987) 2025-04-16 01:51:57 +00:00
19 changed files with 95 additions and 197 deletions
@@ -148,7 +148,12 @@ function InnerDocumentLink(
const color = document?.color || node.color;
// Draggable
const [{ isDragging }, drag] = useDragDocument(node, depth, document);
const [{ isDragging }, drag] = useDragDocument(
node,
depth,
document,
isEditing
);
// Drop to re-parent
const parentRef = React.useRef<HTMLDivElement>(null);
@@ -270,6 +275,8 @@ function InnerDocumentLink(
<div ref={dropToReparent}>
<DropToImport documentId={node.id} activeClassName="activeDropZone">
<SidebarLink
// @ts-expect-error react-router type is wrong, string component is fine.
component={isEditing ? "div" : undefined}
expanded={hasChildren ? isExpanded : undefined}
onDisclosureClick={handleDisclosureClick}
onClickIntent={handlePrefetch}
@@ -285,6 +292,7 @@ function InnerDocumentLink(
<EditableTitle
title={title}
onSubmit={handleTitleChange}
isEditing={isEditing}
onEditing={setIsEditing}
canUpdate={canUpdate}
maxLength={DocumentValidation.maxTitleLength}
+12 -6
View File
@@ -39,6 +39,7 @@ export interface Props extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
location?: Location;
strict?: boolean;
to: LocationDescriptor;
component?: React.ComponentType;
onBeforeClick?: () => void;
}
@@ -146,17 +147,22 @@ const NavLink = ({
setPreActive(undefined);
}, [currentLocation]);
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLAnchorElement>) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
},
[navigateTo]
);
return (
<Link
key={isActive ? "active" : "inactive"}
ref={linkRef}
onClick={handleClick}
onKeyDown={(event) => {
if (["Enter", " "].includes(event.key)) {
navigateTo();
event.currentTarget?.blur();
}
}}
onKeyDown={handleKeyDown}
aria-current={(isActive && ariaCurrent) || undefined}
className={className}
style={style}
@@ -166,11 +166,13 @@ export function useDropToReorderStar(getIndex?: () => string) {
* @param node The NavigationNode model to drag.
* @param depth The depth of the node in the sidebar.
* @param document The related Document model.
* @param isEditing Whether the sidebar item is currently being edited.
*/
export function useDragDocument(
node: NavigationNode,
depth: number,
document?: Document
document?: Document,
isEditing?: boolean
) {
const icon = document?.icon || node.icon || node.emoji;
const color = document?.color || node.color;
@@ -188,7 +190,7 @@ export function useDragDocument(
icon: icon ? <Icon value={icon} color={color} /> : undefined,
collectionId: document?.collectionId || "",
} as DragObject),
canDrag: () => !!document?.isActive,
canDrag: () => !!document?.isActive && !isEditing,
collect: (monitor) => ({
isDragging: monitor.isDragging(),
}),
-10
View File
@@ -8,7 +8,6 @@ import {
GlobeIcon,
TeamIcon,
BeakerIcon,
BuildingBlocksIcon,
SettingsIcon,
ExportIcon,
ImportIcon,
@@ -40,7 +39,6 @@ const Notifications = lazy(() => import("~/scenes/Settings/Notifications"));
const Preferences = lazy(() => import("~/scenes/Settings/Preferences"));
const Profile = lazy(() => import("~/scenes/Settings/Profile"));
const Security = lazy(() => import("~/scenes/Settings/Security"));
const SelfHosted = lazy(() => import("~/scenes/Settings/SelfHosted"));
const Shares = lazy(() => import("~/scenes/Settings/Shares"));
const Templates = lazy(() => import("~/scenes/Settings/Templates"));
const Zapier = lazy(() => import("~/scenes/Settings/Zapier"));
@@ -177,14 +175,6 @@ const useSettingsConfig = () => {
icon: ExportIcon,
},
// Integrations
{
name: t("Self Hosted"),
path: integrationSettingsPath("self-hosted"),
component: SelfHosted,
enabled: can.update && !isCloudHosted,
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
-140
View File
@@ -1,140 +0,0 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import { BuildingBlocksIcon } from "outline-icons";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { IntegrationService, IntegrationType } from "@shared/types";
import Integration from "~/models/Integration";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import useStores from "~/hooks/useStores";
import SettingRow from "./components/SettingRow";
type FormData = {
drawIoUrl: string;
gristUrl: string;
};
function SelfHosted() {
const { integrations } = useStores();
const { t } = useTranslation();
const integrationDiagrams = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
}) as Integration<IntegrationType.Embed> | undefined;
const integrationGrist = find(integrations.orderedData, {
type: IntegrationType.Embed,
service: IntegrationService.Grist,
}) as Integration<IntegrationType.Embed> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Embed,
});
}, [integrations]);
React.useEffect(() => {
reset({
drawIoUrl: integrationDiagrams?.settings.url,
gristUrl: integrationGrist?.settings.url,
});
}, [integrationDiagrams, integrationGrist, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.drawIoUrl) {
await integrations.save({
id: integrationDiagrams?.id,
type: IntegrationType.Embed,
service: IntegrationService.Diagrams,
settings: {
url: data.drawIoUrl,
},
});
} else {
await integrationDiagrams?.delete();
}
if (data.gristUrl) {
await integrations.save({
id: integrationGrist?.id,
type: IntegrationType.Embed,
service: IntegrationService.Grist,
settings: {
url: data.gristUrl,
},
});
} else {
await integrationGrist?.delete();
}
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integrationDiagrams, integrationGrist, t]
);
return (
<Scene title={t("Self Hosted")} icon={<BuildingBlocksIcon />}>
<Heading>{t("Self Hosted")}</Heading>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Draw.io deployment")}
name="drawIoUrl"
description={t(
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents."
)}
border={false}
>
<Input
placeholder="https://app.diagrams.net/"
pattern="https?://.*"
{...register("drawIoUrl")}
/>
</SettingRow>
<SettingRow
label={t("Grist deployment")}
name="gristUrl"
description={t("Add your self-hosted grist installation URL here.")}
border={false}
>
<Input
placeholder="https://docs.getgrist.com/"
pattern="https?://.*"
{...register("gristUrl")}
/>
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(SelfHosted);
+8 -1
View File
@@ -99,7 +99,14 @@ export default abstract class Store<T extends Model> {
const normalized = deburr((query ?? "").trim().toLocaleLowerCase());
if (!normalized) {
return this.orderedData.slice(0, options?.maxResults);
return this.orderedData
.filter((item) => {
if ("deletedAt" in item && item.deletedAt) {
return false;
}
return true;
})
.slice(0, options?.maxResults);
}
return this.orderedData
+2 -1
View File
@@ -377,5 +377,6 @@
"rollup": "^4.5.1",
"prismjs": "1.30.0"
},
"version": "0.83.0"
"version": "0.83.0",
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+2 -2
View File
@@ -206,12 +206,12 @@ export class GitHub {
);
const { data } = await client.requestResource(resource);
if (!data) {
return;
return { error: "Resource not found" };
}
return { ...data, type: resource.type };
} catch (err) {
Logger.warn("Failed to fetch resource from GitHub", err);
return;
return { error: err.message || "Unknown error" };
}
};
}
+6 -4
View File
@@ -1,6 +1,6 @@
import { JSONObject, UnfurlResourceType } from "@shared/types";
import Logger from "@server/logging/Logger";
import { UnfurlSignature } from "@server/types";
import { UnfurlError, UnfurlSignature } from "@server/types";
import fetch from "@server/utils/fetch";
import env from "./env";
@@ -10,7 +10,7 @@ class Iframely {
public static async requestResource(
url: string,
type = "oembed"
): Promise<JSONObject | undefined> {
): Promise<JSONObject | UnfurlError> {
const isDefaultHost = env.IFRAMELY_URL === this.defaultUrl;
// Cloud Iframely requires /api path, while self-hosted does not.
@@ -25,7 +25,7 @@ class Iframely {
return await res.json();
} catch (err) {
Logger.error(`Error fetching data from Iframely for url: ${url}`, err);
return;
return { error: err.message || "Unknown error" };
}
}
@@ -36,7 +36,9 @@ class Iframely {
*/
public static unfurl: UnfurlSignature = async (url: string) => {
const data = await Iframely.requestResource(url);
return { ...data, type: UnfurlResourceType.OEmbed };
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.OEmbed };
};
}
+12 -1
View File
@@ -13,9 +13,11 @@ import {
RichTextItemResponse,
} from "@notionhq/client/build/src/api-endpoints";
import { RateLimit } from "async-sema";
import emojiRegex from "emoji-regex";
import compact from "lodash/compact";
import { z } from "zod";
import { Second } from "@shared/utils/time";
import { isUrl } from "@shared/utils/urls";
import { NotionUtils } from "../shared/NotionUtils";
import { Block, Page, PageType } from "../shared/types";
import env from "./env";
@@ -37,7 +39,16 @@ const AccessTokenResponseSchema = z.object({
bot_id: z.string(),
workspace_id: z.string(),
workspace_name: z.string().nullish(),
workspace_icon: z.string().url().nullish(),
workspace_icon: z
.string()
.nullish()
.transform((val) => {
const emojiRegexp = emojiRegex();
if (val && (isUrl(val) || emojiRegexp.test(val))) {
return val;
}
return undefined;
}),
});
export class NotionClient {
@@ -251,6 +251,10 @@ describe("NotificationHelper", () => {
userId: subscribedUser.id,
collectionId: document.collectionId!,
});
await buildSubscription({
userId: subscribedUser.id,
documentId: document.id,
});
const recipients =
await NotificationHelper.getDocumentNotificationRecipients({
+9 -7
View File
@@ -1,4 +1,5 @@
import uniq from "lodash/uniq";
import uniqBy from "lodash/uniqBy";
import { Op } from "sequelize";
import {
NotificationEventType,
@@ -187,7 +188,6 @@ export default class NotificationHelper {
});
} else {
const subscriptions = await Subscription.findAll({
attributes: ["userId"],
where: {
userId: {
[Op.ne]: actorId,
@@ -206,17 +206,19 @@ export default class NotificationHelper {
],
});
recipients = subscriptions.map((s) => s.user);
recipients = uniqBy(
subscriptions.map((s) => s.user),
(user) => user.id
);
}
recipients = recipients.filter((recipient) =>
recipient.subscribedToEventType(notificationType)
);
const filtered = [];
for (const recipient of recipients) {
if (recipient.isSuspended) {
if (
recipient.isSuspended ||
!recipient.subscribedToEventType(notificationType)
) {
continue;
}
@@ -85,16 +85,21 @@ export default class CommentCreatedNotificationsTask extends BaseTask<CommentEve
)
).filter((recipient) => !userIdsMentioned.includes(recipient.id));
for (const recipient of recipients) {
await Notification.create({
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
});
}
await sequelize.transaction(async (transaction) => {
for (const recipient of recipients) {
await Notification.create(
{
event: NotificationEventType.CreateComment,
userId: recipient.id,
actorId: comment.createdById,
teamId: document.teamId,
commentId: comment.id,
documentId: document.id,
},
{ transaction }
);
}
});
}
public get options() {
+1 -1
View File
@@ -20,7 +20,7 @@ jest.mock("dns", () => ({
jest
.spyOn(Iframely, "requestResource")
.mockImplementation(() => Promise.resolve(undefined));
.mockImplementation(() => Promise.resolve({}));
const server = getTestServer();
+4 -3
View File
@@ -98,11 +98,12 @@ router.post(
}
for (const plugin of plugins) {
const data = await plugin.value.unfurl(url, actor);
if (data) {
if ("error" in data) {
const unfurl = await plugin.value.unfurl(url, actor);
if (unfurl) {
if (unfurl.error) {
return (ctx.response.status = 204);
} else {
const data = unfurl as Unfurl;
await CacheHelper.setData(
CacheHelper.getUnfurlKey(actor.teamId, url),
data,
+3 -1
View File
@@ -578,10 +578,12 @@ export type CollectionJSONExport = {
export type Unfurl = { [x: string]: JSONValue; type: UnfurlResourceType };
export type UnfurlError = { error: string };
export type UnfurlSignature = (
url: string,
actor?: User
) => Promise<Unfurl | void>;
) => Promise<Unfurl | UnfurlError | undefined>;
export type UninstallSignature = (integration: Integration) => Promise<void>;
+3 -1
View File
@@ -128,6 +128,7 @@ const mathStyle = (props: Props) => css`
math-block .math-src .ProseMirror {
width: 100%;
display: block;
outline: none;
}
math-block .katex-display {
@@ -334,7 +335,7 @@ width: 100%;
box-sizing: content-box;
}
.ProseMirror {
& > .ProseMirror {
position: relative;
outline: none;
word-wrap: break-word;
@@ -707,6 +708,7 @@ img.ProseMirror-separator {
resize: none;
user-select: text;
margin: 0 auto !important;
max-width: 100vw;
}
.ProseMirror[contenteditable="false"] {
+1 -1
View File
@@ -279,7 +279,7 @@ export const codeLanguages: Record<string, CodeLanguage> = {
loader: () => import("refractor/lang/typescript").then((m) => m.default),
},
vb: {
lang: "vb",
lang: "vbnet",
label: "Visual Basic",
loader: () => import("refractor/lang/vbnet").then((m) => m.default),
},
@@ -520,7 +520,6 @@
"Groups": "Groups",
"Shared Links": "Shared Links",
"Import": "Import",
"Self Hosted": "Self Hosted",
"Integrations": "Integrations",
"Revoke token": "Revoke token",
"Revoke": "Revoke",
@@ -1045,10 +1044,6 @@
"Allow editors to create new collections within the workspace": "Allow editors to create new collections within the workspace",
"Workspace creation": "Workspace creation",
"Allow editors to create new workspaces": "Allow editors to create new workspaces",
"Draw.io deployment": "Draw.io deployment",
"Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.": "Add your self-hosted draw.io installation url here to enable automatic embedding of diagrams within documents.",
"Grist deployment": "Grist deployment",
"Add your self-hosted grist installation URL here.": "Add your self-hosted grist installation URL here.",
"Could not load shares": "Could not load shares",
"Sharing is currently disabled.": "Sharing is currently disabled.",
"You can globally enable and disable public document sharing in the <em>security settings</em>.": "You can globally enable and disable public document sharing in the <em>security settings</em>.",