Compare commits

...

46 Commits

Author SHA1 Message Date
Tom Moor 682fbeb10a Merge main 2024-06-16 12:42:32 -04:00
Hemachandar 05c1bee412 feat: allow user to set TOC display preference (#6943)
Co-authored-by: Tom Moor <tom.moor@gmail.com>
2024-06-16 09:21:08 -07:00
Tom Moor 3d0160463c chore: Refactor client plugin management (#7053)
* Update clientside plugin management to work as server

* docs

* tsc

* Rebase main
2024-06-16 08:11:26 -07:00
Tom Moor 391f72aeb4 Add config on Settings plugin 2024-06-16 10:16:54 -04:00
Tom Moor 1b95838a16 Rebase main 2024-06-16 10:06:05 -04:00
Tom Moor fc8a491133 tsc 2024-06-16 10:04:53 -04:00
Tom Moor b219e42cc8 docs 2024-06-16 10:04:53 -04:00
Tom Moor f4e2c2de77 Update clientside plugin management to work as server 2024-06-16 10:04:53 -04:00
Sebastian Pietschner a9f1086422 Enhanced Discord Support (#7005)
* Add Discord Provider Prototype

* Add Discord Logo

* Add Plugin to Plugin Manager

* fixed discord auth support and added icon

* add csv role verification

* grab discord server icon and test server id and roles

* subdomain derived from server name

* use discord server specific nickname if available

* Cleanup and comment

* move discord api types to dev deps

* cleanup of server vs default params

* remove commented out lines

* revert envv.development

* revert in vscode

* update yarn lock

* add gif support for discord server icon

* add comment with docs link

* add env section for discord

* fix errors and clarify env

* add new cannot use without

* fix suggestions
2024-06-16 07:04:25 -07:00
Tom Moor 379d2cb788 fix: Attributes lost creating template on server (#7049) 2024-06-15 19:06:37 -07:00
Tom Moor eb1882eb96 fix: Signed file urls not returning inline content disposition 2024-06-15 12:29:58 -04:00
Tom Moor 6318714aee fix: Escape does not close CMD+K when viewing a document or collection 2024-06-14 22:56:39 -04:00
Tom Moor 9415a35795 chore: Add eslint rule to prevent app imports in shared (see: bf130f9915) 2024-06-14 22:22:55 -04:00
Tom Moor da9ea9f82c fix: Tweak top padding on TOC to always align with metadata 2024-06-14 21:30:52 -04:00
Tom Moor e733fd27e4 0.77.1 2024-06-14 20:32:13 -04:00
Tom Moor 63cfa6e25a fix: Restore field in document webhooks for backwards compat (#7044)
closes #7042
2024-06-14 16:36:54 -07:00
Tom Moor f8a9c18650 fix: Scroll does not reset when navigating shared docs on mobile (#7037)
closes #6968
2024-06-14 16:36:36 -07:00
Tom Moor f35676f347 Switch from Alpine -> Debian-slim (#7040)
* Switch from Alpine -> Debian-slim

* Drop arm/v6
2024-06-14 12:51:38 -07:00
Tom Moor bf130f9915 fix: EventBoundary import 2024-06-14 14:55:02 -04:00
Tom Moor dfe36fcbf5 fix: Add a plugin to fix the last column in a table (#7036) 2024-06-14 05:53:32 -07:00
Tom Moor e1c44ba1a8 fix: Exiting lightbox unfocuses image causing rerender part-way through transition, closes #7034 2024-06-13 20:57:50 -04:00
Tom Moor e69c0e62fa Merge branch 'main' of github.com:outline/outline 2024-06-13 18:19:46 -04:00
Tom Moor fd17364ebf 0.77.0 2024-06-13 15:31:33 -04:00
Apoorv Mishra 23c8adc5d1 Replace reakit/Composite with react-roving-tabindex (#6985)
* fix: replace reakit composite with react-roving-tabindex

* fix: touch points

* fix: focus stuck at first list item

* fix: document history navigation

* fix: remove ununsed ListItem components

* fix: keyboard navigation in recent search list

* fix: updated lib
2024-06-13 18:45:44 +05:30
Tom Moor 20b1766e8d Add link to guide in welcome email 2024-06-12 22:33:00 -04:00
Tom Moor 076d564aa3 fix: Sidebar hidden when viewing shared document logged-in 2024-06-12 22:30:39 -04:00
Tom Moor 5b866a7451 fix: isInternalUrl helper returns true for other cloud-hosted workspaces 2024-06-12 21:50:42 -04:00
Tom Moor 4ef3615516 fix: Reduce flashing of loaders in sidebar on first load 2024-06-12 21:30:10 -04:00
Brian Krausz b907d1887a [docs] Remove dead link for Node Security Project (#7022) 2024-06-12 18:13:09 -07:00
Tom Moor 8a4555f565 Allow using / anywhere on a line or table (#7026) 2024-06-12 05:50:54 -07:00
Tom Moor df3cd22aee Matomo integration (#7009) 2024-06-12 05:03:38 -07:00
dependabot[bot] 0bf66cc560 chore(deps): bump braces from 3.0.2 to 3.0.3 (#7018)
Bumps [braces](https://github.com/micromatch/braces) from 3.0.2 to 3.0.3.
- [Changelog](https://github.com/micromatch/braces/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/braces/compare/3.0.2...3.0.3)

---
updated-dependencies:
- dependency-name: braces
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-11 20:09:10 -07:00
Tom Moor b769da2626 fix: Case where email platform will spend the email signin link (#7023) 2024-06-11 20:08:25 -07:00
Tom Moor 7bf5c4e533 Add manage permission to documents (#7003) 2024-06-10 17:38:23 -07:00
dependabot[bot] 1ad7c7409a chore(deps): bump @aws-sdk/s3-request-presigner from 3.577.0 to 3.592.0 (#7015)
Bumps [@aws-sdk/s3-request-presigner](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/s3-request-presigner) from 3.577.0 to 3.592.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/s3-request-presigner/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.592.0/packages/s3-request-presigner)

---
updated-dependencies:
- dependency-name: "@aws-sdk/s3-request-presigner"
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 17:38:04 -07:00
Translate-O-Tron 428908b2df New Crowdin updates (#6915) 2024-06-10 17:37:23 -07:00
dependabot[bot] 1df1b0c110 chore(deps): bump turndown from 7.1.3 to 7.2.0 (#7013)
Bumps [turndown](https://github.com/mixmark-io/turndown) from 7.1.3 to 7.2.0.
- [Release notes](https://github.com/mixmark-io/turndown/releases)
- [Commits](https://github.com/mixmark-io/turndown/compare/v7.1.3...v7.2.0)

---
updated-dependencies:
- dependency-name: turndown
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 17:37:03 -07:00
dependabot[bot] 9d95c673d1 chore(deps): bump prosemirror-model from 1.21.0 to 1.21.1 (#7014)
Bumps [prosemirror-model](https://github.com/prosemirror/prosemirror-model) from 1.21.0 to 1.21.1.
- [Changelog](https://github.com/ProseMirror/prosemirror-model/blob/master/CHANGELOG.md)
- [Commits](https://github.com/prosemirror/prosemirror-model/compare/1.21.0...1.21.1)

---
updated-dependencies:
- dependency-name: prosemirror-model
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 17:35:06 -07:00
dependabot[bot] 203cd3c2a3 chore(deps-dev): bump terser from 5.19.2 to 5.31.1 (#7016)
Bumps [terser](https://github.com/terser/terser) from 5.19.2 to 5.31.1.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.19.2...v5.31.1)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 17:34:48 -07:00
dependabot[bot] f663c5a7ef chore(deps-dev): bump @types/node from 20.10.0 to 20.14.2 (#7017)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.10.0 to 20.14.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-10 17:34:34 -07:00
Tom Moor be0a0f4e40 fix: Unexpected behavior when changing color of existing highlight 2024-06-09 21:19:37 -04:00
Tom Moor f439293a7b fix: Image placeholders incorrectly sized for uploading retina images 2024-06-09 14:28:54 -04:00
Tom Moor 2e466aefc3 fix: Missing delay before mounting loading skeleton 2024-06-09 14:23:48 -04:00
Tom Moor ed59b3e350 Add more highlighter color choices (#7012)
* Add more highlighter color choices, closes #7011

* docs
2024-06-09 10:54:31 -07:00
Tom Moor 808415b906 fix: Indent/outdent list controls, closes #6974 2024-06-08 21:51:52 -04:00
Tom Moor 2f495f0add chore: Extend use of Event.createFromContext (#7010) 2024-06-08 14:57:55 -07:00
194 changed files with 3601 additions and 2875 deletions
+3 -3
View File
@@ -126,7 +126,7 @@ jobs:
docker buildx install
docker context create docker-multiarch
docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx create --name docker-multiarch --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x docker-multiarch
docker buildx inspect --builder docker-multiarch --bootstrap
docker buildx use docker-multiarch
- run:
@@ -142,9 +142,9 @@ jobs:
name: Build and push Docker image
command: |
if [[ "$CIRCLE_TAG" == *"-"* ]]; then
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
docker buildx build -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
else
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v6,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
docker buildx build -t $IMAGE_NAME:latest -t $IMAGE_NAME:${CIRCLE_TAG/v/''} --platform linux/amd64,linux/arm/v7,linux/arm64/v8,linux/ppc64le,linux/s390x --push .
fi
workflows:
+20
View File
@@ -127,6 +127,26 @@ GITHUB_APP_NAME=
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# To configure Discord auth, you'll need to create a Discord Application at
# => https://discord.com/developers/applications/
#
# When configuring the Client ID, add a redirect URL under "OAuth2":
# https://<URL>/api/discord.callback
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
# DISCORD_SERVER_ID should be the ID of the Discord server that Outline is
# integrated with.
# Used to verify that the user is a member of the server as well as server
# metadata such as nicknames, server icon and name.
DISCORD_SERVER_ID=
# DISCORD_SERVER_ROLES should be a comma separated list of role IDs that are
# allowed to access Outline. If this is not set, all members of the server
# will be allowed to access Outline.
# DISCORD_SERVER_ID and DISCORD_SERVER_ROLES must be set together.
DISCORD_SERVER_ROLES=
# –––––––––––––––– OPTIONAL ––––––––––––––––
# Base64 encoded private key and certificate for HTTPS termination. This is only
+4 -5
View File
@@ -5,9 +5,7 @@ ARG APP_PATH
WORKDIR $APP_PATH
# ---
FROM node:20-alpine AS runner
RUN apk update && apk add --no-cache curl && apk add --no-cache ca-certificates
FROM node:20-slim AS runner
LABEL org.opencontainers.image.source="https://github.com/outline/outline"
@@ -22,8 +20,9 @@ COPY --from=base $APP_PATH/.sequelizerc ./.sequelizerc
COPY --from=base $APP_PATH/node_modules ./node_modules
COPY --from=base $APP_PATH/package.json ./package.json
RUN addgroup -g 1001 -S nodejs && \
adduser -S nodejs -u 1001 && \
# Create a non-root user compatible with Debian and BusyBox based images
RUN addgroup --gid 1001 nodejs && \
adduser --uid 1001 --ingroup nodejs nodejs && \
chown -R nodejs:nodejs $APP_PATH/build && \
mkdir -p /var/lib/outline && \
chown -R nodejs:nodejs /var/lib/outline
+1 -1
View File
@@ -1,5 +1,5 @@
ARG APP_PATH=/opt/outline
FROM node:20-alpine AS deps
FROM node:20-slim AS deps
ARG APP_PATH
WORKDIR $APP_PATH
+35 -4
View File
@@ -2,13 +2,14 @@
/* global ga */
import escape from "lodash/escape";
import * as React from "react";
import { IntegrationService } from "@shared/types";
import { IntegrationService, PublicEnv } from "@shared/types";
import env from "~/env";
type Props = {
children?: React.ReactNode;
};
// TODO: Refactor this component to allow injection from plugins
const Analytics: React.FC = ({ children }: Props) => {
// Google Analytics 3
React.useEffect(() => {
@@ -43,12 +44,16 @@ const Analytics: React.FC = ({ children }: Props) => {
React.useEffect(() => {
const measurementIds = [];
if (env.analytics.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(env.analytics.settings?.measurementId));
}
if (env.GOOGLE_ANALYTICS_ID?.startsWith("G-")) {
measurementIds.push(env.GOOGLE_ANALYTICS_ID);
}
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service === IntegrationService.GoogleAnalytics) {
measurementIds.push(escape(integration.settings?.measurementId));
}
});
if (measurementIds.length === 0) {
return;
}
@@ -75,6 +80,32 @@ const Analytics: React.FC = ({ children }: Props) => {
document.getElementsByTagName("head")[0]?.appendChild(script);
}, []);
// Matomo
React.useEffect(() => {
(env.analytics as PublicEnv["analytics"]).forEach((integration) => {
if (integration.service !== IntegrationService.Matomo) {
return;
}
// @ts-expect-error - Matomo global variable
const _paq = (window._paq = window._paq || []);
_paq.push(["trackPageView"]);
_paq.push(["enableLinkTracking"]);
(function () {
const u = integration.settings?.instanceUrl;
_paq.push(["setTrackerUrl", u + "matomo.php"]);
_paq.push(["setSiteId", integration.settings?.measurementId]);
const d = document,
g = d.createElement("script"),
s = d.getElementsByTagName("script")[0];
g.type = "text/javascript";
g.async = true;
g.src = u + "matomo.js";
s.parentNode?.insertBefore(g, s);
})();
});
}, []);
return <>{children}</>;
};
+12 -20
View File
@@ -1,13 +1,9 @@
import { RovingTabIndexProvider } from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import {
useCompositeState,
Composite,
CompositeStateReturn,
} from "reakit/Composite";
type Props = React.HTMLAttributes<HTMLDivElement> & {
children: (composite: CompositeStateReturn) => React.ReactNode;
children: () => React.ReactNode;
onEscape?: (ev: React.KeyboardEvent<HTMLDivElement>) => void;
};
@@ -15,40 +11,36 @@ function ArrowKeyNavigation(
{ children, onEscape, ...rest }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const composite = useCompositeState();
const handleKeyDown = React.useCallback(
(ev) => {
(ev: React.KeyboardEvent<HTMLDivElement>) => {
if (onEscape) {
if (ev.nativeEvent.isComposing) {
return;
}
if (ev.key === "Escape") {
ev.preventDefault();
onEscape(ev);
}
if (
ev.key === "ArrowUp" &&
composite.currentId === composite.items[0].id
// If the first item is focused and the user presses ArrowUp
ev.currentTarget.firstElementChild === document.activeElement
) {
onEscape(ev);
}
}
},
[composite.currentId, composite.items, onEscape]
[onEscape]
);
return (
<Composite
{...rest}
{...composite}
onKeyDown={handleKeyDown}
role="menu"
ref={ref}
>
{children(composite)}
</Composite>
<RovingTabIndexProvider options={{ focusOnClick: true, direction: "both" }}>
<div {...rest} onKeyDown={handleKeyDown} ref={ref}>
{children()}
</div>
</RovingTabIndexProvider>
);
}
+19 -7
View File
@@ -1,15 +1,18 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CompositeStateReturn, CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Badge from "~/components/Badge";
import DocumentMeta from "~/components/DocumentMeta";
import EventBoundary from "~/components/EventBoundary";
import Flex from "~/components/Flex";
import Highlight from "~/components/Highlight";
import NudeButton from "~/components/NudeButton";
@@ -32,7 +35,7 @@ type Props = {
showPin?: boolean;
showDraft?: boolean;
showTemplate?: boolean;
} & CompositeStateReturn;
};
const SEARCH_RESULT_REGEX = /<b\b[^>]*>(.*?)<\/b>/gi;
@@ -49,6 +52,15 @@ function DocumentListItem(
const user = useCurrentUser();
const [menuOpen, handleMenuOpen, handleMenuClose] = useBoolean();
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
const {
document,
showParentDocuments,
@@ -68,9 +80,8 @@ function DocumentListItem(
!document.isDraft && !document.isArchived && !document.isTemplate;
return (
<CompositeItem
as={DocumentLink}
ref={ref}
<DocumentLink
ref={itemRef}
dir={document.dir}
role="menuitem"
$isStarred={document.isStarred}
@@ -82,6 +93,7 @@ function DocumentListItem(
},
}}
{...rest}
{...rovingTabIndex}
>
<Content>
<Heading dir={document.dir}>
@@ -142,7 +154,7 @@ function DocumentListItem(
modal={false}
/>
</Actions>
</CompositeItem>
</DocumentLink>
);
}
+3 -15
View File
@@ -11,16 +11,12 @@ import {
import * as React from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { CompositeStateReturn } from "reakit/Composite";
import styled, { css } from "styled-components";
import { s } from "@shared/styles";
import Document from "~/models/Document";
import Event from "~/models/Event";
import Avatar from "~/components/Avatar";
import CompositeItem, {
Props as ItemProps,
} from "~/components/List/CompositeItem";
import Item, { Actions } from "~/components/List/Item";
import Item, { Actions, Props as ItemProps } from "~/components/List/Item";
import Time from "~/components/Time";
import useStores from "~/hooks/useStores";
import RevisionMenu from "~/menus/RevisionMenu";
@@ -32,7 +28,7 @@ type Props = {
document: Document;
event: Event;
latest?: boolean;
} & CompositeStateReturn;
};
const EventListItem = ({ event, latest, document, ...rest }: Props) => {
const { t } = useTranslation();
@@ -176,11 +172,7 @@ 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} />;
return <ListItem to={to} ref={ref} {...rest} />;
});
const Subtitle = styled.span`
@@ -240,8 +232,4 @@ const ListItem = styled(Item)`
${ItemStyle}
`;
const CompositeListItem = styled(CompositeItem)`
${ItemStyle}
`;
export default observer(EventListItem);
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
color?: string;
/** If true, the icon will retain its color in selected menus and other places that attempt to override it */
retainColor?: boolean;
};
export default function CircleIcon({
size = 24,
color = "currentColor",
retainColor,
...rest
}: Props) {
return (
<svg
fill={color}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
style={retainColor ? { fill: color } : undefined}
{...rest}
>
<circle xmlns="http://www.w3.org/2000/svg" cx="12" cy="12" r="8" />
</svg>
);
}
+5 -4
View File
@@ -23,15 +23,16 @@ function InputSelectPermission(
ref={ref}
label={t("Permission")}
options={[
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
divider: true,
label: t("No access"),
value: EmptySelectValue,
},
-17
View File
@@ -1,17 +0,0 @@
import * as React from "react";
import {
CompositeStateReturn,
CompositeItem as BaseCompositeItem,
} from "reakit/Composite";
import Item, { Props as ItemProps } from "./Item";
export type Props = ItemProps & CompositeStateReturn;
function CompositeItem(
{ to, ...rest }: Props,
ref?: React.Ref<HTMLAnchorElement>
) {
return <BaseCompositeItem as={Item} to={to} {...rest} ref={ref} />;
}
export default React.forwardRef(CompositeItem);
+25 -2
View File
@@ -1,3 +1,7 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme } from "styled-components";
@@ -33,6 +37,18 @@ const ListItem = (
const theme = useTheme();
const compact = !subtitle;
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(
itemRef as React.RefObject<HTMLAnchorElement>,
to ? false : true
);
useFocusEffect(focused, itemRef as React.RefObject<HTMLAnchorElement>);
const content = (selected: boolean) => (
<>
{image && <Image>{image}</Image>}
@@ -59,13 +75,20 @@ const ListItem = (
if (to) {
return (
<Wrapper
ref={ref}
ref={itemRef}
$border={border}
$small={small}
activeStyle={{
background: theme.accent,
}}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
as={NavLink}
to={to}
>
@@ -75,7 +98,7 @@ const ListItem = (
}
return (
<Wrapper ref={ref} $border={border} $small={small} {...rest}>
<Wrapper ref={itemRef} $border={border} $small={small} {...rest}>
{content(false)}
</Wrapper>
);
-28
View File
@@ -1,28 +0,0 @@
import * as React from "react";
import styled from "styled-components";
import useMediaQuery from "~/hooks/useMediaQuery";
import useMobile from "~/hooks/useMobile";
type Props = {
children: React.ReactNode;
};
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`;
const MobileScrollWrapper = ({ children }: Props) => {
const isMobile = useMobile();
const isPrinting = useMediaQuery("print");
return isMobile && !isPrinting ? (
<MobileWrapper>{children}</MobileWrapper>
) : (
<>{children}</>
);
};
export default MobileScrollWrapper;
+39
View File
@@ -0,0 +1,39 @@
import * as React from "react";
import styled from "styled-components";
import useMediaQuery from "~/hooks/useMediaQuery";
import useMobile from "~/hooks/useMobile";
import ScrollContext from "./ScrollContext";
type Props = {
children: React.ReactNode;
};
const MobileWrapper = styled.div`
width: 100vw;
height: 100vh;
overflow: auto;
-webkit-overflow-scrolling: touch;
`;
/**
* A component that wraps its children in a scrollable container on mobile devices.
* This allows us to place a fixed toolbar at the bottom of the page in the document
* editor, which would otherwise be obscured by the on-screen keyboard.
*
* On desktop devices, the children are rendered directly without any wrapping.
*/
const PageScroll = ({ children }: Props) => {
const isMobile = useMobile();
const isPrinting = useMediaQuery("print");
const ref = React.useRef<HTMLDivElement>(null);
return isMobile && !isPrinting ? (
<ScrollContext.Provider value={ref}>
<MobileWrapper ref={ref}>{children}</MobileWrapper>
</ScrollContext.Provider>
) : (
<>{children}</>
);
};
export default PageScroll;
+1 -2
View File
@@ -42,7 +42,7 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
fetch={fetch}
options={options}
renderError={(props) => <Error {...props} />}
renderItem={(item: Document, _index, compositeProps) => (
renderItem={(item: Document, _index) => (
<DocumentListItem
key={item.id}
document={item}
@@ -52,7 +52,6 @@ const PaginatedDocumentList = React.memo<Props>(function PaginatedDocumentList({
showPublished={showPublished}
showTemplate={showTemplate}
showDraft={showDraft}
{...compositeProps}
/>
)}
{...rest}
+1 -2
View File
@@ -30,13 +30,12 @@ const PaginatedEventList = React.memo<Props>(function PaginatedEventList({
heading={heading}
fetch={fetch}
options={options}
renderItem={(item: Event, index, compositeProps) => (
renderItem={(item: Event, index) => (
<EventListItem
key={item.id}
event={item}
document={document}
latest={index === 0}
{...compositeProps}
/>
)}
renderHeading={(name) => <Heading>{name}</Heading>}
+3 -8
View File
@@ -4,7 +4,6 @@ import { observer } from "mobx-react";
import * as React from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { Waypoint } from "react-waypoint";
import { CompositeStateReturn } from "reakit/Composite";
import { Pagination } from "@shared/constants";
import RootStore from "~/stores/RootStore";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
@@ -30,11 +29,7 @@ type Props<T> = WithTranslation &
loading?: React.ReactElement;
items?: T[];
className?: string;
renderItem: (
item: T,
index: number,
compositeProps: CompositeStateReturn
) => React.ReactNode;
renderItem: (item: T, index: number) => React.ReactNode;
renderError?: (options: {
error: Error;
retry: () => void;
@@ -194,10 +189,10 @@ class PaginatedList<T extends PaginatedItem> extends React.Component<Props<T>> {
onEscape={onEscape}
className={this.props.className}
>
{(composite: CompositeStateReturn) => {
{() => {
let previousHeading = "";
return items.slice(0, this.renderCount).map((item, index) => {
const children = this.props.renderItem(item, index, composite);
const children = this.props.renderItem(item, index);
// If there is no renderHeading method passed then no date
// headings are rendered
+9 -5
View File
@@ -5,13 +5,17 @@ import Fade from "~/components/Fade";
import Flex from "~/components/Flex";
import PlaceholderText from "~/components/PlaceholderText";
type Props = {
/** Whether to include a title placeholder. */
includeTitle?: boolean;
/** Delay before mounting the component. Defaults to 500ms */
delay?: number;
};
export default function PlaceholderDocument({
includeTitle,
delay,
}: {
includeTitle?: boolean;
delay?: number;
}) {
delay = 500,
}: Props) {
const content = (
<>
<PlaceholderText delay={0.2} />
+15 -7
View File
@@ -1,29 +1,37 @@
import { observer } from "mobx-react";
import * as React from "react";
import styled from "styled-components";
import PluginLoader from "~/utils/PluginLoader";
import Logger from "~/utils/Logger";
import { Hook, usePluginValue } from "~/utils/PluginManager";
type Props = {
/** The ID of the plugin to render an Icon for. */
id: string;
/** The size of the icon. */
size?: number;
/** The color of the icon. */
color?: string;
};
/**
* Renders an icon defined in a plugin (Hook.Icon).
*/
function PluginIcon({ id, color, size = 24 }: Props) {
const plugin = PluginLoader.plugins[id];
const Icon = plugin?.icon;
const Icon = usePluginValue(Hook.Icon, id);
if (Icon) {
return (
<Wrapper>
<IconPosition>
<Icon size={size} fill={color} />
</Wrapper>
</IconPosition>
);
}
Logger.warn("No Icon registered for plugin", { id });
return null;
}
const Wrapper = styled.div`
const IconPosition = styled.div`
display: flex;
align-items: center;
justify-content: center;
@@ -32,4 +40,4 @@ const Wrapper = styled.div`
height: 24px;
`;
export default PluginIcon;
export default observer(PluginIcon);
+15
View File
@@ -0,0 +1,15 @@
import * as React from "react";
/**
* Context to provide a reference to the scrollable container
*/
const ScrollContext = React.createContext<
React.RefObject<HTMLDivElement> | undefined
>(undefined);
/**
* Hook to get the scrollable container reference
*/
export const useScrollContext = () => React.useContext(ScrollContext);
export default ScrollContext;
+4 -1
View File
@@ -2,6 +2,7 @@
import * as React from "react";
import { useLocation } from "react-router-dom";
import usePrevious from "~/hooks/usePrevious";
import { useScrollContext } from "./ScrollContext";
type Props = {
children: JSX.Element;
@@ -10,6 +11,7 @@ type Props = {
export default function ScrollToTop({ children }: Props) {
const location = useLocation<{ retainScrollPosition?: boolean }>();
const previousLocationPathname = usePrevious(location.pathname);
const scrollContainerRef = useScrollContext();
React.useEffect(() => {
if (
@@ -25,8 +27,9 @@ export default function ScrollToTop({ children }: Props) {
) {
return;
}
window.scrollTo(0, 0);
(scrollContainerRef?.current || window).scrollTo(0, 0);
}, [
scrollContainerRef,
location.pathname,
previousLocationPathname,
location.state?.retainScrollPosition,
+23 -5
View File
@@ -1,7 +1,10 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { observer } from "mobx-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled, { css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s, ellipsis } from "@shared/styles";
@@ -34,10 +37,18 @@ function DocumentListItem(
) {
const { document, highlight, context, shareId, ...rest } = props;
let itemRef: React.Ref<HTMLAnchorElement> =
React.useRef<HTMLAnchorElement>(null);
if (ref) {
itemRef = ref;
}
const { focused, ...rovingTabIndex } = useRovingTabIndex(itemRef, false);
useFocusEffect(focused, itemRef);
return (
<CompositeItem
as={DocumentLink}
ref={ref}
<DocumentLink
ref={itemRef}
dir={document.dir}
to={{
pathname: shareId
@@ -48,6 +59,13 @@ function DocumentListItem(
},
}}
{...rest}
{...rovingTabIndex}
onClick={(ev) => {
if (rest.onClick) {
rest.onClick(ev);
}
rovingTabIndex.onClick(ev);
}}
>
<Content>
<Heading dir={document.dir}>
@@ -66,7 +84,7 @@ function DocumentListItem(
/>
}
</Content>
</CompositeItem>
</DocumentLink>
);
}
+1 -2
View File
@@ -206,7 +206,7 @@ function SearchPopover({ shareId }: Props) {
<NoResults>{t("No results for {{query}}", { query })}</NoResults>
}
loading={<PlaceholderList count={3} header={{ height: 20 }} />}
renderItem={(item: SearchResult, index, compositeProps) => (
renderItem={(item: SearchResult, index) => (
<SearchListItem
key={item.document.id}
shareId={shareId}
@@ -215,7 +215,6 @@ function SearchPopover({ shareId }: Props) {
context={item.context}
highlight={cachedQuery}
onClick={handleSearchItemClick}
{...compositeProps}
/>
)}
/>
@@ -53,16 +53,16 @@ function CollectionMemberList({ collection, invitedInSession }: Props) {
() =>
[
{
label: t("Admin"),
value: CollectionPermission.Admin,
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: CollectionPermission.Read,
label: t("Manage"),
value: CollectionPermission.Admin,
},
{
divider: true,
@@ -59,6 +59,9 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
useKeyDown(
"Escape",
(ev) => {
if (!visible) {
return;
}
ev.preventDefault();
ev.stopImmediatePropagation();
@@ -229,16 +232,16 @@ function SharePopover({ collection, visible, onRequestClose }: Props) {
() =>
[
{
label: t("Admin"),
value: CollectionPermission.Admin,
label: t("View only"),
value: CollectionPermission.Read,
},
{
label: t("Can edit"),
value: CollectionPermission.ReadWrite,
},
{
label: t("View only"),
value: CollectionPermission.Read,
label: t("Manage"),
value: CollectionPermission.Admin,
},
] as Permission[],
[t]
@@ -51,6 +51,10 @@ const DocumentMemberListItem = ({
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
},
{
label: t("Manage"),
value: DocumentPermission.Admin,
},
{
divider: true,
label: t("Remove"),
@@ -67,6 +67,9 @@ function SharePopover({
useKeyDown(
"Escape",
(ev) => {
if (!visible) {
return;
}
ev.preventDefault();
ev.stopImmediatePropagation();
@@ -202,13 +205,17 @@ function SharePopover({
const permissions = React.useMemo(
() =>
[
{
label: t("View only"),
value: DocumentPermission.Read,
},
{
label: t("Can edit"),
value: DocumentPermission.ReadWrite,
},
{
label: t("View only"),
value: DocumentPermission.Read,
label: t("Manage"),
value: DocumentPermission.Admin,
},
] as Permission[],
[t]
+1 -1
View File
@@ -55,7 +55,7 @@ function AppSidebar() {
);
return (
<Sidebar ref={handleSidebarRef}>
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
<HistoryNavigation />
{dndArea && (
<DndProvider backend={HTML5Backend} options={html5Options}>
+8 -4
View File
@@ -24,11 +24,12 @@ const ANIMATION_MS = 250;
type Props = {
children: React.ReactNode;
hidden?: boolean;
className?: string;
};
const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
{ children, className }: Props,
{ children, hidden = false, className }: Props,
ref: React.RefObject<HTMLDivElement>
) {
const [isCollapsing, setCollapsing] = React.useState(false);
@@ -178,6 +179,7 @@ const Sidebar = React.forwardRef<HTMLDivElement, Props>(function _Sidebar(
<Container
ref={ref}
style={style}
$hidden={hidden}
$isHovering={isHovering}
$isAnimating={isAnimating}
$isSmallerThanMinimum={isSmallerThanMinimum}
@@ -249,6 +251,7 @@ type ContainerProps = {
$isSmallerThanMinimum: boolean;
$isHovering: boolean;
$collapsed: boolean;
$hidden: boolean;
};
const hoverStyles = (props: ContainerProps) => `
@@ -267,13 +270,14 @@ const hoverStyles = (props: ContainerProps) => `
`;
const Container = styled(Flex)<ContainerProps>`
opacity: ${(props) => (props.$hidden ? 0 : 1)};
position: fixed;
top: 0;
bottom: 0;
width: 100%;
background: ${s("sidebarBackground")};
transition: box-shadow 100ms ease-in-out, opacity 100ms ease-in-out,
transform 100ms ease-out,
transition: box-shadow 150ms ease-in-out, opacity 150ms ease-in-out,
transform 150ms ease-out,
${s("backgroundTransition")}
${(props: ContainerProps) =>
props.$isAnimating ? `,width ${ANIMATION_MS}ms ease-out` : ""};
@@ -316,7 +320,7 @@ const Container = styled(Flex)<ContainerProps>`
& > div {
opacity: 1;
}
}
}
`};
`;
@@ -2,9 +2,9 @@ import { LocationDescriptor } from "history";
import * as React from "react";
import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "@shared/components/EventBoundary";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import EventBoundary from "~/components/EventBoundary";
import EmojiIcon from "~/components/Icons/EmojiIcon";
import NudeButton from "~/components/NudeButton";
import { UnreadBadge } from "~/components/UnreadBadge";
+4 -4
View File
@@ -3,10 +3,10 @@ import { EditorState, NodeSelection, TextSelection } from "prosemirror-state";
import * as React from "react";
import createAndInsertLink from "@shared/editor/commands/createAndInsertLink";
import filterExcessSeparators from "@shared/editor/lib/filterExcessSeparators";
import getMarkRange from "@shared/editor/queries/getMarkRange";
import isInCode from "@shared/editor/queries/isInCode";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { getMarkRange } from "@shared/editor/queries/getMarkRange";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { getColumnIndex, getRowIndex } from "@shared/editor/queries/table";
import { MenuItem } from "@shared/editor/types";
import { creatingUrlPrefix } from "@shared/utils/urls";
+2 -2
View File
@@ -20,7 +20,7 @@ type Props = {
/*
* Renders a dropdown menu in the floating toolbar.
*/
function ToolbarDropdown(props: { item: MenuItem }) {
function ToolbarDropdown(props: { active: boolean; item: MenuItem }) {
const menu = useMenuState();
const { commands, view } = useEditor();
const { item } = props;
@@ -102,7 +102,7 @@ function ToolbarMenu(props: Props) {
key={index}
>
{item.children ? (
<ToolbarDropdown item={item} />
<ToolbarDropdown active={isActive && !item.label} item={item} />
) : (
<ToolbarButton
onClick={handleClick(item)}
+3 -2
View File
@@ -12,8 +12,9 @@ import BlockMenu from "../components/BlockMenu";
export default class BlockMenuExtension extends Suggestion {
get defaultOptions() {
return {
openRegex: /^\/(\w+)?$/,
closeRegex: /(^(?!\/(\w+)?)(.*)$|^\/(([\w\W]+)\s.*|\s)$|^\/((\W)+)$)/,
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()\/([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()\/(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
};
}
-1
View File
@@ -24,7 +24,6 @@ export default class EmojiMenuExtension extends Suggestion {
),
closeRegex:
/(?:^|\s|\():(([0-9a-zA-Z_+-]*\s+)|(\s+[0-9a-zA-Z_+-]+)|[^0-9a-zA-Z_+-]+)$/,
enabledInTable: true,
};
}
+1 -1
View File
@@ -8,7 +8,7 @@ import {
Command,
} from "prosemirror-state";
import Extension from "@shared/editor/lib/Extension";
import isInCode from "@shared/editor/queries/isInCode";
import { isInCode } from "@shared/editor/queries/isInCode";
export default class Keys extends Extension {
get name() {
-1
View File
@@ -10,7 +10,6 @@ export default class MentionMenuExtension extends Suggestion {
// ported from https://github.com/tc39/proposal-regexp-unicode-property-escapes#unicode-aware-version-of-w
openRegex: /(?:^|\s|\()@([\p{L}\p{M}\d]+)?$/u,
closeRegex: /(?:^|\s|\()@(([\p{L}\p{M}\d]*\s+)|(\s+[\p{L}\p{M}\d]+))$/u,
enabledInTable: true,
};
}
+2 -2
View File
@@ -5,8 +5,8 @@ import { LANGUAGES } from "@shared/editor/extensions/Prism";
import Extension from "@shared/editor/lib/Extension";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
import isInCode from "@shared/editor/queries/isInCode";
import isInList from "@shared/editor/queries/isInList";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { isDocumentUrl, isUrl } from "@shared/utils/urls";
import stores from "~/stores";
+2 -4
View File
@@ -2,10 +2,9 @@ import { action, observable } from "mobx";
import { InputRule } from "prosemirror-inputrules";
import { NodeType, Schema } from "prosemirror-model";
import { EditorState, Plugin } from "prosemirror-state";
import { isInTable } from "prosemirror-tables";
import Extension from "@shared/editor/lib/Extension";
import { SuggestionsMenuPlugin } from "@shared/editor/plugins/Suggestions";
import isInCode from "@shared/editor/queries/isInCode";
import { isInCode } from "@shared/editor/queries/isInCode";
export default class Suggestion extends Extension {
state: {
@@ -50,8 +49,7 @@ export default class Suggestion extends Extension {
match &&
(parent.type.name === "paragraph" ||
parent.type.name === "heading") &&
(!isInCode(state) || this.options.enabledInCode) &&
(!isInTable(state) || this.options.enabledInTable)
(!isInCode(state) || this.options.enabledInCode)
) {
this.state.open = true;
this.state.query = match[1];
+1 -1
View File
@@ -1,7 +1,7 @@
import { PageBreakIcon, HorizontalRuleIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
+42 -10
View File
@@ -20,11 +20,14 @@ import {
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import isInCode from "@shared/editor/queries/isInCode";
import isInList from "@shared/editor/queries/isInList";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import Highlight from "@shared/editor/marks/Highlight";
import { getMarksBetween } from "@shared/editor/queries/getMarksBetween";
import { isInCode } from "@shared/editor/queries/isInCode";
import { isInList } from "@shared/editor/queries/isInList";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import CircleIcon from "~/components/Icons/CircleIcon";
import { Dictionary } from "~/hooks/useDictionary";
export default function formattingMenuItems(
@@ -34,11 +37,16 @@ export default function formattingMenuItems(
dictionary: Dictionary
): MenuItem[] {
const { schema } = state;
const isList = isInList(state);
const isCode = isInCode(state);
const isCodeBlock = isInCode(state, { onlyBlock: true });
const isEmpty = state.selection.empty;
const highlight = getMarksBetween(
state.selection.from,
state.selection.to,
state
).find(({ mark }) => mark.type.name === "highlight");
return [
{
name: "placeholder",
@@ -73,11 +81,21 @@ export default function formattingMenuItems(
visible: !isCode && (!isMobile || !isEmpty),
},
{
name: "highlight",
tooltip: dictionary.mark,
icon: <HighlightIcon />,
active: isMarkActive(schema.marks.highlight),
icon: highlight ? (
<CircleIcon color={highlight.mark.attrs.color} />
) : (
<HighlightIcon />
),
active: () => !!highlight,
visible: !isCode && (!isMobile || !isEmpty),
children: Highlight.colors.map((color, index) => ({
name: "highlight",
label: Highlight.colorNames[index],
icon: <CircleIcon retainColor color={color} />,
active: isMarkActive(schema.marks.highlight, { color }),
attrs: { color },
})),
},
{
name: "code_inline",
@@ -152,13 +170,27 @@ export default function formattingMenuItems(
name: "outdentList",
tooltip: dictionary.outdent,
icon: <OutdentIcon />,
visible: isList && isMobile,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "indentList",
tooltip: dictionary.indent,
icon: <IndentIcon />,
visible: isList && isMobile,
visible:
isMobile && isInList(state, { types: ["ordered_list", "bullet_list"] }),
},
{
name: "outdentCheckboxList",
tooltip: dictionary.outdent,
icon: <OutdentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "indentCheckboxList",
tooltip: dictionary.indent,
icon: <IndentIcon />,
visible: isMobile && isInList(state, { types: ["checkbox_list"] }),
},
{
name: "separator",
+1 -1
View File
@@ -9,7 +9,7 @@ import {
} from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
+1 -1
View File
@@ -1,7 +1,7 @@
import { CommentIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import isMarkActive from "@shared/editor/queries/isMarkActive";
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
+1 -1
View File
@@ -1,7 +1,7 @@
import { AlignFullWidthIcon, TrashIcon } from "outline-icons";
import { EditorState } from "prosemirror-state";
import * as React from "react";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem, TableLayout } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
+1 -1
View File
@@ -12,7 +12,7 @@ import {
import { EditorState } from "prosemirror-state";
import * as React from "react";
import styled from "styled-components";
import isNodeActive from "@shared/editor/queries/isNodeActive";
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
import { MenuItem } from "@shared/editor/types";
import { Dictionary } from "~/hooks/useDictionary";
+16
View File
@@ -0,0 +1,16 @@
import { computed } from "mobx";
import { type DependencyList, useMemo } from "react";
/**
* Hook around MobX computed function that runs computation whenever observable values change.
*
* @param callback Function which returns a memorized value.
* @param inputs Dependency list for useMemo.
*/
export function useComputed<T>(
callback: () => T,
inputs: DependencyList = []
): T {
const value = useMemo(() => computed(callback), inputs);
return value.get();
}
+16 -34
View File
@@ -18,12 +18,12 @@ import {
import React, { ComponentProps } from "react";
import { useTranslation } from "react-i18next";
import { integrationSettingsPath } from "@shared/utils/routeHelpers";
import GoogleIcon from "~/components/Icons/GoogleIcon";
import ZapierIcon from "~/components/Icons/ZapierIcon";
import PluginLoader from "~/utils/PluginLoader";
import { Hook, PluginManager } from "~/utils/PluginManager";
import isCloudHosted from "~/utils/isCloudHosted";
import lazy from "~/utils/lazyWithRetry";
import { settingsPath } from "~/utils/routeHelpers";
import { useComputed } from "./useComputed";
import useCurrentTeam from "./useCurrentTeam";
import useCurrentUser from "./useCurrentUser";
import usePolicy from "./usePolicy";
@@ -32,7 +32,6 @@ const ApiKeys = lazy(() => import("~/scenes/Settings/ApiKeys"));
const Details = lazy(() => import("~/scenes/Settings/Details"));
const Export = lazy(() => import("~/scenes/Settings/Export"));
const Features = lazy(() => import("~/scenes/Settings/Features"));
const GoogleAnalytics = lazy(() => import("~/scenes/Settings/GoogleAnalytics"));
const Groups = lazy(() => import("~/scenes/Settings/Groups"));
const Import = lazy(() => import("~/scenes/Settings/Import"));
const Members = lazy(() => import("~/scenes/Settings/Members"));
@@ -60,7 +59,7 @@ const useSettingsConfig = () => {
const can = usePolicy(team);
const { t } = useTranslation();
const config = React.useMemo(() => {
const config = useComputed(() => {
const items: ConfigItem[] = [
// Account
{
@@ -177,14 +176,6 @@ const useSettingsConfig = () => {
group: t("Integrations"),
icon: BuildingBlocksIcon,
},
{
name: t("Google Analytics"),
path: integrationSettingsPath("google-analytics"),
component: GoogleAnalytics,
enabled: can.update,
group: t("Integrations"),
icon: GoogleIcon,
},
{
name: "Zapier",
path: integrationSettingsPath("zapier"),
@@ -196,29 +187,20 @@ const useSettingsConfig = () => {
];
// Plugins
Object.values(PluginLoader.plugins).map((plugin) => {
const hasSettings = !!plugin.settings;
const enabledInDeployment =
!plugin.config?.deployments ||
plugin.config.deployments.length === 0 ||
(plugin.config.deployments.includes("cloud") && isCloudHosted) ||
(plugin.config.deployments.includes("enterprise") && !isCloudHosted);
const item = {
name: t(plugin.config.name),
PluginManager.getHooks(Hook.Settings).forEach((plugin) => {
const insertIndex = plugin.value.after
? items.findIndex((i) => i.name === t(plugin.value.after!)) + 1
: items.findIndex(
(i) => i.group === t(plugin.value.group ?? "Integrations")
);
items.splice(insertIndex, 0, {
name: t(plugin.name),
path: integrationSettingsPath(plugin.id),
// TODO: Remove hardcoding of plugin id here
group: plugin.id === "collections" ? t("Workspace") : t("Integrations"),
component: plugin.settings,
enabled:
enabledInDeployment &&
hasSettings &&
(plugin.config.roles?.includes(user.role) || can.update),
icon: plugin.icon,
} as ConfigItem;
const insertIndex = items.findIndex((i) => i.group === t("Integrations"));
items.splice(insertIndex, 0, item);
group: t(plugin.value.group),
component: plugin.value.component,
enabled: plugin.roles?.includes(user.role) || can.update,
icon: plugin.value.icon,
} as ConfigItem);
});
return items;
+7 -3
View File
@@ -20,12 +20,16 @@ import env from "~/env";
import { initI18n } from "~/utils/i18n";
import Desktop from "./components/DesktopEventHandler";
import LazyPolyfill from "./components/LazyPolyfills";
import MobileScrollWrapper from "./components/MobileScrollWrapper";
import PageScroll from "./components/PageScroll";
import Routes from "./routes";
import Logger from "./utils/Logger";
import { PluginManager } from "./utils/PluginManager";
import history from "./utils/history";
import { initSentry } from "./utils/sentry";
// Load plugins as soon as possible
void PluginManager.loadPlugins();
initI18n(env.DEFAULT_LANGUAGE);
const element = window.document.getElementById("root");
@@ -61,7 +65,7 @@ if (element) {
<LazyPolyfill>
<LazyMotion features={loadFeatures}>
<Router history={history}>
<MobileScrollWrapper>
<PageScroll>
<PageTheme />
<ScrollToTop>
<Routes />
@@ -69,7 +73,7 @@ if (element) {
<Toasts />
<Dialogs />
<Desktop />
</MobileScrollWrapper>
</PageScroll>
</Router>
</LazyMotion>
</LazyPolyfill>
+1 -1
View File
@@ -4,13 +4,13 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import { useMenuState } from "reakit/Menu";
import { toast } from "sonner";
import EventBoundary from "@shared/components/EventBoundary";
import Comment from "~/models/Comment";
import CommentDeleteDialog from "~/components/CommentDeleteDialog";
import ContextMenu from "~/components/ContextMenu";
import MenuItem from "~/components/ContextMenu/MenuItem";
import OverflowMenuButton from "~/components/ContextMenu/OverflowMenuButton";
import Separator from "~/components/ContextMenu/Separator";
import EventBoundary from "~/components/EventBoundary";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import { commentPath, urlify } from "~/utils/routeHelpers";
+38 -59
View File
@@ -3,15 +3,14 @@ import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { s } from "@shared/styles";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { depths, s } from "@shared/styles";
import Text from "~/components/Text";
import useWindowScrollPosition from "~/hooks/useWindowScrollPosition";
const HEADING_OFFSET = 20;
type Props = {
/** Whether the document is rendering full width or not. */
isFullWidth: boolean;
/** The headings to render in the contents. */
headings: {
title: string;
@@ -20,9 +19,9 @@ type Props = {
}[];
};
export default function Contents({ headings, isFullWidth }: Props) {
export default function Contents({ headings }: Props) {
const [activeSlug, setActiveSlug] = React.useState<string>();
const position = useWindowScrollPosition({
const scrollPosition = useWindowScrollPosition({
throttle: 100,
});
@@ -43,7 +42,7 @@ export default function Contents({ headings, isFullWidth }: Props) {
}
}
}
}, [position, headings]);
}, [scrollPosition, headings]);
// calculate the minimum heading level and adjust all the headings to make
// that the top-most. This prevents the contents from being weirdly indented
@@ -56,70 +55,53 @@ export default function Contents({ headings, isFullWidth }: Props) {
const { t } = useTranslation();
return (
<Wrapper isFullWidth={isFullWidth}>
<Sticky>
<Heading>{t("Contents")}</Heading>
{headings.length ? (
<List>
{headings
.filter((heading) => heading.level < 4)
.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
</List>
) : (
<Empty>
{t("Headings you add to the document will appear here")}
</Empty>
)}
</Sticky>
</Wrapper>
<StickyWrapper>
<Heading>{t("Contents")}</Heading>
{headings.length ? (
<List>
{headings
.filter((heading) => heading.level < 4)
.map((heading) => (
<ListItem
key={heading.id}
level={heading.level - headingAdjustment}
active={activeSlug === heading.id}
>
<Link href={`#${heading.id}`}>{heading.title}</Link>
</ListItem>
))}
</List>
) : (
<Empty>{t("Headings you add to the document will appear here")}</Empty>
)}
</StickyWrapper>
);
}
const Wrapper = styled.div<{ isFullWidth: boolean }>`
width: 256px;
const StickyWrapper = styled.div`
display: none;
${breakpoint("tablet")`
display: block;
`};
${(props) =>
!props.isFullWidth &&
breakpoint("desktopLarge")`
transform: translateX(-256px);
width: 0;
`}
`;
const Sticky = styled.div`
position: sticky;
top: 80px;
max-height: calc(100vh - 80px);
top: 90px;
max-height: calc(100vh - 90px);
width: ${EditorStyleHelper.tocWidth}px;
padding: 0 16px;
overflow-y: auto;
border-radius: 8px;
background: ${s("background")};
transition: ${s("backgroundTransition")};
margin-top: 80px;
margin-right: 52px;
min-width: 204px;
width: 228px;
min-height: 40px;
overflow-y: auto;
padding: 0 16px;
border-radius: 8px;
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
background: ${(props) => transparentize(0.2, props.theme.background)};
}
${breakpoint("tablet")`
display: block;
z-index: ${depths.toc};
`};
`;
const Heading = styled.h3`
@@ -131,15 +113,12 @@ const Heading = styled.h3`
`;
const Empty = styled(Text)`
margin: 1em 0 4em;
padding-right: 2em;
font-size: 14px;
`;
const ListItem = styled.li<{ level: number; active?: boolean }>`
margin-left: ${(props) => (props.level - 1) * 10}px;
margin-bottom: 8px;
padding-right: 2em;
line-height: 1.3;
word-break: break-word;
+124 -56
View File
@@ -17,8 +17,9 @@ import {
import { toast } from "sonner";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
import { s } from "@shared/styles";
import { NavigationNode } from "@shared/types";
import { NavigationNode, TOCPosition, TeamPreference } from "@shared/types";
import { ProsemirrorHelper, Heading } from "@shared/utils/ProsemirrorHelper";
import { parseDomain } from "@shared/utils/domains";
import RootStore from "~/stores/RootStore";
@@ -403,6 +404,9 @@ class DocumentScene extends React.Component<Props> {
const hasHeadings = this.headings.length > 0;
const showContents =
ui.tocVisible && ((readOnly && hasHeadings) || !readOnly);
const tocPosition =
(team?.getPreference(TeamPreference.TocPosition) as TOCPosition) ||
TOCPosition.Left;
const multiplayerEditor =
!document.isArchived && !document.isDeleted && !revision && !isShare;
@@ -449,7 +453,7 @@ class DocumentScene extends React.Component<Props> {
favicon={document.emoji ? emojiToUrl(document.emoji) : undefined}
/>
{(this.isUploading || this.isSaving) && <LoadingIndicator />}
<Container justify="center" column auto>
<Container column>
{!readOnly && (
<Prompt
when={this.isUploading && !this.isEditorDirty}
@@ -476,27 +480,39 @@ class DocumentScene extends React.Component<Props> {
onSave={this.onSave}
headings={this.headings}
/>
<MeasuredContainer
as={MaxWidth}
name="document"
archived={document.isArchived}
showContents={showContents}
isEditing={!readOnly}
isFullWidth={document.fullWidth}
column
auto
>
<Flex justify="center">
<Notices document={document} readOnly={readOnly} />
</Flex>
<MeasuredContainer
as={Main}
name="document"
fullWidth={document.fullWidth}
tocPosition={tocPosition}
>
<React.Suspense fallback={<PlaceholderDocument />}>
<Flex auto={!readOnly} reverse>
{revision ? (
{revision ? (
<RevisionContainer docFullWidth={document.fullWidth}>
<RevisionViewer
document={document}
revision={revision}
id={revision.id}
/>
) : (
<>
</RevisionContainer>
) : (
<>
{showContents && (
<ContentsContainer
docFullWidth={document.fullWidth}
position={tocPosition}
>
<Contents headings={this.headings} />
</ContentsContainer>
)}
<EditorContainer
docFullWidth={document.fullWidth}
showContents={showContents}
tocPosition={tocPosition}
>
<Editor
id={document.id}
key={embedsDisabled ? "disabled" : "enabled"}
@@ -543,16 +559,9 @@ class DocumentScene extends React.Component<Props> {
</>
)}
</Editor>
{showContents && (
<Contents
headings={this.headings}
isFullWidth={document.fullWidth}
/>
)}
</>
)}
</Flex>
</EditorContainer>
</>
)}
</React.Suspense>
</MeasuredContainer>
{isShare &&
@@ -573,6 +582,95 @@ class DocumentScene extends React.Component<Props> {
}
}
type MainProps = {
fullWidth: boolean;
tocPosition: TOCPosition;
};
const Main = styled.div<MainProps>`
margin-top: 4px;
${breakpoint("tablet")`
display: grid;
grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) =>
fullWidth
? tocPosition === TOCPosition.Left
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(46em + 76px)`}) 1fr`};
`};
${breakpoint("desktopLarge")`
grid-template-columns: ${({ fullWidth, tocPosition }: MainProps) =>
fullWidth
? tocPosition === TOCPosition.Left
? `${EditorStyleHelper.tocWidth}px minmax(0, 1fr)`
: `minmax(0, 1fr) ${EditorStyleHelper.tocWidth}px`
: `1fr minmax(0, ${`calc(52em + 76px)`}) 1fr`};
`};
`;
type ContentsContainerProps = {
docFullWidth: boolean;
position: TOCPosition;
};
const ContentsContainer = styled.div<ContentsContainerProps>`
margin-top: calc(44px + 6vh);
${breakpoint("tablet")`
grid-row: 1;
grid-column: ${({ docFullWidth, position }: ContentsContainerProps) =>
position === TOCPosition.Left ? 1 : docFullWidth ? 2 : 3};
justify-self: ${({ position }: ContentsContainerProps) =>
position === TOCPosition.Left ? "end" : "start"};
`};
`;
type EditorContainerProps = {
docFullWidth: boolean;
showContents: boolean;
tocPosition: TOCPosition;
};
const EditorContainer = styled.div<EditorContainerProps>`
// Adds space to the gutter to make room for icon & heading annotations
padding: 0 44px;
${breakpoint("tablet")`
grid-row: 1;
// Decides the editor column position & span
grid-column: ${({
docFullWidth,
showContents,
tocPosition,
}: EditorContainerProps) =>
docFullWidth
? showContents
? tocPosition === TOCPosition.Left
? 2
: 1
: "1 / -1"
: 2};
`};
`;
type RevisionContainerProps = {
docFullWidth: boolean;
};
const RevisionContainer = styled.div<RevisionContainerProps>`
// Adds space to the gutter to make room for icon
padding: 0 44px;
${breakpoint("tablet")`
grid-row: 1;
grid-column: ${({ docFullWidth }: RevisionContainerProps) =>
docFullWidth ? "1 / -1" : 2};
`}
`;
const Footer = styled.div`
position: absolute;
width: 100%;
@@ -595,34 +693,4 @@ const ReferencesWrapper = styled.div`
}
`;
type MaxWidthProps = {
isEditing?: boolean;
isFullWidth?: boolean;
archived?: boolean;
showContents?: boolean;
};
const MaxWidth = styled(Flex)<MaxWidthProps>`
// Adds space to the gutter to make room for heading annotations
padding: 0 32px;
transition: padding 100ms;
max-width: 100vw;
width: 100%;
padding-bottom: 16px;
${breakpoint("tablet")`
margin: 4px auto 12px;
max-width: ${(props: MaxWidthProps) =>
props.isFullWidth
? "100vw"
: `calc(64px + 46em + ${props.showContents ? "256px" : "0px"});`}
`};
${breakpoint("desktopLarge")`
max-width: ${(props: MaxWidthProps) =>
props.isFullWidth ? "100vw" : `calc(64px + 52em);`}
`};
`;
export default withTranslation()(withStores(withRouter(DocumentScene)));
@@ -4,7 +4,7 @@ import { Selection } from "prosemirror-state";
import { __parseFromClipboard } from "prosemirror-view";
import * as React from "react";
import { mergeRefs } from "react-merge-refs";
import styled, { css } from "styled-components";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import isMarkdown from "@shared/editor/lib/isMarkdown";
import normalizePastedMarkdown from "@shared/editor/lib/markdown/normalize";
@@ -33,8 +33,6 @@ type Props = {
title: string;
/** Emoji to display */
emoji?: string | null;
/** Position of the emoji relative to text */
emojiPosition: "side" | "top";
/** Placeholder to display when the document has no title */
placeholder?: string;
/** Should the title be editable, policies will also be considered separately */
@@ -59,7 +57,6 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
documentId,
title,
emoji,
emojiPosition,
readOnly,
onChangeTitle,
onChangeEmoji,
@@ -247,12 +244,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
ref={mergeRefs([ref, externalRef])}
>
{can.update && !readOnly ? (
<EmojiWrapper
align="center"
justify="center"
$position={emojiPosition}
dir={dir}
>
<EmojiWrapper align="center" justify="center" dir={dir}>
<React.Suspense fallback={emojiIcon}>
<StyledEmojiPicker
value={emoji}
@@ -265,12 +257,7 @@ const DocumentTitle = React.forwardRef(function _DocumentTitle(
</React.Suspense>
</EmojiWrapper>
) : emoji ? (
<EmojiWrapper
align="center"
justify="center"
$position={emojiPosition}
dir={dir}
>
<EmojiWrapper align="center" justify="center" dir={dir}>
{emojiIcon}
</EmojiWrapper>
) : null}
@@ -282,25 +269,17 @@ const StyledEmojiPicker = styled(EmojiPicker)`
${extraArea(8)}
`;
const EmojiWrapper = styled(Flex)<{ $position: "top" | "side"; dir?: string }>`
const EmojiWrapper = styled(Flex)<{ dir?: string }>`
position: absolute;
top: 8px;
height: 32px;
width: 32px;
// Always move above TOC
z-index: 1;
${(props) =>
props.$position === "top"
? css`
position: relative;
top: -8px;
`
: css`
position: absolute;
top: 8px;
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
`}
${(props: { dir?: string }) =>
props.dir === "rtl" ? "right: -40px" : "left: -40px"};
`;
type TitleProps = {
@@ -187,7 +187,6 @@ function DocumentEditor(props: Props, ref: React.RefObject<any>) {
: document.title
}
emoji={document.emoji}
emojiPosition={document.fullWidth ? "top" : "side"}
onChangeTitle={onChangeTitle}
onChangeEmoji={onChangeEmoji}
onGoToNextInput={handleGoToNextInput}
@@ -31,7 +31,6 @@ function RevisionViewer(props: Props) {
documentId={revision.documentId}
title={revision.title}
emoji={revision.emoji}
emojiPosition={document.fullWidth ? "top" : "side"}
readOnly
/>
<DocumentMeta
@@ -1,49 +0,0 @@
import { observer } from "mobx-react";
import { PlusIcon } from "outline-icons";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
import Button from "~/components/Button";
import ListItem from "~/components/List/Item";
import Time from "~/components/Time";
type Props = {
user: User;
canEdit: boolean;
onAdd: () => void;
};
const UserListItem = ({ user, onAdd, canEdit }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={user.name}
image={<Avatar model={user} size={32} />}
subtitle={
<>
{user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Never signed in")
)}
{user.isInvited && <Badge>{t("Invited")}</Badge>}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
</>
}
actions={
canEdit ? (
<Button type="button" onClick={onAdd} icon={<PlusIcon />} neutral>
{t("Add")}
</Button>
) : undefined
}
/>
);
};
export default observer(UserListItem);
@@ -93,17 +93,15 @@ function AuthenticationProvider(props: Props) {
}
return (
<Wrapper>
<ButtonLarge
onClick={() => (window.location.href = href)}
icon={<PluginIcon id={id} />}
fullwidth
>
{t("Continue with {{ authProviderName }}", {
authProviderName: name,
})}
</ButtonLarge>
</Wrapper>
<ButtonLarge
onClick={() => (window.location.href = href)}
icon={<PluginIcon id={id} />}
fullwidth
>
{t("Continue with {{ authProviderName }}", {
authProviderName: name,
})}
</ButtonLarge>
);
}
+7 -21
View File
@@ -53,8 +53,8 @@ function Search(props: Props) {
// refs
const searchInputRef = React.useRef<HTMLInputElement | null>(null);
const resultListCompositeRef = React.useRef<HTMLDivElement | null>(null);
const recentSearchesCompositeRef = React.useRef<HTMLDivElement | null>(null);
const resultListRef = React.useRef<HTMLDivElement | null>(null);
const recentSearchesRef = React.useRef<HTMLDivElement | null>(null);
// filters
const query = decodeURIComponentSafe(routeMatch.params.term ?? "");
@@ -178,19 +178,9 @@ function Search(props: Props) {
}
}
const firstResultItem = (
resultListCompositeRef.current?.querySelectorAll(
"[href]"
) as NodeListOf<HTMLAnchorElement>
)?.[0];
const firstItem = (resultListRef.current?.firstElementChild ??
recentSearchesRef.current?.firstElementChild) as HTMLAnchorElement;
const firstRecentSearchItem = (
recentSearchesCompositeRef.current?.querySelectorAll(
"li > [href]"
) as NodeListOf<HTMLAnchorElement>
)?.[0];
const firstItem = firstResultItem ?? firstRecentSearchItem;
firstItem?.focus();
}
};
@@ -277,11 +267,11 @@ function Search(props: Props) {
)}
<ResultList column>
<StyledArrowKeyNavigation
ref={resultListCompositeRef}
ref={resultListRef}
onEscape={handleEscape}
aria-label={t("Search Results")}
>
{(compositeProps) =>
{() =>
data?.length
? data.map((result) => (
<DocumentListItem
@@ -291,7 +281,6 @@ function Search(props: Props) {
context={result.context}
showCollection
showTemplate
{...compositeProps}
/>
))
: null
@@ -305,10 +294,7 @@ function Search(props: Props) {
</ResultList>
</>
) : documentId || collectionId ? null : (
<RecentSearches
ref={recentSearchesCompositeRef}
onEscape={handleEscape}
/>
<RecentSearches ref={recentSearchesRef} onEscape={handleEscape} />
)}
</ResultsWrapper>
</Scene>
@@ -0,0 +1,92 @@
import {
useFocusEffect,
useRovingTabIndex,
} from "@getoutline/react-roving-tabindex";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import { s } from "@shared/styles";
import type SearchQuery from "~/models/SearchQuery";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import { hover } from "~/styles";
import { searchPath } from "~/utils/routeHelpers";
type Props = {
searchQuery: SearchQuery;
};
function RecentSearchListItem({ searchQuery }: Props) {
const { t } = useTranslation();
const ref = React.useRef<HTMLAnchorElement>(null);
const { focused, ...rovingTabIndex } = useRovingTabIndex(ref, false);
useFocusEffect(focused, ref);
return (
<RecentSearch
to={searchPath(searchQuery.query)}
ref={ref}
{...rovingTabIndex}
>
{searchQuery.query}
<Tooltip content={t("Remove search")} delay={150}>
<RemoveButton
aria-label={t("Remove search")}
onClick={async (ev) => {
ev.preventDefault();
await searchQuery.delete();
}}
>
<CloseIcon />
</RemoveButton>
</Tooltip>
</RecentSearch>
);
}
const RemoveButton = styled(NudeButton)`
opacity: 0;
color: ${s("textTertiary")};
&:hover {
color: ${s("text")};
}
`;
const RecentSearch = styled(Link)`
display: flex;
justify-content: space-between;
color: ${s("textSecondary")};
cursor: var(--pointer);
padding: 1px 4px;
border-radius: 4px;
position: relative;
font-size: 14px;
&:before {
content: "·";
color: ${s("textTertiary")};
position: absolute;
left: -8px;
}
&:focus-visible {
outline: none;
}
&:focus,
&:${hover} {
color: ${s("text")};
background: ${s("secondaryBackground")};
${RemoveButton} {
opacity: 1;
}
}
`;
export default RecentSearchListItem;
+16 -87
View File
@@ -1,18 +1,12 @@
import { observer } from "mobx-react";
import { CloseIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { CompositeItem } from "reakit/Composite";
import styled from "styled-components";
import { s } from "@shared/styles";
import ArrowKeyNavigation from "~/components/ArrowKeyNavigation";
import Fade from "~/components/Fade";
import NudeButton from "~/components/NudeButton";
import Tooltip from "~/components/Tooltip";
import useStores from "~/hooks/useStores";
import { hover } from "~/styles";
import { searchPath } from "~/utils/routeHelpers";
import RecentSearchListItem from "./RecentSearchListItem";
type Props = {
/** Callback when the Escape key is pressed while navigating the list */
@@ -36,39 +30,20 @@ function RecentSearches(
const content = searches.recent.length ? (
<>
<Heading>{t("Recent searches")}</Heading>
<List>
<ArrowKeyNavigation
ref={ref}
onEscape={onEscape}
aria-label={t("Search Results")}
>
{(compositeProps) =>
searches.recent.map((searchQuery) => (
<ListItem key={searchQuery.id}>
<CompositeItem
as={RecentSearch}
to={searchPath(searchQuery.query)}
role="menuitem"
{...compositeProps}
>
{searchQuery.query}
<Tooltip content={t("Remove search")} delay={150}>
<RemoveButton
aria-label={t("Remove search")}
onClick={async (ev) => {
ev.preventDefault();
await searchQuery.delete();
}}
>
<CloseIcon />
</RemoveButton>
</Tooltip>
</CompositeItem>
</ListItem>
))
}
</ArrowKeyNavigation>
</List>
<StyledArrowKeyNavigation
ref={ref}
onEscape={onEscape}
aria-label={t("Recent searches")}
>
{() =>
searches.recent.map((searchQuery) => (
<RecentSearchListItem
key={searchQuery.id}
searchQuery={searchQuery}
/>
))
}
</StyledArrowKeyNavigation>
</>
) : null;
@@ -83,55 +58,9 @@ const Heading = styled.h2`
margin-bottom: 0;
`;
const List = styled.ol`
const StyledArrowKeyNavigation = styled(ArrowKeyNavigation)`
padding: 0;
margin-top: 8px;
`;
const ListItem = styled.li`
font-size: 14px;
padding: 0;
list-style: none;
position: relative;
&:before {
content: "·";
color: ${s("textTertiary")};
position: absolute;
left: -8px;
}
`;
const RemoveButton = styled(NudeButton)`
opacity: 0;
color: ${s("textTertiary")};
&:hover {
color: ${s("text")};
}
`;
const RecentSearch = styled(Link)`
display: flex;
justify-content: space-between;
color: ${s("textSecondary")};
cursor: var(--pointer);
padding: 1px 4px;
border-radius: 4px;
&:focus-visible {
outline: none;
}
&:focus,
&: ${hover} {
color: ${s("text")};
background: ${s("secondaryBackground")};
${RemoveButton} {
opacity: 1;
}
}
`;
export default observer(React.forwardRef(RecentSearches));
+31 -3
View File
@@ -8,7 +8,7 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { ThemeProvider, useTheme } from "styled-components";
import { buildDarkTheme, buildLightTheme } from "@shared/styles/theme";
import { CustomTheme, TeamPreference } from "@shared/types";
import { CustomTheme, TOCPosition, TeamPreference } from "@shared/types";
import { getBaseDomain } from "@shared/utils/domains";
import Button from "~/components/Button";
import ButtonLink from "~/components/ButtonLink";
@@ -16,6 +16,7 @@ import DefaultCollectionInputSelect from "~/components/DefaultCollectionInputSel
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import InputColor from "~/components/InputColor";
import InputSelect from "~/components/InputSelect";
import Scene from "~/components/Scene";
import Switch from "~/components/Switch";
import Text from "~/components/Text";
@@ -58,6 +59,10 @@ function Details() {
isHexColor
);
const [tocPosition, setTocPosition] = useState(
team.getPreference(TeamPreference.TocPosition) as TOCPosition
);
const handleSubmit = React.useCallback(
async (event?: React.SyntheticEvent) => {
if (event) {
@@ -73,6 +78,7 @@ function Details() {
...team.preferences,
publicBranding,
customTheme,
tocPosition,
},
});
toast.success(t("Settings saved"));
@@ -174,7 +180,6 @@ function Details() {
/>
</SettingRow>
<SettingRow
border={false}
label={t("Theme")}
name="accent"
description={
@@ -212,7 +217,6 @@ function Details() {
</SettingRow>
{team.avatarUrl && (
<SettingRow
border={false}
name={TeamPreference.PublicBranding}
label={t("Public branding")}
description={t(
@@ -229,6 +233,30 @@ function Details() {
/>
</SettingRow>
)}
<SettingRow
border={false}
label={t("Table of contents position")}
name="tocPosition"
description={t(
"The side to display the table of contents in relation to the main content."
)}
>
<InputSelect
ariaLabel={t("Table of contents position")}
options={[
{
label: t("Left"),
value: TOCPosition.Left,
},
{
label: t("Right"),
value: TOCPosition.Right,
},
]}
value={tocPosition}
onChange={(p: TOCPosition) => setTocPosition(p)}
/>
</SettingRow>
<Heading as="h2">{t("Behavior")}</Heading>
@@ -1,39 +0,0 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import Share from "~/models/Share";
import ListItem from "~/components/List/Item";
import Time from "~/components/Time";
import ShareMenu from "~/menus/ShareMenu";
type Props = {
share: Share;
};
const ShareListItem = ({ share }: Props) => {
const { t } = useTranslation();
const { lastAccessedAt } = share;
return (
<ListItem
title={share.documentTitle}
subtitle={
<>
{t("Shared")} <Time dateTime={share.createdAt} addSuffix />{" "}
{t("by {{ name }}", {
name: share.createdBy.name,
})}{" "}
{lastAccessedAt && (
<>
{" "}
&middot; {t("Last accessed")}{" "}
<Time dateTime={lastAccessedAt} addSuffix />
</>
)}
</>
}
actions={<ShareMenu share={share} />}
/>
);
};
export default ShareListItem;
@@ -1,50 +0,0 @@
import { observer } from "mobx-react";
import * as React from "react";
import { Trans, useTranslation } from "react-i18next";
import styled from "styled-components";
import User from "~/models/User";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
import ListItem from "~/components/List/Item";
import Time from "~/components/Time";
import UserMenu from "~/menus/UserMenu";
type Props = {
user: User;
showMenu: boolean;
};
const UserListItem = ({ user, showMenu }: Props) => {
const { t } = useTranslation();
return (
<ListItem
title={<Title>{user.name}</Title>}
image={<Avatar model={user} size={32} />}
subtitle={
<>
{user.email ? `${user.email} · ` : undefined}
{user.lastActiveAt ? (
<Trans>
Active <Time dateTime={user.lastActiveAt} /> ago
</Trans>
) : (
t("Invited")
)}
{user.isAdmin && <Badge primary={user.isAdmin}>{t("Admin")}</Badge>}
{user.isSuspended && <Badge>{t("Suspended")}</Badge>}
</>
}
actions={showMenu ? <UserMenu user={user} /> : undefined}
/>
);
};
const Title = styled.span`
&:hover {
text-decoration: underline;
cursor: var(--pointer);
}
`;
export default observer(UserListItem);
+14 -1
View File
@@ -3,6 +3,7 @@ import { light as defaultTheme } from "@shared/styles/theme";
import Storage from "@shared/utils/Storage";
import Document from "~/models/Document";
import type { ConnectionStatus } from "~/scenes/Document/components/MultiplayerEditor";
import type RootStore from "./RootStore";
const UI_STORE = "UI_STORE";
@@ -82,7 +83,11 @@ class UiStore {
@observable
multiplayerErrorCode?: number;
constructor() {
rootStore: RootStore;
constructor(rootStore: RootStore) {
this.rootStore = rootStore;
// Rehydrate
const data: PersistedData = Storage.get(UI_STORE) || {};
this.languagePromptDismissed = data.languagePromptDismissed;
@@ -270,6 +275,14 @@ class UiStore {
this.mobileSidebarVisible = false;
};
@computed
get readyToShow() {
return (
!this.rootStore.auth.user ||
(this.rootStore.collections.isLoaded && this.rootStore.documents.isLoaded)
);
}
/**
* Returns the current state of the sidebar taking into account user preference
* and whether the sidebar has been hidden as part of launching in a new
+2 -1
View File
@@ -8,7 +8,8 @@ type LogCategory =
| "editor"
| "router"
| "collaboration"
| "misc";
| "misc"
| "plugins";
type Extra = Record<string, any>;
-53
View File
@@ -1,53 +0,0 @@
import React from "react";
import { UserRole } from "@shared/types";
interface Plugin {
id: string;
config: {
name: string;
description: string;
roles?: UserRole[];
deployments?: string[];
};
settings: React.FC;
icon: React.FC<{ size?: number; fill?: string }>;
}
export default class PluginLoader {
private static pluginsCache: { [id: string]: Plugin };
public static get plugins(): { [id: string]: Plugin } {
if (this.pluginsCache) {
return this.pluginsCache;
}
const plugins = {};
function importAll(r: any, property: string) {
Object.keys(r).forEach((key: string) => {
const id = key.split("/")[3];
plugins[id] = plugins[id] || {
id,
};
plugins[id][property] = r[key].default ?? React.lazy(r[key]);
});
}
importAll(
import.meta.glob("../../plugins/*/client/Settings.{ts,js,tsx,jsx}"),
"settings"
);
importAll(
import.meta.glob("../../plugins/*/client/Icon.{ts,js,tsx,jsx}", {
eager: true,
}),
"icon"
);
importAll(
import.meta.glob("../../plugins/*/plugin.json", { eager: true }),
"config"
);
this.pluginsCache = plugins;
return plugins;
}
}
+147
View File
@@ -0,0 +1,147 @@
import isArray from "lodash/isArray";
import sortBy from "lodash/sortBy";
import { action, observable } from "mobx";
import { useComputed } from "~/hooks/useComputed";
import Logger from "./Logger";
import isCloudHosted from "./isCloudHosted";
/**
* The different types of client plugins that can be registered.
*/
export enum Hook {
Settings = "settings",
Icon = "icon",
}
/**
* A map of plugin types to their values, each plugin type has a different shape of value.
*/
type PluginValueMap = {
[Hook.Settings]: {
/** The group in settings sidebar this plugin belongs to. */
group: string;
/** An optional settings item to display this after. */
after?: string;
/** The displayed icon of the plugin. */
icon: React.ElementType;
/** The settings screen somponent, should be lazy loaded. */
component: React.LazyExoticComponent<React.ComponentType>;
};
[Hook.Icon]: React.ElementType;
};
export type Plugin<T extends Hook> = {
/** A unique identifier for the plugin */
id: string;
/** Plugin type */
type: T;
/** The plugin's display name */
name: string;
/** A brief description of the plugin */
description?: string;
/** The plugin content */
value: PluginValueMap[T];
/** Priority will affect order in menus and execution. Lower is earlier. */
priority?: number;
/** The deployments this plugin is enabled for (default: all) */
deployments?: string[];
/** The roles this plugin is enabled for. (default: admin) */
roles?: string[];
};
/**
* Client plugin manager.
*/
export class PluginManager {
/**
* Add plugins to the manager.
*
* @param plugins
*/
public static add(plugins: Array<Plugin<Hook>> | Plugin<Hook>) {
if (isArray(plugins)) {
return plugins.forEach((plugin) => this.register(plugin));
}
this.register(plugins);
}
@action
private static register<T extends Hook>(plugin: Plugin<T>) {
const enabledInDeployment =
!plugin?.deployments ||
plugin.deployments.length === 0 ||
(plugin.deployments.includes("cloud") && isCloudHosted) ||
(plugin.deployments.includes("community") && !isCloudHosted) ||
(plugin.deployments.includes("enterprise") && !isCloudHosted);
if (!enabledInDeployment) {
return;
}
if (!this.plugins.has(plugin.type)) {
this.plugins.set(plugin.type, observable.array([]));
}
this.plugins
.get(plugin.type)!
.push({ ...plugin, priority: plugin.priority ?? 0 });
Logger.debug(
"plugins",
`Plugin(type=${plugin.type}) registered ${plugin.name} ${
plugin.description ? `(${plugin.description})` : ""
}`
);
}
/**
* Returns all the plugins of a given type in order of priority.
*
* @param type The type of plugin to filter by
* @returns A list of plugins
*/
public static getHooks<T extends Hook>(type: T) {
return sortBy(this.plugins.get(type) || [], "priority") as Plugin<T>[];
}
/**
* Returns a plugin of a given type by its id.
*
* @param type The type of plugin to filter by
* @param id The id of the plugin
* @returns A plugin
*/
public static getHook<T extends Hook>(type: T, id: string) {
return this.plugins.get(type)?.find((hook) => hook.id === id) as
| Plugin<T>
| undefined;
}
/**
* Load plugin client components, must be in `/<plugin>/client/index.ts(x)`
*/
public static async loadPlugins() {
if (this.loaded) {
return;
}
const r = import.meta.glob("../../plugins/*/client/index.{ts,js,tsx,jsx}");
await Promise.all(Object.keys(r).map((key: string) => r[key]()));
this.loaded = true;
}
private static plugins = observable.map<Hook, Plugin<Hook>[]>();
@observable
private static loaded = false;
}
/**
* Convenience hook to get the value for a specific plugin and type.
*/
export function usePluginValue<T extends Hook>(type: T, id: string) {
return useComputed(
() => PluginManager.getHook<T>(type, id)?.value,
[type, id]
);
}
+1 -1
View File
@@ -6,4 +6,4 @@ The Outline team takes security bugs seriously. We appreciate your efforts to re
If you discover a security vulnerability in outline, please disclose it via [GitHub](https://github.com/outline/outline/security/advisories/new). The Outline maintainers will send a response indicating the next steps in handling your report. After the initial reply to your report you will be kept informed of the progress towards a fix and full announcement.
Report security bugs in third-party dependencies to the person or team maintaining the module. You can also report a vulnerability through the [Node Security Project](https://nodesecurity.io/report).
Report security bugs in third-party dependencies to the person or team maintaining the module.
+8 -6
View File
@@ -50,7 +50,7 @@
"@aws-sdk/client-s3": "3.577.0",
"@aws-sdk/lib-storage": "3.577.0",
"@aws-sdk/s3-presigned-post": "3.588.0",
"@aws-sdk/s3-request-presigner": "3.577.0",
"@aws-sdk/s3-request-presigner": "3.592.0",
"@aws-sdk/signature-v4-crt": "^3.577.0",
"@babel/core": "^7.23.7",
"@babel/plugin-proposal-decorators": "^7.23.2",
@@ -70,6 +70,7 @@
"@fortawesome/fontawesome-svg-core": "^6.5.2",
"@fortawesome/free-solid-svg-icons": "^6.5.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"@getoutline/react-roving-tabindex": "^3.2.2",
"@getoutline/y-prosemirror": "^1.0.18",
"@hocuspocus/extension-throttle": "1.1.2",
"@hocuspocus/provider": "1.1.2",
@@ -178,7 +179,7 @@
"prosemirror-inputrules": "^1.3.0",
"prosemirror-keymap": "^1.2.2",
"prosemirror-markdown": "^1.13.0",
"prosemirror-model": "^1.21.0",
"prosemirror-model": "^1.21.1",
"prosemirror-schema-list": "^1.3.0",
"prosemirror-state": "^1.4.3",
"prosemirror-tables": "^1.3.7",
@@ -232,7 +233,7 @@
"throng": "^5.0.0",
"tiny-cookie": "^2.5.1",
"tmp": "^0.2.3",
"turndown": "^7.1.3",
"turndown": "^7.2.0",
"umzug": "^3.2.1",
"utility-types": "^3.10.0",
"uuid": "^8.3.2",
@@ -285,7 +286,7 @@
"@types/mermaid": "^9.2.0",
"@types/mime-types": "^2.1.4",
"@types/natural-sort": "^0.0.24",
"@types/node": "20.10.0",
"@types/node": "20.14.2",
"@types/node-fetch": "^2.6.9",
"@types/nodemailer": "^6.4.14",
"@types/passport-oauth2": "^1.4.17",
@@ -327,6 +328,7 @@
"babel-plugin-tsconfig-paths-module-resolver": "^1.0.4",
"browserslist-to-esbuild": "^1.2.0",
"concurrently": "^8.2.2",
"discord-api-types": "^0.37.87",
"eslint": "^8.57.0",
"eslint-config-prettier": "^8.10.0",
"eslint-import-resolver-typescript": "^3.6.1",
@@ -350,7 +352,7 @@
"react-refresh": "^0.14.0",
"rimraf": "^2.5.4",
"rollup-plugin-webpack-stats": "^0.2.4",
"terser": "^5.19.2",
"terser": "^5.31.1",
"typescript": "^5.4.5",
"vite-plugin-static-copy": "^0.17.0",
"yarn-deduplicate": "^6.0.2"
@@ -366,5 +368,5 @@
"qs": "6.9.7",
"rollup": "^4.5.1"
},
"version": "0.76.2-0"
"version": "0.77.1"
}
+11
View File
@@ -0,0 +1,11 @@
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Icon,
value: Icon,
},
]);
+30
View File
@@ -0,0 +1,30 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
className?: string;
};
function DiscordLogo({ size = 24, fill = "currentColor", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M17.5535 7.01557C16.5023 6.5343 15.3925 6.19287 14.2526 6C14.0966 6.27886 13.9555 6.56577 13.8298 6.85952C12.6155 6.67655 11.3807 6.67655 10.1664 6.85952C10.0406 6.5658 9.89949 6.27889 9.74357 6C8.60289 6.1945 7.4924 6.53674 6.44013 7.01809C4.3511 10.1088 3.78479 13.1228 4.06794 16.0941C5.29133 16.9979 6.66066 17.6854 8.11639 18.1265C8.44417 17.6856 8.73422 17.2179 8.98346 16.7283C8.51007 16.5515 8.05317 16.3334 7.61804 16.0764C7.73256 15.9934 7.84456 15.9078 7.95279 15.8247C9.21891 16.4202 10.6008 16.7289 12 16.7289C13.3991 16.7289 14.781 16.4202 16.0472 15.8247C16.1566 15.9141 16.2686 15.9997 16.3819 16.0764C15.9459 16.3338 15.4882 16.5523 15.014 16.7296C15.2629 17.2189 15.553 17.6862 15.881 18.1265C17.338 17.6871 18.7084 17 19.932 16.0953C20.2642 12.6497 19.3644 9.66336 17.5535 7.01557ZM9.34212 14.2668C8.55307 14.2668 7.90119 13.5507 7.90119 12.6698C7.90119 11.7889 8.53042 11.0665 9.3396 11.0665C10.1488 11.0665 10.7956 11.7889 10.7818 12.6698C10.7679 13.5507 10.1463 14.2668 9.34212 14.2668ZM14.6578 14.2668C13.8675 14.2668 13.2182 13.5507 13.2182 12.6698C13.2182 11.7889 13.8474 11.0665 14.6578 11.0665C15.4683 11.0665 16.1101 11.7889 16.0962 12.6698C16.0824 13.5507 15.462 14.2668 14.6578 14.2668Z"
/>
</svg>
);
}
export default DiscordLogo;
+11
View File
@@ -0,0 +1,11 @@
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Icon,
value: Icon,
},
]);
+6
View File
@@ -0,0 +1,6 @@
{
"id": "discord",
"name": "Discord",
"priority": 10,
"description": "Adds a Discord authentication provider."
}
+215
View File
@@ -0,0 +1,215 @@
import passport from "@outlinewiki/koa-passport";
import type {
RESTGetAPICurrentUserGuildsResult,
RESTGetAPICurrentUserResult,
RESTGetCurrentUserGuildMemberResult,
} from "discord-api-types/v10";
import type { Context } from "koa";
import Router from "koa-router";
import { Strategy } from "passport-oauth2";
import { languages } from "@shared/i18n";
import { slugifyDomain } from "@shared/utils/domains";
import slugify from "@shared/utils/slugify";
import accountProvisioner from "@server/commands/accountProvisioner";
import { InvalidRequestError, TeamDomainRequiredError } from "@server/errors";
import passportMiddleware from "@server/middlewares/passport";
import { User } from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
getTeamFromContext,
getClientFromContext,
request,
} from "@server/utils/passport";
import config from "../../plugin.json";
import env from "../env";
import { DiscordGuildError, DiscordGuildRoleError } from "../errors";
const router = new Router();
const scope = ["identify", "email"];
if (env.DISCORD_SERVER_ID) {
scope.push("guilds", "guilds.members.read");
}
if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
passport.use(
config.id,
new Strategy(
{
clientID: env.DISCORD_CLIENT_ID,
clientSecret: env.DISCORD_CLIENT_SECRET,
passReqToCallback: true,
scope,
// @ts-expect-error custom state store
store: new StateStore(),
state: true,
callbackURL: `${env.URL}/auth/${config.id}.callback`,
authorizationURL: "https://discord.com/api/oauth2/authorize",
tokenURL: "https://discord.com/api/oauth2/token",
pkce: false,
},
async function (
ctx: Context,
accessToken: string,
refreshToken: string,
params: { expires_in: number },
_profile: unknown,
done: (
err: Error | null,
user: User | null,
result?: AuthenticationResult
) => void
) {
try {
const team = await getTeamFromContext(ctx);
const client = getClientFromContext(ctx);
/** Fetch the user's profile */
const profile: RESTGetAPICurrentUserResult = await request(
"https://discord.com/api/users/@me",
accessToken
);
const email = profile.email;
if (!email) {
/** We have the email scope, so this should never happen */
throw InvalidRequestError("Discord profile email is missing");
}
const parts = email.toLowerCase().split("@");
const domain = parts.length && parts[1];
if (!domain) {
throw TeamDomainRequiredError();
}
/** Determine the user's language from the locale */
const { locale } = profile;
const language = locale
? languages.find((l) => l.startsWith(locale))
: undefined;
/** Default user and team names metadata */
let userName = profile.username;
let teamName = "Wiki";
let userAvatarUrl: string = `https://cdn.discordapp.com/avatars/${profile.id}/${profile.avatar}.png`;
let teamAvatarUrl: string | undefined = undefined;
let subdomain = slugifyDomain(domain);
/**
* If a Discord server is configured, we will check if the user is a member of the server
* Additionally, we can get the user's nickname in the server if it exists
*/
if (env.DISCORD_SERVER_ID) {
/** Fetch the guilds a user is in */
const guilds: RESTGetAPICurrentUserGuildsResult = await request(
"https://discord.com/api/users/@me/guilds",
accessToken
);
/** Find the guild that matches the configured server ID */
const guild = guilds?.find((g) => g.id === env.DISCORD_SERVER_ID);
/** If the user is not in the server, throw an error */
if (!guild) {
throw DiscordGuildError();
}
/**
* Get the guild's icon
* https://discord.com/developers/docs/reference#image-formatting-cdn-endpoints
**/
if (guild.icon) {
const isGif = guild.icon.startsWith("a_");
if (isGif) {
teamAvatarUrl = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.gif`;
} else {
teamAvatarUrl = `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.png`;
}
}
/** Guild Name */
teamName = guild.name;
subdomain = slugify(guild.name);
/** Fetch the user's member object in the server for nickname and roles */
const guildMember: RESTGetCurrentUserGuildMemberResult =
await request(
`https://discord.com/api/users/@me/guilds/${env.DISCORD_SERVER_ID}/member`,
accessToken
);
/** If the user has a nickname in the server, use that as the name */
if (guildMember.nick) {
userName = guildMember.nick;
}
/** If the user has a custom avatar in the server, use that as the avatar */
if (guildMember.avatar) {
userAvatarUrl = `https://cdn.discordapp.com/guilds/${guild.id}/users/${profile.id}/avatars/${guildMember.avatar}.png`;
}
/** If server roles are configured, check if the user has any of the roles */
if (env.DISCORD_SERVER_ROLES) {
const { roles } = guildMember;
const hasRole = roles?.some((role) =>
env.DISCORD_SERVER_ROLES?.includes(role)
);
/** If the user does not have any of the roles, throw an error */
if (!hasRole) {
throw DiscordGuildRoleError();
}
}
}
// if a team can be inferred, we assume the user is only interested in signing into
// that team in particular; otherwise, we will do a best effort at finding their account
// or provisioning a new one (within AccountProvisioner)
const result = await accountProvisioner({
ip: ctx.ip,
team: {
teamId: team?.id,
name: teamName,
domain,
subdomain,
avatarUrl: teamAvatarUrl,
},
user: {
email,
name: userName,
language,
avatarUrl: userAvatarUrl,
},
authenticationProvider: {
name: config.id,
providerId: env.DISCORD_SERVER_ID ?? "",
},
authentication: {
providerId: profile.id,
accessToken,
refreshToken,
expiresIn: params.expires_in,
scopes: scope,
},
});
return done(null, result.user, { ...result, client });
} catch (err) {
return done(err, null);
}
}
)
);
router.get(
config.id,
passport.authenticate(config.id, {
scope,
prompt: "consent",
})
);
router.get(`${config.id}.callback`, passportMiddleware(config.id));
}
export default router;
+18
View File
@@ -0,0 +1,18 @@
import invariant from "invariant";
import OAuthClient from "@server/utils/oauth";
import env from "./env";
export default class DiscordClient extends OAuthClient {
endpoints = {
authorize: "https://discord.com/oauth2/authorize",
token: "https://discord.com/api/oauth2/token",
userinfo: "https://discord.com/api/users/@me",
};
constructor() {
invariant(env.DISCORD_CLIENT_ID, "DISCORD_CLIENT_ID is required");
invariant(env.DISCORD_CLIENT_SECRET, "DISCORD_CLIENT_SECRET is required");
super(env.DISCORD_CLIENT_ID, env.DISCORD_CLIENT_SECRET);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { IsOptional } from "class-validator";
import { Environment } from "@server/env";
import environment from "@server/utils/environment";
import { CannotUseWithout } from "@server/utils/validators";
class DiscordPluginEnvironment extends Environment {
/**
* Discord OAuth2 client credentials. To enable authentication with Discord.
*/
@IsOptional()
@CannotUseWithout("DISCORD_CLIENT_ID")
public DISCORD_CLIENT_ID = this.toOptionalString(
environment.DISCORD_CLIENT_ID
);
@IsOptional()
@CannotUseWithout("DISCORD_CLIENT_SECRET")
public DISCORD_CLIENT_SECRET = this.toOptionalString(
environment.DISCORD_CLIENT_SECRET
);
@IsOptional()
@CannotUseWithout("DISCORD_CLIENT_SECRET")
public DISCORD_SERVER_ID = this.toOptionalString(
environment.DISCORD_SERVER_ID
);
@CannotUseWithout("DISCORD_SERVER_ID")
@IsOptional()
public DISCORD_SERVER_ROLES = this.toOptionalCommaList(
environment.DISCORD_SERVER_ROLES
);
}
export default new DiscordPluginEnvironment();
+17
View File
@@ -0,0 +1,17 @@
import httpErrors from "http-errors";
export function DiscordGuildError(
message = "User is not a member of the required Discord server"
) {
return httpErrors(400, message, {
id: "discord_guild_error",
});
}
export function DiscordGuildRoleError(
message = "User does not have the required role from the Discord server"
) {
return httpErrors(400, message, {
id: "discord_guild_role_error",
});
}
+14
View File
@@ -0,0 +1,14 @@
import { PluginManager, Hook } from "@server/utils/PluginManager";
import config from "../plugin.json";
import router from "./auth/discord";
import env from "./env";
const enabled = !!env.DISCORD_CLIENT_ID && !!env.DISCORD_CLIENT_SECRET;
if (enabled) {
PluginManager.add({
...config,
type: Hook.AuthProvider,
value: { router, id: config.id },
});
}
+9 -1
View File
@@ -90,7 +90,15 @@ router.get(
"email.callback",
validate(T.EmailCallbackSchema),
async (ctx: APIContext<T.EmailCallbackReq>) => {
const { token, client } = ctx.input.query;
const { token, client, follow } = ctx.input.query;
// The link in the email does not include the follow query param, this
// is to help prevent anti-virus, and email clients from pre-fetching the link
// and spending the token before the user clicks on it. Instead we redirect
// to the same URL with the follow query param added from the client side.
if (!follow) {
return ctx.redirectOnClient(ctx.request.href + "&follow=true");
}
let user!: User;
+1
View File
@@ -15,6 +15,7 @@ export const EmailCallbackSchema = BaseSchema.extend({
query: z.object({
token: z.string(),
client: z.nativeEnum(Client).default(Client.Web),
follow: z.string().default(""),
}),
});
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+11
View File
@@ -0,0 +1,11 @@
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Icon,
value: Icon,
},
]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "google",
"name": "Google",
"priority": 10,
"priority": 0,
"description": "Adds a Google authentication provider."
}
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
className?: string;
};
function GoogleLogo({ size = 24, fill = "currentColor", className }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<path d="M19.2312 10.5455H11.8276V13.6364H16.0892C15.6919 15.6 14.0306 16.7273 11.8276 16.7273C9.22733 16.7273 7.13267 14.6182 7.13267 12C7.13267 9.38182 9.22733 7.27273 11.8276 7.27273C12.9472 7.27273 13.9584 7.67273 14.7529 8.32727L17.0643 6C15.6558 4.76364 13.85 4 11.8276 4C7.42159 4 3.88232 7.56364 3.88232 12C3.88232 16.4364 7.42159 20 11.8276 20C15.8002 20 19.4117 17.0909 19.4117 12C19.4117 11.5273 19.3395 11.0182 19.2312 10.5455Z" />
</svg>
);
}
export default GoogleLogo;
@@ -6,6 +6,7 @@ import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import GoogleIcon from "~/components/Icons/GoogleIcon";
@@ -13,7 +14,6 @@ import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import SettingRow from "./components/SettingRow";
type FormData = {
measurementId: string;
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+6
View File
@@ -0,0 +1,6 @@
{
"id": "googleanalytics",
"name": "Google Analytics",
"priority": 30,
"description": "Adds support for reporting analytics to a Google."
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react";
type Props = {
/** The size of the icon, 24px is default to match standard icons */
size?: number;
/** The color of the icon, defaults to the current text color */
fill?: string;
};
export default function Icon({ size = 24, fill = "currentColor" }: Props) {
return (
<svg
fill={fill}
width={size}
height={size}
viewBox="0 0 24 24"
version="1.1"
>
<path d="M15.7624 15.309L18.2171 11.588C18.5688 11.0797 18.7702 10.4684 18.7702 9.81395C18.7702 9.72425 18.7668 9.63787 18.76 9.5515L21.4981 13.6113C21.5084 13.6246 21.5118 13.6346 21.522 13.6478L21.5732 13.7243L21.5698 13.7276C21.8395 14.1561 21.9966 14.6578 22 15.1927C22 16.7409 20.7095 17.9967 19.1185 17.9967C18.142 17.9967 17.2851 17.5249 16.7627 16.804L16.7593 16.8073L16.7422 16.7807C16.7286 16.7608 16.7149 16.7375 16.6979 16.7176M15.5336 14.9635L17.1041 12.5814C15.1767 13.6317 12.5053 12.4636 12.2253 10.1993C12.2253 10.1927 12.2253 10.186 12.2219 10.1794L10.8938 8.35216H10.8904C10.3817 7.5515 9.47695 7.01661 8.43564 7.01661C8.43223 7.01661 8.43223 7.01661 8.42882 7.01661C8.4254 7.01661 8.4254 7.01661 8.42199 7.01661C7.38409 7.01661 6.47593 7.5515 5.97064 8.35216H5.96722L3.29737 12.4086C3.76852 12.1528 4.30795 12.01 4.88153 12.01C6.55446 12.01 7.93718 13.2359 8.13179 14.8106L9.63059 16.8571H9.63401C10.1564 17.5482 10.9997 18 11.9522 18H11.959H11.9659C12.9184 18 13.7583 17.5515 14.2841 16.8571H14.2875L14.3114 16.8206C14.3694 16.7409 14.424 16.6578 14.4787 16.5681L15.5336 14.9668V14.9635ZM12.6009 9.80731C12.6009 11.3588 13.8914 12.6146 15.4858 12.6146C17.0802 12.6146 18.3708 11.3588 18.3708 9.80731C18.3708 8.25581 17.0802 7 15.4858 7C13.8914 7 12.6009 8.25581 12.6009 9.80731ZM2 15.196C2 16.7442 3.29054 18 4.88153 18C6.47252 18 7.76306 16.7442 7.76306 15.196C7.76306 13.6478 6.47252 12.392 4.88153 12.392C3.29054 12.392 2 13.6478 2 15.196Z" />
</svg>
);
}
+128
View File
@@ -0,0 +1,128 @@
import find from "lodash/find";
import { observer } from "mobx-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { useTranslation, Trans } from "react-i18next";
import { toast } from "sonner";
import { IntegrationType, IntegrationService } from "@shared/types";
import Integration from "~/models/Integration";
import SettingRow from "~/scenes/Settings/components/SettingRow";
import Button from "~/components/Button";
import Heading from "~/components/Heading";
import Input from "~/components/Input";
import Scene from "~/components/Scene";
import Text from "~/components/Text";
import useStores from "~/hooks/useStores";
import Icon from "./Icon";
type FormData = {
instanceUrl: string;
measurementId: string;
};
function Matomo() {
const { integrations } = useStores();
const { t } = useTranslation();
const integration = find(integrations.orderedData, {
type: IntegrationType.Analytics,
service: IntegrationService.Matomo,
}) as Integration<IntegrationType.Analytics> | undefined;
const {
register,
reset,
handleSubmit: formHandleSubmit,
formState,
} = useForm<FormData>({
mode: "all",
defaultValues: {
instanceUrl: integration?.settings.instanceUrl,
measurementId: integration?.settings.measurementId,
},
});
React.useEffect(() => {
void integrations.fetchPage({
type: IntegrationType.Analytics,
});
}, [integrations]);
React.useEffect(() => {
reset({
measurementId: integration?.settings.measurementId,
instanceUrl: integration?.settings.instanceUrl,
});
}, [integration, reset]);
const handleSubmit = React.useCallback(
async (data: FormData) => {
try {
if (data.instanceUrl && data.measurementId) {
await integrations.save({
id: integration?.id,
type: IntegrationType.Analytics,
service: IntegrationService.Matomo,
settings: {
measurementId: data.measurementId,
// Ensure the URL ends with a trailing slash
instanceUrl: data.instanceUrl.replace(/\/?$/, "/"),
} as Integration<IntegrationType.Analytics>["settings"],
});
} else {
await integration?.delete();
}
toast.success(t("Settings saved"));
} catch (err) {
toast.error(err.message);
}
},
[integrations, integration, t]
);
return (
<Scene title="Matomo" icon={<Icon />}>
<Heading>Matomo</Heading>
<Text as="p" type="secondary">
<Trans>
Configure a Matomo installation to send views and analytics from the
workspace to your own Matomo instance.
</Trans>
</Text>
<form onSubmit={formHandleSubmit(handleSubmit)}>
<SettingRow
label={t("Instance URL")}
name="instanceUrl"
description={t(
"The URL of your Matomo instance. If you are using Matomo Cloud it will end in matomo.cloud/"
)}
border={false}
>
<Input
required
placeholder="https://instance.matomo.cloud/"
{...register("instanceUrl")}
/>
</SettingRow>
<SettingRow
label={t("Site ID")}
name="measurementId"
description={t(
"An ID that uniquely identifies the website in your Matomo instance."
)}
border={false}
>
<Input required placeholder="1" {...register("measurementId")} />
</SettingRow>
<Button type="submit" disabled={formState.isSubmitting}>
{formState.isSubmitting ? `${t("Saving")}` : t("Save")}
</Button>
</form>
</Scene>
);
}
export default observer(Matomo);
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+8
View File
@@ -0,0 +1,8 @@
{
"id": "matomo",
"name": "Matomo",
"roles": ["admin"],
"priority": 40,
"description": "Adds support for reporting analytics to a Matomo server.",
"deployments": ["community", "enterprise"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "oidc",
"name": "OIDC",
"priority": 30,
"priority": 10,
"description": "Adds an OpenID compatible authentication provider."
}
+7 -11
View File
@@ -14,9 +14,9 @@ import { User } from "@server/models";
import { AuthenticationResult } from "@server/types";
import {
StateStore,
request,
getTeamFromContext,
getClientFromContext,
request,
} from "@server/utils/passport";
import config from "../../plugin.json";
import env from "../env";
@@ -24,15 +24,6 @@ import env from "../env";
const router = new Router();
const scopes = env.OIDC_SCOPES.split(" ");
Strategy.prototype.userProfile = async function (accessToken, done) {
try {
const response = await request(env.OIDC_USERINFO_URI ?? "", accessToken);
return done(null, response);
} catch (err) {
return done(err);
}
};
const authorizationParams = Strategy.prototype.authorizationParams;
Strategy.prototype.authorizationParams = function (options) {
return {
@@ -81,7 +72,7 @@ if (
accessToken: string,
refreshToken: string,
params: { expires_in: number },
profile: Record<string, string>,
_profile: unknown,
done: (
err: Error | null,
user: User | null,
@@ -89,6 +80,11 @@ if (
) => void
) {
try {
const profile = await request(
env.OIDC_USERINFO_URI ?? "",
accessToken
);
if (!profile.email) {
throw AuthenticationError(
`An email field was not returned in the profile parameter, but is required.`
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
{
...config,
type: Hook.Icon,
value: Icon,
},
]);
+1 -1
View File
@@ -1,7 +1,7 @@
{
"id": "slack",
"name": "Slack",
"priority": 40,
"priority": 20,
"roles": ["admin", "member"],
"description": "Adds a Slack authentication provider, support for the /outline slash command, and link unfurling."
}
+14 -16
View File
@@ -72,34 +72,32 @@ router.get(
async (ctx: APIContext<T.FilesGetReq>) => {
const actor = ctx.state.auth.user;
const key = getKeyFromContext(ctx);
const forceDownload = !!ctx.input.query.download;
const isSignedRequest = !!ctx.input.query.sig;
const { isPublicBucket, fileName } = AttachmentHelper.parseKey(key);
const skipAuthorize = isPublicBucket || isSignedRequest;
const cacheHeader = "max-age=604800, immutable";
let contentType =
(fileName ? mime.lookup(fileName) : undefined) ||
"application/octet-stream";
if (skipAuthorize) {
ctx.set("Cache-Control", cacheHeader);
ctx.set(
"Content-Type",
(fileName ? mime.lookup(fileName) : undefined) ||
"application/octet-stream"
);
ctx.attachment(fileName);
} else {
if (!skipAuthorize) {
const attachment = await Attachment.findOne({
where: { key },
rejectOnEmpty: true,
});
authorize(actor, "read", attachment);
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", attachment.contentType);
ctx.attachment(attachment.name, {
type: FileStorage.getContentDisposition(attachment.contentType),
});
contentType = attachment.contentType;
}
ctx.set("Cache-Control", cacheHeader);
ctx.set("Content-Type", contentType);
ctx.attachment(fileName, {
type: forceDownload
? "attachment"
: FileStorage.getContentDisposition(contentType),
});
// Handle byte range requests
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
const stats = await (FileStorage as LocalStorage).stat(key);
+1
View File
@@ -24,6 +24,7 @@ export const FilesGetSchema = z.object({
.optional()
.transform((val) => (val ? ValidateKey.sanitize(val) : undefined)),
sig: z.string().optional(),
download: z.string().optional(),
})
.refine((obj) => !(isEmpty(obj.key) && isEmpty(obj.sig)), {
message: "One of key or sig is required",
+16
View File
@@ -0,0 +1,16 @@
import * as React from "react";
import { Hook, PluginManager } from "~/utils/PluginManager";
import config from "../plugin.json";
import Icon from "./Icon";
PluginManager.add([
{
...config,
type: Hook.Settings,
value: {
group: "Integrations",
icon: Icon,
component: React.lazy(() => import("./Settings")),
},
},
]);
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "webhooks",
"name": "Webhooks",
"priority": 200,
"priority": 0,
"description": "Adds HTTP webhooks for various events."
}
@@ -520,7 +520,12 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
subscription,
payload: {
id: event.documentId,
model: model && (await presentDocument(undefined, model)),
model:
model &&
(await presentDocument(undefined, model, {
includeData: true,
includeText: true,
})),
},
});
}
@@ -546,7 +551,12 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
payload: {
id: event.modelId,
model: model && presentMembership(model),
document: model && (await presentDocument(undefined, model.document!)),
document:
model &&
(await presentDocument(undefined, model.document!, {
includeData: true,
includeText: true,
})),
user: model && presentUser(model.user),
},
});
+13
View File
@@ -1,6 +1,7 @@
import { Transaction } from "sequelize";
import { Optional } from "utility-types";
import { Document, Event, User } from "@server/models";
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
import { TextHelper } from "@server/models/helpers/TextHelper";
type Props = Optional<
@@ -58,6 +59,12 @@ export default async function documentCreator({
}: Props): Promise<Document> {
const templateId = templateDocument ? templateDocument.id : undefined;
if (state && templateDocument) {
throw new Error(
"State cannot be set when creating a document from a template"
);
}
if (urlId) {
const existing = await Document.unscoped().findOne({
attributes: ["id"],
@@ -103,6 +110,12 @@ export default async function documentCreator({
ip,
transaction
),
content: templateDocument
? ProsemirrorHelper.replaceTemplateVariables(
templateDocument.content,
user
)
: undefined,
state,
},
{
@@ -73,7 +73,7 @@ View Document: ${teamUrl}${document.path}
const documentUrl = `${teamUrl}${document.path}?ref=notification-email`;
const permission =
membership.permission === DocumentPermission.ReadWrite ? "edit" : "view";
membership.permission === DocumentPermission.Read ? "view" : "edit";
return (
<EmailTemplate

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