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:
@@ -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 &&
|
||||
|
||||
44
app/components/DesktopEventHandler.tsx
Normal file
44
app/components/DesktopEventHandler.tsx
Normal 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;
|
||||
}
|
||||
@@ -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")`
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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)};
|
||||
|
||||
46
app/hooks/useDesktopTitlebar.ts
Normal file
46
app/hooks/useDesktopTitlebar.ts
Normal 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")
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
58
app/scenes/DesktopRedirect.tsx
Normal file
58
app/scenes/DesktopRedirect.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
68
app/typings/window.d.ts
vendored
Normal 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
36
app/utils/Desktop.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user