mirror of
https://github.com/outline/outline.git
synced 2026-06-13 19:35:02 +03:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bee7911bee | |||
| 86714a353f | |||
| fd5391cbb6 | |||
| 6e685ee8d9 | |||
| b595a0d427 | |||
| 1c86119065 | |||
| c629006642 | |||
| 326f733d4c | |||
| d4d683c046 | |||
| 8204ac343f | |||
| cae8de7c7a | |||
| 8efa601967 | |||
| 86c3ea8e9d | |||
| c222782534 | |||
| 19ea7ee52b | |||
| d1de84a07e |
@@ -39,6 +39,7 @@ function DocumentCard(props: Props) {
|
||||
const { collections } = useStores();
|
||||
const theme = useTheme();
|
||||
const { document, pin, canUpdatePin, isDraggable } = props;
|
||||
const pinnedToHome = React.useRef(!pin?.collectionId).current;
|
||||
const collection = document.collectionId
|
||||
? collections.get(document.collectionId)
|
||||
: undefined;
|
||||
@@ -122,13 +123,13 @@ function DocumentCard(props: Props) {
|
||||
<Squircle
|
||||
color={
|
||||
collection?.color ??
|
||||
(!pin?.collectionId ? theme.slateLight : theme.slateDark)
|
||||
(pinnedToHome ? theme.slateLight : theme.slateDark)
|
||||
}
|
||||
>
|
||||
{collection?.icon &&
|
||||
collection?.icon !== "letter" &&
|
||||
collection?.icon !== "collection" &&
|
||||
!pin?.collectionId ? (
|
||||
pinnedToHome ? (
|
||||
<CollectionIcon collection={collection} color="white" />
|
||||
) : (
|
||||
<DocumentIcon color="white" />
|
||||
|
||||
@@ -47,14 +47,16 @@ export default function LanguagePrompt() {
|
||||
<br />
|
||||
<Link
|
||||
onClick={async () => {
|
||||
ui.setLanguagePromptDismissed();
|
||||
ui.set({ languagePromptDismissed: true });
|
||||
await user.save({ language });
|
||||
}}
|
||||
>
|
||||
{t("Change Language")}
|
||||
</Link>{" "}
|
||||
·{" "}
|
||||
<Link onClick={ui.setLanguagePromptDismissed}>{t("Dismiss")}</Link>
|
||||
<Link onClick={() => ui.set({ languagePromptDismissed: true })}>
|
||||
{t("Dismiss")}
|
||||
</Link>
|
||||
</span>
|
||||
</Flex>
|
||||
</Wrapper>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { ReactionIcon } from "outline-icons";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { PopoverDisclosure, usePopoverState } from "reakit";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
@@ -11,6 +10,7 @@ import Popover from "~/components/Popover";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import useOnClickOutside from "~/hooks/useOnClickOutside";
|
||||
import useWindowSize from "~/hooks/useWindowSize";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
const EmojiPanel = React.lazy(
|
||||
() => import("~/components/IconPicker/components/EmojiPanel")
|
||||
@@ -98,15 +98,22 @@ const ReactionPicker: React.FC<Props> = ({
|
||||
<>
|
||||
<PopoverDisclosure {...popover}>
|
||||
{(props) => (
|
||||
<PopoverButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
<Tooltip
|
||||
content={t("Add reaction")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</PopoverButton>
|
||||
<NudeButton
|
||||
{...props}
|
||||
aria-label={t("Reaction picker")}
|
||||
className={className}
|
||||
onClick={handlePopoverButtonClick}
|
||||
size={size}
|
||||
>
|
||||
<ReactionIcon size={22} />
|
||||
</NudeButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</PopoverDisclosure>
|
||||
<Popover
|
||||
@@ -151,8 +158,4 @@ const Placeholder = React.memo(
|
||||
);
|
||||
Placeholder.displayName = "ReactionPickerPlaceholder";
|
||||
|
||||
const PopoverButton = styled(NudeButton)`
|
||||
border-radius: 50%;
|
||||
`;
|
||||
|
||||
export default ReactionPicker;
|
||||
|
||||
@@ -32,13 +32,13 @@ function Right({ children, border, className }: Props) {
|
||||
Math.min(window.innerWidth - event.pageX, maxWidth),
|
||||
minWidth
|
||||
);
|
||||
ui.setRightSidebarWidth(width);
|
||||
ui.set({ sidebarRightWidth: width });
|
||||
},
|
||||
[minWidth, maxWidth, ui]
|
||||
);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setRightSidebarWidth(theme.sidebarRightWidth);
|
||||
ui.set({ sidebarRightWidth: theme.sidebarRightWidth });
|
||||
}, [ui, theme.sidebarRightWidth]);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
|
||||
@@ -46,7 +46,6 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const maxWidth = theme.sidebarMaxWidth;
|
||||
const minWidth = theme.sidebarMinWidth + 16; // padding
|
||||
|
||||
const setWidth = ui.setSidebarWidth;
|
||||
const [offset, setOffset] = React.useState(0);
|
||||
const [isHovering, setHovering] = React.useState(false);
|
||||
const [isAnimating, setAnimating] = React.useState(false);
|
||||
@@ -62,13 +61,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
const width = Math.min(event.pageX - offset, maxWidth);
|
||||
const isSmallerThanCollapsePoint = width < minWidth / 2;
|
||||
|
||||
if (isSmallerThanCollapsePoint) {
|
||||
setWidth(theme.sidebarCollapsedWidth);
|
||||
} else {
|
||||
setWidth(width);
|
||||
}
|
||||
ui.set({
|
||||
sidebarWidth: isSmallerThanCollapsePoint
|
||||
? theme.sidebarCollapsedWidth
|
||||
: width,
|
||||
});
|
||||
},
|
||||
[theme, offset, minWidth, maxWidth, setWidth]
|
||||
[ui, theme, offset, minWidth, maxWidth]
|
||||
);
|
||||
|
||||
const handleStopDrag = React.useCallback(() => {
|
||||
@@ -86,13 +85,13 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
setCollapsing(true);
|
||||
ui.collapseSidebar();
|
||||
} else {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setAnimating(true);
|
||||
}
|
||||
} else {
|
||||
setWidth(width);
|
||||
ui.set({ sidebarWidth: width });
|
||||
}
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
|
||||
}, [ui, isSmallerThanMinimum, minWidth, width]);
|
||||
|
||||
const handleBlur = React.useCallback(() => {
|
||||
setHovering(false);
|
||||
@@ -149,11 +148,11 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
React.useEffect(() => {
|
||||
if (isCollapsing) {
|
||||
setTimeout(() => {
|
||||
setWidth(minWidth);
|
||||
ui.set({ sidebarWidth: minWidth });
|
||||
setCollapsing(false);
|
||||
}, ANIMATION_MS);
|
||||
}
|
||||
}, [setWidth, minWidth, isCollapsing]);
|
||||
}, [ui, minWidth, isCollapsing]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isResizing) {
|
||||
@@ -174,7 +173,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
|
||||
}, [isResizing, handleDrag, handleBlur, handleStopDrag]);
|
||||
|
||||
const handleReset = React.useCallback(() => {
|
||||
ui.setSidebarWidth(theme.sidebarWidth);
|
||||
ui.set({ sidebarWidth: theme.sidebarWidth });
|
||||
}, [ui, theme.sidebarWidth]);
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -248,14 +248,19 @@ export default class FindAndReplaceExtension extends Extension {
|
||||
let m;
|
||||
const search = this.findRegExp;
|
||||
|
||||
while ((m = search.exec(deburr(text)))) {
|
||||
// We construct a string with the text stripped of diacritics plus the original text for
|
||||
// search allowing to search for diacritics-insensitive matches easily.
|
||||
while ((m = search.exec(deburr(text) + text))) {
|
||||
if (m[0] === "") {
|
||||
break;
|
||||
}
|
||||
|
||||
// Reconstruct the correct match position
|
||||
const i = m.index > text.length ? m.index - text.length : m.index;
|
||||
|
||||
this.results.push({
|
||||
from: pos + m.index,
|
||||
to: pos + m.index + m[0].length,
|
||||
from: pos + i,
|
||||
to: pos + i + m[0].length,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { differenceInMilliseconds } from "date-fns";
|
||||
import { action } from "mobx";
|
||||
import { observer } from "mobx-react";
|
||||
import { DoneIcon } from "outline-icons";
|
||||
import { darken } from "polished";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -16,10 +17,14 @@ import Comment from "~/models/Comment";
|
||||
import { Avatar } from "~/components/Avatar";
|
||||
import ButtonSmall from "~/components/ButtonSmall";
|
||||
import Flex from "~/components/Flex";
|
||||
import NudeButton from "~/components/NudeButton";
|
||||
import ReactionList from "~/components/Reactions/ReactionList";
|
||||
import ReactionPicker from "~/components/Reactions/ReactionPicker";
|
||||
import Text from "~/components/Text";
|
||||
import Time from "~/components/Time";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { resolveCommentFactory } from "~/actions/definitions/comments";
|
||||
import useActionContext from "~/hooks/useActionContext";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import CommentMenu from "~/menus/CommentMenu";
|
||||
@@ -242,11 +247,13 @@ function CommentThreadItem({
|
||||
onRemoveReaction={handleRemoveReaction}
|
||||
picker={
|
||||
!comment.isResolved ? (
|
||||
<StyledReactionPicker
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
size={28}
|
||||
rounded
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
@@ -257,14 +264,20 @@ function CommentThreadItem({
|
||||
<EventBoundary>
|
||||
{!isEditing && (
|
||||
<Actions gap={4} dir={dir}>
|
||||
{firstOfThread && (
|
||||
<ResolveButton onUpdate={handleUpdate} comment={comment} />
|
||||
)}
|
||||
{!comment.isResolved && (
|
||||
<StyledReactionPicker
|
||||
<Action
|
||||
as={ReactionPicker}
|
||||
onSelect={handleAddReaction}
|
||||
onOpen={disableScroll}
|
||||
onClose={enableScroll}
|
||||
rounded
|
||||
/>
|
||||
)}
|
||||
<StyledMenu
|
||||
<Action
|
||||
as={CommentMenu}
|
||||
comment={comment}
|
||||
onEdit={setEditing}
|
||||
onDelete={handleDelete}
|
||||
@@ -278,6 +291,38 @@ function CommentThreadItem({
|
||||
);
|
||||
}
|
||||
|
||||
const ResolveButton = ({
|
||||
comment,
|
||||
onUpdate,
|
||||
}: {
|
||||
comment: Comment;
|
||||
onUpdate: (attrs: { resolved: boolean }) => void;
|
||||
}) => {
|
||||
const context = useActionContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={t("Mark as resolved")}
|
||||
placement="top"
|
||||
delay={500}
|
||||
hideOnClick
|
||||
>
|
||||
<Action
|
||||
as={NudeButton}
|
||||
context={context}
|
||||
action={resolveCommentFactory({
|
||||
comment,
|
||||
onResolve: () => onUpdate({ resolved: true }),
|
||||
})}
|
||||
rounded
|
||||
>
|
||||
<DoneIcon size={22} outline />
|
||||
</Action>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const StyledCommentEditor = styled(CommentEditor)`
|
||||
${(props) =>
|
||||
!props.readOnly &&
|
||||
@@ -308,25 +353,13 @@ const Body = styled.form`
|
||||
border-radius: 2px;
|
||||
`;
|
||||
|
||||
const StyledMenu = styled(CommentMenu)`
|
||||
color: ${s("textSecondary")};
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&: ${hover}, &[aria-expanded= "true"] {
|
||||
background: ${s("backgroundQuaternary")};
|
||||
|
||||
svg {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledReactionPicker = styled(ReactionPicker)`
|
||||
const Action = styled.span<{ rounded?: boolean }>`
|
||||
color: ${s("textSecondary")};
|
||||
${(props) =>
|
||||
props.rounded &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
|
||||
svg {
|
||||
fill: currentColor;
|
||||
@@ -352,7 +385,7 @@ const Actions = styled(Flex)<{ dir?: "rtl" | "ltr" }>`
|
||||
background: ${s("backgroundSecondary")};
|
||||
padding-left: 4px;
|
||||
|
||||
&:has(${StyledReactionPicker}[aria-expanded="true"], ${StyledMenu}[aria-expanded="true"]) {
|
||||
&:has(${Action}[aria-expanded="true"]) {
|
||||
opacity: 1;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -103,6 +103,10 @@ function DocumentHeader({
|
||||
});
|
||||
}, [onSave]);
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
ui.set({ tocVisible: !ui.tocVisible });
|
||||
}, [ui]);
|
||||
|
||||
const context = useActionContext({
|
||||
activeDocumentId: document?.id,
|
||||
});
|
||||
@@ -129,7 +133,7 @@ function DocumentHeader({
|
||||
placement="bottom"
|
||||
>
|
||||
<Button
|
||||
onClick={showContents ? ui.hideTableOfContents : ui.showTableOfContents}
|
||||
onClick={handleToggle}
|
||||
icon={<TableOfContentsIcon />}
|
||||
borderOnHover
|
||||
neutral
|
||||
@@ -180,7 +184,7 @@ function DocumentHeader({
|
||||
|
||||
useKeyDown(
|
||||
(event) => event.ctrlKey && event.altKey && event.key === "˙",
|
||||
ui.tocVisible ? ui.hideTableOfContents : ui.showTableOfContents,
|
||||
handleToggle,
|
||||
{
|
||||
allowInInput: true,
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ function Invite({ onSubmit }: Props) {
|
||||
<Trans>{{ collectionCount }} collections</Trans>
|
||||
</strong>
|
||||
</Tooltip>
|
||||
.
|
||||
.{" "}
|
||||
</span>
|
||||
) : undefined;
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ function PeopleTable({ canManage, ...rest }: Props) {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "isAdmin",
|
||||
id: "role",
|
||||
Header: t("Role"),
|
||||
accessor: "rank",
|
||||
Cell: observer(({ row }: { row: { original: User } }) => (
|
||||
|
||||
+9
-20
@@ -7,31 +7,19 @@ import { CustomTheme } from "@shared/types";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
|
||||
import RootStore from "~/stores/RootStore";
|
||||
import Policy from "~/models/Policy";
|
||||
import Team from "~/models/Team";
|
||||
import User from "~/models/User";
|
||||
import env from "~/env";
|
||||
import { setPostLoginPath } from "~/hooks/useLastVisitedPath";
|
||||
import { PartialExcept } from "~/types";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
import Logger from "~/utils/Logger";
|
||||
import isCloudHosted from "~/utils/isCloudHosted";
|
||||
import Store from "./base/Store";
|
||||
|
||||
type PersistedData = {
|
||||
user?: PartialExcept<User, "id">;
|
||||
team?: PartialExcept<Team, "id">;
|
||||
collaborationToken?: string;
|
||||
availableTeams?: {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
url: string;
|
||||
isSignedIn: boolean;
|
||||
}[];
|
||||
policies?: Policy[];
|
||||
};
|
||||
type PersistedData = Pick<
|
||||
AuthStore,
|
||||
"user" | "team" | "collaborationToken" | "availableTeams" | "policies"
|
||||
>;
|
||||
|
||||
type Provider = {
|
||||
id: string;
|
||||
@@ -165,9 +153,10 @@ export default class AuthStore extends Store<Team> {
|
||||
/** The current team's policies */
|
||||
@computed
|
||||
get policies() {
|
||||
return this.currentTeamId
|
||||
? [this.rootStore.policies.get(this.currentTeamId)]
|
||||
: [];
|
||||
const policy = this.currentTeamId
|
||||
? this.rootStore.policies.get(this.currentTeamId)
|
||||
: undefined;
|
||||
return policy ? [policy] : [];
|
||||
}
|
||||
|
||||
/** Whether the user is signed in */
|
||||
@@ -177,7 +166,7 @@ export default class AuthStore extends Store<Team> {
|
||||
}
|
||||
|
||||
@computed
|
||||
get asJson() {
|
||||
get asJson(): PersistedData {
|
||||
return {
|
||||
user: this.user,
|
||||
team: this.team,
|
||||
|
||||
+30
-43
@@ -1,4 +1,4 @@
|
||||
import { action, autorun, computed, observable } from "mobx";
|
||||
import { action, computed, observable } from "mobx";
|
||||
import { flushSync } from "react-dom";
|
||||
import { light as defaultTheme } from "@shared/styles/theme";
|
||||
import Storage from "@shared/utils/Storage";
|
||||
@@ -23,15 +23,16 @@ export enum SystemTheme {
|
||||
Dark = "dark",
|
||||
}
|
||||
|
||||
type PersistedData = {
|
||||
languagePromptDismissed: boolean | undefined;
|
||||
theme: Theme;
|
||||
sidebarCollapsed: boolean;
|
||||
sidebarWidth: number;
|
||||
sidebarRightWidth: number;
|
||||
tocVisible: boolean | undefined;
|
||||
commentsExpanded: string[];
|
||||
};
|
||||
type PersistedData = Pick<
|
||||
UiStore,
|
||||
| "languagePromptDismissed"
|
||||
| "commentsExpanded"
|
||||
| "theme"
|
||||
| "sidebarWidth"
|
||||
| "sidebarRightWidth"
|
||||
| "sidebarCollapsed"
|
||||
| "tocVisible"
|
||||
>;
|
||||
|
||||
class UiStore {
|
||||
// has the user seen the prompt to change the UI language and actioned it
|
||||
@@ -134,10 +135,6 @@ class UiStore {
|
||||
this.tocVisible = newData.tocVisible;
|
||||
}
|
||||
});
|
||||
|
||||
autorun(() => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
});
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -147,12 +144,7 @@ class UiStore {
|
||||
this.theme = theme;
|
||||
});
|
||||
});
|
||||
Storage.set("theme", this.theme);
|
||||
};
|
||||
|
||||
@action
|
||||
setLanguagePromptDismissed = () => {
|
||||
this.languagePromptDismissed = true;
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -205,25 +197,24 @@ class UiStore {
|
||||
this.activeCollectionId = undefined;
|
||||
};
|
||||
|
||||
@action
|
||||
setSidebarWidth = (width: number): void => {
|
||||
this.sidebarWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
setRightSidebarWidth = (width: number): void => {
|
||||
this.sidebarRightWidth = width;
|
||||
};
|
||||
|
||||
@action
|
||||
collapseSidebar = () => {
|
||||
this.sidebarCollapsed = true;
|
||||
this.set({ sidebarCollapsed: true });
|
||||
};
|
||||
|
||||
@action
|
||||
expandSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.sidebarCollapsed = false;
|
||||
this.set({ sidebarCollapsed: false });
|
||||
};
|
||||
|
||||
@action
|
||||
set = (data: Partial<PersistedData>) => {
|
||||
for (const key in data) {
|
||||
// @ts-expect-error doesn't understand PersistedData is subset of keys
|
||||
this[key] = data[key];
|
||||
}
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -231,6 +222,7 @@ class UiStore {
|
||||
this.commentsExpanded = this.commentsExpanded.filter(
|
||||
(id) => id !== documentId
|
||||
);
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -238,6 +230,7 @@ class UiStore {
|
||||
if (!this.commentsExpanded.includes(documentId)) {
|
||||
this.commentsExpanded.push(documentId);
|
||||
}
|
||||
this.persist();
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -252,17 +245,7 @@ class UiStore {
|
||||
@action
|
||||
toggleCollapsedSidebar = () => {
|
||||
sidebarHidden = false;
|
||||
this.sidebarCollapsed = !this.sidebarCollapsed;
|
||||
};
|
||||
|
||||
@action
|
||||
showTableOfContents = () => {
|
||||
this.tocVisible = true;
|
||||
};
|
||||
|
||||
@action
|
||||
hideTableOfContents = () => {
|
||||
this.tocVisible = false;
|
||||
this.set({ sidebarCollapsed: !this.sidebarCollapsed });
|
||||
};
|
||||
|
||||
@action
|
||||
@@ -324,6 +307,10 @@ class UiStore {
|
||||
theme: this.theme,
|
||||
};
|
||||
}
|
||||
|
||||
private persist = () => {
|
||||
Storage.set(UI_STORE, this.asJson);
|
||||
};
|
||||
}
|
||||
|
||||
export default UiStore;
|
||||
|
||||
+9
-8
@@ -48,11 +48,11 @@
|
||||
"> 0.25%, not dead"
|
||||
],
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "3.616.0",
|
||||
"@aws-sdk/lib-storage": "3.616.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.616.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.616.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.616.0",
|
||||
"@aws-sdk/client-s3": "3.693.0",
|
||||
"@aws-sdk/lib-storage": "3.693.0",
|
||||
"@aws-sdk/s3-presigned-post": "3.693.0",
|
||||
"@aws-sdk/s3-request-presigner": "3.693.0",
|
||||
"@aws-sdk/signature-v4-crt": "^3.693.0",
|
||||
"@babel/core": "^7.24.7",
|
||||
"@babel/plugin-proposal-decorators": "^7.24.7",
|
||||
"@babel/plugin-transform-class-properties": "^7.24.7",
|
||||
@@ -79,11 +79,11 @@
|
||||
"@hocuspocus/server": "1.1.2",
|
||||
"@joplin/turndown-plugin-gfm": "^1.0.49",
|
||||
"@juggle/resize-observer": "^3.4.0",
|
||||
"@octokit/auth-app": "^6.1.2",
|
||||
"@octokit/auth-app": "^6.1.3",
|
||||
"@outlinewiki/koa-passport": "^4.2.1",
|
||||
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
|
||||
"@renderlesskit/react": "^0.11.0",
|
||||
"@sentry/node": "^7.117.0",
|
||||
"@sentry/node": "^7.119.0",
|
||||
"@sentry/react": "^7.119.0",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/form-data": "^2.5.0",
|
||||
@@ -208,6 +208,7 @@
|
||||
"react-waypoint": "^10.3.0",
|
||||
"react-window": "^1.8.10",
|
||||
"reakit": "^1.3.11",
|
||||
"redlock": "^5.0.0-beta.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"refractor": "^3.6.0",
|
||||
"request-filtering-agent": "^1.1.2",
|
||||
@@ -254,7 +255,7 @@
|
||||
"@babel/cli": "^7.25.9",
|
||||
"@babel/preset-typescript": "^7.24.1",
|
||||
"@faker-js/faker": "^8.4.1",
|
||||
"@relative-ci/agent": "^4.2.12",
|
||||
"@relative-ci/agent": "^4.2.13",
|
||||
"@testing-library/react": "^12.0.0",
|
||||
"@types/addressparser": "^1.0.3",
|
||||
"@types/body-scroll-lock": "^3.1.2",
|
||||
|
||||
@@ -51,6 +51,15 @@ export default abstract class BaseEmail<
|
||||
* @returns A promise that resolves once the email is placed on the task queue
|
||||
*/
|
||||
public schedule(options?: Bull.JobOptions) {
|
||||
// No-op to schedule emails if SMTP is not configured
|
||||
if (!env.SMTP_FROM_EMAIL) {
|
||||
Logger.info(
|
||||
"email",
|
||||
`Email ${this.constructor.name} not sent due to missing SMTP configuration`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = this.constructor.name;
|
||||
|
||||
Metrics.increment("email.scheduled", {
|
||||
|
||||
@@ -7,6 +7,7 @@ import HTMLHelper from "@server/models/helpers/HTMLHelper";
|
||||
import NotificationSettingsHelper from "@server/models/helpers/NotificationSettingsHelper";
|
||||
import SubscriptionHelper from "@server/models/helpers/SubscriptionHelper";
|
||||
import { can } from "@server/policies";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import BaseEmail, { EmailMessageCategory, EmailProps } from "./BaseEmail";
|
||||
import Body from "./components/Body";
|
||||
import Button from "./components/Button";
|
||||
@@ -68,21 +69,28 @@ export default class DocumentPublishedOrUpdatedEmail extends BaseEmail<
|
||||
|
||||
let body;
|
||||
if (revisionId && team?.getPreference(TeamPreference.PreviewsInEmails)) {
|
||||
// generate the diff html for the email
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
body = await CacheHelper.getDataOrSet<string>(
|
||||
`diff:${revisionId}`,
|
||||
async () => {
|
||||
// generate the diff html for the email
|
||||
const revision = await Revision.findByPk(revisionId);
|
||||
|
||||
if (revision) {
|
||||
const before = await revision.before();
|
||||
const content = await DocumentHelper.toEmailDiff(before, revision, {
|
||||
includeTitle: false,
|
||||
centered: false,
|
||||
signedUrls: 4 * Day.seconds,
|
||||
baseUrl: props.teamUrl,
|
||||
});
|
||||
if (revision) {
|
||||
const before = await revision.before();
|
||||
const content = await DocumentHelper.toEmailDiff(before, revision, {
|
||||
includeTitle: false,
|
||||
centered: false,
|
||||
signedUrls: 4 * Day.seconds,
|
||||
baseUrl: props.teamUrl,
|
||||
});
|
||||
|
||||
// inline all css so that it works in as many email providers as possible.
|
||||
body = content ? await HTMLHelper.inlineCSS(content) : undefined;
|
||||
}
|
||||
// inline all css so that it works in as many email providers as possible.
|
||||
return content ? await HTMLHelper.inlineCSS(content) : undefined;
|
||||
}
|
||||
return;
|
||||
},
|
||||
30
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -558,7 +558,7 @@ export class ProsemirrorHelper {
|
||||
// Inject Mermaid script
|
||||
if (mermaidElements.length) {
|
||||
element.innerHTML = `
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@9/dist/mermaid.esm.min.mjs';
|
||||
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
|
||||
mermaid.initialize({
|
||||
startOnLoad: true,
|
||||
fontFamily: "inherit",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Day } from "@shared/utils/time";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import Redis from "@server/storage/redis";
|
||||
import { MutexLock } from "./MutexLock";
|
||||
|
||||
/**
|
||||
* A Helper class for server-side cache management
|
||||
@@ -9,6 +10,54 @@ export class CacheHelper {
|
||||
// Default expiry time for cache data in seconds
|
||||
private static defaultDataExpiry = Day.seconds;
|
||||
|
||||
/**
|
||||
* Given a key this method will attempt to get the data from cache store first
|
||||
* If data is not found, it will call the callback to get the data and save it in cache
|
||||
* using a distributed lock to prevent multiple writes.
|
||||
*
|
||||
* @param key Cache key
|
||||
* @param callback Callback to get the data if not found in cache
|
||||
* @param expiry Cache data expiry in seconds
|
||||
*/
|
||||
public static async getDataOrSet<T>(
|
||||
key: string,
|
||||
callback: () => Promise<T | undefined>,
|
||||
expiry?: number
|
||||
): Promise<T | undefined> {
|
||||
let cache = await this.getData<T>(key);
|
||||
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Nothing in the cache, acquire a lock to prevent multiple writes
|
||||
let lock;
|
||||
const lockKey = `lock:${key}`;
|
||||
try {
|
||||
try {
|
||||
lock = await MutexLock.lock.acquire(
|
||||
[lockKey],
|
||||
MutexLock.defaultLockTimeout
|
||||
);
|
||||
} catch (err) {
|
||||
Logger.error(`Could not acquire lock for ${key}`, err);
|
||||
}
|
||||
cache = await this.getData<T>(key);
|
||||
if (cache) {
|
||||
return cache;
|
||||
}
|
||||
|
||||
// Get the data from the callback and save it in cache
|
||||
const value = await callback();
|
||||
if (value) {
|
||||
await this.setData<T>(key, value, expiry);
|
||||
}
|
||||
return value;
|
||||
} finally {
|
||||
await lock?.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a key, gets the data from cache store
|
||||
*
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Redlock from "redlock";
|
||||
import Redis from "@server/storage/redis";
|
||||
|
||||
export class MutexLock {
|
||||
// Default expiry time for qcuiring lock in milliseconds
|
||||
public static defaultLockTimeout = 5000;
|
||||
|
||||
/**
|
||||
* Returns the redlock instance
|
||||
*/
|
||||
public static get lock(): Redlock {
|
||||
this.redlock ??= new Redlock([Redis.defaultClient], {
|
||||
retryJitter: 10,
|
||||
});
|
||||
|
||||
return this.redlock;
|
||||
}
|
||||
|
||||
private static redlock: Redlock;
|
||||
}
|
||||
@@ -1310,7 +1310,9 @@ mark {
|
||||
}
|
||||
|
||||
.ProseMirror[contenteditable="false"] .code-block[data-language=mermaidjs] {
|
||||
display: none;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
margin: -0.5em 0 0 0;
|
||||
}
|
||||
|
||||
.code-block.with-line-numbers {
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
"{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}": "{{ firstUsername }} and {{ secondUsername }} reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}": "{{ firstUsername }} and {{ count }} other reacted with {{ emoji }}",
|
||||
"{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}_plural": "{{ firstUsername }} and {{ count }} others reacted with {{ emoji }}",
|
||||
"Add reaction": "Add reaction",
|
||||
"Reaction picker": "Reaction picker",
|
||||
"Could not load reactions": "Could not load reactions",
|
||||
"Reaction": "Reaction",
|
||||
|
||||
+1
-1
@@ -159,7 +159,7 @@ export default () =>
|
||||
build: {
|
||||
outDir: "./build/app",
|
||||
manifest: true,
|
||||
sourcemap: true,
|
||||
sourcemap: process.env.CI ? false : "hidden",
|
||||
minify: "terser",
|
||||
// Prevent asset inling as it does not conform to CSP rules
|
||||
assetsInlineLimit: 0,
|
||||
|
||||
Reference in New Issue
Block a user