Compare commits

...

21 Commits

Author SHA1 Message Date
codegen-sh[bot] e6d552b92d Add emoji settings page with table management (#9323)
* Add emoji settings page with table management

- Add EmojisTable component with sortable columns for emoji, name, creator, and creation date
- Add EmojiMenu component for emoji management actions (delete)
- Add EmojiUploadDialog with drag-and-drop file upload and name validation
- Add emoji actions for create and delete operations
- Integrate emoji settings page into workspace settings navigation
- Follow existing Table component patterns and styling
- Include proper permissions checking and error handling

* fixes

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-06-14 12:09:44 -04:00
codegen-sh[bot] cc1ec6cb71 Add frontend EmojiModel and EmojisStore (#9321)
* Add frontend EmojiModel and EmojisStore

- Create Emoji model with properties matching server presenter
- Add EmojisStore with team-scoped emoji filtering
- Register EmojisStore in RootStore
- Follow existing patterns for model and store implementation

* Remove teamId and teamEmojis, add @Relation decorator

- Remove teamId field from Emoji model (not needed in team context)
- Remove teamEmojis computed property from EmojisStore
- Add @Relation decorator to createdBy property
- Update emojisByName to use orderedData directly

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-27 08:57:14 -04:00
Tom Moor 3ad0e58d35 refactor 2025-05-24 19:14:22 -04:00
Tom Moor 4c75f31b2a Add emojis.info, minor fixes 2025-05-24 19:06:34 -04:00
codegen-sh[bot] 72e4fca1c4 test: Add comprehensive test coverage for emoji API routes
- Add buildEmoji factory method to test/factories.ts
- Create emojis.test.ts with full test coverage for all endpoints
- Test authentication, authorization, validation, and business logic
- Follow existing test patterns and handle all edge cases
2025-05-24 17:20:16 +00:00
codegen-sh[bot] 495b0e1a56 refactor: Improve emoji backend implementation
- Move max length values to EmojiValidation class in shared/validations
- Remove teamId override ability from emojis.list for better security
- Use createWithCtx and destroyWithCtx methods for automatic transaction handling
- Consolidate emoji delete policy into single rule using 'or' utility
- Clean up unused imports

These changes improve security, maintainability, and consistency with existing patterns.
2025-05-24 16:43:14 +00:00
codegen-sh[bot] 03de720f6f feat: Add custom emoji backend implementation
- Add Emoji model with team and user associations
- Create migration for emojis table with proper indexes
- Implement API routes: emojis.list, emojis.create, emojis.delete
- Add emoji presenter for consistent API responses
- Implement emoji policies for team-based authorization
- Register emoji routes and exports in main API

Addresses issue #9278
2025-05-24 16:27:03 +00:00
Tom Moor 5b7a5d751c Fix: Properly escape backslashes in search queries (#9297)
* Fix: Properly escape backslashes in search queries

This commit fixes a bug where backslashes in search queries were not properly escaped, leading to database errors.
The `escapeQuery` method is now applied to quoted queries and URLs to ensure that all parts of the search query are correctly escaped.

A test case has been added to verify that searching with backslashes works as expected.

* Fix: Properly escape backslashes in search query URLs

This commit fixes a bug where backslashes in URLs within search queries were not properly escaped, leading to database errors.
The `escapeQuery` method is now applied to `likelyUrls` before they are used in `iLike` conditions.

Quoted queries were found to be already escaped and are no longer double-escaped.
The existing test case for searching with backslashes remains relevant for verifying URL escaping.

* lint

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-05-24 11:30:00 -04:00
Tom Moor 73fea094a8 Prevent outdated clients from connecting to collaboration server (#9291)
Follow up to #8751
2025-05-23 23:49:22 -04:00
Hemachandar 82b5b87440 fix: Skip fetching children for unsupported Notion blocks (#9289)
* fix: Skip fetching children for unsupported Notion blocks

* better naming
2025-05-23 17:17:05 -04:00
Tom Moor 0170b98974 perf: Various improvements to collaborators facepile (#9281) 2025-05-23 14:51:17 -04:00
Tom Moor f7f1f07716 perf: Utilize plugin state in placeholder plugin (#9283)
* perf: Utilize plugin state in placeholder plugin

* perf: Move textContent calculation out of loop

* Update shared/editor/plugins/PlaceholderPlugin.ts

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>

* Update PlaceholderPlugin.ts

---------

Co-authored-by: Apoorv Mishra <apoorvmishra101092@gmail.com>
2025-05-23 14:51:09 -04:00
Tom Moor b7e80036eb Add warning on large documents (#9282)
* Add warning on large documents

* Update Document.tsx
2025-05-23 08:55:03 -04:00
Tom Moor 3ffee1239b Add revision deletion endpoints (#9240) 2025-05-21 22:57:02 -04:00
Tom Moor 22c0f18b6b fix: null collectionId received in websocket payload for unpublished drafts (#9276) 2025-05-21 22:44:06 -04:00
Tom Moor 76d9a02fee fix: Use team name on link metadata if public branding enabled (#9273) 2025-05-21 21:01:31 -04:00
Tom Moor 1752c04c06 fix: Initial avatars in notification popover malformed (#9274)
closes #9272
2025-05-21 21:01:23 -04:00
Tom Moor 084490ba6b chore: Remove React in scope requirement (#9261)
* Add rules

* codemod: update-react-imports

* Update babelrc
2025-05-20 19:26:11 -04:00
codegen-sh[bot] eaf2fd102e Add attachments.list API endpoint (#9259)
* Add attachments.list endpoint to retrieve user's attachments

* Fix TypeScript error in attachments.list endpoint

* Address review comments: restore tests, properly type WhereOptions, remove comments

* fix: Pagination, user filtering

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
Co-authored-by: Codegen Bot <codegen@example.com>
Co-authored-by: Tom Moor <tom@getoutline.com>
2025-05-20 19:25:55 -04:00
codegen-sh[bot] 3bc566915e Fix document URL generation for titles with only special characters (#9258)
* Fix document URL generation for titles with only special characters

* Refactor: Update instance path method to use static getPath method

---------

Co-authored-by: codegen-sh[bot] <131295404+codegen-sh[bot]@users.noreply.github.com>
2025-05-20 07:32:07 -04:00
Tom Moor 19627f4d07 fix: Guard all import tasks being filtered out (#9256) 2025-05-19 23:18:31 -04:00
324 changed files with 2765 additions and 985 deletions
+7 -2
View File
@@ -1,6 +1,11 @@
{
"presets": [
"@babel/preset-react",
[
"@babel/preset-react",
{
"runtime": "automatic"
}
],
"@babel/preset-env",
"@babel/preset-typescript"
],
@@ -60,4 +65,4 @@
]
}
}
}
}
+43 -12
View File
@@ -2,7 +2,9 @@
"parser": "@typescript-eslint/parser",
"parserOptions": {
"sourceType": "module",
"extraFileExtensions": [".json"],
"extraFileExtensions": [
".json"
],
"project": "./tsconfig.json",
"ecmaFeatures": {
"jsx": true
@@ -17,6 +19,7 @@
],
"plugins": [
"es",
"react",
"@typescript-eslint",
"eslint-plugin-import",
"eslint-plugin-node",
@@ -27,21 +30,30 @@
"eqeqeq": 2,
"curly": 2,
"no-console": "error",
"arrow-body-style": ["error", "as-needed"],
"arrow-body-style": [
"error",
"as-needed"
],
"spaced-comment": "error",
"object-shorthand": "error",
"no-mixed-operators": "off",
"no-useless-escape": "off",
"no-shadow": "off",
"es/no-regexp-lookbehind-assertions": "error",
"react/self-closing-comp": ["error", {
"component": true,
"html": true
}],
"react/react-in-jsx-scope": "off",
"react/self-closing-comp": [
"error",
{
"component": true,
"html": true
}
],
"@typescript-eslint/no-shadow": [
"warn",
{
"allow": ["transaction"],
"allow": [
"transaction"
],
"hoist": "all",
"ignoreTypeValueShadow": true
}
@@ -63,9 +75,25 @@
"ignoreRestSiblings": true
}
],
"padding-line-between-statements": ["error", { "blankLine": "always", "prev": "*", "next": "export" }],
"lines-between-class-members": ["error", "always", { "exceptAfterSingleLine": true }],
"lodash/import-scope": ["error", "method"],
"padding-line-between-statements": [
"error",
{
"blankLine": "always",
"prev": "*",
"next": "export"
}
],
"lines-between-class-members": [
"error",
"always",
{
"exceptAfterSingleLine": true
}
],
"lodash/import-scope": [
"error",
"method"
],
"import/no-named-as-default": "off",
"import/no-named-as-default-member": "off",
"import/newline-after-import": 2,
@@ -134,10 +162,13 @@
"version": "detect"
},
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"]
"@typescript-eslint/parser": [
".ts",
".tsx"
]
},
"import/resolver": {
"typescript": {}
}
}
}
}
+3
View File
@@ -7,6 +7,9 @@
"plugins": [
"eslint-plugin-react-hooks"
],
"rules": {
"react/react-in-jsx-scope": "off"
},
"env": {
"jest": true,
"browser": true
-1
View File
@@ -1,5 +1,4 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import ApiKeyNew from "~/scenes/ApiKeyNew";
import { createAction } from "..";
-1
View File
@@ -13,7 +13,6 @@ import {
UnstarredIcon,
UnsubscribeIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Collection from "~/models/Collection";
import { CollectionEdit } from "~/components/Collection/CollectionEdit";
-1
View File
@@ -1,5 +1,4 @@
import { DoneIcon, SmileyIcon, TrashIcon } from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import stores from "~/stores";
import Comment from "~/models/Comment";
-1
View File
@@ -7,7 +7,6 @@ import {
TrashIcon,
UserIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import { createAction } from "~/actions";
import { DeveloperSection } from "~/actions/sections";
-1
View File
@@ -31,7 +31,6 @@ import {
LogoutIcon,
CaseSensitiveIcon,
} from "outline-icons";
import * as React from "react";
import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import {
+37
View File
@@ -0,0 +1,37 @@
import { PlusIcon } from "outline-icons";
import stores from "~/stores";
import Emoji from "~/models/Emoji";
import { EmojiUploadDialog } from "~/components/EmojiDialogs";
import { createAction } from "~/actions";
import { TeamSection } from "~/actions/sections";
export const createEmoji = createAction({
name: ({ t }) => `${t("Add emoji")}`,
analyticsName: "Create emoji",
icon: <PlusIcon />,
keywords: "emoji custom upload image",
section: TeamSection,
visible: () =>
stores.policies.abilities(stores.auth.team?.id || "").createEmoji,
perform: ({ t }) => {
stores.dialogs.openModal({
title: t("Add custom emoji"),
content: <EmojiUploadDialog onSubmit={stores.dialogs.closeAllModals} />,
});
},
});
export const deleteEmojiActionFactory = (emoji: Emoji) =>
createAction({
name: ({ t }) => `${t("Delete")}`,
analyticsName: "Delete emoji",
section: TeamSection,
dangerous: true,
visible: () => {
const can = stores.policies.abilities(emoji.id);
return can.delete;
},
perform: async () => {
await stores.emojis.delete(emoji);
},
});
-1
View File
@@ -13,7 +13,6 @@ import {
ShapesIcon,
DraftsIcon,
} from "outline-icons";
import * as React from "react";
import { UrlHelper } from "@shared/utils/UrlHelper";
import { isMac } from "@shared/utils/browser";
import stores from "~/stores";
@@ -1,5 +1,4 @@
import { ArchiveIcon, MarkAsReadIcon } from "outline-icons";
import * as React from "react";
import { createAction } from "..";
import { NotificationSection } from "../sections";
-1
View File
@@ -1,5 +1,4 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { OAuthClientNew } from "~/components/OAuthClient/OAuthClientNew";
import { createAction } from "..";
+34 -3
View File
@@ -1,6 +1,5 @@
import copy from "copy-to-clipboard";
import { LinkIcon, RestoreIcon } from "outline-icons";
import * as React from "react";
import { LinkIcon, RestoreIcon, TrashIcon } from "outline-icons";
import { matchPath } from "react-router-dom";
import { toast } from "sonner";
import stores from "~/stores";
@@ -13,7 +12,7 @@ import {
} from "~/utils/routeHelpers";
export const restoreRevision = createAction({
name: ({ t }) => t("Restore revision"),
name: ({ t }) => t("Restore"),
analyticsName: "Restore revision",
icon: <RestoreIcon />,
section: RevisionSection,
@@ -42,6 +41,38 @@ export const restoreRevision = createAction({
},
});
export const deleteRevision = createAction({
name: ({ t }) => t("Delete"),
analyticsName: "Delete revision",
icon: <TrashIcon />,
section: RevisionSection,
dangerous: true,
visible: ({ activeDocumentId }) =>
!!activeDocumentId && stores.policies.abilities(activeDocumentId).update,
perform: async ({ t, event, location, activeDocumentId }) => {
event?.preventDefault();
if (!activeDocumentId) {
return;
}
const document = stores.documents.get(activeDocumentId);
if (!document) {
return;
}
const match = matchPath<{ revisionId: string }>(location.pathname, {
path: matchDocumentHistory,
});
const revisionId = match?.params.revisionId;
if (revisionId) {
const revision = stores.revisions.get(revisionId);
await revision?.delete();
toast.success(t("This version of the document was deleted"));
history.push(documentHistoryPath(document));
}
},
});
export const copyLinkToRevision = createAction({
name: ({ t }) => t("Copy link"),
analyticsName: "Copy link to revision",
-1
View File
@@ -1,5 +1,4 @@
import { SunIcon, MoonIcon, BrowserIcon } from "outline-icons";
import * as React from "react";
import stores from "~/stores";
import { Theme } from "~/stores/UiStore";
import { createAction } from "~/actions";
-1
View File
@@ -1,5 +1,4 @@
import { ArrowIcon, PlusIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { stringToColor } from "@shared/utils/color";
import RootStore from "~/stores/RootStore";
-1
View File
@@ -1,5 +1,4 @@
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { UserRole } from "@shared/types";
import { UserRoleHelper } from "@shared/utils/UserRoleHelper";
import stores from "~/stores";
-1
View File
@@ -1,5 +1,4 @@
import flattenDeep from "lodash/flattenDeep";
import * as React from "react";
import { toast } from "sonner";
import { Optional } from "utility-types";
import { v4 as uuidv4 } from "uuid";
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react";
export default function Arrow() {
return (
<svg
+2 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Redirect } from "react-router-dom";
import useCurrentUser from "~/hooks/useCurrentUser";
@@ -19,7 +19,7 @@ const Authenticated = ({ children }: Props) => {
// Watching for language changes here as this is the earliest point we might have the user
// available and means we can start loading translations faster
React.useEffect(() => {
useEffect(() => {
void changeLanguage(language, i18n);
}, [i18n, language]);
+15 -2
View File
@@ -45,12 +45,23 @@ type Props = {
};
function Avatar(props: Props) {
const { model, style, variant = AvatarVariant.Round, ...rest } = props;
const {
model,
style,
variant = AvatarVariant.Round,
className,
...rest
} = props;
const src = props.src || model?.avatarUrl;
const [error, handleError] = useBoolean(false);
return (
<Relative style={style} $variant={variant} $size={props.size}>
<Relative
style={style}
$variant={variant}
$size={props.size}
className={className}
>
{src && !error ? (
<Image onError={handleError} src={src} {...rest} />
) : model ? (
@@ -75,6 +86,8 @@ const Relative = styled.div<{ $variant: AvatarVariant; $size: AvatarSize }>`
border-radius: ${(props) =>
props.$variant === AvatarVariant.Round ? "50%" : `${props.$size / 8}px`};
overflow: hidden;
width: ${(props) => props.$size}px;
height: ${(props) => props.$size}px;
`;
const Image = styled.img<{ size: number }>`
-1
View File
@@ -1,5 +1,4 @@
import { GroupIcon } from "outline-icons";
import * as React from "react";
import { useTheme } from "styled-components";
import Squircle from "@shared/components/Squircle";
import Group from "~/models/Group";
+2 -2
View File
@@ -1,7 +1,7 @@
import Avatar, { IAvatar, AvatarSize } from "./Avatar";
import Avatar, { IAvatar, AvatarSize, AvatarVariant } from "./Avatar";
import AvatarWithPresence from "./AvatarWithPresence";
import { GroupAvatar } from "./GroupAvatar";
export { Avatar, GroupAvatar, AvatarSize, AvatarWithPresence };
export { Avatar, GroupAvatar, AvatarSize, AvatarVariant, AvatarWithPresence };
export type { IAvatar };
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { depths, s } from "@shared/styles";
+2 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { changeLanguage } from "~/utils/language";
@@ -9,7 +9,7 @@ type Props = {
export default function ChangeLanguage({ locale }: Props) {
const { i18n } = useTranslation();
React.useEffect(() => {
useEffect(() => {
void changeLanguage(locale, i18n);
}, [locale, i18n]);
-1
View File
@@ -1,4 +1,3 @@
import React from "react";
import styled, { useTheme } from "styled-components";
const cleanPercentage = (percentage: number) => {
+70 -34
View File
@@ -3,7 +3,7 @@ import isEqual from "lodash/isEqual";
import orderBy from "lodash/orderBy";
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useMemo, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { usePopoverState, PopoverDisclosure } from "reakit/Popover";
import Document from "~/models/Document";
@@ -31,58 +31,97 @@ function Collaborators(props: Props) {
const { t } = useTranslation();
const user = useCurrentUser();
const currentUserId = user?.id;
const [requestedUserIds, setRequestedUserIds] = React.useState<string[]>([]);
const [requestedUserIds, setRequestedUserIds] = useState<string[]>([]);
const { users, presence, ui } = useStores();
const { document } = props;
const { observingUserId } = ui;
const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresenceArray.map((p) => p.userId);
const editingIds = documentPresenceArray
.filter((p) => p.isEditing)
.map((p) => p.userId);
// Use Set for O(1) lookups and stable references
const presentIds = useMemo(
() => new Set(documentPresenceArray.map((p) => p.userId)),
[documentPresenceArray]
);
const editingIds = useMemo(
() =>
new Set(
documentPresenceArray.filter((p) => p.isEditing).map((p) => p.userId)
),
[documentPresenceArray]
);
// ensure currently present via websocket are always ordered first
const collaborators = React.useMemo(
// Memoize collaboratorIds as a Set for efficient lookup
const collaboratorIdsSet = useMemo(
() => new Set(document.collaboratorIds),
[document.collaboratorIds]
);
const collaborators = useMemo(
() =>
orderBy(
filter(
users.all,
(u) =>
(presentIds.includes(u.id) ||
document.collaboratorIds.includes(u.id)) &&
(presentIds.has(u.id) || collaboratorIdsSet.has(u.id)) &&
!u.isSuspended
),
[(u) => presentIds.includes(u.id), "id"],
[(u) => presentIds.has(u.id), "id"],
["asc", "asc"]
),
[document.collaboratorIds, users.all, presentIds]
[collaboratorIdsSet, users.all, presentIds]
);
// load any users we don't yet have in memory
React.useEffect(() => {
const ids = uniq([...document.collaboratorIds, ...presentIds])
.filter((userId) => !users.get(userId))
.sort();
// Memoize ids to avoid unnecessary effect executions
const missingUserIds = useMemo(
() =>
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
.filter((userId) => !users.get(userId))
.sort(),
[document.collaboratorIds, presentIds, users]
);
if (!isEqual(requestedUserIds, ids) && ids.length > 0) {
setRequestedUserIds(ids);
void users.fetchPage({ ids, limit: 100 });
useEffect(() => {
if (
!isEqual(requestedUserIds, missingUserIds) &&
missingUserIds.length > 0
) {
setRequestedUserIds(missingUserIds);
void users.fetchPage({ ids: missingUserIds, limit: 100 });
}
}, [document, users, presentIds, document.collaboratorIds, requestedUserIds]);
}, [missingUserIds, requestedUserIds, users]);
const popover = usePopoverState({
gutter: 0,
placement: "bottom-end",
});
const renderAvatar = React.useCallback(
// Memoize onClick handler to avoid inline function creation
const handleAvatarClick = useCallback(
(
collaboratorId: string,
isPresent: boolean,
isObserving: boolean,
isObservable: boolean
) =>
(ev: React.MouseEvent) => {
if (isObservable && isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(isObserving ? undefined : collaboratorId);
}
},
[ui]
);
const renderAvatar = useCallback(
({ model: collaborator, ...rest }) => {
const isPresent = presentIds.includes(collaborator.id);
const isEditing = editingIds.includes(collaborator.id);
const isObserving = ui.observingUserId === collaborator.id;
const isPresent = presentIds.has(collaborator.id);
const isEditing = editingIds.has(collaborator.id);
const isObserving = observingUserId === collaborator.id;
const isObservable = collaborator.id !== currentUserId;
return (
@@ -96,21 +135,18 @@ function Collaborators(props: Props) {
isCurrentUser={currentUserId === collaborator.id}
onClick={
isObservable
? (ev) => {
if (isPresent) {
ev.preventDefault();
ev.stopPropagation();
ui.setObservingUser(
isObserving ? undefined : collaborator.id
);
}
}
? handleAvatarClick(
collaborator.id,
isPresent,
isObserving,
isObservable
)
: undefined
}
/>
);
},
[presentIds, ui, currentUserId, editingIds]
[presentIds, editingIds, observingUserId, currentUserId, handleAvatarClick]
);
return (
@@ -133,7 +169,7 @@ function Collaborators(props: Props) {
)}
</PopoverDisclosure>
<Popover {...popover} width={300} aria-label={t("Viewers")} tabIndex={0}>
<DocumentViews document={document} isOpen={popover.visible} />
{popover.visible && <DocumentViews document={document} />}
</Popover>
</>
);
+2 -2
View File
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import { CollectionForm, FormData } from "./CollectionForm";
@@ -16,7 +16,7 @@ export const CollectionEdit = observer(function CollectionEdit_({
const { collections } = useStores();
const collection = collections.get(collectionId);
const handleSubmit = React.useCallback(
const handleSubmit = useCallback(
async (data: FormData) => {
try {
await collection?.save(data);
+8 -8
View File
@@ -1,6 +1,6 @@
import uniq from "lodash/uniq";
import { observer } from "mobx-react";
import * as React from "react";
import { useMemo, useEffect, useCallback, Suspense } from "react";
import { Controller, useForm } from "react-hook-form";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -40,7 +40,7 @@ const useIconColor = (collection?: Collection) => {
collections.orderedData.map((c) => c.color).filter(Boolean)
) as string[];
const iconColor = React.useMemo(
const iconColor = useMemo(
() =>
collection?.color ??
// If all the existing collections have the same color, use that color,
@@ -90,11 +90,11 @@ export const CollectionForm = observer(function CollectionForm_({
const values = watch();
// Preload the IconPicker component on mount
React.useEffect(() => {
useEffect(() => {
void IconPicker.preload();
}, []);
React.useEffect(() => {
useEffect(() => {
// If the user hasn't picked an icon yet, go ahead and suggest one based on
// the name of the collection. It's the little things sometimes.
if (!hasOpenedIconPicker && !collection) {
@@ -107,11 +107,11 @@ export const CollectionForm = observer(function CollectionForm_({
}
}, [collection, hasOpenedIconPicker, setValue, values.name, values.icon]);
React.useEffect(() => {
useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
const handleIconChange = React.useCallback(
const handleIconChange = useCallback(
(icon: string, color: string | null) => {
if (icon !== values.icon) {
setFocus("name");
@@ -140,7 +140,7 @@ export const CollectionForm = observer(function CollectionForm_({
maxLength: CollectionValidation.maxNameLength,
})}
prefix={
<React.Suspense fallback={fallbackIcon}>
<Suspense fallback={fallbackIcon}>
<StyledIconPicker
icon={values.icon}
color={values.color ?? iconColor}
@@ -149,7 +149,7 @@ export const CollectionForm = observer(function CollectionForm_({
onOpen={setHasOpenedIconPicker}
onChange={handleIconChange}
/>
</React.Suspense>
</Suspense>
}
autoComplete="off"
autoFocus
+2 -2
View File
@@ -1,6 +1,6 @@
import { runInAction } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { useCallback } from "react";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
import history from "~/utils/history";
@@ -14,7 +14,7 @@ export const CollectionNew = observer(function CollectionNew_({
onSubmit,
}: Props) {
const { collections } = useStores();
const handleSubmit = React.useCallback(
const handleSubmit = useCallback(
async (data: FormData) => {
try {
const collection = await collections.save(data);
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
@@ -1,5 +1,4 @@
import { useMatches, KBarResults } from "kbar";
import * as React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import CommandBarItem from "./CommandBarItem";
@@ -1,5 +1,5 @@
import { DocumentIcon } from "outline-icons";
import * as React from "react";
import { useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import { RecentSection } from "~/actions/sections";
@@ -10,7 +10,7 @@ import { documentPath } from "~/utils/routeHelpers";
const useRecentDocumentActions = (count = 6) => {
const { documents, ui } = useStores();
return React.useMemo(
return useMemo(
() =>
documents.recentlyViewed
.filter((document) => document.id !== ui.activeDocumentId)
@@ -1,5 +1,5 @@
import { SettingsIcon } from "outline-icons";
import * as React from "react";
import { useMemo } from "react";
import { createAction } from "~/actions";
import { NavigationSection } from "~/actions/sections";
import useSettingsConfig from "~/hooks/useSettingsConfig";
@@ -7,7 +7,7 @@ import history from "~/utils/history";
const useSettingsAction = () => {
const config = useSettingsConfig();
const actions = React.useMemo(
const actions = useMemo(
() =>
config.map((item) => {
const Icon = item.icon;
@@ -22,7 +22,7 @@ const useSettingsAction = () => {
[config]
);
const navigateToSettings = React.useMemo(
const navigateToSettings = useMemo(
() =>
createAction({
id: "settings",
@@ -1,5 +1,5 @@
import { NewDocumentIcon, ShapesIcon } from "outline-icons";
import * as React from "react";
import { useEffect, useMemo } from "react";
import Icon from "@shared/components/Icon";
import { createAction } from "~/actions";
import {
@@ -14,11 +14,11 @@ import { newDocumentPath } from "~/utils/routeHelpers";
const useTemplatesAction = () => {
const { documents } = useStores();
React.useEffect(() => {
useEffect(() => {
void documents.fetchAllTemplates();
}, [documents]);
const actions = React.useMemo(
const actions = useMemo(
() =>
documents.templatesAlphabetical.map((template) =>
createAction({
@@ -61,7 +61,7 @@ const useTemplatesAction = () => {
[documents.templatesAlphabetical]
);
const newFromTemplate = React.useMemo(
const newFromTemplate = useMemo(
() =>
createAction({
id: "templates",
-1
View File
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import Comment from "~/models/Comment";
-1
View File
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import { CollectionPermission, NavigationNode } from "@shared/types";
+3 -3
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { useState, useEffect } from "react";
type Props = {
delay?: number;
@@ -6,9 +6,9 @@ type Props = {
};
export default function DelayedMount({ delay = 250, children }: Props) {
const [isShowing, setShowing] = React.useState(false);
const [isShowing, setShowing] = useState(false);
React.useEffect(() => {
useEffect(() => {
const timeout = setTimeout(() => setShowing(true), delay);
return () => {
clearTimeout(timeout);
+3 -3
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
@@ -12,9 +12,9 @@ export default function DesktopEventHandler() {
const { t } = useTranslation();
const history = useHistory();
const { dialogs } = useStores();
const hasDisabledUpdateMessage = React.useRef(false);
const hasDisabledUpdateMessage = useRef(false);
React.useEffect(() => {
useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
-1
View File
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import Guide from "~/components/Guide";
import Modal from "~/components/Modal";
import useStores from "~/hooks/useStores";
+4 -4
View File
@@ -4,7 +4,7 @@ import { subDays } from "date-fns";
import { m } from "framer-motion";
import { observer } from "mobx-react";
import { CloseIcon, DocumentIcon, ClockIcon, EyeIcon } from "outline-icons";
import * as React from "react";
import { useRef, useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled, { useTheme } from "styled-components";
@@ -40,7 +40,7 @@ function DocumentCard(props: Props) {
const { collections } = useStores();
const theme = useTheme();
const { document, pin, canUpdatePin, isDraggable } = props;
const pinnedToHome = React.useRef(!pin?.collectionId).current;
const pinnedToHome = useRef(!pin?.collectionId).current;
const collection = document.collectionId
? collections.get(document.collectionId)
: undefined;
@@ -63,7 +63,7 @@ function DocumentCard(props: Props) {
transition,
};
const handleUnpin = React.useCallback(
const handleUnpin = useCallback(
async (ev) => {
ev.preventDefault();
ev.stopPropagation();
@@ -178,7 +178,7 @@ function DocumentCard(props: Props) {
const ReadingTime = ({ document }: { document: Document }) => {
const { t } = useTranslation();
const markdown = React.useMemo(() => document.toMarkdown(), [document]);
const markdown = useMemo(() => document.toMarkdown(), [document]);
const stats = useTextStats(markdown);
return (
+4 -4
View File
@@ -1,5 +1,5 @@
import { action, computed, observable } from "mobx";
import React, { PropsWithChildren } from "react";
import { createContext, useContext, useMemo, PropsWithChildren } from "react";
import { Heading } from "@shared/utils/ProsemirrorHelper";
import Document from "~/models/Document";
import { Editor } from "~/editor";
@@ -64,10 +64,10 @@ class DocumentContext {
}
}
const Context = React.createContext<DocumentContext | null>(null);
const Context = createContext<DocumentContext | null>(null);
export const useDocumentContext = () => {
const ctx = React.useContext(Context);
const ctx = useContext(Context);
if (!ctx) {
throw new Error(
"useDocumentContext must be used within DocumentContextProvider"
@@ -79,6 +79,6 @@ export const useDocumentContext = () => {
export const DocumentContextProvider = ({
children,
}: PropsWithChildren<unknown>) => {
const context = React.useMemo(() => new DocumentContext(), []);
const context = useMemo(() => new DocumentContext(), []);
return <Context.Provider value={context}>{children}</Context.Provider>;
};
-1
View File
@@ -1,7 +1,6 @@
import { TFunction } from "i18next";
import { observer } from "mobx-react";
import { DoneIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { useTheme } from "styled-components";
import Document from "~/models/Document";
+63 -55
View File
@@ -1,7 +1,7 @@
import compact from "lodash/compact";
import sortBy from "lodash/sortBy";
import { observer } from "mobx-react";
import * as React from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { dateLocale, dateToRelative } from "@shared/utils/date";
import Document from "~/models/Document";
@@ -14,78 +14,86 @@ import useStores from "~/hooks/useStores";
type Props = {
document: Document;
isOpen?: boolean;
};
function DocumentViews({ document, isOpen }: Props) {
function DocumentViews({ document }: Props) {
const { t } = useTranslation();
const { views, presence } = useStores();
const user = useCurrentUser();
const locale = dateLocale(user.language);
const documentPresence = presence.get(document.id);
const documentPresenceArray = documentPresence
? Array.from(documentPresence.values())
: [];
const presentIds = documentPresenceArray.map((p) => p.userId);
const editingIds = documentPresenceArray
.filter((p) => p.isEditing)
.map((p) => p.userId);
// Use Set for O(1) lookups and stable references
const presentIds = useMemo(
() => new Set(documentPresenceArray.map((p) => p.userId)),
[documentPresenceArray]
);
const editingIds = useMemo(
() =>
new Set(
documentPresenceArray.filter((p) => p.isEditing).map((p) => p.userId)
),
[documentPresenceArray]
);
// ensure currently present via websocket are always ordered first
const documentViews = views.inDocument(document.id);
const sortedViews = sortBy(
documentViews,
(view) => !presentIds.includes(view.userId)
const documentViews = useMemo(
() => views.inDocument(document.id),
[views, document.id]
);
const users = React.useMemo(
const sortedViews = useMemo(
() => sortBy(documentViews, (view) => !presentIds.has(view.userId)),
[documentViews, presentIds]
);
const users = useMemo(
() => compact(sortedViews.map((v) => v.user)),
[sortedViews]
);
return (
<>
{isOpen && (
<PaginatedList<User>
aria-label={t("Viewers")}
items={users}
renderItem={(model) => {
const view = documentViews.find((v) => v.userId === model.id);
const isPresent = presentIds.includes(model.id);
const isEditing = editingIds.includes(model.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }}", {
timeAgo: dateToRelative(
view ? Date.parse(view.lastViewedAt) : new Date(),
{
addSuffix: true,
locale,
}
),
});
return (
<ListItem
key={model.id}
title={model.name}
subtitle={subtitle}
image={
<Avatar
key={model.id}
model={model}
size={AvatarSize.Large}
/>
}
border={false}
small
/>
);
}}
// Memoize renderItem for PaginatedList
const renderItem = useCallback(
(model: User) => {
const view = documentViews.find((v) => v.userId === model.id);
const isPresent = presentIds.has(model.id);
const isEditing = editingIds.has(model.id);
const subtitle = isPresent
? isEditing
? t("Currently editing")
: t("Currently viewing")
: t("Viewed {{ timeAgo }}", {
timeAgo: dateToRelative(
view ? Date.parse(view.lastViewedAt) : new Date(),
{
addSuffix: true,
locale,
}
),
});
return (
<ListItem
key={model.id}
title={model.name}
subtitle={subtitle}
image={
<Avatar key={model.id} model={model} size={AvatarSize.Large} />
}
border={false}
small
/>
)}
</>
);
},
[documentViews, presentIds, editingIds, t, locale]
);
return (
<PaginatedList<User>
aria-label={t("Viewers")}
items={users}
renderItem={renderItem}
/>
);
}
+155
View File
@@ -0,0 +1,155 @@
import * as React from "react";
import { useDropzone } from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import styled from "styled-components";
import { s } from "@shared/styles";
import { AttachmentPreset } from "@shared/types";
import ConfirmationDialog from "~/components/ConfirmationDialog";
import Flex from "~/components/Flex";
import Input from "~/components/Input";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import { uploadFile } from "~/utils/files";
type Props = {
onSubmit: () => void;
};
export function EmojiUploadDialog({ onSubmit }: Props) {
const { t } = useTranslation();
const { emojis } = useStores();
const [name, setName] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [isUploading, setIsUploading] = React.useState(false);
const onDrop = React.useCallback((acceptedFiles: File[]) => {
if (acceptedFiles.length > 0) {
setFile(acceptedFiles[0]);
}
}, []);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: [".png", ".jpg", ".jpeg", ".gif", ".webp"],
maxFiles: 1,
maxSize: 1024 * 1024, // 1MB
});
const handleSubmit = async () => {
if (!name.trim()) {
toast.error(t("Please enter a name for the emoji"));
return;
}
if (!file) {
toast.error(t("Please select an image file"));
return;
}
setIsUploading(true);
try {
const attachment = await uploadFile(file, {
name: file.name,
preset: AttachmentPreset.Avatar,
});
await emojis.create({
name: name.trim(),
url: attachment.url,
});
toast.success(t("Emoji created successfully"));
onSubmit();
} catch (error) {
toast.error(t("Failed to create emoji"));
} finally {
setIsUploading(false);
}
};
const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
// Remove special characters and spaces, convert to lowercase
const value = event.target.value
.toLowerCase()
.replace(/[^a-z0-9_]/g, "")
.slice(0, 32);
setName(value);
};
const isValid = name.trim().length > 0 && file;
return (
<ConfirmationDialog
onSubmit={handleSubmit}
disabled={!isValid || isUploading}
savingText={isUploading ? `${t("Uploading")}` : undefined}
submitText={t("Add emoji")}
>
<Text as="p" type="secondary">
{t(
"Upload an image to create a custom emoji. The name should be unique and contain only lowercase letters, numbers, and underscores."
)}
</Text>
<Input
label={t("Emoji name")}
value={name}
onChange={handleNameChange}
placeholder="my_custom_emoji"
autoFocus
required
/>
<DropZone {...getRootProps()}>
<input {...getInputProps()} />
<Flex column align="center" gap={8}>
{file ? (
<>
<PreviewImage src={URL.createObjectURL(file)} alt="Preview" />
<Text>{file.name}</Text>
<Text type="secondary">{t("Click or drag to replace")}</Text>
</>
) : (
<>
<Text>
{isDragActive
? t("Drop the image here")
: t("Click or drag an image here")}
</Text>
<Text type="secondary">
{t("PNG, JPG, GIF, or WebP up to 1MB")}
</Text>
</>
)}
</Flex>
</DropZone>
{name && (
<Text type="secondary">
{t("This emoji will be available as")} <code>:{name}:</code>
</Text>
)}
</ConfirmationDialog>
);
}
const DropZone = styled.div`
border: 2px dashed ${s("divider")};
border-radius: 8px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s;
&:hover {
border-color: ${s("accent")};
}
`;
const PreviewImage = styled.img`
width: 64px;
height: 64px;
object-fit: contain;
border-radius: 4px;
`;
+42 -27
View File
@@ -11,7 +11,7 @@ import {
UserIcon,
CrossIcon,
} from "outline-icons";
import * as React from "react";
import { useRef } from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import styled, { css } from "styled-components";
@@ -48,10 +48,12 @@ export type DocumentEvent = {
userId: string;
};
export type Event = { id: string; actorId: string; createdAt: string } & (
| RevisionEvent
| DocumentEvent
);
export type Event = {
id: string;
actorId: string;
createdAt: string;
deletedAt?: string;
} & (RevisionEvent | DocumentEvent);
type Props = {
document: Document;
@@ -65,7 +67,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
const user = "userId" in event ? users.get(event.userId) : undefined;
const location = useLocation();
const sidebarContext = useLocationSidebarContext();
const revisionLoadedRef = React.useRef(false);
const revisionLoadedRef = useRef(false);
const opts = {
userName: actor?.name,
};
@@ -74,7 +76,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
event.id === RevisionHelper.latestId(document.id);
let meta, icon, to: LocationDescriptor | undefined;
const ref = React.useRef<HTMLAnchorElement>(null);
const ref = useRef<HTMLAnchorElement>(null);
// the time component tends to steal focus when clicked
// ...so forward the focus back to the parent item
const handleTimeClick = () => {
@@ -85,6 +87,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
if (
!document.isDeleted &&
event.name === "revisions.create" &&
!event.deletedAt &&
!isDerivedFromDocument &&
!revisionLoadedRef.current
) {
@@ -95,24 +98,31 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
switch (event.name) {
case "revisions.create":
icon = <EditIcon size={16} />;
meta = event.latest ? (
<>
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
{
if (event.deletedAt) {
icon = <TrashIcon />;
meta = t("Revision deleted");
} else {
icon = <EditIcon size={16} />;
meta = event.latest ? (
<>
{t("Current version")} &middot; {actor?.name}
</>
) : (
t("{{userName}} edited", opts)
);
to = {
pathname: documentHistoryPath(
document,
isDerivedFromDocument ? "latest" : event.id
),
state: {
sidebarContext,
retainScrollPosition: true,
},
};
}
}
break;
case "documents.archive":
@@ -181,7 +191,7 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
to = undefined;
}
return event.name === "revisions.create" ? (
return event.name === "revisions.create" && !event.deletedAt ? (
<RevisionItem
small
exact
@@ -218,7 +228,12 @@ const EventListItem = ({ event, document, ...rest }: Props) => {
</IconWrapper>
<Text size="xsmall" type="secondary">
{meta} &middot;{" "}
<Time dateTime={event.createdAt} relative shorten addSuffix />
<Time
dateTime={event.deletedAt ?? event.createdAt}
relative
shorten
addSuffix
/>
</Text>
</EventItem>
);
+2 -2
View File
@@ -1,4 +1,4 @@
import React from "react";
import { useState } from "react";
import styled from "styled-components";
import { fadeIn } from "~/styles/animations";
@@ -21,7 +21,7 @@ type Props = {
* Wraps children in a <Fade> if loading is true on mount.
*/
export const ConditionalFade = ({ animate, children }: Props) => {
const [isAnimated] = React.useState(animate);
const [isAnimated] = useState(animate);
return isAnimated ? <Fade>{children}</Fade> : <>{children}</>;
};
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react";
import styled from "styled-components";
import Empty from "~/components/Empty";
import Fade from "~/components/Fade";
@@ -1,5 +1,5 @@
import { BackIcon } from "outline-icons";
import React from "react";
import * as React from "react";
import styled from "styled-components";
import { breakpoints, s, hover } from "@shared/styles";
import { colorPalette } from "@shared/utils/collections";
@@ -1,5 +1,5 @@
import concat from "lodash/concat";
import React from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { EmojiCategory, EmojiSkinTone, IconType } from "@shared/types";
@@ -1,6 +1,6 @@
import chunk from "lodash/chunk";
import compact from "lodash/compact";
import React from "react";
import * as React from "react";
import styled from "styled-components";
import { IconType } from "@shared/types";
import { IconLibrary } from "@shared/utils/IconLibrary";
@@ -1,4 +1,4 @@
import React from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { IconType } from "@shared/types";
@@ -1,4 +1,4 @@
import React from "react";
import { useMemo, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Menu, MenuButton, MenuItem, useMenuState } from "reakit";
import styled from "styled-components";
@@ -19,16 +19,13 @@ const SkinTonePicker = ({
}) => {
const { t } = useTranslation();
const handEmojiVariants = React.useMemo(
() => getEmojiVariants({ id: "hand" }),
[]
);
const handEmojiVariants = useMemo(() => getEmojiVariants({ id: "hand" }), []);
const menu = useMenuState({
placement: "bottom-end",
});
const handleSkinClick = React.useCallback(
const handleSkinClick = useCallback(
(emojiSkin) => {
menu.hide();
onChange(emojiSkin);
@@ -36,7 +33,7 @@ const SkinTonePicker = ({
[menu, onChange]
);
const menuItems = React.useMemo(
const menuItems = useMemo(
() =>
Object.entries(handEmojiVariants).map(([eskin, emoji]) => (
<MenuItem {...menu} key={emoji.value}>
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
-1
View File
@@ -1,7 +1,6 @@
import { observer } from "mobx-react";
import { CollectionIcon, PrivateCollectionIcon } from "outline-icons";
import { getLuminance } from "polished";
import * as React from "react";
import Icon from "@shared/components/Icon";
import { colorPalette } from "@shared/utils/collections";
import Collection from "~/models/Collection";
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react";
export function LanguageIcon({ className }: { className?: string }) {
return (
<svg
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
-2
View File
@@ -1,5 +1,3 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
+1 -1
View File
@@ -1,6 +1,6 @@
import * as VisuallyHidden from "@radix-ui/react-visually-hidden";
import { transparentize } from "polished";
import React from "react";
import * as React from "react";
import styled from "styled-components";
import Text from "~/components/Text";
import useMobile from "~/hooks/useMobile";
-1
View File
@@ -1,6 +1,5 @@
import { m } from "framer-motion";
import find from "lodash/find";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { languages, languageOptions } from "@shared/i18n";
-1
View File
@@ -1,5 +1,4 @@
import { DisconnectedIcon, WarningIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { s } from "@shared/styles";
-1
View File
@@ -1,5 +1,4 @@
import times from "lodash/times";
import * as React from "react";
import styled from "styled-components";
import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
@@ -1,11 +1,11 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect } from "react";
import useStores from "~/hooks/useStores";
function LoadingIndicator() {
const { ui } = useStores();
React.useEffect(() => {
useEffect(() => {
ui.enableProgressBar();
return () => ui.disableProgressBar();
}, [ui]);
@@ -1,4 +1,3 @@
import * as React from "react";
import styled, { keyframes } from "styled-components";
import { depths, s } from "@shared/styles";
+1 -1
View File
@@ -1,4 +1,4 @@
import React from "react";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import Flex from "./Flex";
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import { SubscribeIcon } from "outline-icons";
import * as React from "react";
import styled from "styled-components";
import { s } from "@shared/styles";
import useStores from "~/hooks/useStores";
@@ -8,7 +8,7 @@ import { s, hover, truncateMultiline } from "@shared/styles";
import Notification from "~/models/Notification";
import CommentEditor from "~/scenes/Document/components/CommentEditor";
import useStores from "~/hooks/useStores";
import { Avatar, AvatarSize } from "../Avatar";
import { Avatar, AvatarSize, AvatarVariant } from "../Avatar";
import Flex from "../Flex";
import Text from "../Text";
import Time from "../Time";
@@ -41,7 +41,7 @@ function NotificationListItem({ notification, onNavigate }: Props) {
return (
<StyledLink to={notification.path ?? ""} onClick={handleClick}>
<Container gap={8} $unread={!notification.viewedAt}>
<StyledAvatar model={notification.actor} size={AvatarSize.Large} />
<StyledAvatar model={notification.actor} />
<Flex column>
<Text as="div" size="small">
<Text weight="bold">
@@ -79,7 +79,10 @@ const StyledCommentEditor = styled(CommentEditor)`
${truncateMultiline(3)}
`;
const StyledAvatar = styled(Avatar)`
const StyledAvatar = styled(Avatar).attrs({
variant: AvatarVariant.Round,
size: AvatarSize.Medium,
})`
margin-top: 4px;
`;
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect } from "react";
import { Controller, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { OAuthClientValidation } from "@shared/validations";
@@ -49,7 +49,7 @@ export const OAuthClientForm = observer(function OAuthClientForm_({
},
});
React.useEffect(() => {
useEffect(() => {
setTimeout(() => setFocus("name", { shouldSelect: true }), 100);
}, [setFocus]);
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useCallback } from "react";
import { useHistory } from "react-router-dom";
import { toast } from "sonner";
import useStores from "~/hooks/useStores";
@@ -16,7 +16,7 @@ export const OAuthClientNew = observer(function OAuthClientNew_({
const { oauthClients } = useStores();
const history = useHistory();
const handleSubmit = React.useCallback(
const handleSubmit = useCallback(
async (data: FormData) => {
try {
const oauthClient = await oauthClients.save(data);
+2 -2
View File
@@ -1,4 +1,4 @@
import * as React from "react";
import { useEffect } from "react";
import { useTheme } from "styled-components";
import useStores from "~/hooks/useStores";
@@ -6,7 +6,7 @@ export default function PageTheme() {
const { ui } = useStores();
const theme = useTheme();
React.useEffect(() => {
useEffect(() => {
// wider page background beyond the React root
if (document.body) {
document.body.style.background = theme.background;
-1
View File
@@ -2,7 +2,6 @@ import "../stores";
import { render } from "@testing-library/react";
import { TFunction } from "i18next";
import { Provider } from "mobx-react";
import * as React from "react";
import { getI18n } from "react-i18next";
import { Pagination } from "@shared/constants";
import PaginatedList from "./PaginatedList";
+5 -5
View File
@@ -17,7 +17,7 @@ import {
import fractionalIndex from "fractional-index";
import { AnimatePresence } from "framer-motion";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Pin from "~/models/Pin";
@@ -44,12 +44,12 @@ function PinnedDocuments({
...rest
}: Props) {
const { documents } = useStores();
const [items, setItems] = React.useState(pins.map((pin) => pin.documentId));
const showPlaceholderRef = React.useRef(true);
const [items, setItems] = useState(pins.map((pin) => pin.documentId));
const showPlaceholderRef = useRef(true);
const showPlaceholder =
placeholderCount && !items.length && showPlaceholderRef.current;
React.useEffect(() => {
useEffect(() => {
setItems(pins.map((pin) => pin.documentId));
}, [pins]);
@@ -65,7 +65,7 @@ function PinnedDocuments({
})
);
const handleDragEnd = React.useCallback(
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
-1
View File
@@ -1,4 +1,3 @@
import * as React from "react";
import styled from "styled-components";
import DelayedMount from "~/components/DelayedMount";
import Fade from "~/components/Fade";
-1
View File
@@ -1,5 +1,4 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import Logger from "~/utils/Logger";
import { Hook, usePluginValue } from "~/utils/PluginManager";
+1 -1
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { transparentize } from "polished";
import React from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled, { css } from "styled-components";
import { s, hover } from "@shared/styles";
+1 -1
View File
@@ -1,6 +1,6 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import React from "react";
import * as React from "react";
import Comment from "~/models/Comment";
import useHover from "~/hooks/useHover";
import useStores from "~/hooks/useStores";
+1 -1
View File
@@ -1,5 +1,5 @@
import { ReactionIcon } from "outline-icons";
import React from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { PopoverDisclosure, usePopoverState } from "reakit";
import EventBoundary from "@shared/components/EventBoundary";
@@ -1,6 +1,6 @@
import compact from "lodash/compact";
import { observer } from "mobx-react";
import React from "react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Tab, TabPanel, useTabState } from "reakit";
import { toast } from "sonner";
+3 -3
View File
@@ -1,15 +1,15 @@
import * as React from "react";
import { createContext, useContext } from "react";
/**
* Context to provide a reference to the scrollable container
*/
const ScrollContext = React.createContext<
const ScrollContext = createContext<
React.RefObject<HTMLDivElement> | undefined
>(undefined);
/**
* Hook to get the scrollable container reference
*/
export const useScrollContext = () => React.useContext(ScrollContext);
export const useScrollContext = () => useContext(ScrollContext);
export default ScrollContext;
+2 -2
View File
@@ -1,5 +1,5 @@
// based on: https://reacttraining.com/react-router/web/guides/scroll-restoration
import * as React from "react";
import { useEffect } from "react";
import { useLocation } from "react-router-dom";
import usePrevious from "~/hooks/usePrevious";
import { useScrollContext } from "./ScrollContext";
@@ -13,7 +13,7 @@ export default function ScrollToTop({ children }: Props) {
const previousLocationPathname = usePrevious(location.pathname);
const scrollContainerRef = useScrollContext();
React.useEffect(() => {
useEffect(() => {
if (
location.pathname === previousLocationPathname ||
location.state?.retainScrollPosition
+2 -2
View File
@@ -1,5 +1,5 @@
import { useKBar } from "kbar";
import * as React from "react";
import { useEffect } from "react";
import { searchDocumentsForQuery } from "~/actions/definitions/documents";
import { navigateToRecentSearchQuery } from "~/actions/definitions/navigation";
@@ -9,7 +9,7 @@ import useStores from "~/hooks/useStores";
export default function SearchActions() {
const { searches } = useStores();
React.useEffect(() => {
useEffect(() => {
if (!searches.isLoaded && !searches.isFetching) {
void searches.fetchPage({
source: "app",
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useCallback, Fragment } from "react";
import { Trans, useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
@@ -31,7 +31,7 @@ const DocumentMemberListItem = ({
}: Props) => {
const { t } = useTranslation();
const handleChange = React.useCallback(
const handleChange = useCallback(
(permission: DocumentPermission | typeof EmptySelectValue) => {
if (permission === EmptySelectValue) {
onRemove?.();
@@ -68,7 +68,7 @@ const DocumentMemberListItem = ({
if (!currentPermission) {
return null;
}
const MaybeLink = membership?.source ? StyledLink : React.Fragment;
const MaybeLink = membership?.source ? StyledLink : Fragment;
return (
<ListItem
@@ -1,5 +1,5 @@
import { LinkIcon } from "outline-icons";
import * as React from "react";
import { useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import CopyToClipboard from "~/components/CopyToClipboard";
@@ -14,9 +14,9 @@ export function CopyLinkButton({
onCopy: () => void;
}) {
const { t } = useTranslation();
const timeout = React.useRef<ReturnType<typeof setTimeout>>();
const timeout = useRef<ReturnType<typeof setTimeout>>();
const handleCopied = React.useCallback(() => {
const handleCopied = useCallback(() => {
onCopy();
timeout.current = setTimeout(() => {
@@ -1,4 +1,3 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "@shared/components/Flex";
@@ -1,5 +1,4 @@
import times from "lodash/times";
import * as React from "react";
import { AvatarSize } from "~/components/Avatar";
import Fade from "~/components/Fade";
import PlaceholderText from "~/components/PlaceholderText";
+5 -5
View File
@@ -1,6 +1,6 @@
import { observer } from "mobx-react";
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { useEffect, useState, useCallback, useMemo } from "react";
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import { useTranslation } from "react-i18next";
@@ -38,7 +38,7 @@ function AppSidebar() {
const user = useCurrentUser();
const can = usePolicy(team);
React.useEffect(() => {
useEffect(() => {
void collections.fetchAll();
if (!user.isViewer) {
@@ -46,9 +46,9 @@ function AppSidebar() {
}
}, [documents, collections, user.isViewer]);
const [dndArea, setDndArea] = React.useState();
const handleSidebarRef = React.useCallback((node) => setDndArea(node), []);
const html5Options = React.useMemo(
const [dndArea, setDndArea] = useState();
const handleSidebarRef = useCallback((node) => setDndArea(node), []);
const html5Options = useMemo(
() => ({
rootElement: dndArea,
}),
+2 -2
View File
@@ -1,7 +1,7 @@
import groupBy from "lodash/groupBy";
import { observer } from "mobx-react";
import { BackIcon, SidebarIcon } from "outline-icons";
import * as React from "react";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useHistory, useLocation } from "react-router-dom";
import styled from "styled-components";
@@ -38,7 +38,7 @@ function SettingsSidebar() {
"group"
);
const returnToApp = React.useCallback(() => {
const returnToApp = useCallback(() => {
history.push("/home");
}, [history]);
-1
View File
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import { SidebarIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { hover } from "@shared/styles";
@@ -1,7 +1,7 @@
import isUndefined from "lodash/isUndefined";
import { observer } from "mobx-react";
import { ArchiveIcon } from "outline-icons";
import * as React from "react";
import { useState, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import Flex from "@shared/components/Flex";
import Collection from "~/models/Collection";
@@ -21,43 +21,43 @@ function ArchiveLink() {
const { collections } = useStores();
const { t } = useTranslation();
const [disclosure, setDisclosure] = React.useState<boolean>(false);
const [expanded, setExpanded] = React.useState<boolean | undefined>();
const [disclosure, setDisclosure] = useState<boolean>(false);
const [expanded, setExpanded] = useState<boolean | undefined>();
const { request, data, loading, error } = useRequest(
collections.fetchArchived,
true
);
React.useEffect(() => {
useEffect(() => {
if (!isUndefined(data) && !loading && isUndefined(error)) {
setDisclosure(data.length > 0);
}
}, [data, loading, error]);
React.useEffect(() => {
useEffect(() => {
setDisclosure(collections.archived.length > 0);
}, [collections.archived]);
React.useEffect(() => {
useEffect(() => {
if (disclosure && isUndefined(expanded)) {
setExpanded(false);
}
}, [disclosure]);
React.useEffect(() => {
useEffect(() => {
if (expanded) {
void request();
}
}, [expanded, request]);
const handleDisclosureClick = React.useCallback((ev) => {
const handleDisclosureClick = useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((e) => !e);
}, []);
const handleClick = React.useCallback(() => {
const handleClick = useCallback(() => {
setExpanded(true);
}, []);
@@ -1,4 +1,4 @@
import * as React from "react";
import { useState, useCallback } from "react";
import Collection from "~/models/Collection";
import useStores from "~/hooks/useStores";
import CollectionLink from "./CollectionLink";
@@ -13,15 +13,15 @@ type Props = {
export function ArchivedCollectionLink({ collection, depth }: Props) {
const { documents } = useStores();
const [expanded, setExpanded] = React.useState(false);
const [expanded, setExpanded] = useState(false);
const handleDisclosureClick = React.useCallback((ev) => {
const handleDisclosureClick = useCallback((ev) => {
ev.preventDefault();
ev.stopPropagation();
setExpanded((e) => !e);
}, []);
const handleClick = React.useCallback(() => {
const handleClick = useCallback(() => {
setExpanded(true);
}, []);
@@ -1,6 +1,6 @@
import noop from "lodash/noop";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useRef, useEffect, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import styled from "styled-components";
@@ -37,8 +37,8 @@ function CollectionLinkChildren({
const { documents } = useStores();
const { t } = useTranslation();
const childDocuments = useCollectionDocuments(collection, documents.active);
const [showing, setShowing] = React.useState(pageSize);
const dummyRef = React.useRef<HTMLDivElement>(null);
const [showing, setShowing] = useState(pageSize);
const dummyRef = useRef<HTMLDivElement>(null);
const [{ isOver, canDrop }, dropRef] = useDropToChangeCollection(
collection,
@@ -46,13 +46,13 @@ function CollectionLinkChildren({
dummyRef
);
React.useEffect(() => {
useEffect(() => {
if (!expanded) {
setShowing(pageSize);
}
}, [expanded]);
const showMore = React.useCallback(() => {
const showMore = useCallback(() => {
if (childDocuments && childDocuments.length > showing) {
setShowing((value) => value + pageSize);
}
@@ -1,6 +1,6 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useMemo } from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
@@ -24,7 +24,7 @@ function Collections() {
const { t } = useTranslation();
const orderedCollections = collections.allActive;
const params = React.useMemo(
const params = useMemo(
() => ({
limit: 100,
}),
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import { DraftsIcon } from "outline-icons";
import React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "~/components/Flex";
@@ -1,6 +1,6 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useState, useEffect, useCallback } from "react";
import { useDrop, useDrag, DropTargetMonitor } from "react-dnd";
import { getEmptyImage } from "react-dnd-html5-backend";
import styled from "styled-components";
@@ -32,7 +32,7 @@ function DraggableCollectionLink({
const locationSidebarContext = useLocationSidebarContext();
const sidebarContext = useSidebarContext();
const { ui, policies, collections } = useStores();
const [expanded, setExpanded] = React.useState(
const [expanded, setExpanded] = useState(
collection.id === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
);
@@ -73,13 +73,13 @@ function DraggableCollectionLink({
}),
});
React.useEffect(() => {
useEffect(() => {
preview(getEmptyImage(), { captureDraggingState: false });
}, [preview]);
// If the current collection is active and relevant to the sidebar section we
// are in then expand it automatically
React.useEffect(() => {
useEffect(() => {
if (
collection.id === ui.activeCollectionId &&
sidebarContext === locationSidebarContext
@@ -93,7 +93,7 @@ function DraggableCollectionLink({
locationSidebarContext,
]);
const handleDisclosureClick = React.useCallback((ev) => {
const handleDisclosureClick = useCallback((ev) => {
ev?.preventDefault();
setExpanded((e) => !e);
}, []);
@@ -1,6 +1,6 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { useCallback } from "react";
import Dropzone from "react-dropzone";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -33,7 +33,7 @@ function DropToImport({ disabled, children, collectionId, documentId }: Props) {
const canCollection = usePolicy(collectionId);
const canDocument = usePolicy(documentId);
const handleRejection = React.useCallback(() => {
const handleRejection = useCallback(() => {
toast.error(
t("Document not supported try Markdown, Plain text, HTML, or Word")
);
@@ -1,6 +1,6 @@
import fractionalIndex from "fractional-index";
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { Pagination } from "@shared/constants";
@@ -38,7 +38,7 @@ function SharedWithMe() {
fractionalIndex(null, user.documentMemberships[0].index)
);
React.useEffect(() => {
useEffect(() => {
if (error) {
toast.error(t("Could not load shared documents"));
}
@@ -1,6 +1,5 @@
import invariant from "invariant";
import { observer } from "mobx-react";
import * as React from "react";
import { actionToMenuItem } from "~/actions";
import useActionContext from "~/hooks/useActionContext";
import { Action } from "~/types";
@@ -1,4 +1,4 @@
import * as React from "react";
import { createContext, useContext } from "react";
import Document from "~/models/Document";
import User from "~/models/User";
@@ -10,9 +10,9 @@ export type SidebarContextType =
| `starred-${string}`
| undefined;
const SidebarContext = React.createContext<SidebarContextType>(undefined);
const SidebarContext = createContext<SidebarContextType>(undefined);
export const useSidebarContext = () => React.useContext(SidebarContext);
export const useSidebarContext = () => useContext(SidebarContext);
export const groupSidebarContext = (groupId: string): SidebarContextType =>
`group-${groupId}`;
@@ -1,5 +1,5 @@
import { observer } from "mobx-react";
import * as React from "react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import Star from "~/models/Star";
@@ -30,7 +30,7 @@ function Starred() {
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
const [createStarProps, dropToStarRef] = useDropToCreateStar();
React.useEffect(() => {
useEffect(() => {
if (error) {
toast.error(t("Could not load starred documents"));
}
@@ -1,6 +1,5 @@
import { observer } from "mobx-react";
import { TrashIcon } from "outline-icons";
import * as React from "react";
import { useDrop } from "react-dnd";
import { useTranslation } from "react-i18next";
import DocumentDelete from "~/scenes/DocumentDelete";

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