diff --git a/app/components/Theme.tsx b/app/components/Theme.tsx index 07d95af94..4da706e47 100644 --- a/app/components/Theme.tsx +++ b/app/components/Theme.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "styled-components"; import { breakpoints } from "@shared/styles"; import GlobalStyles from "@shared/styles/globals"; import { dark, light, lightMobile, darkMobile } from "@shared/styles/theme"; +import { UserPreference } from "@shared/types"; import useMediaQuery from "~/hooks/useMediaQuery"; import useStores from "~/hooks/useStores"; @@ -28,7 +29,10 @@ const Theme: React.FC = ({ children }) => { <> {children} diff --git a/app/components/WebsocketProvider.tsx b/app/components/WebsocketProvider.tsx index fba51939a..79899313a 100644 --- a/app/components/WebsocketProvider.tsx +++ b/app/components/WebsocketProvider.tsx @@ -296,7 +296,7 @@ class WebsocketProvider extends React.Component { ); this.socket.on("teams.update", (event: PartialWithId) => { - auth.updateTeam(event); + auth.team?.updateFromJson(event); }); this.socket.on("pins.create", (event: PartialWithId) => { diff --git a/app/hooks/useAuthorizedSettingsConfig.ts b/app/hooks/useAuthorizedSettingsConfig.ts index 71e30ed0c..30e3acd65 100644 --- a/app/hooks/useAuthorizedSettingsConfig.ts +++ b/app/hooks/useAuthorizedSettingsConfig.ts @@ -130,7 +130,7 @@ const useAuthorizedSettingsConfig = () => { name: t("Features"), path: "/settings/features", component: Features, - enabled: can.update, + enabled: can.update && team.collaborativeEditing, group: t("Team"), icon: BeakerIcon, }, diff --git a/app/menus/DocumentMenu.tsx b/app/menus/DocumentMenu.tsx index 8efc650b4..2d28db984 100644 --- a/app/menus/DocumentMenu.tsx +++ b/app/menus/DocumentMenu.tsx @@ -263,7 +263,7 @@ function DocumentMenu({ type: "route", title: t("Edit"), to: editDocumentUrl(document), - visible: !!can.update && !team.collaborativeEditing, + visible: !!can.update && !team.seamlessEditing, icon: , }, { diff --git a/app/models/Team.ts b/app/models/Team.ts index d829d518f..fd2be5885 100644 --- a/app/models/Team.ts +++ b/app/models/Team.ts @@ -1,4 +1,5 @@ import { computed, observable } from "mobx"; +import { TeamPreference, TeamPreferences } from "@shared/types"; import BaseModel from "./BaseModel"; import Field from "./decorators/Field"; @@ -51,6 +52,10 @@ class Team extends BaseModel { @observable defaultUserRole: string; + @Field + @observable + preferences: TeamPreferences | null; + domain: string | null | undefined; url: string; @@ -63,6 +68,45 @@ class Team extends BaseModel { get signinMethods(): string { return "SSO"; } + + /** + * Returns whether this team is using a separate editing mode behind an "Edit" + * button rather than seamless always-editing. + * + * @returns True if editing mode is seamless (no button) + */ + @computed + get seamlessEditing(): boolean { + return ( + this.collaborativeEditing && + this.getPreference(TeamPreference.SeamlessEdit, true) + ); + } + + /** + * Get the value for a specific preference key, or return the fallback if + * none is set. + * + * @param key The TeamPreference key to retrieve + * @param fallback An optional fallback value, defaults to false. + * @returns The value + */ + getPreference(key: TeamPreference, fallback = false): boolean { + return this.preferences?.[key] ?? fallback; + } + + /** + * Set the value for a specific preference key. + * + * @param key The TeamPreference key to retrieve + * @param value The value to set + */ + setPreference(key: TeamPreference, value: boolean) { + this.preferences = { + ...this.preferences, + [key]: value, + }; + } } export default Team; diff --git a/app/models/User.ts b/app/models/User.ts index 5fa7936e3..ceb7372db 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -1,7 +1,7 @@ import { subMinutes } from "date-fns"; import { computed, observable } from "mobx"; import { now } from "mobx-utils"; -import type { Role, UserPreferences } from "@shared/types"; +import type { Role, UserPreference, UserPreferences } from "@shared/types"; import ParanoidModel from "./ParanoidModel"; import Field from "./decorators/Field"; @@ -66,6 +66,31 @@ class User extends ParanoidModel { return "member"; } } + + /** + * Get the value for a specific preference key, or return the fallback if + * none is set. + * + * @param key The UserPreference key to retrieve + * @param fallback An optional fallback value, defaults to false. + * @returns The value + */ + getPreference(key: UserPreference, fallback = false): boolean { + return this.preferences?.[key] ?? fallback; + } + + /** + * Set the value for a specific preference key. + * + * @param key The UserPreference key to retrieve + * @param value The value to set + */ + setPreference(key: UserPreference, value: boolean) { + this.preferences = { + ...this.preferences, + [key]: value, + }; + } } export default User; diff --git a/app/scenes/Document/components/DataLoader.tsx b/app/scenes/Document/components/DataLoader.tsx index 819fb4b7b..3194b13e5 100644 --- a/app/scenes/Document/components/DataLoader.tsx +++ b/app/scenes/Document/components/DataLoader.tsx @@ -12,7 +12,6 @@ import Logger from "~/utils/Logger"; import { NotFoundError, OfflineError } from "~/utils/errors"; import history from "~/utils/history"; import { matchDocumentEdit } from "~/utils/routeHelpers"; -import HideSidebar from "./HideSidebar"; import Loading from "./Loading"; type Params = { @@ -65,7 +64,7 @@ function DataLoader({ match, children }: Props) { ? documents.getSharedTree(document.id) : undefined; const isEditRoute = match.path === matchDocumentEdit; - const isEditing = isEditRoute || !!auth.team?.collaborativeEditing; + const isEditing = isEditRoute || !!auth.team?.seamlessEditing; const can = usePolicy(document?.id); const location = useLocation(); @@ -176,7 +175,6 @@ function DataLoader({ match, children }: Props) { return ( <> - {isEditing && !team?.collaborativeEditing && } ); } @@ -191,7 +189,6 @@ function DataLoader({ match, children }: Props) { return ( - {isEditing && !team.collaborativeEditing && } {children({ document, revision, diff --git a/app/scenes/Document/components/Document.tsx b/app/scenes/Document/components/Document.tsx index 881cf24d3..632ae139a 100644 --- a/app/scenes/Document/components/Document.tsx +++ b/app/scenes/Document/components/Document.tsx @@ -540,7 +540,7 @@ class DocumentScene extends React.Component { shareId={shareId} isRevision={!!revision} isDraft={document.isDraft} - isEditing={!readOnly && !team?.collaborativeEditing} + isEditing={!readOnly && !team?.seamlessEditing} isSaving={this.isSaving} isPublishing={this.isPublishing} publishingIsDisabled={ diff --git a/app/scenes/Document/components/Header.tsx b/app/scenes/Document/components/Header.tsx index 02af12cb6..c7043b8b8 100644 --- a/app/scenes/Document/components/Header.tsx +++ b/app/scenes/Document/components/Header.tsx @@ -220,11 +220,11 @@ function DocumentHeader({ <> - {!isPublishing && isSaving && !team?.collaborativeEditing && ( + {!isPublishing && isSaving && !team?.seamlessEditing && ( {t("Saving")}… )} {!isDeleted && !isRevision && } - {(isEditing || team?.collaborativeEditing) && !isTemplate && isNew && ( + {(isEditing || team?.seamlessEditing) && !isTemplate && isNew && ( )} - {canEdit && - !team?.collaborativeEditing && - !isRevision && - editAction} + {canEdit && !team?.seamlessEditing && !isRevision && editAction} {canEdit && can.createChildDocument && !isRevision && !isMobile && ( { - componentDidMount() { - this.props.ui.enableEditMode(); - } - - componentWillUnmount() { - this.props.ui.disableEditMode(); - } - - render() { - return this.props.children || null; - } -} - -export default HideSidebar; diff --git a/app/scenes/Document/index.tsx b/app/scenes/Document/index.tsx index f6e5583c1..ce852efb8 100644 --- a/app/scenes/Document/index.tsx +++ b/app/scenes/Document/index.tsx @@ -44,7 +44,6 @@ export default function DocumentScene(props: Props) { const urlParts = documentSlug ? documentSlug.split("-") : []; const urlId = urlParts.length ? urlParts[urlParts.length - 1] : undefined; const key = [urlId, revisionId].join("/"); - const isMultiplayer = team.collaborativeEditing; return ( diff --git a/app/scenes/Settings/Features.tsx b/app/scenes/Settings/Features.tsx index ccf4104ab..78255db2e 100644 --- a/app/scenes/Settings/Features.tsx +++ b/app/scenes/Settings/Features.tsx @@ -1,9 +1,8 @@ import { observer } from "mobx-react"; import { BeakerIcon } from "outline-icons"; -import { useState } from "react"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; -import ConfirmationDialog from "~/components/ConfirmationDialog"; +import { TeamPreference } from "@shared/types"; import Heading from "~/components/Heading"; import Scene from "~/components/Scene"; import Switch from "~/components/Switch"; @@ -11,48 +10,28 @@ import Text from "~/components/Text"; import useCurrentTeam from "~/hooks/useCurrentTeam"; import useStores from "~/hooks/useStores"; import useToasts from "~/hooks/useToasts"; -import isCloudHosted from "~/utils/isCloudHosted"; import SettingRow from "./components/SettingRow"; function Features() { - const { auth, dialogs } = useStores(); + const { auth } = useStores(); const team = useCurrentTeam(); const { t } = useTranslation(); const { showToast } = useToasts(); - const [data, setData] = useState({ - collaborativeEditing: team.collaborativeEditing, - }); - const handleChange = async (ev: React.ChangeEvent) => { - const newData = { ...data, [ev.target.name]: ev.target.checked }; - setData(newData); + const handlePreferenceChange = async ( + ev: React.ChangeEvent + ) => { + const preferences = { + ...team.preferences, + [ev.target.name]: ev.target.checked, + }; - await auth.updateTeam(newData); + await auth.updateTeam({ preferences }); showToast(t("Settings saved"), { type: "success", }); }; - const handleCollabDisable = async () => { - const newData = { ...data, collaborativeEditing: false }; - setData(newData); - - await auth.updateTeam(newData); - showToast(t("Settings saved"), { - type: "success", - }); - }; - - const handleCollabDisableConfirm = () => { - dialogs.openModal({ - isCentered: true, - title: t("Are you sure you want to disable collaborative editing?"), - content: ( - - ), - }); - }; - return ( }> {t("Features")} @@ -62,53 +41,24 @@ function Features() { the experience for all team members. - - - + {team.collaborativeEditing && ( + + + + )} ); } -function DisableCollaborativeEditingDialog({ - onSubmit, -}: { - onSubmit: () => void; -}) { - const { t } = useTranslation(); - - return ( - - <> - - - Enabling collaborative editing again in the future may cause some - documents to revert to this point in time. It is not advised to - disable this feature. - - - - - ); -} - export default observer(Features); diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx index d43ff76d4..8e8282814 100644 --- a/app/scenes/Settings/Preferences.tsx +++ b/app/scenes/Settings/Preferences.tsx @@ -3,6 +3,7 @@ import { SettingsIcon } from "outline-icons"; import * as React from "react"; import { Trans, useTranslation } from "react-i18next"; import { languageOptions } from "@shared/i18n"; +import { UserPreference } from "@shared/types"; import Button from "~/components/Button"; import Heading from "~/components/Heading"; import InputSelect from "~/components/InputSelect"; @@ -93,7 +94,7 @@ function Preferences() { diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index 9f8a05955..122a121f3 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -2,6 +2,7 @@ import * as Sentry from "@sentry/react"; import invariant from "invariant"; import { observable, action, computed, autorun, runInAction } from "mobx"; import { getCookie, setCookie, removeCookie } from "tiny-cookie"; +import { TeamPreferences, UserPreferences } from "@shared/types"; import { getCookieDomain, parseDomain } from "@shared/utils/domains"; import RootStore from "~/stores/RootStore"; import Policy from "~/models/Policy"; @@ -221,7 +222,7 @@ export default class AuthStore { name?: string; avatarUrl?: string | null; language?: string; - preferences?: Record; + preferences?: UserPreferences; }) => { this.isSaving = true; @@ -246,6 +247,7 @@ export default class AuthStore { defaultCollectionId?: string | null; subdomain?: string | null | undefined; allowedDomains?: string[] | null | undefined; + preferences?: TeamPreferences; }) => { this.isSaving = true; diff --git a/server/commands/teamUpdater.ts b/server/commands/teamUpdater.ts index ebdd660a1..28f339906 100644 --- a/server/commands/teamUpdater.ts +++ b/server/commands/teamUpdater.ts @@ -1,4 +1,6 @@ +import { has } from "lodash"; import { Transaction } from "sequelize"; +import { TeamPreference } from "@shared/types"; import { sequelize } from "@server/database/sequelize"; import env from "@server/env"; import { Event, Team, TeamDomain, User } from "@server/models"; @@ -24,6 +26,7 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => { defaultUserRole, inviteRequired, allowedDomains, + preferences, } = params; const transaction: Transaction = await sequelize.transaction(); @@ -101,6 +104,13 @@ const teamUpdater = async ({ params, user, team, ip }: TeamUpdaterProps) => { team.allowedDomains = newAllowedDomains; } + if (preferences) { + for (const value of Object.values(TeamPreference)) { + if (has(preferences, value)) { + team.setPreference(value, Boolean(preferences[value])); + } + } + } const changes = team.changed(); diff --git a/server/models/Team.ts b/server/models/Team.ts index 11308ac6a..d72d8cd9d 100644 --- a/server/models/Team.ts +++ b/server/models/Team.ts @@ -19,7 +19,7 @@ import { IsUrl, AllowNull, } from "sequelize-typescript"; -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, TeamPreference } from "@shared/types"; import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains"; import env from "@server/env"; import { generateAvatarUrl } from "@server/utils/avatars"; @@ -169,6 +169,35 @@ class Team extends ParanoidModel { ); } + /** + * Preferences that decide behavior for the team. + * + * @param preference The team preference to set + * @param value Sets the preference value + * @returns The current team preferences + */ + public setPreference = (preference: TeamPreference, value: boolean) => { + if (!this.preferences) { + this.preferences = {}; + } + this.preferences[preference] = value; + this.changed("preferences", true); + + return this.preferences; + }; + + /** + * Returns the passed preference value + * + * @param preference The user preference to retrieve + * @returns The preference value if set, else undefined + */ + public getPreference = (preference: TeamPreference) => { + return !!this.preferences && this.preferences[preference] + ? this.preferences[preference] + : undefined; + }; + provisionFirstCollection = async (userId: string) => { await this.sequelize!.transaction(async (transaction) => { const collection = await Collection.create( diff --git a/server/routes/api/team.ts b/server/routes/api/team.ts index 871ae3a78..68ddc8f20 100644 --- a/server/routes/api/team.ts +++ b/server/routes/api/team.ts @@ -22,6 +22,7 @@ router.post("team.update", auth(), async (ctx) => { defaultUserRole, inviteRequired, allowedDomains, + preferences, } = ctx.body; const { user } = ctx.state; @@ -48,6 +49,7 @@ router.post("team.update", auth(), async (ctx) => { defaultUserRole, inviteRequired, allowedDomains, + preferences, }, user, team, diff --git a/server/services/web.ts b/server/services/web.ts index 09c6ba268..9b3decbea 100644 --- a/server/services/web.ts +++ b/server/services/web.ts @@ -67,6 +67,10 @@ export default function init(app: Koa = new Koa()): Koa { poll: 1000, ignored: ["node_modules", "flow-typed", "server", "build", "__mocks__"], }, + // Uncomment to test service worker + // headers: { + // "Service-Worker-Allowed": "/", + // }, // public path to bind the middleware to // use the same as in webpack publicPath: config.output.publicPath, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index 989b7700a..53b96e064 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -655,12 +655,9 @@ "Requesting Export": "Requesting Export", "Export Data": "Export Data", "Recent exports": "Recent exports", - "Are you sure you want to disable collaborative editing?": "Are you sure you want to disable collaborative editing?", "Manage optional and beta features. Changing these settings will affect the experience for all team members.": "Manage optional and beta features. Changing these settings will affect the experience for all team members.", - "Collaborative editing": "Collaborative editing", - "When enabled multiple people can edit documents at the same time with shared presence and live cursors.": "When enabled multiple people can edit documents at the same time with shared presence and live cursors.", - "I’m sure – Disable": "I’m sure – Disable", - "Enabling collaborative editing again in the future may cause some documents to revert to this point in time. It is not advised to disable this feature.": "Enabling collaborative editing again in the future may cause some documents to revert to this point in time. It is not advised to disable this feature.", + "Seamless editing": "Seamless editing", + "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.": "When enabled documents are always editable for team members that have permission. When disabled there is a separate editing view.", "New group": "New group", "Groups can be used to organize and manage the people on your team.": "Groups can be used to organize and manage the people on your team.", "All groups": "All groups", diff --git a/shared/types.ts b/shared/types.ts index f2cfae6f1..12fbb2297 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -45,8 +45,17 @@ export type IntegrationSettings = T extends IntegrationType.Embed | { serviceTeamId: string }; export enum UserPreference { + /** Whether reopening the app should redirect to the last viewed document. */ RememberLastPath = "rememberLastPath", + /** If web-style hand pointer should be used on interactive elements. */ UseCursorPointer = "useCursorPointer", } export type UserPreferences = { [key in UserPreference]?: boolean }; + +export enum TeamPreference { + /** Whether documents have a separate edit mode instead of seamless editing. */ + SeamlessEdit = "seamlessEdit", +} + +export type TeamPreferences = { [key in TeamPreference]?: boolean };