Desktop support (#4484)

* Remove home link on desktop app

* Spellcheck, installation toasts, background styling, …

* Add email,slack, auth support

* More desktop style tweaks

* Move redirect to client

* cleanup

* Record desktop usage

* docs

* fix: Selection state in search input when double clicking header
This commit is contained in:
Tom Moor
2022-11-27 15:07:48 -08:00
committed by GitHub
parent ea9680c3d7
commit cc333637dd
38 changed files with 492 additions and 83 deletions

View File

@@ -6,6 +6,7 @@ import styled from "styled-components";
import ActionButton, {
Props as ActionButtonProps,
} from "~/components/ActionButton";
import { undraggableOnDesktop } from "~/styles";
type RealProps = {
$fullwidth?: boolean;
@@ -33,6 +34,7 @@ const RealButton = styled(ActionButton)<RealProps>`
cursor: var(--pointer);
user-select: none;
appearance: none !important;
${undraggableOnDesktop()}
${(props) =>
!props.$borderOnHover &&

View File

@@ -0,0 +1,44 @@
import * as React from "react";
import { useHistory } from "react-router-dom";
import { useDesktopTitlebar } from "~/hooks/useDesktopTitlebar";
import useToasts from "~/hooks/useToasts";
import Desktop from "~/utils/Desktop";
export default function DesktopEventHandler() {
useDesktopTitlebar();
const history = useHistory();
const { showToast } = useToasts();
React.useEffect(() => {
Desktop.bridge?.redirect((path: string, replace = false) => {
if (replace) {
history.replace(path);
} else {
history.push(path);
}
});
Desktop.bridge?.updateDownloaded(() => {
showToast("An update is ready to install.", {
type: "info",
timeout: Infinity,
action: {
text: "Install now",
onClick: () => {
Desktop.bridge?.restartAndInstall();
},
},
});
});
Desktop.bridge?.focus(() => {
window.document.body.classList.remove("backgrounded");
});
Desktop.bridge?.blur(() => {
window.document.body.classList.add("backgrounded");
});
}, [history, showToast]);
return null;
}

View File

@@ -12,6 +12,8 @@ import Flex from "~/components/Flex";
import useEventListener from "~/hooks/useEventListener";
import useMobile from "~/hooks/useMobile";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import Desktop from "~/utils/Desktop";
import { supportsPassiveListener } from "~/utils/browser";
type Props = {
@@ -26,6 +28,7 @@ function Header({ left, title, actions, hasSidebar }: Props) {
const isMobile = useMobile();
const hasMobileSidebar = hasSidebar && isMobile;
const sidebarCollapsed = ui.isEditing || ui.sidebarCollapsed;
const passThrough = !actions && !left && !title;
@@ -50,7 +53,12 @@ function Header({ left, title, actions, hasSidebar }: Props) {
}, []);
return (
<Wrapper align="center" shrink={false} $passThrough={passThrough}>
<Wrapper
align="center"
shrink={false}
$passThrough={passThrough}
$insetTitleAdjust={sidebarCollapsed && Desktop.hasInsetTitlebar()}
>
{left || hasMobileSidebar ? (
<Breadcrumbs>
{hasMobileSidebar && (
@@ -98,7 +106,12 @@ const Actions = styled(Flex)`
`};
`;
const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
type WrapperProps = {
$passThrough?: boolean;
$insetTitleAdjust?: boolean;
};
const Wrapper = styled(Flex)<WrapperProps>`
top: 0;
z-index: ${depths.header};
position: sticky;
@@ -120,6 +133,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
transform: translate3d(0, 0, 0);
min-height: 64px;
justify-content: flex-start;
${draggableOnDesktop()}
${fadeOnDesktopBackgrounded()}
@supports (backdrop-filter: blur(20px)) {
backdrop-filter: blur(20px);
@@ -133,7 +148,8 @@ const Wrapper = styled(Flex)<{ $passThrough?: boolean }>`
${breakpoint("tablet")`
padding: 16px;
justify-content: center;
`};
${(props: WrapperProps) => props.$insetTitleAdjust && `padding-left: 64px;`}
`};
`;
const Title = styled("div")`

View File

@@ -5,6 +5,7 @@ import { VisuallyHidden } from "reakit/VisuallyHidden";
import styled from "styled-components";
import breakpoint from "styled-components-breakpoint";
import Flex from "~/components/Flex";
import { undraggableOnDesktop } from "~/styles";
const RealTextarea = styled.textarea<{ hasIcon?: boolean }>`
border: 0;
@@ -32,6 +33,7 @@ const RealInput = styled.input<{ hasIcon?: boolean }>`
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
${undraggableOnDesktop()}
&:disabled,
&::placeholder {
@@ -98,6 +100,9 @@ export const Outline = styled(Flex)<{
align-items: center;
overflow: hidden;
background: ${(props) => props.theme.background};
/* Prevents an issue where input placeholder appears in a selected style when double clicking title bar */
user-select: none;
`;
export const LabelText = styled.div`

View File

@@ -15,6 +15,7 @@ import useMobile from "~/hooks/useMobile";
import usePrevious from "~/hooks/usePrevious";
import useUnmount from "~/hooks/useUnmount";
import { fadeAndScaleIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
let openModals = 0;
type Props = {
@@ -222,7 +223,7 @@ const Back = styled(NudeButton)`
position: absolute;
display: none;
align-items: center;
top: 2rem;
top: ${Desktop.hasInsetTitlebar() ? "3rem" : "2rem"};
left: 2rem;
opacity: 0.75;
color: ${(props) => props.theme.text};

View File

@@ -14,6 +14,7 @@ import useCurrentUser from "~/hooks/useCurrentUser";
import usePolicy from "~/hooks/usePolicy";
import useStores from "~/hooks/useStores";
import OrganizationMenu from "~/menus/OrganizationMenu";
import Desktop from "~/utils/Desktop";
import {
homePath,
draftsPath,
@@ -63,7 +64,16 @@ function AppSidebar() {
<HeaderButton
{...props}
title={team.name}
image={<TeamLogo model={team} size={32} alt={t("Logo")} />}
image={
<TeamLogo
model={team}
size={Desktop.hasInsetTitlebar() ? 24 : 32}
alt={t("Logo")}
/>
}
style={
Desktop.hasInsetTitlebar() ? { paddingLeft: 8 } : undefined
}
showDisclosure
/>
)}

View File

@@ -8,6 +8,7 @@ import styled from "styled-components";
import Flex from "~/components/Flex";
import Scrollable from "~/components/Scrollable";
import useAuthorizedSettingsConfig from "~/hooks/useAuthorizedSettingsConfig";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import Sidebar from "./Sidebar";
import Header from "./components/Header";
@@ -32,7 +33,7 @@ function SettingsSidebar() {
title={t("Return to App")}
image={<StyledBackIcon color="currentColor" />}
onClick={returnToApp}
minHeight={48}
minHeight={Desktop.hasInsetTitlebar() ? undefined : 48}
/>
<Flex auto column>

View File

@@ -11,7 +11,9 @@ import useMenuContext from "~/hooks/useMenuContext";
import usePrevious from "~/hooks/usePrevious";
import useStores from "~/hooks/useStores";
import AccountMenu from "~/menus/AccountMenu";
import { draggableOnDesktop, fadeOnDesktopBackgrounded } from "~/styles";
import { fadeIn } from "~/styles/animations";
import Desktop from "~/utils/Desktop";
import Avatar from "../Avatar";
import HeaderButton, { HeaderButtonProps } from "./components/HeaderButton";
import ResizeBorder from "./components/ResizeBorder";
@@ -251,6 +253,9 @@ const Container = styled(Flex)<ContainerProps>`
z-index: ${depths.sidebar};
max-width: 70%;
min-width: 280px;
padding-top: ${Desktop.hasInsetTitlebar() ? 24 : 0}px;
${draggableOnDesktop()}
${fadeOnDesktopBackgrounded()}
${Positioner} {
display: none;
@@ -265,7 +270,9 @@ const Container = styled(Flex)<ContainerProps>`
margin: 0;
min-width: 0;
transform: translateX(${(props: ContainerProps) =>
props.$collapsed ? "calc(-100% + 16px)" : 0});
props.$collapsed
? `calc(-100% + ${Desktop.hasInsetTitlebar() ? 8 : 16}px)`
: 0});
&:hover,
&:focus-within {

View File

@@ -3,7 +3,7 @@ import * as React from "react";
import styled from "styled-components";
import Flex from "~/components/Flex";
export type HeaderButtonProps = {
export type HeaderButtonProps = React.ComponentProps<typeof Wrapper> & {
title: React.ReactNode;
image: React.ReactNode;
minHeight?: number;

View File

@@ -1,4 +1,5 @@
import styled from "styled-components";
import { undraggableOnDesktop } from "~/styles";
const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
position: absolute;
@@ -8,6 +9,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
left: ${(props) => (props.dir === "right" ? "-1px" : "auto")};
width: 2px;
cursor: col-resize;
${undraggableOnDesktop()}
&:hover {
transition-delay: 500ms;
@@ -22,6 +24,7 @@ const ResizeBorder = styled.div<{ dir?: "left" | "right" }>`
bottom: 0;
right: -4px;
width: 10px;
${undraggableOnDesktop()}
}
`;

View File

@@ -4,6 +4,7 @@ import styled, { useTheme, css } from "styled-components";
import breakpoint from "styled-components-breakpoint";
import EventBoundary from "~/components/EventBoundary";
import NudeButton from "~/components/NudeButton";
import { undraggableOnDesktop } from "~/styles";
import { NavigationNode } from "~/types";
import Disclosure from "./Disclosure";
import NavLink, { Props as NavLinkProps } from "./NavLink";
@@ -181,6 +182,7 @@ const Link = styled(NavLink)<{
font-size: 16px;
cursor: var(--pointer);
overflow: hidden;
${undraggableOnDesktop()}
${(props) =>
props.$disabled &&

View File

@@ -2,6 +2,7 @@ import * as React from "react";
import styled from "styled-components";
import { LabelText } from "~/components/Input";
import Text from "~/components/Text";
import { undraggableOnDesktop } from "~/styles";
type Props = React.HTMLAttributes<HTMLInputElement> & {
width?: number;
@@ -62,6 +63,7 @@ function Switch({
const Wrapper = styled.div`
padding-bottom: 8px;
${undraggableOnDesktop()}
`;
const InlineLabelText = styled(LabelText)`

View File

@@ -69,14 +69,14 @@ function Toast({ closeAfterMs = 3000, onRequestClose, toast }: Props) {
const Action = styled.span`
display: inline-block;
padding: 10px 12px;
height: 100%;
text-transform: uppercase;
font-size: 12px;
padding: 4px 8px;
color: ${(props) => props.theme.toastText};
background: ${(props) => darken(0.05, props.theme.toastBackground)};
border-top-right-radius: 5px;
border-bottom-right-radius: 5px;
border-radius: 4px;
margin-left: 8px;
margin-right: -4px;
font-weight: 500;
user-select: none;
&:hover {
background: ${(props) => darken(0.1, props.theme.toastBackground)};

View File

@@ -0,0 +1,46 @@
import * as React from "react";
import Desktop from "~/utils/Desktop";
export const useDesktopTitlebar = () => {
React.useEffect(() => {
if (!Desktop.isElectron()) {
return;
}
const handleDoubleClick = (event: MouseEvent) => {
// Ignore double clicks on interactive elements such as inputs and buttons
if (event.composedPath().some(elementIsInteractive)) {
return;
}
// Ignore if the mouse position is further down than the header height
if (event.clientY > 64) {
return;
}
event.preventDefault();
Desktop.bridge.onTitlebarDoubleClick();
};
window.addEventListener("dblclick", handleDoubleClick);
return () => window.removeEventListener("dblclick", handleDoubleClick);
}, []);
};
/**
* Check if an element is user interactive.
*
* @param target HTML element
* @returns boolean
*/
function elementIsInteractive(target: EventTarget) {
return (
target &&
target instanceof HTMLElement &&
(target instanceof HTMLSelectElement ||
target instanceof HTMLInputElement ||
target instanceof HTMLButtonElement ||
target.getAttribute("role") === "button" ||
target.getAttribute("role") === "textarea")
);
}

View File

@@ -15,6 +15,7 @@ import ScrollToTop from "~/components/ScrollToTop";
import Theme from "~/components/Theme";
import Toasts from "~/components/Toasts";
import env from "~/env";
import Desktop from "./components/DesktopEventHandler";
import LazyPolyfill from "./components/LazyPolyfills";
import Routes from "./routes";
import Logger from "./utils/Logger";
@@ -92,6 +93,7 @@ if (element) {
</ScrollToTop>
<Toasts />
<Dialogs />
<Desktop />
</>
</Router>
</LazyMotion>

View File

@@ -1,5 +1,6 @@
import * as React from "react";
import { Switch, Redirect } from "react-router-dom";
import DesktopRedirect from "~/scenes/DesktopRedirect";
import DelayedMount from "~/components/DelayedMount";
import FullscreenLoading from "~/components/FullscreenLoading";
import Route from "~/components/ProfiledRoute";
@@ -54,6 +55,7 @@ export default function Routes() {
<Route exact path="/" component={Login} />
<Route exact path="/create" component={Login} />
<Route exact path="/logout" component={Logout} />
<Route exact path="/desktop-redirect" component={DesktopRedirect} />
<Redirect exact from="/share/:shareId" to="/s/:shareId" />
<Route exact path="/s/:shareId" component={SharedDocument} />

View File

@@ -0,0 +1,58 @@
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import Flex from "~/components/Flex";
import Heading from "~/components/Heading";
import PageTitle from "~/components/PageTitle";
import Text from "~/components/Text";
import useQuery from "~/hooks/useQuery";
const DesktopRedirect = () => {
const params = useQuery();
const token = params.get("token");
const { t } = useTranslation();
React.useEffect(() => {
if (token) {
window.location.href = `outline://${window.location.host}/auth/redirect?token=${token}`;
// Clean the url so it's not possible to hit reload, re-using the transfer token will not work.
window.location.search = "";
}
}, [token]);
return (
<Centered align="center" justify="center" column auto>
<PageTitle title={`${t("Signing in")}`} />
<Heading centered>{t("Signing in")}</Heading>
<Note>
{t(
"You can safely close this window once the Outline desktop app has opened"
)}
.
</Note>
</Centered>
);
};
const Note = styled(Text)`
color: ${(props) => props.theme.textTertiary};
text-align: center;
font-size: 14px;
margin-top: 8px;
em {
font-style: normal;
font-weight: 500;
}
`;
const Centered = styled(Flex)`
user-select: none;
width: 90vw;
height: 100%;
max-width: 320px;
margin: 0 auto;
`;
export default DesktopRedirect;

View File

@@ -2,12 +2,14 @@ import { EmailIcon } from "outline-icons";
import * as React from "react";
import { useTranslation } from "react-i18next";
import styled from "styled-components";
import { Client } from "@shared/types";
import { parseDomain } from "@shared/utils/domains";
import AuthLogo from "~/components/AuthLogo";
import ButtonLarge from "~/components/ButtonLarge";
import InputLarge from "~/components/InputLarge";
import env from "~/env";
import { client } from "~/utils/ApiClient";
import Desktop from "~/utils/Desktop";
type Props = {
id: string;
@@ -39,6 +41,7 @@ function AuthenticationProvider(props: Props) {
try {
const response = await client.post(event.currentTarget.action, {
email,
client: Desktop.isElectron() ? "desktop" : undefined,
});
if (response.redirect) {
@@ -95,7 +98,9 @@ function AuthenticationProvider(props: Props) {
// and keep the user on the same page.
const { custom, teamSubdomain, host } = parseDomain(window.location.origin);
const needsRedirect = custom || teamSubdomain;
const href = needsRedirect
const href = Desktop.isElectron()
? `${env.URL}${authUrl}?client=${Client.Desktop}`
: needsRedirect
? `${env.URL}${authUrl}?host=${encodeURI(host)}`
: authUrl;

View File

@@ -21,6 +21,8 @@ import env from "~/env";
import useLastVisitedPath from "~/hooks/useLastVisitedPath";
import useQuery from "~/hooks/useQuery";
import useStores from "~/hooks/useStores";
import { draggableOnDesktop } from "~/styles";
import Desktop from "~/utils/Desktop";
import isCloudHosted from "~/utils/isCloudHosted";
import { changeLanguage, detectLanguage } from "~/utils/language";
import AuthenticationProvider from "./AuthenticationProvider";
@@ -30,7 +32,11 @@ function Header({ config }: { config?: Config | undefined }) {
const { t } = useTranslation();
const isSubdomain = !!config?.hostname;
if (!isCloudHosted || parseDomain(window.location.origin).custom) {
if (
!isCloudHosted ||
parseDomain(window.location.origin).custom ||
Desktop.isElectron()
) {
return null;
}
@@ -274,6 +280,7 @@ const Background = styled(Fade)`
height: 100%;
background: ${(props) => props.theme.background};
display: flex;
${draggableOnDesktop()}
`;
const Logo = styled.div`

View File

@@ -1,3 +1,4 @@
import Desktop from "~/utils/Desktop";
import { isTouchDevice } from "~/utils/browser";
/**
@@ -6,3 +7,34 @@ import { isTouchDevice } from "~/utils/browser";
* using `&:hover {...}`.
*/
export const hover = isTouchDevice() ? "active" : "hover";
/**
* Mixin to make an element drag the window when rendered in the desktop app.
*
* @returns string of CSS
*/
export const draggableOnDesktop = () =>
Desktop.isElectron() ? "-webkit-app-region: drag;" : "";
/**
* Mixin to make an element not drag the window when rendered in the desktop app.
*
* @returns string of CSS
*/
export const undraggableOnDesktop = () =>
Desktop.isElectron() ? "-webkit-app-region: no-drag;" : "";
/**
* Mixin to make an element fade when the desktop app is backgrounded.
*
* @returns string of CSS
*/
export const fadeOnDesktopBackgrounded = () => {
if (!Desktop.isElectron()) {
return "";
}
return `
body.backgrounded & { opacity: 0.75; }
`;
};

68
app/typings/window.d.ts vendored Normal file
View File

@@ -0,0 +1,68 @@
declare global {
interface Window {
DesktopBridge: {
/**
* The name of the platform running on.
*/
platform: string;
/**
* The version of the loaded application.
*/
version: () => Promise<string>;
/**
* Restarts the application.
*/
restart: () => Promise<void>;
/**
* Restarts the application and installs the update.
*/
restartAndInstall: () => Promise<void>;
/**
* Tells the updater to check for updates now.
*/
checkForUpdates: () => Promise<void>;
/**
* Passes double click events from titlebar area
*/
onTitlebarDoubleClick: () => Promise<void>;
/**
* Adds a custom host to config
*/
addCustomHost: (host: string) => Promise<void>;
/**
* Set the language used by the spellchecker on Windows/Linux.
*/
setSpellCheckerLanguages: (languages: string[]) => Promise<void>;
/**
* Registers a callback to be called when the window is focused.
*/
focus: (callback: () => void) => void;
/**
* Registers a callback to be called when the window loses focus.
*/
blur: (callback: () => void) => void;
/**
* Registers a callback to be called when a route change is requested from the main process.
* This would usually be when it is responding to a deeplink.
*/
redirect: (callback: (path: string, replace: boolean) => void) => void;
/**
* Registers a callback to be called when the application is ready to update.
*/
updateDownloaded: (callback: () => void) => void;
};
}
}
export {};

36
app/utils/Desktop.ts Normal file
View File

@@ -0,0 +1,36 @@
import { isMac, isWindows } from "./browser";
export default class Desktop {
/**
* Returns true if the client has inset/floating window controls.
*/
static hasInsetTitlebar() {
return this.isMacApp();
}
/**
* Returns true if the client is running in the macOS app.
*/
static isMacApp() {
return this.isElectron() && isMac();
}
/**
* Returns true if the client is running in the Windows app.
*/
static isWindowsApp() {
return this.isElectron() && isWindows();
}
/**
* Returns true if the client is running in a desktop app.
*/
static isElectron() {
return navigator?.userAgent?.includes("Electron");
}
/**
* The bridge provides secure access to API's in desktop wrapper.
*/
static bridge = window.DesktopBridge;
}

View File

@@ -12,8 +12,14 @@ export function isTouchDevice(): boolean {
* Returns true if the client is running on a Mac.
*/
export function isMac(): boolean {
const SSR = typeof window === "undefined";
return !SSR && window.navigator.platform === "MacIntel";
return window.navigator.platform === "MacIntel";
}
/**
* Returns true if the client is running on Windows.
*/
export function isWindows(): boolean {
return window.navigator.platform === "Win32";
}
let supportsPassive = false;

View File

@@ -1,4 +1,5 @@
import { i18n } from "i18next";
import Desktop from "./Desktop";
export function detectLanguage() {
const [ln, r] = navigator.language.split("-");
@@ -13,6 +14,9 @@ export function changeLanguage(
if (toLanguageString && i18n.language !== toLanguageString) {
// Languages are stored in en_US format in the database, however the
// frontend translation framework (i18next) expects en-US
i18n.changeLanguage(toLanguageString.replace("_", "-"));
const locale = toLanguageString.replace("_", "-");
i18n.changeLanguage(locale);
Desktop.bridge?.setSpellCheckerLanguages(["en-US", locale]);
}
}