diff --git a/app/actions/definitions/navigation.tsx b/app/actions/definitions/navigation.tsx index a5c188c27..8a26452ff 100644 --- a/app/actions/definitions/navigation.tsx +++ b/app/actions/definitions/navigation.tsx @@ -28,6 +28,7 @@ import history from "~/utils/history"; import { organizationSettingsPath, profileSettingsPath, + accountPreferencesPath, homePath, searchPath, draftsPath, @@ -104,6 +105,14 @@ export const navigateToProfileSettings = createAction({ perform: () => history.push(profileSettingsPath()), }); +export const navigateToAccountPreferences = createAction({ + name: ({ t }) => t("Preferences"), + section: NavigationSection, + iconInContextMenu: false, + icon: , + perform: () => history.push(accountPreferencesPath()), +}); + export const openAPIDocumentation = createAction({ name: ({ t }) => t("API documentation"), section: NavigationSection, diff --git a/app/hooks/useAuthorizedSettingsConfig.ts b/app/hooks/useAuthorizedSettingsConfig.ts index aca3a250a..0783bbf14 100644 --- a/app/hooks/useAuthorizedSettingsConfig.ts +++ b/app/hooks/useAuthorizedSettingsConfig.ts @@ -12,6 +12,7 @@ import { BuildingBlocksIcon, DownloadIcon, WebhooksIcon, + SettingsIcon, } from "outline-icons"; import React from "react"; import { useTranslation } from "react-i18next"; @@ -23,6 +24,7 @@ import Groups from "~/scenes/Settings/Groups"; import Import from "~/scenes/Settings/Import"; import Members from "~/scenes/Settings/Members"; import Notifications from "~/scenes/Settings/Notifications"; +import Preferences from "~/scenes/Settings/Preferences"; import Profile from "~/scenes/Settings/Profile"; import Security from "~/scenes/Settings/Security"; import Shares from "~/scenes/Settings/Shares"; @@ -34,6 +36,7 @@ import SlackIcon from "~/components/SlackIcon"; import ZapierIcon from "~/components/ZapierIcon"; import env from "~/env"; import isCloudHosted from "~/utils/isCloudHosted"; +import { accountPreferencesPath } from "~/utils/routeHelpers"; import useCurrentTeam from "./useCurrentTeam"; import usePolicy from "./usePolicy"; @@ -82,6 +85,14 @@ const useAuthorizedSettingsConfig = () => { group: t("Account"), icon: ProfileIcon, }, + Preferences: { + name: t("Preferences"), + path: accountPreferencesPath(), + component: Preferences, + enabled: true, + group: t("Account"), + icon: SettingsIcon, + }, Notifications: { name: t("Notifications"), path: "/settings/notifications", diff --git a/app/hooks/useLastVisitedPath.tsx b/app/hooks/useLastVisitedPath.tsx new file mode 100644 index 000000000..8564cea3e --- /dev/null +++ b/app/hooks/useLastVisitedPath.tsx @@ -0,0 +1,14 @@ +import usePersistedState from "~/hooks/usePersistedState"; + +export default function useLastVisitedPath() { + const [lastVisitedPath, setLastVisitedPath] = usePersistedState( + "lastVisitedPath", + "/" + ); + + const setPathAsLastVisitedPath = (path: string) => { + path !== lastVisitedPath && setLastVisitedPath(path); + }; + + return [lastVisitedPath, setPathAsLastVisitedPath]; +} diff --git a/app/menus/AccountMenu.tsx b/app/menus/AccountMenu.tsx index 1c63b5aa3..b275f168b 100644 --- a/app/menus/AccountMenu.tsx +++ b/app/menus/AccountMenu.tsx @@ -6,6 +6,7 @@ import ContextMenu from "~/components/ContextMenu"; import Template from "~/components/ContextMenu/Template"; import { navigateToProfileSettings, + navigateToAccountPreferences, openKeyboardShortcuts, openChangelog, openAPIDocumentation, @@ -44,6 +45,7 @@ const AccountMenu: React.FC = ({ children }) => { openBugReportUrl, changeTheme, navigateToProfileSettings, + navigateToAccountPreferences, separator(), logout, ]; diff --git a/app/models/User.ts b/app/models/User.ts index b36602a02..8a32069c6 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 } from "@shared/types"; +import type { Role, UserPreferences } from "@shared/types"; import ParanoidModel from "./ParanoidModel"; import Field from "./decorators/Field"; @@ -26,6 +26,8 @@ class User extends ParanoidModel { @observable language: string; + preferences: UserPreferences | null | undefined; + email: string; isAdmin: boolean; diff --git a/app/scenes/Collection.tsx b/app/scenes/Collection.tsx index 5cd993d64..05080b75e 100644 --- a/app/scenes/Collection.tsx +++ b/app/scenes/Collection.tsx @@ -8,6 +8,7 @@ import { Route, useHistory, useRouteMatch, + useLocation, } from "react-router-dom"; import styled from "styled-components"; import breakpoint from "styled-components-breakpoint"; @@ -30,6 +31,7 @@ import Tabs from "~/components/Tabs"; import Tooltip from "~/components/Tooltip"; import { editCollection } from "~/actions/definitions/collections"; import useCommandBarActions from "~/hooks/useCommandBarActions"; +import useLastVisitedPath from "~/hooks/useLastVisitedPath"; import usePolicy from "~/hooks/usePolicy"; import useStores from "~/hooks/useStores"; import { collectionUrl, updateCollectionUrl } from "~/utils/routeHelpers"; @@ -42,16 +44,23 @@ function CollectionScene() { const params = useParams<{ id?: string }>(); const history = useHistory(); const match = useRouteMatch(); + const location = useLocation(); const { t } = useTranslation(); const { documents, pins, collections, ui } = useStores(); const [isFetching, setFetching] = React.useState(false); const [error, setError] = React.useState(); + const currentPath = location.pathname; + const [, setLastVisitedPath] = useLastVisitedPath(); const id = params.id || ""; const collection: Collection | null | undefined = collections.getByUrl(id) || collections.get(id); const can = usePolicy(collection?.id || ""); + React.useEffect(() => { + setLastVisitedPath(currentPath); + }, [currentPath, setLastVisitedPath]); + React.useEffect(() => { if (collection?.name) { const canonicalUrl = updateCollectionUrl(match.url, collection); diff --git a/app/scenes/Document/index.tsx b/app/scenes/Document/index.tsx index 201dd25cb..f6e5583c1 100644 --- a/app/scenes/Document/index.tsx +++ b/app/scenes/Document/index.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { StaticContext } from "react-router"; import { RouteComponentProps } from "react-router-dom"; import useCurrentTeam from "~/hooks/useCurrentTeam"; +import useLastVisitedPath from "~/hooks/useLastVisitedPath"; import useStores from "~/hooks/useStores"; import DataLoader from "./components/DataLoader"; import Document from "./components/Document"; @@ -25,6 +26,12 @@ export default function DocumentScene(props: Props) { const { ui } = useStores(); const team = useCurrentTeam(); const { documentSlug, revisionId } = props.match.params; + const currentPath = props.location.pathname; + const [, setLastVisitedPath] = useLastVisitedPath(); + + React.useEffect(() => { + setLastVisitedPath(currentPath); + }, [currentPath, setLastVisitedPath]); React.useEffect(() => { return () => ui.clearActiveDocument(); diff --git a/app/scenes/Login/index.tsx b/app/scenes/Login/index.tsx index e08e120cd..66e961993 100644 --- a/app/scenes/Login/index.tsx +++ b/app/scenes/Login/index.tsx @@ -19,6 +19,7 @@ import PageTitle from "~/components/PageTitle"; import TeamLogo from "~/components/TeamLogo"; import Text from "~/components/Text"; import env from "~/env"; +import useLastVisitedPath from "~/hooks/useLastVisitedPath"; import useQuery from "~/hooks/useQuery"; import useStores from "~/hooks/useStores"; import isCloudHosted from "~/utils/isCloudHosted"; @@ -62,6 +63,9 @@ function Login({ children }: Props) { const [error, setError] = React.useState(null); const [emailLinkSentTo, setEmailLinkSentTo] = React.useState(""); const isCreate = location.pathname === "/create"; + const rememberLastPath = !!auth.user?.preferences?.rememberLastPath; + const [lastVisitedPath] = useLastVisitedPath(); + const handleReset = React.useCallback(() => { setEmailLinkSentTo(""); }, []); @@ -91,6 +95,14 @@ function Login({ children }: Props) { } }, [query]); + if ( + auth.authenticated && + rememberLastPath && + lastVisitedPath !== location.pathname + ) { + return ; + } + if (auth.authenticated && auth.team?.defaultCollectionId) { return ; } diff --git a/app/scenes/Settings/Preferences.tsx b/app/scenes/Settings/Preferences.tsx new file mode 100644 index 000000000..0d3fe019c --- /dev/null +++ b/app/scenes/Settings/Preferences.tsx @@ -0,0 +1,57 @@ +import { observer } from "mobx-react"; +import { SettingsIcon } from "outline-icons"; +import * as React from "react"; +import { useTranslation } from "react-i18next"; +import Heading from "~/components/Heading"; +import Scene from "~/components/Scene"; +import Switch from "~/components/Switch"; +import useCurrentUser from "~/hooks/useCurrentUser"; +import useStores from "~/hooks/useStores"; +import useToasts from "~/hooks/useToasts"; +import SettingRow from "./components/SettingRow"; + +function Preferences() { + const { t } = useTranslation(); + const { showToast } = useToasts(); + const { auth } = useStores(); + const user = useCurrentUser(); + + const handleChange = async (ev: React.ChangeEvent) => { + const newPreferences = { + ...user.preferences, + [ev.target.name]: ev.target.checked, + }; + + await auth.updateUser({ + preferences: newPreferences, + }); + showToast(t("Preferences saved"), { + type: "success", + }); + }; + + return ( + } + > + {t("Preferences")} + + + + + ); +} + +export default observer(Preferences); diff --git a/app/stores/AuthStore.ts b/app/stores/AuthStore.ts index f5b1f993d..9f8a05955 100644 --- a/app/stores/AuthStore.ts +++ b/app/stores/AuthStore.ts @@ -221,6 +221,7 @@ export default class AuthStore { name?: string; avatarUrl?: string | null; language?: string; + preferences?: Record; }) => { this.isSaving = true; diff --git a/app/utils/routeHelpers.test.ts b/app/utils/routeHelpers.test.ts index 49dd176bb..f9a718755 100644 --- a/app/utils/routeHelpers.test.ts +++ b/app/utils/routeHelpers.test.ts @@ -1,4 +1,4 @@ -import { sharedDocumentPath } from "./routeHelpers"; +import { sharedDocumentPath, accountPreferencesPath } from "./routeHelpers"; describe("#sharedDocumentPath", () => { test("should return share path for a document", () => { @@ -12,3 +12,9 @@ describe("#sharedDocumentPath", () => { ); }); }); + +describe("#accountPreferencesPath", () => { + test("should return account preferences path", () => { + expect(accountPreferencesPath()).toBe("/settings/preferences"); + }); +}); diff --git a/app/utils/routeHelpers.ts b/app/utils/routeHelpers.ts index 7a7486655..fa7722054 100644 --- a/app/utils/routeHelpers.ts +++ b/app/utils/routeHelpers.ts @@ -34,6 +34,10 @@ export function profileSettingsPath(): string { return "/settings"; } +export function accountPreferencesPath(): string { + return "/settings/preferences"; +} + export function groupSettingsPath(): string { return "/settings/groups"; } diff --git a/server/models/User.ts b/server/models/User.ts index 42568300b..e7ac269c7 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -22,7 +22,11 @@ import { AllowNull, } from "sequelize-typescript"; import { languages } from "@shared/i18n"; -import { CollectionPermission } from "@shared/types"; +import { + CollectionPermission, + UserPreference, + UserPreferences, +} from "@shared/types"; import { stringToColor } from "@shared/utils/color"; import env from "@server/env"; import { ValidationError } from "../errors"; @@ -54,12 +58,6 @@ export enum UserRole { Viewer = "viewer", } -export enum UserPreference { - RememberLastPath = "rememberLastPath", -} - -export type UserPreferences = { [key in UserPreference]?: boolean }; - @Scopes(() => ({ withAuthentications: { include: [ diff --git a/server/presenters/user.ts b/server/presenters/user.ts index 03b280f62..abf5a90cc 100644 --- a/server/presenters/user.ts +++ b/server/presenters/user.ts @@ -1,6 +1,6 @@ +import { UserPreferences } from "@shared/types"; import env from "@server/env"; import { User } from "@server/models"; -import { UserPreferences } from "@server/models/User"; type Options = { includeDetails?: boolean; diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts index 5cba9404e..cc3974439 100644 --- a/server/routes/api/users.ts +++ b/server/routes/api/users.ts @@ -1,6 +1,8 @@ import crypto from "crypto"; import Router from "koa-router"; +import { has } from "lodash"; import { Op, WhereOptions } from "sequelize"; +import { UserPreference } from "@shared/types"; import { UserValidation } from "@shared/validations"; import { RateLimiterStrategy } from "@server/RateLimiter"; import userDemoter from "@server/commands/userDemoter"; @@ -17,7 +19,7 @@ import logger from "@server/logging/Logger"; import auth from "@server/middlewares/authentication"; import { rateLimiter } from "@server/middlewares/rateLimiter"; import { Event, User, Team } from "@server/models"; -import { UserFlag, UserRole, UserPreference } from "@server/models/User"; +import { UserFlag, UserRole } from "@server/models/User"; import { can, authorize } from "@server/policies"; import { presentUser, presentPolicies } from "@server/presenters"; import { @@ -188,7 +190,7 @@ router.post("users.update", auth(), async (ctx) => { } if (preferences) { assertKeysIn(preferences, UserPreference); - if (preferences.rememberLastPath) { + if (has(preferences, UserPreference.RememberLastPath)) { assertBoolean(preferences.rememberLastPath); user.setPreference( UserPreference.RememberLastPath, diff --git a/shared/i18n/locales/en_US/translation.json b/shared/i18n/locales/en_US/translation.json index b397f789c..6d5122d07 100644 --- a/shared/i18n/locales/en_US/translation.json +++ b/shared/i18n/locales/en_US/translation.json @@ -50,6 +50,7 @@ "Trash": "Trash", "Settings": "Settings", "Profile": "Profile", + "Preferences": "Preferences", "API documentation": "API documentation", "Send us feedback": "Send us feedback", "Report a bug": "Report a bug", @@ -683,6 +684,9 @@ "Email address": "Email address", "Your email address should be updated in your SSO provider.": "Your email address should be updated in your SSO provider.", "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.": "The email integration is currently disabled. Please set the associated environment variables and restart the server to enable notifications.", + "Preferences saved": "Preferences saved", + "Remember previous location": "Remember previous location", + "Automatically return to the document you were last viewing when the app is re-opened": "Automatically return to the document you were last viewing when the app is re-opened", "Profile saved": "Profile saved", "Profile picture updated": "Profile picture updated", "Unable to upload new profile picture": "Unable to upload new profile picture", diff --git a/shared/types.ts b/shared/types.ts index 131fe0b6b..ac8c70022 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -43,3 +43,9 @@ export type IntegrationSettings = T extends IntegrationType.Embed | { url: string } | { url: string; channel: string; channelId: string } | { serviceTeamId: string }; + +export enum UserPreference { + RememberLastPath = "rememberLastPath", +} + +export type UserPreferences = { [key in UserPreference]?: boolean };