Compare commits

..

79 Commits

Author SHA1 Message Date
Tom Moor 8e9beac59f test 2023-08-08 23:12:41 -04:00
Tom Moor a0f7c76405 Add support for SSL in development 2023-08-08 22:46:31 -04:00
dependabot[bot] 454a4e9a8d chore(deps): bump y-indexeddb from 9.0.9 to 9.0.11 (#5656)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2023-08-07 15:08:18 -07:00
dependabot[bot] ef9c410d97 chore(deps-dev): bump terser from 5.18.2 to 5.19.2 (#5658)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-07 14:56:21 -07:00
dependabot[bot] 7c2f779f68 chore(deps): bump fs-extra from 11.1.0 to 11.1.1 (#5657)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-08-07 14:56:04 -07:00
Tom Moor c45de6904b chore: Upgrade dd-trace 2023-08-07 17:54:54 -04:00
Tom Moor 4758778fc7 test 2023-08-06 13:10:50 -04:00
Apoorv Mishra 401ae73a04 Request validation for /api/collections.* (#5619) 2023-08-06 09:54:13 -07:00
Apoorv Mishra 0ddbd9c608 Calculate HoverPreview position inside useLayoutEffect (#5636) 2023-08-06 09:00:05 -07:00
Tom Moor 6c4e2a9d11 perf: Narrow scopes of Slack hook queries 2023-08-06 11:54:48 -04:00
Tom Moor d8f1f55a80 fix: type is optional input for integrations.list endpoint 2023-08-06 11:09:22 -04:00
Tom Moor 9b811c999d fix: Cannot exit code block with mod-enter shortcut with edit mode enabled 2023-08-05 19:45:54 -04:00
Tom Moor 5a60329021 fix: Unable to access document without reload after 24h+ session 2023-08-05 08:24:37 -04:00
Tom Moor 042ea7b61f Misc fixes from qa pass (#5650) 2023-08-04 20:40:36 -07:00
Tom Moor 80acc16791 fix: Badge's do not correctly use accent text color 2023-08-04 08:46:05 -04:00
Tom Moor 3c25b2b047 Merge branch 'main' of github.com:outline/outline 2023-08-04 08:45:15 -04:00
Adrien Ballet 16f1328a83 Added syntax highlighting for the Verilog and VHDL languages (#5641) 2023-08-03 20:26:41 -07:00
Tom Moor d1a7a30c00 fix: Closing find and replace on long document jumps to end 2023-08-03 22:41:49 -04:00
Tom Moor 5b67273d8f fix: Account for older desktop versions 2023-08-03 21:10:36 -04:00
Tom Moor fdd8ecc79d Add find and replace hooks for desktop app 2023-08-03 20:46:03 -04:00
Tom Moor 7c15d03b50 fix: Crash with some find characters
fix: Warning on close of find dialog
2023-08-03 19:32:09 -04:00
Tom Moor b691311f88 feat: Add find and replace interface (#5642) 2023-08-03 15:47:44 -07:00
Tom Moor eda023c908 Restore code blocks in notices 2023-08-01 21:42:19 -04:00
Apoorv Mishra 2331bbbd36 Request validation for /api/integrations.* (#5638) 2023-08-01 18:17:01 -07:00
Tom Moor 228d1faa9f feat: Add Czech translations, remove Russian translations 2023-08-01 19:43:33 -04:00
Tom Moor ff6d30581a New Crowdin updates (#5637) 2023-08-01 16:29:14 -07:00
Apoorv Mishra 027545a768 Close hover preview upon scroll (#5629) 2023-07-31 15:08:14 -07:00
Tom Moor 91585ee09d feat: Add tracking pixel to notifications for mark-as-read functionality (#5626) 2023-07-31 15:01:50 -07:00
Tom Moor a13f2c7311 New Crowdin updates (#5593) 2023-07-31 15:01:40 -07:00
dependabot[bot] d4a51b420f chore(deps-dev): bump vite-plugin-static-copy from 0.13.0 to 0.17.0 (#5631)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-31 15:01:20 -07:00
dependabot[bot] faa02623b3 chore(deps-dev): bump concurrently from 7.4.0 to 7.6.0 (#5632)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-31 14:59:31 -07:00
dependabot[bot] 2baf4d7d8b chore(deps): bump patch-package from 7.0.0 to 7.0.2 (#5630)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-31 14:59:23 -07:00
dependabot[bot] 2b21ac1b97 chore(deps-dev): bump @types/markdown-it-container from 2.0.5 to 2.0.6 (#5634)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-31 14:58:59 -07:00
Tom Moor d2fcd1dee6 fix: Skip unsupported node types when uploading
closes #5544
2023-07-30 15:52:08 -04:00
Tom Moor 3b43460a0a fix: Restrict content in notices, closes #5624 2023-07-29 23:42:58 -04:00
Tom Moor 1864ed605f fix: Allow copy code block to clipboard in read-only
closes #5614
2023-07-29 23:24:50 -04:00
Tom Moor 20932a08d0 fix: Selection jumps when dragging and selection ends outside editor bounds 2023-07-29 23:04:21 -04:00
Tom Moor 7e1ea69939 fix: Transparent thumbnails show document behind in hover previews 2023-07-29 22:51:14 -04:00
Tom Moor a3983c36c9 fix: Do not use CDN image component for hover card previews 2023-07-29 22:33:23 -04:00
Tom Moor ccdcda372f chore: Move last usage of sequelize.transaction to middleware 2023-07-29 22:30:26 -04:00
Tom Moor 07ad5032b4 Protect usage of navigator 2023-07-29 21:56:31 -04:00
Tom Moor 286aea2701 fix: Improve empty state for math blocks 2023-07-29 21:22:52 -04:00
Tom Moor 30e63e022c fix: Improve empty state for mermaid diagrams 2023-07-29 21:12:55 -04:00
Tom Moor b88670b58d fix: Improve emoji trigger for french language
closes #5611
2023-07-29 20:58:33 -04:00
Apoorv Mishra ddc883bfcd Preview arbitrary urls within a document (#5598) 2023-07-29 16:51:49 -07:00
Tom Moor 67691477a9 fix: Hover card timer should reset on url change 2023-07-29 19:51:22 -04:00
Apoorv Mishra e3807a1c75 fix: tests 2023-07-26 21:40:34 +05:30
Apoorv Mishra f95ce018e1 perf: cache response 2023-07-26 18:26:39 +05:30
Apoorv Mishra 2201fd7bd6 fix: description chopping and some cleanup 2023-07-26 13:08:43 +05:30
Apoorv Mishra fbb793ab8e fix: styles 2023-07-25 23:16:53 +05:30
Apoorv Mishra 31f8a3fb44 fix: hover card styling 2023-07-25 19:58:35 +05:30
Apoorv Mishra 03ebca2f0c fix: no overloading 2023-07-25 19:35:31 +05:30
Apoorv Mishra 2a17e0cbf6 fix: send user context for authorize calls 2023-07-25 19:35:31 +05:30
Apoorv Mishra 9ac1e13227 fix: just return 204 2023-07-25 19:35:31 +05:30
Apoorv Mishra bd0240b7a5 fix: handle errors from Iframely 2023-07-25 19:35:31 +05:30
Apoorv Mishra 81bd68380e feat: preview arbitrary url 2023-07-25 19:35:31 +05:30
Apoorv Mishra b3d8bd1cc8 cleanup: separate info and description 2023-07-25 19:35:31 +05:30
Apoorv Mishra a30487c2d7 fix: presentUnfurl 2023-07-25 19:35:31 +05:30
Apoorv Mishra 8b3c58a162 fix: coalesce to empty str 2023-07-25 19:35:30 +05:30
Apoorv Mishra 43a91626b2 feat: pipe external urls through iframely 2023-07-25 19:35:30 +05:30
Tom Moor 15c8a4867f fix: Text caret not placed in new math block after creation
fix: Excessive padding on inline math node
2023-07-25 00:04:14 -04:00
Tom Moor d94caf2783 fix: Missing translation for Slack hook 2023-07-24 23:41:34 -04:00
Tom Moor aaeb6f7dc6 fix: Flash of incorrect cursor when hover preview opens 2023-07-24 23:39:27 -04:00
Tom Moor e0289aed40 chore: Enable recommended React eslint rules (#5600) 2023-07-24 18:23:54 -07:00
dependabot[bot] 8865d394c6 chore(deps): bump @joplin/turndown-plugin-gfm from 1.0.47 to 1.0.49 (#5602)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-24 18:23:30 -07:00
dependabot[bot] 1d893a06f9 chore(deps): bump i18next from 22.5.0 to 22.5.1 (#5604)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-24 17:54:23 -07:00
dependabot[bot] 0c291ee806 chore(deps): bump styled-components and @types/styled-components (#5601)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-24 17:53:55 -07:00
dependabot[bot] 8732155dbb chore(deps-dev): bump @typescript-eslint/parser from 5.60.1 to 5.62.0 (#5605)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-24 17:51:13 -07:00
dependabot[bot] 56e01b784d chore(deps): bump sequelize from 6.29.0 to 6.32.1 (#5603)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-24 17:50:52 -07:00
Tom Moor e47d493d13 fix: Remove absolute positiioning of insights management, closes #5599 2023-07-24 08:28:35 -04:00
Tom Moor 2677c964a5 chore: Improve constraints on file_operations table 2023-07-23 19:51:42 -04:00
Tom Moor f8927ff819 tsc 2023-07-23 17:50:33 -04:00
j0ok34n 72adcd10ef Comment fix
- Workspace administrators will not be able to delete or edit comments within private collections for which they do not have permissions.
- Users will not be able to delete or modify their comments if they have been removed from a private collection.
2023-07-23 15:57:20 -04:00
Tom Moor 7bc37cb700 tsc 2023-07-23 13:11:02 -04:00
Tom Moor 217e53d8b6 fix: Enable toggling of insights while document is draft 2023-07-23 13:06:34 -04:00
Tom Moor 404f5ff871 Merge branch 'main' of github.com:outline/outline 2023-07-23 12:01:54 -04:00
Apoorv Mishra 0db6f39f43 Display correct info in hover preview (#5597) 2023-07-23 09:01:46 -07:00
Tom Moor 479b805613 Add per-document control over who can see viewer insights (#5594) 2023-07-23 09:01:36 -07:00
Tom Moor 48f1047016 chore: improve collections router 2023-07-22 16:39:47 -04:00
185 changed files with 5604 additions and 3316 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ REDIS_URL=redis://localhost:6379
# URL should point to the fully qualified, publicly accessible URL. If using a
# proxy the port in URL and PORT may be different.
URL=http://localhost:3000
URL=https://app.outline.dev:3000
PORT=3000
# See [documentation](docs/SERVICES.md) on running a separate collaboration
+1
View File
@@ -1,5 +1,6 @@
up:
docker-compose up -d redis postgres s3
yarn install-local-ssl
yarn install --pure-lockfile
yarn dev:watch
+1
View File
@@ -1,6 +1,7 @@
{
"extends": [
"../.eslintrc",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
],
"plugins": [
+6 -3
View File
@@ -61,8 +61,11 @@ export const openDocument = createAction({
// cache if the document is renamed
id: path.url,
name: path.title,
icon: () =>
stores.documents.get(path.id)?.isStarred ? <StarredIcon /> : null,
icon: function _Icon() {
return stores.documents.get(path.id)?.isStarred ? (
<StarredIcon />
) : null;
},
section: DocumentSection,
perform: () => history.push(path.url),
}));
@@ -159,7 +162,7 @@ export const publishDocument = createAction({
}
if (document?.collectionId) {
await document.save({
await document.save(undefined, {
publish: true,
});
stores.toasts.showToast(t("Document published"), {
+3 -2
View File
@@ -43,8 +43,9 @@ export const changeTheme = createAction({
isContextMenu ? t("Appearance") : t("Change theme"),
analyticsName: "Change theme",
placeholder: ({ t }) => t("Change theme to"),
icon: () =>
stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />,
icon: function _Icon() {
return stores.ui.resolvedTheme === "light" ? <SunIcon /> : <MoonIcon />;
},
keywords: "appearance display",
section: SettingsSection,
children: [changeToLightTheme, changeToDarkTheme, changeToSystemTheme],
+14 -12
View File
@@ -16,18 +16,20 @@ export const createTeamsList = ({ stores }: { stores: RootStore }) =>
analyticsName: "Switch workspace",
section: TeamSection,
keywords: "change switch workspace organization team",
icon: () => (
<StyledTeamLogo
alt={session.name}
model={{
initial: session.name[0],
avatarUrl: session.avatarUrl,
id: session.id,
color: stringToColor(session.id),
}}
size={24}
/>
),
icon: function _Icon() {
return (
<StyledTeamLogo
alt={session.name}
model={{
initial: session.name[0],
avatarUrl: session.avatarUrl,
id: session.id,
color: stringToColor(session.id),
}}
size={24}
/>
);
},
visible: ({ currentTeamId }: ActionContext) => currentTeamId !== session.id,
perform: () => (window.location.href = session.url),
})) ?? [];
+5 -4
View File
@@ -1,8 +1,9 @@
/* eslint-disable react/prop-types */
import * as React from "react";
import Tooltip, { Props as TooltipProps } from "~/components/Tooltip";
import { Action, ActionContext } from "~/types";
export type Props = React.ComponentPropsWithoutRef<"button"> & {
export type Props = React.HTMLAttributes<HTMLButtonElement> & {
/** Show the button in a disabled state */
disabled?: boolean;
/** Hide the button entirely if action is not applicable */
@@ -18,11 +19,11 @@ export type Props = React.ComponentPropsWithoutRef<"button"> & {
/**
* Button that can be used to trigger an action definition.
*/
const ActionButton = React.forwardRef(
(
const ActionButton = React.forwardRef<HTMLButtonElement, Props>(
function _ActionButton(
{ action, context, tooltip, hideOnActionDisabled, ...rest }: Props,
ref: React.Ref<HTMLButtonElement>
) => {
) {
const [executing, setExecuting] = React.useState(false);
const disabled = rest.disabled;
+5 -1
View File
@@ -5,7 +5,11 @@ import * as React from "react";
import { IntegrationService } from "@shared/types";
import env from "~/env";
const Analytics: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3
React.useEffect(() => {
if (!env.GOOGLE_ANALYTICS_ID?.startsWith("UA-")) {
+5 -1
View File
@@ -37,7 +37,11 @@ const DocumentInsights = lazyWithRetry(
);
const CommandBar = lazyWithRetry(() => import("~/components/CommandBar"));
const AuthenticatedLayout: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const AuthenticatedLayout: React.FC = ({ children }: Props) => {
const { ui, auth } = useStores();
const location = useLocation();
const can = usePolicy(ui.activeCollectionId);
+5 -1
View File
@@ -7,7 +7,11 @@ const Badge = styled.span<{ yellow?: boolean; primary?: boolean }>`
background-color: ${({ yellow, primary, theme }) =>
yellow ? theme.yellow : primary ? theme.accent : "transparent"};
color: ${({ primary, yellow, theme }) =>
primary ? theme.white : yellow ? theme.almostBlack : theme.textTertiary};
primary
? theme.accentText
: yellow
? theme.almostBlack
: theme.textTertiary};
border: 1px solid
${({ primary, yellow, theme }) =>
primary || yellow
+2 -1
View File
@@ -3,6 +3,7 @@ import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
type Props = {
children?: React.ReactNode;
withStickyHeader?: boolean;
};
@@ -26,7 +27,7 @@ const Content = styled.div`
`};
`;
const CenteredContent: React.FC<Props> = ({ children, ...rest }) => (
const CenteredContent: React.FC<Props> = ({ children, ...rest }: Props) => (
<Container {...rest}>
<Content>{children}</Content>
</Container>
+5 -1
View File
@@ -52,7 +52,11 @@ function CommandBar() {
);
}
const KBarPortal: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const KBarPortal: React.FC = ({ children }: Props) => {
const { showing } = useKBar((state) => ({
showing: state.visualState !== "hidden",
}));
+2 -1
View File
@@ -17,6 +17,7 @@ type Props = {
danger?: boolean;
/** Keep the submit button disabled */
disabled?: boolean;
children?: React.ReactNode;
};
const ConfirmationDialog: React.FC<Props> = ({
@@ -26,7 +27,7 @@ const ConfirmationDialog: React.FC<Props> = ({
savingText,
danger,
disabled = false,
}) => {
}: Props) => {
const [isSaving, setIsSaving] = React.useState(false);
const { dialogs } = useStores();
const { showToast } = useToasts();
+125 -131
View File
@@ -30,144 +30,138 @@ export type RefHandle = {
* Defines a content editable component with the same interface as a native
* HTMLInputElement (or, as close as we can get).
*/
const ContentEditable = React.forwardRef(
(
{
disabled,
onChange,
onInput,
onBlur,
onKeyDown,
value,
children,
className,
maxLength,
autoFocus,
placeholder,
readOnly,
dir,
onClick,
...rest
}: Props,
ref: React.RefObject<RefHandle>
) => {
const contentRef = React.useRef<HTMLSpanElement>(null);
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef(value);
const ContentEditable = React.forwardRef(function _ContentEditable(
{
disabled,
onChange,
onInput,
onBlur,
onKeyDown,
value,
children,
className,
maxLength,
autoFocus,
placeholder,
readOnly,
dir,
onClick,
...rest
}: Props,
ref: React.RefObject<RefHandle>
) {
const contentRef = React.useRef<HTMLSpanElement>(null);
const [innerValue, setInnerValue] = React.useState<string>(value);
const lastValue = React.useRef(value);
React.useImperativeHandle(ref, () => ({
focus: () => {
if (contentRef.current) {
contentRef.current.focus();
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
if (!contentRef.current.innerText) {
placeCaret(contentRef.current, true);
}
}
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
React.useImperativeHandle(ref, () => ({
focus: () => {
if (contentRef.current) {
contentRef.current.focus();
// looks unnecessary but required because of https://github.com/outline/outline/issues/5198
if (!contentRef.current.innerText) {
placeCaret(contentRef.current, true);
}
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
const wrappedEvent =
(
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) =>
(event: any) => {
if (readOnly) {
return;
}
const text = event.currentTarget.textContent || "";
if (
maxLength &&
isPrintableKeyEvent(event) &&
text.length >= maxLength
) {
event?.preventDefault();
return;
}
if (text !== lastValue.current) {
lastValue.current = text;
onChange?.(text);
}
callback?.(event);
};
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
setInnerValue(value);
},
focusAtStart: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, true);
}
}, [value, contentRef]);
},
focusAtEnd: () => {
if (contentRef.current) {
contentRef.current.focus();
placeCaret(contentRef.current, false);
}
},
getComputedDirection: () => {
if (contentRef.current) {
return window.getComputedStyle(contentRef.current).direction;
}
return "ltr";
},
}));
// Ensure only plain text can be pasted into input when pasting from another
// rich text source. Note: If `onPaste` prop is passed then it takes
// priority over this behavior.
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
const wrappedEvent =
(
callback:
| React.FocusEventHandler<HTMLSpanElement>
| React.FormEventHandler<HTMLSpanElement>
| React.KeyboardEventHandler<HTMLSpanElement>
| undefined
) =>
(event: any) => {
if (readOnly) {
return;
}
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
{...rest}
>
{innerValue}
</Content>
{children}
</div>
);
}
);
const text = event.currentTarget.textContent || "";
if (maxLength && isPrintableKeyEvent(event) && text.length >= maxLength) {
event?.preventDefault();
return;
}
if (text !== lastValue.current) {
lastValue.current = text;
onChange?.(text);
}
callback?.(event);
};
// This is to account for being within a React.Suspense boundary, in this
// case the component may be rendered with display: none. React 18 may solve
// this in the future by delaying useEffect hooks:
// https://github.com/facebook/react/issues/14536#issuecomment-861980492
const isVisible = useOnScreen(contentRef);
React.useEffect(() => {
if (autoFocus && isVisible && !disabled && !readOnly) {
contentRef.current?.focus();
}
}, [autoFocus, disabled, isVisible, readOnly, contentRef]);
React.useEffect(() => {
if (contentRef.current && value !== contentRef.current.textContent) {
setInnerValue(value);
}
}, [value, contentRef]);
// Ensure only plain text can be pasted into input when pasting from another
// rich text source. Note: If `onPaste` prop is passed then it takes
// priority over this behavior.
const handlePaste = React.useCallback(
(event: React.ClipboardEvent<HTMLSpanElement>) => {
event.preventDefault();
const text = event.clipboardData.getData("text/plain");
window.document.execCommand("insertText", false, text);
},
[]
);
return (
<div className={className} dir={dir} onClick={onClick}>
<Content
ref={contentRef}
contentEditable={!disabled && !readOnly}
onInput={wrappedEvent(onInput)}
onBlur={wrappedEvent(onBlur)}
onKeyDown={wrappedEvent(onKeyDown)}
onPaste={handlePaste}
data-placeholder={placeholder}
suppressContentEditableWarning
role="textbox"
{...rest}
>
{innerValue}
</Content>
{children}
</div>
);
});
function placeCaret(element: HTMLElement, atStart: boolean) {
if (
+2 -1
View File
@@ -22,6 +22,7 @@ type Props = {
level?: number;
icon?: React.ReactElement;
children?: React.ReactNode;
ref?: React.LegacyRef<HTMLButtonElement> | undefined;
};
const MenuItem = (
@@ -80,7 +81,7 @@ const MenuItem = (
</MenuAnchor>
);
},
[active, as, hide, icon, onClick, ref, selected]
[active, as, hide, icon, onClick, ref, children, selected]
);
return (
+28 -30
View File
@@ -44,37 +44,35 @@ type SubMenuProps = MenuStateReturn & {
title: React.ReactNode;
};
const SubMenu = React.forwardRef(
(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) => {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState();
const SubMenu = React.forwardRef(function _Template(
{ templateItems, title, parentMenuState, ...rest }: SubMenuProps,
ref: React.LegacyRef<HTMLButtonElement>
) {
const { t } = useTranslation();
const theme = useTheme();
const menu = useMenuState();
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
}
);
return (
<>
<MenuButton ref={ref} {...menu} {...rest}>
{(props) => (
<MenuAnchor disclosure {...props}>
{title} <Disclosure color={theme.textTertiary} />
</MenuAnchor>
)}
</MenuButton>
<ContextMenu
{...menu}
aria-label={t("Submenu")}
onClick={parentMenuState.hide}
parentMenuState={parentMenuState}
>
<MouseSafeArea parentRef={menu.unstable_popoverRef} />
<Template {...menu} items={templateItems} />
</ContextMenu>
</>
);
});
export function filterTemplateItems(items: TMenuItem[]): TMenuItem[] {
return items
+2 -1
View File
@@ -46,6 +46,7 @@ type Props = MenuStateReturn & {
onClose?: () => void;
/** Called when the context menu is clicked. */
onClick?: (ev: React.MouseEvent) => void;
children?: React.ReactNode;
};
const ContextMenu: React.FC<Props> = ({
@@ -54,7 +55,7 @@ const ContextMenu: React.FC<Props> = ({
onClose,
parentMenuState,
...rest
}) => {
}: Props) => {
const previousVisible = usePrevious(rest.visible);
const maxHeight = useMenuHeight({
visible: rest.visible,
+7 -2
View File
@@ -17,6 +17,7 @@ import {
} from "~/utils/routeHelpers";
type Props = {
children?: React.ReactNode;
document: Document;
onlyText?: boolean;
};
@@ -58,7 +59,7 @@ const DocumentBreadcrumb: React.FC<Props> = ({
document,
children,
onlyText,
}) => {
}: Props) => {
const { collections } = useStores();
const { t } = useTranslation();
const category = useCategory(document);
@@ -129,7 +130,11 @@ const DocumentBreadcrumb: React.FC<Props> = ({
);
}
return <Breadcrumb items={items} children={children} highlightFirstItem />;
return (
<Breadcrumb items={items} highlightFirstItem>
{children}
</Breadcrumb>
);
};
const SmallSlash = styled(GoToIcon)`
+15 -10
View File
@@ -335,16 +335,21 @@ function DocumentExplorer({ onSubmit, onSelect, items }: Props) {
const innerElementType = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ style, ...rest }, ref) => (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
));
>(function innerElementType(
{ style, ...rest }: React.HTMLAttributes<HTMLDivElement>,
ref
) {
return (
<div
ref={ref}
style={{
...style,
height: `${parseFloat(style?.height + "") + VERTICAL_PADDING * 2}px`,
}}
{...rest}
/>
);
});
return (
<Container tabIndex={-1} onKeyDown={handleKeyDown}>
+2 -1
View File
@@ -15,6 +15,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import useStores from "~/hooks/useStores";
type Props = {
children?: React.ReactNode;
showCollection?: boolean;
showPublished?: boolean;
showLastViewed?: boolean;
@@ -36,7 +37,7 @@ const DocumentMeta: React.FC<Props> = ({
replace,
to,
...rest
}) => {
}: Props) => {
const { t } = useTranslation();
const { collections } = useStores();
const user = useCurrentUser();
+2 -2
View File
@@ -1,8 +1,8 @@
import { formatDistanceToNow } from "date-fns";
import { sortBy } from "lodash";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
@@ -53,7 +53,7 @@ function DocumentViews({ document, isOpen }: Props) {
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }} ago", {
timeAgo: formatDistanceToNow(
timeAgo: dateToRelative(
view ? Date.parse(view.lastViewedAt) : new Date()
),
});
+7 -2
View File
@@ -1,4 +1,3 @@
import { formatDistanceToNow } from "date-fns";
import { deburr, difference, sortBy } from "lodash";
import { observer } from "mobx-react";
import { DOMParser as ProsemirrorDOMParser } from "prosemirror-model";
@@ -10,6 +9,7 @@ import { Optional } from "utility-types";
import insertFiles from "@shared/editor/commands/insertFiles";
import { AttachmentPreset } from "@shared/types";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { getDataTransferFiles } from "@shared/utils/files";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isInternalUrl } from "@shared/utils/urls";
@@ -23,6 +23,7 @@ import useDictionary from "~/hooks/useDictionary";
import useEmbeds from "~/hooks/useEmbeds";
import useStores from "~/hooks/useStores";
import useToasts from "~/hooks/useToasts";
import useUserLocale from "~/hooks/useUserLocale";
import { NotFoundError } from "~/utils/errors";
import { uploadFile } from "~/utils/files";
import { isModKey } from "~/utils/keyboard";
@@ -60,6 +61,8 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
onCreateCommentMark,
onDeleteCommentMark,
} = props;
const userLocale = useUserLocale();
const locale = dateLocale(userLocale);
const { auth, comments, documents } = useStores();
const { showToast } = useToasts();
const dictionary = useDictionary();
@@ -92,8 +95,10 @@ function Editor(props: Props, ref: React.RefObject<SharedEditor> | null) {
try {
const document = await documents.fetch(slug);
const time = formatDistanceToNow(Date.parse(document.updatedAt), {
const time = dateToRelative(Date.parse(document.updatedAt), {
addSuffix: true,
shorten: true,
locale,
});
return [
+2 -1
View File
@@ -1,10 +1,11 @@
import * as React from "react";
type Props = {
children?: React.ReactNode;
className?: string;
};
const EventBoundary: React.FC<Props> = ({ children, className }) => {
const EventBoundary: React.FC<Props> = ({ children, className }: Props) => {
const handleClick = React.useCallback((event: React.SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
+9 -8
View File
@@ -160,15 +160,16 @@ const EventListItem = ({ event, latest, document, ...rest }: Props) => {
);
};
const BaseItem = React.forwardRef(
({ to, ...rest }: ItemProps, ref?: React.Ref<HTMLAnchorElement>) => {
if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
return <ListItem ref={ref} {...rest} />;
const BaseItem = React.forwardRef(function _BaseItem(
{ to, ...rest }: ItemProps,
ref?: React.Ref<HTMLAnchorElement>
) {
if (to) {
return <CompositeListItem to={to} ref={ref} {...rest} />;
}
);
return <ListItem ref={ref} {...rest} />;
});
const Subtitle = styled.span`
svg {
+1 -1
View File
@@ -99,7 +99,7 @@ function ExportDialog({ collection, onSubmit }: Props) {
)}
<Flex gap={12} column>
{items.map((item) => (
<Option>
<Option key={item.value}>
<input
type="radio"
name="format"
+2 -1
View File
@@ -6,6 +6,7 @@ import Scrollable from "~/components/Scrollable";
import usePrevious from "~/hooks/usePrevious";
type Props = {
children?: React.ReactNode;
isOpen: boolean;
title?: string;
onRequestClose: () => void;
@@ -17,7 +18,7 @@ const Guide: React.FC<Props> = ({
title = "Untitled",
onRequestClose,
...rest
}) => {
}: Props) => {
const dialog = useDialogState({
animated: 250,
});
-25
View File
@@ -1,25 +0,0 @@
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
export const Preview = styled(Link)`
cursor: var(--pointer);
margin-bottom: 0;
${(props) => (!props.to ? "pointer-events: none;" : "")}
`;
export const Title = styled.h2`
font-size: 1.25em;
margin: 2px 0 0 0;
color: ${s("text")};
`;
export const Description = styled(Text)`
margin-bottom: 0;
padding-top: 2px;
`;
export const Summary = styled.div`
margin-top: 8px;
`;
+108
View File
@@ -0,0 +1,108 @@
import { transparentize } from "polished";
import { Link } from "react-router-dom";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Text from "~/components/Text";
export const CARD_MARGIN = 16;
const NUMBER_OF_LINES = 10;
const sharedVars = css`
--line-height: 1.6em;
`;
const StyledText = styled(Text)`
margin-bottom: 0;
`;
export const Preview = styled(Link)`
cursor: ${(props: any) =>
props.as === "div" ? "default" : "var(--pointer)"};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
position: absolute;
min-width: 350px;
max-width: 375px;
`;
export const Title = styled.h2`
font-size: 1.25em;
margin: 0;
color: ${s("text")};
`;
export const Info = styled(StyledText).attrs(() => ({
type: "tertiary",
size: "xsmall",
}))`
white-space: nowrap;
`;
export const Description = styled(StyledText)`
${sharedVars}
margin-top: 0.5em;
line-height: var(--line-height);
max-height: calc(var(--line-height) * ${NUMBER_OF_LINES});
`;
export const Thumbnail = styled.img`
object-fit: cover;
height: 200px;
background: ${s("menuBackground")};
`;
export const CardContent = styled.div`
overflow: hidden;
user-select: none;
`;
// &:after — gradient mask for overflow text
export const Card = styled.div<{ fadeOut?: boolean; $borderRadius?: string }>`
backdrop-filter: blur(10px);
background: ${s("menuBackground")};
padding: 16px;
font-size: 0.9em;
position: relative;
.placeholder,
.heading-anchor {
display: none;
}
// fills the gap between the card and pointer to avoid a dead zone
&::before {
content: "";
position: absolute;
top: -10px;
left: 0;
right: 0;
height: 10px;
}
${(props) =>
props.fadeOut !== false
? `&:after {
${sharedVars}
content: "";
display: block;
position: absolute;
pointer-events: none;
background: linear-gradient(
90deg,
${transparentize(1, props.theme.menuBackground)} 0%,
${transparentize(1, props.theme.menuBackground)} 75%,
${props.theme.menuBackground} 90%
);
bottom: 0;
left: 0;
right: 0;
height: var(--line-height);
border-bottom: 16px solid ${props.theme.menuBackground};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}`
: ""}
`;
+72 -107
View File
@@ -1,24 +1,24 @@
import { transparentize } from "polished";
import { m } from "framer-motion";
import * as React from "react";
import { Portal } from "react-portal";
import styled from "styled-components";
import { depths, s } from "@shared/styles";
import { UnfurlType } from "@shared/types";
import LoadingIndicator from "~/components/LoadingIndicator";
import useEventListener from "~/hooks/useEventListener";
import useKeyDown from "~/hooks/useKeyDown";
import useMobile from "~/hooks/useMobile";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import { fadeAndSlideDown } from "~/styles/animations";
import { client } from "~/utils/ApiClient";
import { CARD_MARGIN } from "./Components";
import HoverPreviewDocument from "./HoverPreviewDocument";
import HoverPreviewLink from "./HoverPreviewLink";
import HoverPreviewMention from "./HoverPreviewMention";
const DELAY_OPEN = 300;
const DELAY_CLOSE = 600;
const CARD_PADDING = 16;
const CARD_MAX_WIDTH = 375;
type Props = {
/* The HTML element that is being hovered over */
@@ -34,6 +34,35 @@ function HoverPreviewInternal({ element, onClose }: Props) {
const timerOpen = React.useRef<ReturnType<typeof setTimeout>>();
const cardRef = React.useRef<HTMLDivElement>(null);
const stores = useStores();
const [cardLeft, setCardLeft] = React.useState(0);
const [cardTop, setCardTop] = React.useState(0);
const [pointerOffset, setPointerOffset] = React.useState(0);
React.useLayoutEffect(() => {
if (isVisible && cardRef.current) {
const elem = element.getBoundingClientRect();
const card = cardRef.current.getBoundingClientRect();
const top = elem.bottom + window.scrollY;
setCardTop(top);
let left = elem.left;
let pointerOffset = elem.width / 2;
if (left + card.width > window.innerWidth) {
// shift card leftwards by the amount it went out of screen
let shiftBy = left + card.width - window.innerWidth;
// shift a littler further to leave some margin between card and window boundary
shiftBy += CARD_MARGIN;
left -= shiftBy;
// shift pointer rightwards by same amount so as to position it back correctly
pointerOffset += shiftBy;
}
setCardLeft(left);
setPointerOffset(pointerOffset);
}
}, [isVisible, element]);
const { data, request, loading } = useRequest(
React.useCallback(
@@ -49,6 +78,8 @@ function HoverPreviewInternal({ element, onClose }: Props) {
React.useEffect(() => {
if (url) {
stopOpenTimer();
setVisible(false);
void request();
}
}, [url, request]);
@@ -70,6 +101,7 @@ function HoverPreviewInternal({ element, onClose }: Props) {
useOnClickOutside(cardRef, closePreview);
useKeyDown("Escape", closePreview);
useEventListener("scroll", closePreview, window, { capture: true });
const stopCloseTimer = () => {
if (timerClose.current) {
@@ -119,16 +151,6 @@ function HoverPreviewInternal({ element, onClose }: Props) {
};
}, [element, startCloseTimer, data]);
const elemBounds = element.getBoundingClientRect();
const cardBounds = cardRef.current?.getBoundingClientRect();
const left = cardBounds
? Math.min(
elemBounds.left,
window.innerWidth - CARD_PADDING - CARD_MAX_WIDTH
)
: elemBounds.left;
const leftOffset = elemBounds.left - left;
if (loading) {
return <LoadingIndicator />;
}
@@ -139,39 +161,41 @@ function HoverPreviewInternal({ element, onClose }: Props) {
return (
<Portal>
<Position
top={elemBounds.bottom + window.scrollY}
left={left}
aria-hidden
>
<div ref={cardRef}>
{isVisible ? (
<Animate>
<Card fadeOut={data.type !== UnfurlType.Mention}>
<Margin />
<CardContent>
{data.type === UnfurlType.Mention ? (
<HoverPreviewMention
url={data.thumbnailUrl}
title={data.title}
description={data.description}
color={data.meta.color}
/>
) : data.type === UnfurlType.Document ? (
<HoverPreviewDocument
id={data.meta.id}
url={data.url}
title={data.title}
description={data.description}
summary={data.meta.summary}
/>
) : null}
</CardContent>
</Card>
<Pointer offset={leftOffset + elemBounds.width / 2} />
</Animate>
) : null}
</div>
<Position top={cardTop} left={cardLeft} aria-hidden>
{isVisible ? (
<Animate
initial={{ opacity: 0, y: -20, pointerEvents: "none" }}
animate={{ opacity: 1, y: 0, pointerEvents: "auto" }}
>
{data.type === UnfurlType.Mention ? (
<HoverPreviewMention
ref={cardRef}
url={data.thumbnailUrl}
title={data.title}
info={data.meta.info}
color={data.meta.color}
/>
) : data.type === UnfurlType.Document ? (
<HoverPreviewDocument
ref={cardRef}
id={data.meta.id}
url={data.url}
title={data.title}
description={data.description}
info={data.meta.info}
/>
) : (
<HoverPreviewLink
ref={cardRef}
url={data.url}
thumbnailUrl={data.thumbnailUrl}
title={data.title}
description={data.description}
/>
)}
<Pointer offset={pointerOffset} />
</Animate>
) : null}
</Position>
</Portal>
);
@@ -186,71 +210,12 @@ function HoverPreview({ element, ...rest }: Props) {
return <HoverPreviewInternal {...rest} element={element} />;
}
const Animate = styled.div`
animation: ${fadeAndSlideDown} 150ms ease;
const Animate = styled(m.div)`
@media print {
display: none;
}
`;
// fills the gap between the card and pointer to avoid a dead zone
const Margin = styled.div`
position: absolute;
top: -11px;
left: 0;
right: 0;
height: 11px;
`;
const CardContent = styled.div`
overflow: hidden;
max-height: 20em;
user-select: none;
`;
// &:after — gradient mask for overflow text
const Card = styled.div<{ fadeOut?: boolean }>`
backdrop-filter: blur(10px);
background: ${(props) => props.theme.menuBackground};
border-radius: 4px;
box-shadow: 0 30px 90px -20px rgba(0, 0, 0, 0.3),
0 0 1px 1px rgba(0, 0, 0, 0.05);
padding: ${CARD_PADDING}px;
min-width: 350px;
max-width: ${CARD_MAX_WIDTH}px;
font-size: 0.9em;
position: relative;
.placeholder,
.heading-anchor {
display: none;
}
${(props) =>
props.fadeOut !== false
? `&:after {
content: "";
display: block;
position: absolute;
pointer-events: none;
background: linear-gradient(
90deg,
${transparentize(1, props.theme.menuBackground)} 0%,
${transparentize(1, props.theme.menuBackground)} 75%,
${props.theme.menuBackground} 90%
);
bottom: 0;
left: 0;
right: 0;
height: 1.7em;
border-bottom: 16px solid ${props.theme.menuBackground};
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}`
: ""}
`;
const Position = styled.div<{ fixed?: boolean; top?: number; left?: number }>`
margin-top: 10px;
position: ${({ fixed }) => (fixed ? "fixed" : "absolute")};
@@ -1,7 +1,14 @@
import * as React from "react";
import Editor from "~/components/Editor";
import Flex from "~/components/Flex";
import { Preview, Title, Description, Summary } from "./Components";
import {
Preview,
Title,
Info,
Card,
CardContent,
Description,
} from "./Components";
type Props = {
/** Document id associated with the editor, if any */
@@ -10,28 +17,38 @@ type Props = {
url: string;
/** Title for the preview card */
title: string;
/** Description about recent activity on document */
/** Info about last activity on the document */
info: string;
/** Text preview of document content */
description: string;
/** Summary of document content */
summary: string;
};
function HoverPreviewDocument({ id, url, title, description, summary }: Props) {
const HoverPreviewDocument = React.forwardRef(function _HoverPreviewDocument(
{ id, url, title, info, description }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
<Preview to={url}>
<Flex column>
<Title>{title}</Title>
<Description type="tertiary" size="xsmall">
{description}
</Description>
<Summary>
<React.Suspense fallback={<div />}>
<Editor key={id} defaultValue={summary} embedsDisabled readOnly />
</React.Suspense>
</Summary>
</Flex>
<Card ref={ref}>
<CardContent>
<Flex column gap={2}>
<Title>{title}</Title>
<Info>{info}</Info>
<Description as="div">
<React.Suspense fallback={<div />}>
<Editor
key={id}
defaultValue={description}
embedsDisabled
readOnly
/>
</React.Suspense>
</Description>
</Flex>
</CardContent>
</Card>
</Preview>
);
}
});
export default HoverPreviewDocument;
@@ -0,0 +1,44 @@
import * as React from "react";
import Flex from "~/components/Flex";
import {
Preview,
Title,
Description,
Card,
CardContent,
Thumbnail,
} from "./Components";
type Props = {
/** Link url */
url: string;
/** Title for the preview card */
title: string;
/** Url for thumbnail served by the link provider */
thumbnailUrl: string;
/** Some description about the link provider */
description: string;
};
const HoverPreviewLink = React.forwardRef(function _HoverPreviewLink(
{ url, thumbnailUrl, title, description }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
<Preview as="a" href={url} target="_blank" rel="noopener noreferrer">
<Flex column>
{thumbnailUrl ? <Thumbnail src={thumbnailUrl} alt={""} /> : null}
<Card ref={ref}>
<CardContent>
<Flex column>
<Title>{title}</Title>
<Description>{description}</Description>
</Flex>
</CardContent>
</Card>
</Flex>
</Preview>
);
});
export default HoverPreviewLink;
@@ -2,40 +2,45 @@ import * as React from "react";
import Avatar from "~/components/Avatar";
import { AvatarSize } from "~/components/Avatar/Avatar";
import Flex from "~/components/Flex";
import { Preview, Title, Description } from "./Components";
import { Preview, Title, Info, Card, CardContent } from "./Components";
type Props = {
/** Resource url, avatar url in case of user mention */
url: string;
/** Title for the preview card*/
title: string;
/** Description about mentioned user's recent activity */
description: string;
/** Info about mentioned user's recent activity */
info: string;
/** Used for avatar's background color in absence of avatar url */
color: string;
};
function HoverPreviewMention({ url, title, description, color }: Props) {
const HoverPreviewMention = React.forwardRef(function _HoverPreviewMention(
{ url, title, info, color }: Props,
ref: React.Ref<HTMLDivElement>
) {
return (
<Preview to="">
<Flex gap={12}>
<Avatar
model={{
avatarUrl: url,
initial: title ? title[0] : "?",
color,
}}
size={AvatarSize.XLarge}
/>
<Flex column>
<Title>{title}</Title>
<Description type="tertiary" size="xsmall">
{description}
</Description>
</Flex>
</Flex>
<Preview as="div">
<Card fadeOut={false} ref={ref}>
<CardContent>
<Flex gap={12}>
<Avatar
model={{
avatarUrl: url,
initial: title ? title[0] : "?",
color,
}}
size={AvatarSize.XLarge}
/>
<Flex column gap={2} justify="center">
<Title>{title}</Title>
<Info>{info}</Info>
</Flex>
</Flex>
</CardContent>
</Card>
</Preview>
);
}
});
export default HoverPreviewMention;
+1 -1
View File
@@ -24,7 +24,7 @@ export default function MarkdownIcon({
<path
d="M19.2692 7H3.86538C3.38745 7 3 7.38476 3 7.85938V16.2812C3 16.7559 3.38745 17.1406 3.86538 17.1406H19.2692C19.7472 17.1406 20.1346 16.7559 20.1346 16.2812V7.85938C20.1346 7.38476 19.7472 7 19.2692 7Z"
stroke={color}
stroke-width="2"
strokeWidth="2"
/>
<path
d="M5.16345 14.9922V9.14844H6.89422L8.62499 11.2969L10.3558 9.14844H12.0865V14.9922H10.3558V11.6406L8.62499 13.7891L6.89422 11.6406V14.9922H5.16345ZM15.9808 14.9922L13.3846 12.1562H15.1154V9.14844H16.8461V12.1562H18.5769L15.9808 14.9922Z"
+3
View File
@@ -30,6 +30,8 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
color: ${s("text")};
height: 30px;
min-width: 0;
font-size: 15px;
${ellipsis()}
${undraggableOnDesktop()}
@@ -175,6 +177,7 @@ function Input(
labelHidden,
onFocus,
onBlur,
onRequestSubmit,
children,
...rest
} = props;
+1 -1
View File
@@ -16,7 +16,7 @@ type Props = Omit<InputProps, "onChange"> & {
onChange: (value: string) => void;
};
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }) => {
const InputColor: React.FC<Props> = ({ value, onChange, ...rest }: Props) => {
const { t } = useTranslation();
const menu = useMenuState({
modal: true,
+2 -1
View File
@@ -5,10 +5,11 @@ import { s } from "@shared/styles";
import Flex from "~/components/Flex";
type Props = {
children?: React.ReactNode;
label: React.ReactNode | string;
};
const Labeled: React.FC<Props> = ({ label, children, ...props }) => (
const Labeled: React.FC<Props> = ({ label, children, ...props }: Props) => (
<Flex column {...props}>
<Label>{label}</Label>
{children}
+4 -4
View File
@@ -21,14 +21,14 @@ function Icon({ className }: { className?: string }) {
className={className}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M21 18H16L14 16V6C14 4.89543 14.8954 4 16 4H28C29.1046 4 30 4.89543 30 6V16C30 17.1046 29.1046 18 28 18H27L25.4142 19.5858C24.6332 20.3668 23.3668 20.3668 22.5858 19.5858L21 18ZM16 15.1716V6H28V16H27H26.1716L25.5858 16.5858L24 18.1716L22.4142 16.5858L21.8284 16H21H16.8284L16 15.1716Z"
fill="#2B2F35"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
fillRule="evenodd"
clipRule="evenodd"
d="M16 13H4C2.89543 13 2 13.8954 2 15V25C2 26.1046 2.89543 27 4 27H5L6.58579 28.5858C7.36684 29.3668 8.63316 29.3668 9.41421 28.5858L11 27H16C17.1046 27 18 26.1046 18 25V15C18 13.8954 17.1046 13 16 13ZM9 17L6 16.9681C6 16.9681 5 17.016 5 18C5 18.984 6 19 6 19H8.5H10C10 19 9.57627 20.1885 8.38983 21.0831C7.20339 21.9777 5.7197 23 5.7197 23C5.7197 23 4.99153 23.6054 5.5 24.5C6.00847 25.3946 7 24.8403 7 24.8403L9.74576 22.8722L11.9492 24.6614C11.9492 24.6614 12.6271 25.3771 13.3051 24.4825C13.9831 23.5879 13.3051 23.0512 13.3051 23.0512L11.1017 21.262C11.1017 21.262 11.5 21 12 20L12.5 19H14C14 19 15 19.0319 15 18C15 16.9681 14 16.9681 14 16.9681L11 17V16C11 16 11.0169 15 10 15C8.98305 15 9 16 9 16V17Z"
fill="#2B2F35"
/>
+2 -1
View File
@@ -16,6 +16,7 @@ import useStores from "~/hooks/useStores";
import { isModKey } from "~/utils/keyboard";
type Props = {
children?: React.ReactNode;
title?: string;
sidebar?: React.ReactNode;
sidebarRight?: React.ReactNode;
@@ -26,7 +27,7 @@ const Layout: React.FC<Props> = ({
children,
sidebar,
sidebarRight,
}) => {
}: Props) => {
const { ui } = useStores();
const sidebarCollapsed = !sidebar || ui.sidebarIsClosed;
+5 -1
View File
@@ -2,10 +2,14 @@ import * as React from "react";
import Logger from "~/utils/Logger";
import { loadPolyfills } from "~/utils/polyfills";
type Props = {
children?: React.ReactNode;
};
/**
* Asyncronously load required polyfills. Should wrap the React tree.
*/
export const LazyPolyfill: React.FC = ({ children }) => {
export const LazyPolyfill: React.FC = ({ children }: Props) => {
const [isLoaded, setIsLoaded] = React.useState(false);
React.useEffect(() => {
+9 -13
View File
@@ -1,6 +1,6 @@
import { format as formatDate, formatDistanceToNow } from "date-fns";
import { format as formatDate } from "date-fns";
import * as React from "react";
import { dateLocale, locales } from "@shared/utils/date";
import { dateLocale, dateToRelative, locales } from "@shared/utils/date";
import Tooltip from "~/components/Tooltip";
import useUserLocale from "~/hooks/useUserLocale";
@@ -21,6 +21,7 @@ function eachMinute(fn: () => void) {
}
type Props = {
children?: React.ReactNode;
dateTime: string;
tooltipDelay?: number;
addSuffix?: boolean;
@@ -37,7 +38,7 @@ const LocaleTime: React.FC<Props> = ({
format,
relative,
tooltipDelay,
}) => {
}: Props) => {
const userLocale: string = useUserLocale() || "";
const dateFormatLong = {
en_US: "MMMM do, yyyy h:mm a",
@@ -59,26 +60,21 @@ const LocaleTime: React.FC<Props> = ({
};
}, []);
const date = new Date(Date.parse(dateTime));
const locale = dateLocale(userLocale);
let relativeContent = formatDistanceToNow(Date.parse(dateTime), {
const relativeContent = dateToRelative(date, {
addSuffix,
locale,
shorten,
});
if (shorten) {
relativeContent = relativeContent
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
const tooltipContent = formatDate(Date.parse(dateTime), formatLocaleLong, {
const tooltipContent = formatDate(date, formatLocaleLong, {
locale,
});
const content =
relative !== false
? relativeContent
: formatDate(Date.parse(dateTime), formatLocale, {
: formatDate(date, formatLocale, {
locale,
});
+3 -1
View File
@@ -19,7 +19,9 @@ import Desktop from "~/utils/Desktop";
import ErrorBoundary from "./ErrorBoundary";
let openModals = 0;
type Props = {
children?: React.ReactNode;
isOpen: boolean;
isCentered?: boolean;
title?: React.ReactNode;
@@ -32,7 +34,7 @@ const Modal: React.FC<Props> = ({
isCentered,
title = "Untitled",
onRequestClose,
}) => {
}: Props) => {
const dialog = useDialogState({
animated: 250,
});
+2 -1
View File
@@ -5,11 +5,12 @@ import Flex from "./Flex";
import Text from "./Text";
type Props = {
children?: React.ReactNode;
icon?: JSX.Element;
description?: JSX.Element;
};
const Notice: React.FC<Props> = ({ children, icon, description }) => (
const Notice: React.FC<Props> = ({ children, icon, description }: Props) => (
<Container>
<Flex as="span" gap={8}>
{icon}
@@ -7,7 +7,11 @@ import { depths } from "@shared/styles";
import Popover from "~/components/Popover";
import Notifications from "./Notifications";
const NotificationsPopover: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const NotificationsPopover: React.FC = ({ children }: Props) => {
const { t } = useTranslation();
const scrollableRef = React.useRef<HTMLDivElement>(null);
+1 -1
View File
@@ -25,7 +25,7 @@ const Popover: React.FC<Props> = ({
flex,
mobilePosition,
...rest
}) => {
}: Props) => {
const isMobile = useMobile();
if (isMobile) {
+2 -1
View File
@@ -11,6 +11,7 @@ type Props = {
left?: React.ReactNode;
actions?: React.ReactNode;
centered?: boolean;
children?: React.ReactNode;
};
const Scene: React.FC<Props> = ({
@@ -21,7 +22,7 @@ const Scene: React.FC<Props> = ({
left,
children,
centered,
}) => (
}: Props) => (
<FillWidth>
<PageTitle title={textTitle || title} />
<Header
+6 -5
View File
@@ -17,7 +17,7 @@ import useStores from "~/hooks/useStores";
import { SearchResult } from "~/types";
import SearchListItem from "./SearchListItem";
type Props = { shareId: string };
type Props = React.HTMLAttributes<HTMLInputElement> & { shareId: string };
function SearchPopover({ shareId }: Props) {
const { t } = useTranslation();
@@ -32,6 +32,7 @@ function SearchPopover({ shareId }: Props) {
const [query, setQuery] = React.useState("");
const searchResults = documents.searchResults(query);
const { show, hide } = popover;
const [cachedQuery, setCachedQuery] = React.useState(query);
const [cachedSearchResults, setCachedSearchResults] = React.useState<
@@ -42,9 +43,9 @@ function SearchPopover({ shareId }: Props) {
if (searchResults) {
setCachedQuery(query);
setCachedSearchResults(searchResults);
popover.show();
show();
}
}, [searchResults, query, popover.show]);
}, [searchResults, query, show]);
const performSearch = React.useCallback(
async ({ query, ...options }) => {
@@ -141,12 +142,12 @@ function SearchPopover({ shareId }: Props) {
);
const handleSearchItemClick = React.useCallback(() => {
popover.hide();
hide();
if (searchInputRef.current) {
searchInputRef.current.value = "";
focusRef.current = document.getElementById(bodyContentId);
}
}, [popover.hide]);
}, [searchInputRef, hide]);
useKeyDown("/", (ev) => {
if (
+172 -171
View File
@@ -27,197 +27,198 @@ type Props = {
children: React.ReactNode;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(
({ children }: Props, ref: React.RefObject<HTMLDivElement>) => {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui, auth } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const { user } = auth;
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
const theme = useTheme();
const { t } = useTranslation();
const { ui, auth } = useStores();
const location = useLocation();
const previousLocation = usePrevious(location);
const { isMenuOpen } = useMenuContext();
const { user } = auth;
const width = ui.sidebarWidth;
const collapsed = ui.sidebarIsClosed && !isMenuOpen;
const maxWidth = theme.sidebarMaxWidth;
const minWidth = theme.sidebarMinWidth + 16; // padding
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const setWidth = ui.setSidebarWidth;
const [offset, setOffset] = React.useState(0);
const [isAnimating, setAnimating] = React.useState(false);
const [isResizing, setResizing] = React.useState(false);
const isSmallerThanMinimum = width < minWidth;
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = width < minWidth / 2;
const handleDrag = React.useCallback(
(event: MouseEvent) => {
// suppresses text selection
event.preventDefault();
// this is simple because the sidebar is always against the left edge
const width = Math.min(event.pageX - offset, maxWidth);
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
},
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setAnimating(false);
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
setAnimating(true);
}
if (isSmallerThanCollapsePoint) {
setWidth(theme.sidebarCollapsedWidth);
} else {
setWidth(width);
}
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
},
[theme, offset, minWidth, maxWidth, setWidth]
);
const handleMouseDown = React.useCallback(
(event) => {
setOffset(event.pageX - width);
setResizing(true);
const handleStopDrag = React.useCallback(() => {
setResizing(false);
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur();
}
if (isSmallerThanMinimum) {
const isSmallerThanCollapsePoint = width < minWidth / 2;
if (isSmallerThanCollapsePoint) {
setAnimating(false);
},
[width]
);
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), ANIMATION_MS);
setCollapsing(true);
ui.collapseSidebar();
} else {
setWidth(minWidth);
setAnimating(true);
}
}, [isAnimating]);
} else {
setWidth(width);
}
}, [ui, isSmallerThanMinimum, minWidth, width, setWidth]);
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
const handleMouseDown = React.useCallback(
(event) => {
setOffset(event.pageX - width);
setResizing(true);
setAnimating(false);
},
[width]
);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
React.useEffect(() => {
if (isAnimating) {
setTimeout(() => setAnimating(false), ANIMATION_MS);
}
}, [isAnimating]);
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
React.useEffect(() => {
if (isCollapsing) {
setTimeout(() => {
setWidth(minWidth);
setCollapsing(false);
}, ANIMATION_MS);
}
}, [setWidth, minWidth, isCollapsing]);
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
React.useEffect(() => {
if (isResizing) {
document.addEventListener("mousemove", handleDrag);
document.addEventListener("mouseup", handleStopDrag);
}
React.useEffect(() => {
ui.setSidebarResizing(isResizing);
}, [ui, isResizing]);
return () => {
document.removeEventListener("mousemove", handleDrag);
document.removeEventListener("mouseup", handleStopDrag);
};
}, [isResizing, handleDrag, handleStopDrag]);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
const handleReset = React.useCallback(() => {
ui.setSidebarWidth(theme.sidebarWidth);
}, [ui, theme.sidebarWidth]);
const style = React.useMemo(
() => ({
width: `${width}px`,
}),
[width]
);
React.useEffect(() => {
ui.setSidebarResizing(isResizing);
}, [ui, isResizing]);
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
);
React.useEffect(() => {
if (location !== previousLocation) {
ui.hideMobileSidebar();
}
}, [ui, location, previousLocation]);
return (
<>
<Container
ref={ref}
style={style}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{ui.mobileSidebarVisible && (
<Portal>
<Backdrop onClick={ui.toggleMobileSidebar} />
</Portal>
)}
{children}
const style = React.useMemo(
() => ({
width: `${width}px`,
}),
[width]
);
{user && (
<AccountMenu>
{(props: HeaderButtonProps) => (
<HeaderButton
{...props}
showMoreMenu
title={user.name}
image={
<StyledAvatar
alt={user.name}
model={user}
size={24}
showBorder={false}
/>
}
>
<NotificationsPopover>
{(rest: HeaderButtonProps) => (
<HeaderButton {...rest} image={<NotificationIcon />} />
)}
</NotificationsPopover>
</HeaderButton>
)}
</AccountMenu>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
{ui.sidebarIsClosed && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</Container>
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarIsClosed ? "right" : "left"}
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
const toggleStyle = React.useMemo(
() => ({
right: "auto",
marginLeft: `${collapsed ? theme.sidebarCollapsedWidth : width}px`,
}),
[width, theme.sidebarCollapsedWidth, collapsed]
);
return (
<>
<Container
ref={ref}
style={style}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
$mobileSidebarVisible={ui.mobileSidebarVisible}
$collapsed={collapsed}
column
>
{ui.mobileSidebarVisible && (
<Portal>
<Backdrop onClick={ui.toggleMobileSidebar} />
</Portal>
)}
{children}
{user && (
<AccountMenu>
{(props: HeaderButtonProps) => (
<HeaderButton
{...props}
showMoreMenu
title={user.name}
image={
<StyledAvatar
alt={user.name}
model={user}
size={24}
showBorder={false}
/>
}
>
<NotificationsPopover>
{(rest: HeaderButtonProps) => (
<HeaderButton {...rest} image={<NotificationIcon />} />
)}
</NotificationsPopover>
</HeaderButton>
)}
</AccountMenu>
)}
<ResizeBorder
onMouseDown={handleMouseDown}
onDoubleClick={ui.sidebarIsClosed ? undefined : handleReset}
/>
</>
);
}
);
{ui.sidebarIsClosed && (
<Toggle
onClick={ui.toggleCollapsedSidebar}
direction={"right"}
aria-label={t("Expand")}
/>
)}
</Container>
<Toggle
style={toggleStyle}
onClick={ui.toggleCollapsedSidebar}
direction={ui.sidebarIsClosed ? "right" : "left"}
aria-label={ui.sidebarIsClosed ? t("Expand") : t("Collapse")}
/>
</>
);
});
const StyledAvatar = styled(Avatar)`
margin-left: 4px;
@@ -37,7 +37,7 @@ const CollectionLink: React.FC<Props> = ({
expanded,
onDisclosureClick,
isDraggingAnyCollection,
}) => {
}: Props) => {
const itemRef = React.useRef<
NavigationNode & { depth: number; active: boolean; collectionId: string }
>();
@@ -69,7 +69,7 @@ function InnerDocumentLink(
if (isActiveDocument && hasChildDocuments) {
void fetchChildDocuments(node.id);
}
}, [fetchChildDocuments, node, hasChildDocuments, isActiveDocument]);
}, [fetchChildDocuments, node.id, hasChildDocuments, isActiveDocument]);
const pathToNode = React.useMemo(
() => collection?.pathToDocument(node.id).map((entry) => entry.id),
+2 -1
View File
@@ -3,9 +3,10 @@ import styled from "styled-components";
type Props = {
expanded: boolean;
children?: React.ReactNode;
};
const Folder: React.FC<Props> = ({ expanded, children }) => {
const Folder: React.FC<Props> = ({ expanded, children }: Props) => {
const [openedOnce, setOpenedOnce] = React.useState(expanded);
// allows us to avoid rendering all children when the folder hasn't been opened
+2 -1
View File
@@ -9,12 +9,13 @@ type Props = {
/** Unique header id if passed the header will become toggleable */
id?: string;
title: React.ReactNode;
children?: React.ReactNode;
};
/**
* Toggleable sidebar header
*/
export const Header: React.FC<Props> = ({ id, title, children }) => {
export const Header: React.FC<Props> = ({ id, title, children }: Props) => {
const [firstRender, setFirstRender] = React.useState(true);
const [expanded, setExpanded] = usePersistedState<boolean>(
`sidebar-header-${id}`,
@@ -17,7 +17,7 @@ export type HeaderButtonProps = React.ComponentProps<typeof Button> & {
};
const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
(
function _HeaderButton(
{
showDisclosure,
showMoreMenu,
@@ -28,25 +28,27 @@ const HeaderButton = React.forwardRef<HTMLButtonElement, HeaderButtonProps>(
...rest
}: HeaderButtonProps,
ref
) => (
<Flex justify="space-between" align="center" shrink={false}>
<Button
{...rest}
minHeight={minHeight}
as="button"
ref={ref}
role="button"
>
<Title gap={8} align="center">
{image}
{title}
</Title>
{showDisclosure && <ExpandedIcon />}
{showMoreMenu && <MoreIcon />}
</Button>
{children}
</Flex>
)
) {
return (
<Flex justify="space-between" align="center" shrink={false}>
<Button
{...rest}
minHeight={minHeight}
as="button"
ref={ref}
role="button"
>
<Title gap={8} align="center">
{image}
{title}
</Title>
{showDisclosure && <ExpandedIcon />}
{showMoreMenu && <MoreIcon />}
</Button>
{children}
</Flex>
);
}
);
const Title = styled(Flex)`
@@ -200,6 +200,7 @@ const Link = styled(NavLink)<{
text-overflow: ellipsis;
padding: 6px 16px;
border-radius: 4px;
min-height: 32px;
transition: background 50ms, color 50ms;
user-select: none;
background: ${(props) =>
+33 -32
View File
@@ -12,41 +12,42 @@ type Props = {
onClick?: React.MouseEventHandler<HTMLButtonElement>;
};
const Toggle = React.forwardRef<HTMLButtonElement, Props>(
({ direction = "left", onClick, style }: Props, ref) => {
const { t } = useTranslation();
const [hovering, setHovering] = React.useState(false);
const positionRef = React.useRef<HTMLDivElement>(null);
const Toggle = React.forwardRef<HTMLButtonElement, Props>(function Toggle_(
{ direction = "left", onClick, style }: Props,
ref
) {
const { t } = useTranslation();
const [hovering, setHovering] = React.useState(false);
const positionRef = React.useRef<HTMLDivElement>(null);
// Not using CSS hover here so that we can disable pointer events on this
// div and allow click through to the editor elements behind.
useEventListener("mousemove", (event: MouseEvent) => {
if (!positionRef.current) {
return;
}
// Not using CSS hover here so that we can disable pointer events on this
// div and allow click through to the editor elements behind.
useEventListener("mousemove", (event: MouseEvent) => {
if (!positionRef.current) {
return;
}
const bound = positionRef.current.getBoundingClientRect();
const withinBounds =
event.clientX >= bound.left && event.clientX <= bound.right;
if (withinBounds !== hovering) {
setHovering(withinBounds);
}
});
const bound = positionRef.current.getBoundingClientRect();
const withinBounds =
event.clientX >= bound.left && event.clientX <= bound.right;
if (withinBounds !== hovering) {
setHovering(withinBounds);
}
});
return (
<Positioner style={style} ref={positionRef} $hovering={hovering}>
<ToggleButton
ref={ref}
$direction={direction}
onClick={onClick}
aria-label={t("Toggle sidebar")}
>
<Arrow />
</ToggleButton>
</Positioner>
);
}
);
return (
<Positioner style={style} ref={positionRef} $hovering={hovering}>
<ToggleButton
ref={ref}
$direction={direction}
onClick={onClick}
aria-label={t("Toggle sidebar")}
>
<Arrow />
</ToggleButton>
</Positioner>
);
});
export const ToggleButton = styled.button<{ $direction?: "left" | "right" }>`
opacity: 0;
+2 -1
View File
@@ -5,9 +5,10 @@ import Flex from "./Flex";
type Props = {
size?: number;
color?: string;
children?: React.ReactNode;
};
const Squircle: React.FC<Props> = ({ color, size = 28, children }) => (
const Squircle: React.FC<Props> = ({ color, size = 28, children }: Props) => (
<Wrapper
style={{ width: size, height: size }}
align="center"
+2 -1
View File
@@ -3,6 +3,7 @@ import styled from "styled-components";
import { s } from "@shared/styles";
type Props = {
children?: React.ReactNode;
sticky?: boolean;
};
@@ -34,7 +35,7 @@ const Background = styled.div<{ sticky?: boolean }>`
z-index: 1;
`;
const Subheading: React.FC<Props> = ({ children, sticky, ...rest }) => (
const Subheading: React.FC<Props> = ({ children, sticky, ...rest }: Props) => (
<Background sticky={sticky}>
<H3 {...rest}>
<Underline>{children}</Underline>
+4 -1
View File
@@ -83,8 +83,11 @@ const Input = styled.label<{ width: number; height: number }>`
display: inline-block;
width: ${(props) => props.width}px;
height: ${(props) => props.height}px;
margin-right: 8px;
flex-shrink: 0;
&:not(:last-child) {
margin-right: 8px;
}
`;
const Slider = styled.span<{ width: number; height: number }>`
+2 -1
View File
@@ -8,6 +8,7 @@ import { hover } from "~/styles";
type Props = Omit<React.ComponentProps<typeof NavLink>, "children"> & {
to: string;
exact?: boolean;
children?: React.ReactNode;
};
const TabLink = styled(NavLink)`
@@ -44,7 +45,7 @@ const transition = {
damping: 30,
};
const Tab: React.FC<Props> = ({ children, ...rest }) => {
const Tab: React.FC<Props> = ({ children, ...rest }: Props) => {
const theme = useTheme();
const activeStyle = {
color: theme.textSecondary,
+7 -3
View File
@@ -121,9 +121,12 @@ function Table({
<InnerTable {...getTableProps()}>
<thead>
{headerGroups.map((headerGroup) => (
<tr {...headerGroup.getHeaderGroupProps()}>
<tr {...headerGroup.getHeaderGroupProps()} key={headerGroup.id}>
{headerGroup.headers.map((column) => (
<Head {...column.getHeaderProps(column.getSortByToggleProps())}>
<Head
{...column.getHeaderProps(column.getSortByToggleProps())}
key={column.id}
>
<SortWrapper
align="center"
$sortable={!column.disableSortBy}
@@ -146,7 +149,7 @@ function Table({
{rows.map((row) => {
prepareRow(row);
return (
<Row {...row.getRowProps()}>
<Row {...row.getRowProps()} key={row.id}>
{row.cells.map((cell) => (
<Cell
{...cell.getCellProps([
@@ -155,6 +158,7 @@ function Table({
className: cell.column.className,
},
])}
key={cell.column.id}
>
{cell.render("Cell")}
</Cell>
+5 -1
View File
@@ -57,7 +57,11 @@ export const Separator = styled.span`
margin-top: 6px;
`;
const Tabs: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const Tabs: React.FC = ({ children }: Props) => {
const ref = React.useRef<any>();
const [shadowVisible, setShadow] = React.useState(false);
const { width } = useWindowSize();
+1 -1
View File
@@ -14,7 +14,7 @@ type Props = {
*/
const Text = styled.p<Props>`
margin-top: 0;
text-align: ${(props) => (props.dir ? props.dir : "auto")};
text-align: ${(props) => (props.dir ? props.dir : "initial")};
color: ${(props) =>
props.type === "secondary"
? props.theme.textSecondary
+5 -1
View File
@@ -7,7 +7,11 @@ import useBuildTheme from "~/hooks/useBuildTheme";
import useStores from "~/hooks/useStores";
import { TooltipStyles } from "./Tooltip";
const Theme: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const Theme: React.FC = ({ children }: Props) => {
const { auth, ui } = useStores();
const theme = useBuildTheme(
auth.team?.getPreference(TeamPreference.CustomTheme) ||
+3 -9
View File
@@ -1,5 +1,5 @@
import { formatDistanceToNow } from "date-fns";
import * as React from "react";
import { dateToRelative } from "@shared/utils/date";
import lazyWithRetry from "~/utils/lazyWithRetry";
const LocaleTime = lazyWithRetry(() => import("~/components/LocaleTime"));
@@ -9,17 +9,11 @@ type Props = React.ComponentProps<typeof LocaleTime> & {
};
function Time({ onClick, ...props }: Props) {
let content = formatDistanceToNow(Date.parse(props.dateTime), {
const content = dateToRelative(Date.parse(props.dateTime), {
addSuffix: props.addSuffix,
shorten: props.shorten,
});
if (props.shorten) {
content = content
.replace("about", "")
.replace("less than a minute ago", "just now")
.replace("minute", "min");
}
return (
<span onClick={onClick}>
<React.Suspense
+370
View File
@@ -0,0 +1,370 @@
import {
CaretDownIcon,
CaretUpIcon,
CaseSensitiveIcon,
RegexIcon,
ReplaceIcon,
} from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState } from "reakit/Popover";
import styled, { useTheme } from "styled-components";
import { depths, s } from "@shared/styles";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import NudeButton from "~/components/NudeButton";
import Popover from "~/components/Popover";
import { Portal } from "~/components/Portal";
import { ResizingHeightContainer } from "~/components/ResizingHeightContainer";
import Tooltip from "~/components/Tooltip";
import useKeyDown from "~/hooks/useKeyDown";
import useOnClickOutside from "~/hooks/useOnClickOutside";
import Desktop from "~/utils/Desktop";
import { altDisplay, isModKey, metaDisplay } from "~/utils/keyboard";
import { useEditor } from "./EditorContext";
type Props = {
readOnly?: boolean;
};
export default function FindAndReplace({ readOnly }: Props) {
const editor = useEditor();
const finalFocusRef = React.useRef<HTMLElement>(
editor.view.dom.parentElement
);
const selectionRef = React.useRef<string | undefined>();
const inputRef = React.useRef<HTMLInputElement>(null);
const inputReplaceRef = React.useRef<HTMLInputElement>(null);
const { t } = useTranslation();
const theme = useTheme();
const [showReplace, setShowReplace] = React.useState(false);
const [caseSensitive, setCaseSensitive] = React.useState(false);
const [regexEnabled, setRegex] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState("");
const [replaceTerm, setReplaceTerm] = React.useState("");
const popover = usePopoverState();
const { show } = popover;
// Hooks for desktop app menu items
React.useEffect(() => {
if (!Desktop.bridge) {
return;
}
if ("onFindInPage" in Desktop.bridge) {
Desktop.bridge.onFindInPage(() => {
selectionRef.current = window.getSelection()?.toString();
show();
});
}
if ("onReplaceInPage" in Desktop.bridge) {
Desktop.bridge.onReplaceInPage(() => {
setShowReplace(true);
show();
});
}
}, [show]);
// Close handlers
useKeyDown("Escape", popover.hide);
useOnClickOutside(popover.unstable_referenceRef, popover.hide);
// Keyboard shortcuts
useKeyDown(
(ev) =>
isModKey(ev) &&
!popover.visible &&
ev.code === "KeyF" &&
// Keyboard handler is through the AppMenu on Desktop v1.2.0+
!(Desktop.bridge && "onFindInPage" in Desktop.bridge),
(ev) => {
ev.preventDefault();
selectionRef.current = window.getSelection()?.toString();
popover.show();
}
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyR" && popover.visible,
(ev) => {
ev.preventDefault();
setRegex((state) => !state);
},
{ allowInInput: true }
);
useKeyDown(
(ev) => isModKey(ev) && ev.altKey && ev.code === "KeyC" && popover.visible,
(ev) => {
ev.preventDefault();
setCaseSensitive((state) => !state);
},
{ allowInInput: true }
);
// Callbacks
const handleMore = React.useCallback(() => {
setShowReplace((state) => !state);
setTimeout(() => inputReplaceRef.current?.focus(), 100);
}, []);
const handleCaseSensitive = React.useCallback(() => {
setCaseSensitive((state) => {
const caseSensitive = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
regexEnabled,
});
return caseSensitive;
});
}, [regexEnabled, editor.commands, searchTerm]);
const handleRegex = React.useCallback(() => {
setRegex((state) => {
const regexEnabled = !state;
editor.commands.find({
text: searchTerm,
caseSensitive,
regexEnabled,
});
return regexEnabled;
});
}, [caseSensitive, editor.commands, searchTerm]);
const handleKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
if (ev.shiftKey) {
editor.commands.prevSearchMatch();
} else {
editor.commands.nextSearchMatch();
}
}
},
[editor.commands]
);
const handleReplace = React.useCallback(
(ev) => {
if (readOnly) {
return;
}
ev.preventDefault();
editor.commands.replace({ text: replaceTerm });
},
[editor.commands, readOnly, replaceTerm]
);
const handleReplaceAll = React.useCallback(
(ev) => {
if (readOnly) {
return;
}
ev.preventDefault();
editor.commands.replaceAll({ text: replaceTerm });
},
[editor.commands, readOnly, replaceTerm]
);
const handleChangeFind = React.useCallback(
(ev: React.ChangeEvent<HTMLInputElement>) => {
ev.preventDefault();
ev.stopPropagation();
setSearchTerm(ev.currentTarget.value);
editor.commands.find({
text: ev.currentTarget.value,
caseSensitive,
regexEnabled,
});
},
[caseSensitive, editor.commands, regexEnabled]
);
const handleReplaceKeyDown = React.useCallback(
(ev: React.KeyboardEvent<HTMLInputElement>) => {
if (ev.key === "Enter") {
ev.preventDefault();
handleReplace(ev);
}
},
[handleReplace]
);
const style: React.CSSProperties = React.useMemo(
() => ({
position: "absolute",
left: "initial",
top: 60,
right: 16,
zIndex: depths.popover,
}),
[]
);
React.useEffect(() => {
if (popover.visible) {
const startSearchText = selectionRef.current || searchTerm;
editor.commands.find({
text: startSearchText,
caseSensitive,
regexEnabled,
});
requestAnimationFrame(() => {
inputRef.current?.setSelectionRange(0, startSearchText.length);
});
if (selectionRef.current) {
setSearchTerm(selectionRef.current);
}
} else {
setShowReplace(false);
editor.commands.clearSearch();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [popover.visible]);
const navigation = (
<>
<Tooltip
tooltip={t("Previous match")}
shortcut="shift+enter"
delay={500}
placement="bottom"
>
<ButtonLarge onClick={() => editor.commands.prevSearchMatch()}>
<CaretUpIcon />
</ButtonLarge>
</Tooltip>
<Tooltip
tooltip={t("Next match")}
shortcut="enter"
delay={500}
placement="bottom"
>
<ButtonLarge onClick={() => editor.commands.nextSearchMatch()}>
<CaretDownIcon />
</ButtonLarge>
</Tooltip>
</>
);
return (
<Portal>
<Popover
{...popover}
unstable_finalFocusRef={finalFocusRef}
style={style}
aria-label={t("Find and replace")}
width={420}
>
<Content column>
<Flex gap={8}>
<StyledInput
ref={inputRef}
maxLength={255}
value={searchTerm}
placeholder={`${t("Find")}`}
onChange={handleChangeFind}
onKeyDown={handleKeyDown}
>
<SearchModifiers gap={8}>
<Tooltip
tooltip={t("Match case")}
shortcut={`${altDisplay}+${metaDisplay}+c`}
delay={500}
placement="bottom"
>
<ButtonSmall onClick={handleCaseSensitive}>
<CaseSensitiveIcon
color={caseSensitive ? theme.accent : theme.textSecondary}
/>
</ButtonSmall>
</Tooltip>
<Tooltip
tooltip={t("Enable regex")}
shortcut={`${altDisplay}+${metaDisplay}+r`}
delay={500}
placement="bottom"
>
<ButtonSmall onClick={handleRegex}>
<RegexIcon
color={regexEnabled ? theme.accent : theme.textSecondary}
/>
</ButtonSmall>
</Tooltip>
</SearchModifiers>
</StyledInput>
{navigation}
{!readOnly && (
<Tooltip
tooltip={t("Replace options")}
delay={500}
placement="bottom"
>
<ButtonLarge onClick={handleMore}>
<ReplaceIcon color={theme.textSecondary} />
</ButtonLarge>
</Tooltip>
)}
</Flex>
<ResizingHeightContainer>
{showReplace && !readOnly && (
<Flex gap={8}>
<StyledInput
maxLength={255}
value={replaceTerm}
ref={inputReplaceRef}
placeholder={t("Replacement")}
onKeyDown={handleReplaceKeyDown}
onRequestSubmit={handleReplaceAll}
onChange={(ev) => setReplaceTerm(ev.currentTarget.value)}
/>
<Button onClick={handleReplace} neutral>
{t("Replace")}
</Button>
<Button onClick={handleReplaceAll} neutral>
{t("Replace all")}
</Button>
</Flex>
)}
</ResizingHeightContainer>
</Content>
</Popover>
</Portal>
);
}
const SearchModifiers = styled(Flex)`
margin-right: 4px;
`;
const StyledInput = styled(Input)`
flex: 1;
`;
const ButtonSmall = styled(NudeButton)`
&:hover,
&[aria-expanded="true"] {
background: ${s("sidebarControlHoverBackground")};
}
`;
const ButtonLarge = styled(ButtonSmall)`
width: 32px;
height: 32px;
`;
const Content = styled(Flex)`
padding: 8px 0;
margin-bottom: -16px;
`;
@@ -131,6 +131,10 @@ export default function SelectionToolbar(props: Props) {
return;
}
if (!window.getSelection()?.isCollapsed) {
return;
}
const { dispatch } = view;
dispatch(
view.state.tr.setSelection(new TextSelection(view.state.doc.resolve(0)))
+4 -1
View File
@@ -105,7 +105,10 @@ function ToolbarMenu(props: Props) {
{item.children ? (
<ToolbarDropdown item={item} />
) : (
<ToolbarButton onClick={handleClick(item)} active={isActive}>
<ToolbarButton
onClick={handleClick(item)}
active={isActive && !item.label}
>
{item.label && <Label>{item.label}</Label>}
{item.icon}
</ToolbarButton>
+2 -1
View File
@@ -3,10 +3,11 @@ import styled from "styled-components";
import Tooltip from "~/components/Tooltip";
type Props = {
children?: React.ReactNode;
tooltip?: string;
};
const WrappedTooltip: React.FC<Props> = ({ children, tooltip }) => (
const WrappedTooltip: React.FC<Props> = ({ children, tooltip }: Props) => (
<Tooltip offset={[0, 16]} delay={150} tooltip={tooltip} placement="top">
<TooltipContent>{children}</TooltipContent>
</Tooltip>
+22 -16
View File
@@ -46,6 +46,7 @@ import BlockMenu from "./components/BlockMenu";
import ComponentView from "./components/ComponentView";
import EditorContext from "./components/EditorContext";
import EmojiMenu from "./components/EmojiMenu";
import FindAndReplace from "./components/FindAndReplace";
import { SearchResult } from "./components/LinkEditor";
import LinkToolbar from "./components/LinkToolbar";
import MentionMenu from "./components/MentionMenu";
@@ -770,17 +771,20 @@ export class Editor extends React.PureComponent<
ref={this.elementRef}
/>
{this.view && (
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
<>
<SelectionToolbar
rtl={isRTL}
readOnly={readOnly}
canComment={this.props.canComment}
isTemplate={this.props.template === true}
onOpen={this.handleOpenSelectionToolbar}
onClose={this.handleCloseSelectionToolbar}
onSearchLink={this.props.onSearchLink}
onClickLink={this.props.onClickLink}
onCreateLink={this.props.onCreateLink}
/>
{this.commands.find && <FindAndReplace readOnly={readOnly} />}
</>
)}
{!readOnly && this.view && (
<>
@@ -863,11 +867,13 @@ const EditorContainer = styled(Styles)<{ focusedCommentId?: string }>`
`;
const LazyLoadedEditor = React.forwardRef<Editor, Props>(
(props: Props, ref) => (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
</WithTheme>
)
function _LazyLoadedEditor(props: Props, ref) {
return (
<WithTheme>
{(theme) => <Editor theme={theme} {...props} ref={ref} />}
</WithTheme>
);
}
);
const observe = (
+3 -3
View File
@@ -10,21 +10,21 @@ export default function codeMenuItems(
readOnly: boolean | undefined,
dictionary: Dictionary
): MenuItem[] {
if (readOnly) {
return [];
}
const node = state.selection.$from.node();
return [
{
name: "copyToClipboard",
icon: <CopyIcon />,
label: readOnly ? dictionary.copy : undefined,
tooltip: dictionary.copy,
},
{
name: "separator",
visible: !readOnly,
},
{
visible: !readOnly,
name: "code_block",
icon: <ExpandedIcon />,
label: LANGUAGES[node.attrs.language ?? "none"],
+7 -7
View File
@@ -12,13 +12,6 @@ export default function dividerMenuItems(
const { schema } = state;
return [
{
name: "hr",
tooltip: dictionary.pageBreak,
attrs: { markup: "***" },
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
icon: <PageBreakIcon />,
},
{
name: "hr",
tooltip: dictionary.hr,
@@ -26,5 +19,12 @@ export default function dividerMenuItems(
active: isNodeActive(schema.nodes.hr, { markup: "---" }),
icon: <HorizontalRuleIcon />,
},
{
name: "hr",
tooltip: dictionary.pageBreak,
attrs: { markup: "***" },
active: isNodeActive(schema.nodes.hr, { markup: "***" }),
icon: <PageBreakIcon />,
},
];
}
+5 -1
View File
@@ -8,7 +8,11 @@ type MenuContextType = {
const MenuContext = React.createContext<MenuContextType | null>(null);
export const MenuProvider: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
export const MenuProvider: React.FC = ({ children }: Props) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const memoized = React.useMemo(
() => ({
+1 -1
View File
@@ -8,7 +8,7 @@ import useEventListener from "./useEventListener";
* @param callback The handler to call when a click outside the element is detected.
*/
export default function useOnClickOutside(
ref: React.RefObject<HTMLElement>,
ref: React.RefObject<HTMLElement | null>,
callback?: (event: MouseEvent | TouchEvent) => void
) {
const listener = React.useCallback(
+3 -2
View File
@@ -1,12 +1,13 @@
import * as React from "react";
const isSupported = "IntersectionObserver" in window;
/**
* Hook to return if a given ref is visible on screen.
*
* @returns boolean if the node is visible
*/
export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
const isSupported = "IntersectionObserver" in window;
const [isIntersecting, setIntersecting] = React.useState(!isSupported);
React.useEffect(() => {
@@ -28,7 +29,7 @@ export default function useOnScreen(ref: React.RefObject<HTMLElement>) {
observer?.unobserve(element);
}
};
}, []);
}, [ref]);
return isIntersecting;
}
+5 -1
View File
@@ -20,7 +20,11 @@ import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import separator from "~/menus/separator";
const AccountMenu: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const AccountMenu: React.FC = ({ children }: Props) => {
const menu = useMenuState({
placement: "bottom-end",
modal: true,
+5 -1
View File
@@ -11,7 +11,11 @@ import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import separator from "~/menus/separator";
const OrganizationMenu: React.FC = ({ children }) => {
type Props = {
children?: React.ReactNode;
};
const OrganizationMenu: React.FC = ({ children }: Props) => {
const menu = useMenuState({
unstable_offset: [4, -4],
placement: "bottom-start",
+45 -7
View File
@@ -44,44 +44,76 @@ export default class Document extends ParanoidModel {
store: DocumentsStore;
@Field
@observable
collectionId?: string | null;
@Field
@observable
id: string;
/**
* The id of the collection that this document belongs to, if any.
*/
@Field
@observable
collectionId?: string | null;
/**
* The text content of the document as Markdown.
*/
@observable
text: string;
/**
* The title of the document.
*/
@Field
@observable
title: string;
/**
* Whether this is a template.
*/
@observable
template: boolean;
/**
* Whether the document layout is displayed full page width.
*/
@Field
@observable
fullWidth: boolean;
/**
* Whether team members can see who has viewed this document.
*/
@observable
insightsEnabled: boolean;
/**
* A reference to the template that this document was created from.
*/
@Field
@observable
templateId: string | undefined;
/**
* The id of the parent document that this is a child of, if any.
*/
@Field
@observable
parentDocumentId: string | undefined;
@observable
collaboratorIds: string[];
@observable
createdBy: User;
@observable
updatedBy: User;
@observable
publishedAt: string | undefined;
@observable
archivedAt: string;
url: string;
@@ -319,12 +351,18 @@ export default class Document extends ParanoidModel {
templatize = () => this.store.templatize(this.id);
@action
save = async (options?: SaveOptions | undefined) => {
const params = this.toAPI();
save = async (
fields?: Partial<Document> | undefined,
options?: SaveOptions | undefined
) => {
const params = fields ?? this.toAPI();
this.isSaving = true;
try {
const model = await this.store.save({ ...params, id: this.id }, options);
const model = await this.store.save(
{ ...params, ...fields, id: this.id },
options
);
// if saving is successful set the new values on the model itself
set(this, { ...params, ...model });
+4 -6
View File
@@ -1,5 +1,5 @@
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useTranslation } from "react-i18next";
import Button from "~/components/Button";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
@@ -51,11 +51,9 @@ function APITokenNew({ onSubmit }: Props) {
return (
<form onSubmit={handleSubmit}>
<Text type="secondary">
<Trans>
Name your token something that will help you to remember it's use in
the future, for example "local development", "production", or
"continuous integration".
</Trans>
{t(
`Name your token something that will help you to remember it's use in the future, for example "local development", "production", or "continuous integration".`
)}
</Text>
<Flex>
<Input
+2 -1
View File
@@ -12,6 +12,7 @@ type Props = {
disabled: boolean;
accept: string;
collectionId: string;
children?: React.ReactNode;
};
const DropToImport: React.FC<Props> = ({
@@ -19,7 +20,7 @@ const DropToImport: React.FC<Props> = ({
disabled,
accept,
collectionId,
}) => {
}: Props) => {
const { handleFiles, isImporting } = useImportDocument(collectionId);
const { showToast } = useToasts();
const { t } = useTranslation();
@@ -1,4 +1,4 @@
import { differenceInMilliseconds, formatDistanceToNow } from "date-fns";
import { differenceInMilliseconds } from "date-fns";
import { toJS } from "mobx";
import { observer } from "mobx-react";
import { darken } from "polished";
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { dateToRelative } from "@shared/utils/date";
import { Minute } from "@shared/utils/time";
import Comment from "~/models/Comment";
import Avatar from "~/components/Avatar";
@@ -37,9 +38,9 @@ function useShowTime(
}
const previousTimeStamp = previousCreatedAt
? formatDistanceToNow(Date.parse(previousCreatedAt))
? dateToRelative(Date.parse(previousCreatedAt))
: undefined;
const currentTimeStamp = formatDistanceToNow(Date.parse(createdAt));
const currentTimeStamp = dateToRelative(Date.parse(createdAt));
const msSincePreviousComment = previousCreatedAt
? differenceInMilliseconds(
+1 -1
View File
@@ -305,7 +305,7 @@ class DocumentScene extends React.Component<Props> {
this.isPublishing = !!options.publish;
try {
const savedDocument = await document.save(options);
const savedDocument = await document.save(undefined, options);
this.isEditorDirty = false;
if (options.done) {
+140 -88
View File
@@ -12,9 +12,11 @@ import DocumentViews from "~/components/DocumentViews";
import Flex from "~/components/Flex";
import ListItem from "~/components/List/Item";
import PaginatedList from "~/components/PaginatedList";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
import Time from "~/components/Time";
import useKeyDown from "~/hooks/useKeyDown";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import useTextSelection from "~/hooks/useTextSelection";
import { documentPath } from "~/utils/routeHelpers";
@@ -30,6 +32,7 @@ function Insights() {
const { editor } = useDocumentContext();
const text = editor?.getPlainText();
const stats = useTextStats(text ?? "", selectedText);
const can = usePolicy(document);
const documentViews = document ? views.inDocument(document.id) : [];
const onCloseInsights = () => {
@@ -43,98 +46,137 @@ function Insights() {
return (
<Sidebar title={t("Insights")} onClose={onCloseInsights}>
{document ? (
<>
<Content column>
<Heading>{t("Stats")}</Heading>
<Text type="secondary" size="small">
<List>
{stats.total.words > 0 && (
<Flex
column
shrink={false}
style={{ minHeight: "100%" }}
justify="space-between"
>
<div>
<Content column>
<Heading>{t("Stats")}</Heading>
<Text type="secondary" size="small">
<List>
{stats.total.words > 0 && (
<li>
{t(`{{ count }} minute read`, {
count: stats.total.readingTime,
})}
</li>
)}
<li>
{t(`{{ count }} minute read`, {
count: stats.total.readingTime,
{t(`{{ count }} words`, { count: stats.total.words })}
</li>
<li>
{t(`{{ count }} characters`, {
count: stats.total.characters,
})}
</li>
)}
<li>{t(`{{ count }} words`, { count: stats.total.words })}</li>
<li>
{t(`{{ count }} characters`, {
count: stats.total.characters,
})}
</li>
<li>
{t(`{{ number }} emoji`, { number: stats.total.emoji })}
</li>
{stats.selected.characters === 0 ? (
<li>{t("No text selected")}</li>
) : (
<>
<li>
{t(`{{ count }} words selected`, {
count: stats.selected.words,
})}
</li>
<li>
{t(`{{ count }} characters selected`, {
count: stats.selected.characters,
})}
</li>
</>
)}
</List>
</Text>
</Content>
<Content column>
<Heading>{t("Contributors")}</Heading>
<Text type="secondary" size="small">
{t(`Created`)} <Time dateTime={document.createdAt} addSuffix />.
<br />
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />.
</Text>
<ListSpacing>
<PaginatedList
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model: User) => (
<ListItem
key={model.id}
title={model.name}
image={<Avatar model={model} size={32} />}
subtitle={
model.id === document.createdBy.id
? t("Creator")
: model.id === document.updatedBy.id
? t("Last edited")
: t("Previously edited")
}
border={false}
small
/>
)}
/>
</ListSpacing>
</Content>
<Content column>
<Heading>{t("Views")}</Heading>
<Text type="secondary" size="small">
{documentViews.length <= 1
? t("No one else has viewed yet")
: t(`Viewed {{ count }} times by {{ teamMembers }} people`, {
count: documentViews.reduce(
(memo, view) => memo + view.count,
0
),
teamMembers: documentViews.length,
})}
.
</Text>
{documentViews.length > 1 && (
<ListSpacing>
<DocumentViews document={document} isOpen />
</ListSpacing>
<li>
{t(`{{ number }} emoji`, { number: stats.total.emoji })}
</li>
{stats.selected.characters === 0 ? (
<li>{t("No text selected")}</li>
) : (
<>
<li>
{t(`{{ count }} words selected`, {
count: stats.selected.words,
})}
</li>
<li>
{t(`{{ count }} characters selected`, {
count: stats.selected.characters,
})}
</li>
</>
)}
</List>
</Text>
</Content>
{document.insightsEnabled && (
<>
<Content column>
<Heading>{t("Contributors")}</Heading>
<Text type="secondary" size="small">
{t(`Created`)}{" "}
<Time dateTime={document.createdAt} addSuffix />.
<br />
{t(`Last updated`)}{" "}
<Time dateTime={document.updatedAt} addSuffix />.
</Text>
<ListSpacing>
<PaginatedList
aria-label={t("Contributors")}
items={document.collaborators}
renderItem={(model: User) => (
<ListItem
key={model.id}
title={model.name}
image={<Avatar model={model} size={32} />}
subtitle={
model.id === document.createdBy.id
? t("Creator")
: model.id === document.updatedBy.id
? t("Last edited")
: t("Previously edited")
}
border={false}
small
/>
)}
/>
</ListSpacing>
</Content>
<Content column>
<Heading>{t("Views")}</Heading>
<Text type="secondary" size="small">
{documentViews.length <= 1
? t("No one else has viewed yet")
: t(
`Viewed {{ count }} times by {{ teamMembers }} people`,
{
count: documentViews.reduce(
(memo, view) => memo + view.count,
0
),
teamMembers: documentViews.length,
}
)}
.
</Text>
{documentViews.length > 1 && (
<ListSpacing>
<DocumentViews document={document} isOpen />
</ListSpacing>
)}
</Content>
</>
)}
</Content>
</>
</div>
{can.updateInsights && (
<Manage>
<Flex column>
<Text size="small" weight="bold">
{t("Viewer insights")}
</Text>
<Text type="secondary" size="small">
{t(
"As an admin you can manage if team members can see who has viewed this document"
)}
</Text>
</Flex>
<Switch
checked={document.insightsEnabled}
onChange={async (ev) => {
await document.save({
insightsEnabled: ev.currentTarget.checked,
});
}}
/>
</Manage>
)}
</Flex>
) : null}
</Sidebar>
);
@@ -166,6 +208,16 @@ function countWords(text: string): number {
return t ? t.replace(/-/g, " ").split(/\s+/g).length : 0;
}
const Manage = styled(Flex)`
background: ${s("background")};
border: 1px solid ${s("inputBorder")};
border-bottom-width: 2px;
border-radius: 8px;
margin: 16px;
padding: 16px 16px 0;
justify-self: flex-end;
`;
const ListSpacing = styled("div")`
margin-top: -0.5em;
margin-bottom: 0.5em;
@@ -94,12 +94,9 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
);
provider.on("authenticationFailed", () => {
showToast(
t(
"Sorry, it looks like you dont have permission to access the document"
)
);
history.replace(homePath());
void auth.fetch().catch(() => {
history.replace(homePath());
});
});
provider.on("awarenessChange", (event: AwarenessChangeEvent) => {
@@ -178,7 +175,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
window.removeEventListener("wheel", finishObserving);
window.removeEventListener("scroll", syncScrollPosition);
provider?.destroy();
localProvider?.destroy();
void localProvider?.destroy();
setRemoteProvider(null);
ui.setMultiplayerStatus(undefined);
};
@@ -193,6 +190,7 @@ function MultiplayerEditor({ onSynced, ...props }: Props, ref: any) {
token,
currentUser.id,
isMounted,
auth,
]);
const user = React.useMemo(
@@ -5,6 +5,7 @@ import { MenuInternalLink } from "~/types";
import { sharedDocumentPath } from "~/utils/routeHelpers";
type Props = {
children?: React.ReactNode;
documentId: string;
shareId: string;
sharedTree: NavigationNode | undefined;
@@ -44,7 +45,7 @@ const PublicBreadcrumb: React.FC<Props> = ({
shareId,
sharedTree,
children,
}) => {
}: Props) => {
const items: MenuInternalLink[] = React.useMemo(
() =>
pathToDocument(sharedTree, documentId)
@@ -57,7 +58,7 @@ const PublicBreadcrumb: React.FC<Props> = ({
[sharedTree, shareId, documentId]
);
return <Breadcrumb items={items} children={children} />;
return <Breadcrumb items={items}>{children}</Breadcrumb>;
};
export default PublicBreadcrumb;
@@ -1,4 +1,3 @@
import { formatDistanceToNow } from "date-fns";
import invariant from "invariant";
import { debounce, isEmpty } from "lodash";
import { observer } from "mobx-react";
@@ -8,7 +7,7 @@ import { useTranslation, Trans } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import { dateLocale } from "@shared/utils/date";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import { SHARE_URL_SLUG_REGEX } from "@shared/utils/urlHelpers";
import Document from "~/models/Document";
import Share from "~/models/Share";
@@ -193,13 +192,10 @@ function SharePopover({
<>
.{" "}
{t("The shared link was last accessed {{ timeAgo }}.", {
timeAgo: formatDistanceToNow(
Date.parse(share?.lastAccessedAt),
{
addSuffix: true,
locale,
}
),
timeAgo: dateToRelative(Date.parse(share?.lastAccessedAt), {
addSuffix: true,
locale,
}),
})}
</>
)}
+1 -1
View File
@@ -52,7 +52,7 @@ function DocumentPublish({ document }: Props) {
}
document.collectionId = collectionId;
await document.save({ publish: true });
await document.save(undefined, { publish: true });
showToast(t("Document published"), {
type: "success",
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react/no-unescaped-entities */
import { WarningIcon } from "outline-icons";
import * as React from "react";
import { Trans } from "react-i18next";
+5 -1
View File
@@ -50,7 +50,11 @@ function ApiKeys() {
For more details see the <em>developer documentation</em>."
components={{
em: (
<a href="https://www.getoutline.com/developers" target="_blank" />
<a
href="https://www.getoutline.com/developers"
target="_blank"
rel="noreferrer"
/>
),
}}
/>
@@ -10,10 +10,11 @@ import Button from "~/components/Button";
import Text from "~/components/Text";
type Props = {
children?: React.ReactNode;
title: React.ReactNode;
};
const HelpDisclosure: React.FC<Props> = ({ title, children }) => {
const HelpDisclosure: React.FC<Props> = ({ title, children }: Props) => {
const disclosure = useDisclosureState({ animated: true });
const theme = useTheme();
@@ -1,5 +1,5 @@
import * as React from "react";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import { FileOperationFormat } from "@shared/types";
import Flex from "~/components/Flex";
import Text from "~/components/Text";
@@ -8,6 +8,7 @@ import DropToImport from "./DropToImport";
import HelpDisclosure from "./HelpDisclosure";
function ImportNotionDialog() {
const { t } = useTranslation();
const { dialogs } = useStores();
return (
@@ -17,10 +18,11 @@ function ImportNotionDialog() {
onSubmit={dialogs.closeAllModals}
format={FileOperationFormat.Notion}
>
<Trans>
Drag and drop the zip file from Notion's HTML export option, or
click to upload
</Trans>
<>
{t(
`Drag and drop the zip file from Notion's HTML export option, or click to upload`
)}
</>
</DropToImport>
</Text>
<HelpDisclosure title={<Trans>Where do I find the file?</Trans>}>
@@ -6,6 +6,7 @@ import Flex from "~/components/Flex";
import Text from "~/components/Text";
type Props = {
children?: React.ReactNode;
label: React.ReactNode;
description?: React.ReactNode;
name: string;
@@ -63,7 +64,7 @@ const SettingRow: React.FC<Props> = ({
label,
border,
children,
}) => {
}: Props) => {
if (visible === false) {
return null;
}
+1 -4
View File
@@ -5,7 +5,6 @@ import { getCookie, setCookie, removeCookie } from "tiny-cookie";
import { CustomTheme, TeamPreferences, UserPreferences } from "@shared/types";
import Storage from "@shared/utils/Storage";
import { getCookieDomain, parseDomain } from "@shared/utils/domains";
import { Hour } from "@shared/utils/time";
import RootStore from "~/stores/RootStore";
import Policy from "~/models/Policy";
import Team from "~/models/Team";
@@ -106,9 +105,6 @@ export default class AuthStore {
this.rehydrate(data);
void this.fetch();
// Refresh the auth store every 12 hours that the window is open
setInterval(this.fetch, 12 * Hour);
// persists this entire store to localstorage whenever any keys are changed
autorun(() => {
Storage.set(AUTH_STORE, this.asJson);
@@ -238,6 +234,7 @@ export default class AuthStore {
if (err.error === "user_suspended") {
this.isSuspended = true;
this.suspendedContactEmail = err.data.adminEmail;
return;
}
} finally {
this.isFetching = false;
+10
View File
@@ -96,6 +96,16 @@ declare global {
* Go forward in history, if possible
*/
goForward: () => void;
/**
* Registers a callback to be called when the application wants to open the find in page dialog.
*/
onFindInPage: (callback: () => void) => void;
/**
* Registers a callback to be called when the application wants to open the replace in page dialog.
*/
onReplaceInPage: (callback: () => void) => void;
};
}
}
+16 -15
View File
@@ -16,6 +16,7 @@
"lint": "eslint app server shared plugins",
"prepare": "husky install",
"postinstall": "yarn patch-package",
"install-local-ssl": "node ./server/scripts/install-local-ssl.js",
"heroku-postbuild": "yarn build && yarn db:migrate",
"db:create-migration": "sequelize migration:create",
"db:create": "sequelize db:create",
@@ -62,7 +63,7 @@
"@hocuspocus/extension-throttle": "1.1.2",
"@hocuspocus/provider": "1.1.2",
"@hocuspocus/server": "1.1.2",
"@joplin/turndown-plugin-gfm": "^1.0.47",
"@joplin/turndown-plugin-gfm": "^1.0.49",
"@juggle/resize-observer": "^3.4.0",
"@outlinewiki/koa-passport": "^4.2.1",
"@outlinewiki/passport-azure-ad-oauth2": "^0.1.0",
@@ -91,7 +92,7 @@
"crypto-js": "^4.1.1",
"datadog-metrics": "^0.11.0",
"date-fns": "^2.30.0",
"dd-trace": "^3.21.0",
"dd-trace": "^3.32.1",
"dotenv": "^4.0.0",
"email-providers": "^1.13.1",
"emoji-regex": "^10.2.1",
@@ -101,12 +102,12 @@
"focus-visible": "^5.2.0",
"fractional-index": "^1.0.0",
"framer-motion": "^4.1.17",
"fs-extra": "^11.1.0",
"fs-extra": "^11.1.1",
"fuzzy-search": "^3.2.1",
"gemoji": "6.x",
"glob": "^8.1.0",
"http-errors": "2.0.0",
"i18next": "^22.5.0",
"i18next": "^22.5.1",
"i18next-fs-backend": "^2.1.1",
"i18next-http-backend": "^2.2.0",
"inline-css": "^4.0.2",
@@ -141,13 +142,13 @@
"natural-sort": "^1.0.0",
"node-fetch": "2.6.12",
"nodemailer": "^6.9.1",
"outline-icons": "^2.2.0",
"outline-icons": "^2.3.0",
"oy-vey": "^0.12.0",
"passport": "^0.6.0",
"passport-google-oauth2": "^0.2.0",
"passport-oauth2": "^1.6.1",
"passport-slack-oauth2": "^1.1.1",
"patch-package": "^7.0.0",
"patch-package": "^7.0.2",
"pg": "^8.11.1",
"pg-tsquery": "^8.4.1",
"polished": "^4.2.2",
@@ -191,7 +192,7 @@
"refractor": "^3.6.0",
"request-filtering-agent": "^1.1.2",
"semver": "^7.5.2",
"sequelize": "^6.29.0",
"sequelize": "^6.32.1",
"sequelize-cli": "^6.6.1",
"sequelize-encrypted": "^1.0.0",
"sequelize-typescript": "^2.1.5",
@@ -203,7 +204,7 @@
"socket.io-redis": "^6.1.1",
"stoppable": "^1.1.0",
"string-replace-to-array": "^2.1.0",
"styled-components": "^5.2.3",
"styled-components": "^5.3.11",
"styled-components-breakpoint": "^2.1.1",
"styled-normalize": "^8.0.7",
"throng": "^5.0.0",
@@ -219,7 +220,7 @@
"vite-plugin-pwa": "^0.14.4",
"winston": "^3.10.0",
"ws": "^7.5.3",
"y-indexeddb": "^9.0.9",
"y-indexeddb": "^9.0.11",
"y-protocols": "^1.0.5",
"yjs": "^13.6.1",
"zod": "^3.21.4"
@@ -257,7 +258,7 @@
"@types/koa-sslify": "^4.0.3",
"@types/koa-useragent": "^2.1.2",
"@types/markdown-it": "^12.2.3",
"@types/markdown-it-container": "^2.0.5",
"@types/markdown-it-container": "^2.0.6",
"@types/markdown-it-emoji": "^2.0.2",
"@types/mermaid": "^9.2.0",
"@types/mime-types": "^2.1.1",
@@ -284,21 +285,21 @@
"@types/sequelize": "^4.28.10",
"@types/slug": "^5.0.3",
"@types/stoppable": "^1.1.1",
"@types/styled-components": "^5.1.15",
"@types/styled-components": "^5.1.26",
"@types/throng": "^5.0.4",
"@types/tmp": "^0.2.3",
"@types/turndown": "^5.0.1",
"@types/utf8": "^3.0.1",
"@types/validator": "^13.7.17",
"@typescript-eslint/eslint-plugin": "^5.61.0",
"@typescript-eslint/parser": "^5.60.1",
"@typescript-eslint/parser": "^5.62.0",
"babel-eslint": "^10.1.0",
"babel-jest": "^29.6.1",
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
"babel-plugin-transform-typescript-metadata": "^0.3.2",
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^7.4.0",
"concurrently": "^7.6.0",
"enzyme": "^3.11.0",
"enzyme-adapter-react-16": "^1.15.7",
"eslint": "^8.45.0",
@@ -324,9 +325,9 @@
"react-refresh": "^0.14.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.2.0",
"terser": "^5.18.2",
"terser": "^5.19.2",
"typescript": "^5.0.0",
"vite-plugin-static-copy": "^0.13.0",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
},
"resolutions": {
+9 -9
View File
@@ -60,7 +60,7 @@ describe("email", () => {
email: user.email,
},
headers: {
host: "example.localoutline.com",
host: "example.outline.dev",
},
});
const body = await res.json();
@@ -71,7 +71,7 @@ describe("email", () => {
});
it("should not send email when user is on another subdomain but respond with success", async () => {
env.URL = sharedEnv.URL = "http://localoutline.com";
env.URL = sharedEnv.URL = "https://app.outline.dev";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
env.DEPLOYMENT = "hosted";
@@ -85,7 +85,7 @@ describe("email", () => {
email: user.email,
},
headers: {
host: "example.localoutline.com",
host: "example.outline.dev",
},
});
@@ -109,7 +109,7 @@ describe("email", () => {
email: user.email,
},
headers: {
host: "example.localoutline.com",
host: "example.outline.dev",
},
});
const body = await res.json();
@@ -129,7 +129,7 @@ describe("email", () => {
email: "user@example.com",
},
headers: {
host: "example.localoutline.com",
host: "example.outline.dev",
},
});
const body = await res.json();
@@ -141,7 +141,7 @@ describe("email", () => {
describe("with multiple users matching email", () => {
it("should default to current subdomain with SSO", async () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
env.URL = sharedEnv.URL = "http://localoutline.com";
env.URL = sharedEnv.URL = "https://app.outline.dev";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
const email = "sso-user@example.org";
const team = await buildTeam({
@@ -159,7 +159,7 @@ describe("email", () => {
email,
},
headers: {
host: "example.localoutline.com",
host: "example.outline.dev",
},
});
const body = await res.json();
@@ -171,7 +171,7 @@ describe("email", () => {
it("should default to current subdomain with guest email", async () => {
const spy = jest.spyOn(SigninEmail.prototype, "schedule");
env.URL = sharedEnv.URL = "http://localoutline.com";
env.URL = sharedEnv.URL = "https://app.outline.dev";
env.SUBDOMAINS_ENABLED = sharedEnv.SUBDOMAINS_ENABLED = true;
const email = "guest-user@example.org";
const team = await buildTeam({
@@ -189,7 +189,7 @@ describe("email", () => {
email,
},
headers: {
host: "example.localoutline.com",
host: "example.outline.dev",
},
});
const body = await res.json();
+63 -9
View File
@@ -1,22 +1,76 @@
import fetch from "fetch-with-proxy";
import env from "@server/env";
import { InvalidRequestError } from "@server/errors";
import { InternalError } from "@server/errors";
import Logger from "@server/logging/Logger";
import Redis from "@server/redis";
class Iframely {
private static apiUrl = `${env.IFRAMELY_URL}/api`;
private static apiKey = env.IFRAMELY_API_KEY;
private static cacheKeyPrefix = "unfurl";
private static defaultCacheExpiry = 86400;
public static async get(url: string, type = "oembed") {
private static cacheKey(url: string) {
return `${this.cacheKeyPrefix}-${url}`;
}
private static async cache(url: string, response: any) {
// do not cache error responses
if (response.error) {
return;
}
try {
const res = await fetch(
`${this.apiUrl}/${type}?url=${encodeURIComponent(url)}&api_key=${
this.apiKey
}`
await Redis.defaultClient.set(
this.cacheKey(url),
JSON.stringify(response),
"EX",
response.cache_age || this.defaultCacheExpiry
);
const data = await res.json();
return data;
} catch (err) {
throw InvalidRequestError(err);
// just log it, can skip caching and directly return response
Logger.error("Could not cache Iframely response", err);
}
}
private static async fetch(url: string, type = "oembed") {
const res = await fetch(
`${this.apiUrl}/${type}?url=${encodeURIComponent(url)}&api_key=${
this.apiKey
}`
);
return res.json();
}
private static async cached(url: string) {
try {
const val = await Redis.defaultClient.get(this.cacheKey(url));
if (val) {
return JSON.parse(val);
}
} catch (err) {
// just log it, response can still be obtained using the fetch call
Logger.error("Could not fetch cached Iframely response", err);
}
}
/**
* Fetches the preview data for the given url
* using Iframely oEmbed API
*
* @param url
* @returns Preview data for the url
*/
public static async get(url: string) {
try {
const cached = await this.cached(url);
if (cached) {
return cached;
}
const res = await this.fetch(url);
await this.cache(url, res);
return res;
} catch (err) {
throw InternalError(err);
}
}
}
+7 -1
View File
@@ -1,6 +1,7 @@
import { t } from "i18next";
import Router from "koa-router";
import { escapeRegExp } from "lodash";
import { Op } from "sequelize";
import { IntegrationService } from "@shared/types";
import env from "@server/env";
import { AuthenticationError, InvalidRequestError } from "@server/errors";
@@ -196,6 +197,7 @@ router.post("hooks.slack", async (ctx: APIContext) => {
// via integration
const integration = await Integration.findOne({
where: {
service: IntegrationService.Slack,
settings: {
serviceTeamId: team_id,
},
@@ -217,7 +219,10 @@ router.post("hooks.slack", async (ctx: APIContext) => {
if (text.trim() === "help" || !text.trim()) {
ctx.body = {
response_type: "ephemeral",
text: "How to use /outline",
text: t("How to use {{ command }}", {
command: "/outline",
...opts(user),
}),
attachments: [
{
text: t(
@@ -256,6 +261,7 @@ router.post("hooks.slack", async (ctx: APIContext) => {
if (!user) {
const auth = await IntegrationAuthentication.findOne({
where: {
scopes: { [Op.contains]: ["identity.email"] },
service: IntegrationService.Slack,
teamId: team.id,
},
+6
View File
@@ -19,6 +19,8 @@ type Props = {
templateId?: string | null;
/** If the document should be displayed full-width on the screen */
fullWidth?: boolean;
/** Whether insights should be visible on the document */
insightsEnabled?: boolean;
/** Whether the text be appended to the end instead of replace */
append?: boolean;
/** Whether the document should be published to the collection */
@@ -46,6 +48,7 @@ export default async function documentUpdater({
editorVersion,
templateId,
fullWidth,
insightsEnabled,
append,
publish,
collectionId,
@@ -68,6 +71,9 @@ export default async function documentUpdater({
if (fullWidth !== undefined) {
document.fullWidth = fullWidth;
}
if (insightsEnabled !== undefined) {
document.insightsEnabled = insightsEnabled;
}
if (text !== undefined) {
document = DocumentHelper.applyMarkdownToDocument(document, text, append);
}
+31 -31
View File
@@ -1,7 +1,6 @@
import { has } from "lodash";
import { Transaction } from "sequelize";
import { TeamPreference } from "@shared/types";
import { sequelize } from "@server/database/sequelize";
import env from "@server/env";
import { Event, Team, TeamDomain, User } from "@server/models";
@@ -10,9 +9,16 @@ type TeamUpdaterProps = {
ip?: string;
user: User;
team: Team;
transaction: Transaction;
};
const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
const teamUpdater = async ({
params,
user,
team,
ip,
transaction,
}: TeamUpdaterProps) => {
const {
name,
avatarUrl,
@@ -28,8 +34,6 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
preferences,
} = params;
const transaction: Transaction = await sequelize.transaction();
if (subdomain !== undefined && env.SUBDOMAINS_ENABLED) {
team.subdomain = subdomain === "" ? null : subdomain;
}
@@ -110,35 +114,31 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => {
const changes = team.changed();
try {
const savedTeam = await team.save({
transaction,
});
if (changes) {
const data = changes.reduce(
(acc, curr) => ({ ...acc, [curr]: team[curr] }),
{}
);
const savedTeam = await team.save({
transaction,
});
await Event.create(
{
name: "teams.update",
actorId: user.id,
teamId: user.teamId,
data,
ip,
},
{
transaction,
}
);
}
await transaction.commit();
return savedTeam;
} catch (error) {
await transaction.rollback();
throw error;
if (changes) {
const data = changes.reduce(
(acc, curr) => ({ ...acc, [curr]: team[curr] }),
{}
);
await Event.create(
{
name: "teams.update",
actorId: user.id,
teamId: user.teamId,
data,
ip,
},
{
transaction,
}
);
}
return savedTeam;
};
export default teamUpdater;
View File
+17 -12
View File
@@ -1,4 +1,5 @@
import Bull from "bull";
import * as React from "react";
import mailer from "@server/emails/mailer";
import Logger from "@server/logging/Logger";
import Metrics from "@server/logging/Metrics";
@@ -84,6 +85,9 @@ export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
}
const data = { ...this.props, ...(bsResponse ?? ({} as S)) };
const notification = this.metadata?.notificationId
? await Notification.unscoped().findByPk(this.metadata?.notificationId)
: undefined;
try {
await mailer.sendMail({
@@ -91,7 +95,12 @@ export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
fromName: this.fromName?.(data),
subject: this.subject(data),
previewText: this.preview(data),
component: this.render(data),
component: (
<>
{this.render(data)}
{notification ? this.pixel(notification) : null}
</>
),
text: this.renderAsText(data),
headCSS: this.headCSS?.(data),
});
@@ -105,24 +114,20 @@ export default abstract class BaseEmail<T extends EmailProps, S = unknown> {
throw err;
}
if (this.metadata?.notificationId) {
if (notification) {
try {
await Notification.update(
{
emailedAt: new Date(),
},
{
where: {
id: this.metadata.notificationId,
},
}
);
notification.emailedAt = new Date();
await notification.save();
} catch (err) {
Logger.error(`Failed to update notification`, err, this.metadata);
}
}
}
private pixel(notification: Notification) {
return <img src={notification.pixelUrl} width="1" height="1" />;
}
/**
* Returns the subject of the email.
*

Some files were not shown because too many files have changed in this diff Show More