init commit,

This commit is contained in:
louiscklaw
2025-05-28 09:55:51 +08:00
commit efe70ceb69
8042 changed files with 951668 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
import { IonApp, setupIonicReact } from "@ionic/react";
import { StoreProvider } from "../store";
import {
getAndroidNavMode,
getDeviceMode,
isInstalled,
} from "../helpers/device";
import TabbedRoutes from "../routes/TabbedRoutes";
import Auth from "./Auth";
import { AppContextProvider } from "../features/auth/AppContext";
import Router from "../routes/common/Router";
import BeforeInstallPromptProvider from "../features/pwa/BeforeInstallPromptProvider";
import { UpdateContextProvider } from "../routes/pages/settings/update/UpdateContext";
import GlobalStyles from "./GlobalStyles";
import ConfigProvider from "../services/app";
import { TabContextProvider } from "./TabContext";
import { NavModes } from "capacitor-android-nav-mode";
import { TextRecoveryStartupPrompt } from "../helpers/useTextRecovery";
import HapticsListener from "./listeners/HapticsListener";
import AndroidBackButton from "./listeners/AndroidBackButton";
import { OptimizedRouterProvider } from "../helpers/useOptimizedIonRouter";
import { ErrorBoundary } from "react-error-boundary";
import AppCrash from "./AppCrash";
/* Core CSS required for Ionic components to work properly */
import "@ionic/react/css/core.css";
/* Basic CSS for apps built with Ionic */
import "@ionic/react/css/normalize.css";
import "@ionic/react/css/structure.css";
import "@ionic/react/css/typography.css";
/* Optional CSS utils that can be commented out */
import "@ionic/react/css/padding.css";
import "@ionic/react/css/float-elements.css";
import "@ionic/react/css/text-alignment.css";
import "@ionic/react/css/text-transformation.css";
import "@ionic/react/css/flex-utils.css";
import "@ionic/react/css/display.css";
import "@ionic/react/css/palettes/dark.class.css";
/* Setup global app lifecycle listeners */
import "./listeners";
import AppUrlListener from "./listeners/AppUrlListener";
import { ResetStatusTap } from "./listeners/statusTap";
// index.tsx ensures android nav mode resolves before app is rendered
(async () => {
let navMode;
try {
navMode = await getAndroidNavMode();
} catch (_) {
// ignore errors
}
setupIonicReact({
mode: getDeviceMode(),
statusTap: false, // custom implementation listeners/statusTap.ts
swipeBackEnabled:
isInstalled() &&
getDeviceMode() === "ios" &&
navMode !== NavModes.Gesture,
});
})();
export default function App() {
return (
<ErrorBoundary FallbackComponent={AppCrash}>
<ConfigProvider>
<AppContextProvider>
<StoreProvider>
<GlobalStyles>
<BeforeInstallPromptProvider>
<UpdateContextProvider>
<Router>
<OptimizedRouterProvider>
<AndroidBackButton />
<ResetStatusTap />
<TabContextProvider>
<IonApp>
<HapticsListener />
<AppUrlListener />
<TextRecoveryStartupPrompt />
<Auth>
<TabbedRoutes />
</Auth>
</IonApp>
</TabContextProvider>
</OptimizedRouterProvider>
</Router>
</UpdateContextProvider>
</BeforeInstallPromptProvider>
</GlobalStyles>
</StoreProvider>
</AppContextProvider>
</ConfigProvider>
</ErrorBoundary>
);
}

View File

@@ -0,0 +1,165 @@
import { styled } from "@linaria/react";
import { FallbackProps } from "react-error-boundary";
import { isInstalled, isNative } from "../helpers/device";
import { IonButton, IonIcon, IonLabel } from "@ionic/react";
import { logoGithub } from "ionicons/icons";
import { unloadServiceWorkerAndRefresh } from "../helpers/serviceWorker";
import { memoryHistory } from "../routes/common/Router";
import store from "../store";
import { loggedInSelector } from "../features/auth/authSelectors";
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
text-align: center;
padding: 8px;
position: absolute;
inset: 0;
overflow: auto;
padding-top: max(env(safe-area-inset-top), 8px);
padding-right: max(env(safe-area-inset-right), 8px);
padding-bottom: max(env(safe-area-inset-bottom), 8px);
padding-left: max(env(safe-area-inset-left), 8px);
color: var(--ion-text-color);
`;
const Title = styled.h2``;
const Description = styled.div``;
export default function AppCrash({ error }: FallbackProps) {
// Don't use useLocation/useAppSelector, because they are not available
// (`<AppCrash />` is at the root of the document tree)
const location = memoryHistory ? memoryHistory.location : window.location;
const loggedIn = loggedInSelector(store.getState());
let crashData = `
### Crash description
<!-- Write any information here to help us debug your crash! -->
<!-- What were you doing when the crash occurred? -->
### Device and app metadata
- window.location.href: \`${window.location.href}\`
- react-router location.pathname: \`${location.pathname}\`
- Logged in? \`${loggedIn}\`
- Native app? \`${isNative()}\`
- Installed to home screen? \`${isInstalled()}\`
- Voyager version: \`${APP_VERSION}\`
- BUILD_FOSS_ONLY: \`${BUILD_FOSS_ONLY}\`
- User agent: \`${navigator.userAgent}\`
### Crash data
Error: \`\`${error}\`\`
#### Stack trace
\`\`\`
`.trim();
crashData = `${crashData}\n${error instanceof Error ? error.stack : "Not available"}`;
async function clearData() {
if (
!confirm(
"Are you sure? This will log you out of all accounts and delete all local app data including app configuration, hidden posts and favorites.",
)
)
return;
localStorage.clear();
sessionStorage.clear();
const dbs = await window.indexedDB.databases();
for (const db of dbs) {
if (db.name) window.indexedDB.deleteDatabase(db.name);
}
alert("All data cleared.");
}
return (
<Container>
<Title>🫣 Gah! Voyager crashed!</Title>
<Description>
Voyager does not collect any data, so we would appreciate you
voluntarily submitting this crash for us to investigate.
</Description>
<IonButton
href={generateTruncatedCrashUrl(crashData)}
target="_blank"
rel="noopener noreferrer"
color="success"
>
<IonIcon icon={logoGithub} slot="start" />
<IonLabel>Open Github issue with crash data</IonLabel>
</IonButton>
<hr />
<Description>
You can also try reloading the app to see if that solves the issue.
{isNative() ? " Check the app store for an update, too." : ""}
</Description>
<IonButton onClick={unloadServiceWorkerAndRefresh}>Reload app</IonButton>
<hr />
<Description>
If this crash is affecting many people, you can probably learn more{" "}
<a
href="https://lemmy.world/c/voyagerapp"
target="_blank"
rel="noopener noreferrer"
>
at Voyager&apos;s Lemmy community
</a>
.
</Description>
<Description>As a last resort, try clearing all app data.</Description>
<IonButton color="danger" onClick={clearData}>
Clear app data
</IonButton>
</Container>
);
}
function generateCrashUrl(crashData: string): string {
return `https://github.com/aeharding/voyager/issues/new?title=Crash&body=${encodeURIComponent(
crashData,
)}`;
}
// The GitHub GET endpoint for opening a new issue
// has a restriction for maximum length of a URL: 8192 bytes
// https://github.com/cli/cli/pull/3271
// https://github.com/cli/cli/issues/1575
// https://github.com/cli/cli/blob/trunk/pkg/cmd/issue/create/create.go#L167
// https://github.com/cli/cli/blob/trunk/utils/utils.go#L84
const maxIssueBytes = 8150;
function getStrByteLength(str: string): number {
return new TextEncoder().encode(str).length;
}
function generateTruncatedCrashUrl(crashData: string): string {
let url: string;
let strLength = 1;
do {
url = generateCrashUrl(crashData.slice(0, strLength));
if (strLength === crashData.length) return url;
strLength++;
} while (getStrByteLength(url) < maxIssueBytes);
return url;
}

View File

@@ -0,0 +1,114 @@
import React, { useCallback, useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../store";
import { updateConnectedInstance } from "../features/auth/authSlice";
import { useLocation } from "react-router";
import { getInboxCounts, syncMessages } from "../features/inbox/inboxSlice";
import { useInterval } from "usehooks-ts";
import usePageVisibility from "../helpers/usePageVisibility";
import { getDefaultServer } from "../services/app";
import BackgroundReportSync from "../features/moderation/BackgroundReportSync";
import { getSiteIfNeeded, isAdminSelector } from "../features/auth/siteSlice";
import { instanceSelector, jwtSelector } from "../features/auth/authSelectors";
interface AuthProps {
children: React.ReactNode;
}
export default function Auth({ children }: AuthProps) {
const dispatch = useAppDispatch();
const jwt = useAppSelector(jwtSelector);
const connectedInstance = useAppSelector(
(state) => state.auth.connectedInstance,
);
useEffect(() => {
dispatch(getSiteIfNeeded());
}, [dispatch, jwt, connectedInstance]);
return (
<>
<AuthLocation />
{connectedInstance ? children : undefined}
</>
);
}
/**
* Separate component so that it doesn't rerender react component tree on location change
*/
function AuthLocation() {
const location = useLocation();
const dispatch = useAppDispatch();
const pageVisibility = usePageVisibility();
const jwt = useAppSelector(jwtSelector);
const selectedInstance = useAppSelector(instanceSelector);
const connectedInstance = useAppSelector(
(state) => state.auth.connectedInstance,
);
const hasModdedSubs = useAppSelector(
(state) =>
!!state.site.response?.my_user?.moderates.length ||
!!isAdminSelector(state),
);
const shouldSyncMessages = useCallback(() => {
return jwt && location.pathname.startsWith("/inbox/messages");
}, [jwt, location]);
useEffect(() => {
if (connectedInstance) return;
const potentialConnectedInstance = location.pathname.split("/")[2];
if (
potentialConnectedInstance &&
connectedInstance === potentialConnectedInstance
)
return;
if (selectedInstance) {
dispatch(updateConnectedInstance(selectedInstance));
} else if (potentialConnectedInstance?.includes(".")) {
dispatch(updateConnectedInstance(potentialConnectedInstance));
} else {
dispatch(updateConnectedInstance(getDefaultServer()));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname]);
useInterval(
() => {
if (!pageVisibility) return;
if (!shouldSyncMessages()) return;
dispatch(syncMessages());
},
shouldSyncMessages() ? 1_000 * 15 : null,
);
useInterval(() => {
if (!pageVisibility) return;
if (!jwt) return;
dispatch(getInboxCounts());
}, 1_000 * 60);
useEffect(() => {
if (!pageVisibility) return;
dispatch(getInboxCounts());
}, [pageVisibility, jwt, dispatch]);
useEffect(() => {
if (!pageVisibility) return;
if (!shouldSyncMessages()) return;
dispatch(syncMessages());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageVisibility]);
return <>{hasModdedSubs && <BackgroundReportSync />}</>;
}

View File

@@ -0,0 +1,147 @@
import { useAppSelector } from "../store";
import React, {
createContext,
useContext,
useEffect,
useLayoutEffect,
} from "react";
import { StatusBar, Style } from "@capacitor/status-bar";
import { isNative } from "../helpers/device";
import { Keyboard, KeyboardStyle } from "@capacitor/keyboard";
import useSystemDarkMode, {
DARK_MEDIA_SELECTOR,
} from "../helpers/useSystemDarkMode";
import { css } from "@linaria/core";
import { getThemeByStyle } from "./theme/AppThemes";
import "./theme/variables";
import { AppThemeType } from "../services/db";
import { stateWithLocalstorageItems as initialCriticalSettingsState } from "../features/settings/settingsSlice";
export const DARK_CLASSNAME = "ion-palette-dark";
export const PURE_BLACK_CLASSNAME = "theme-pure-black";
export const THEME_HAS_CUSTOM_BACKGROUND = "theme-has-custom-background";
function updateDocumentTheme(
isDark: boolean,
isPureBlack: boolean,
theme: AppThemeType,
) {
const { primary, background, insetItemBackground, tabBarBackground } =
getThemeByStyle(theme, isDark ? "dark" : "light");
document.documentElement.style.setProperty("--app-primary", primary);
document.documentElement.style.setProperty(
"--app-background",
background ?? "",
);
document.documentElement.style.setProperty(
"--app-inset-item-background",
insetItemBackground ?? "",
);
document.documentElement.style.setProperty(
"--app-tab-bar-background",
tabBarBackground ?? "",
);
const documentClasses = document.documentElement.classList;
if (background) {
documentClasses.add(THEME_HAS_CUSTOM_BACKGROUND);
} else {
documentClasses.remove(THEME_HAS_CUSTOM_BACKGROUND);
}
if (isDark && isPureBlack) {
documentClasses.add(PURE_BLACK_CLASSNAME);
} else {
documentClasses.remove(PURE_BLACK_CLASSNAME);
}
if (isDark) {
documentClasses.add(DARK_CLASSNAME);
} else {
documentClasses.remove(DARK_CLASSNAME);
}
}
// Prevent flash of white content and repaint before react component setup
updateDocumentTheme(
initialCriticalSettingsState.appearance.dark.usingSystemDarkMode
? window.matchMedia(DARK_MEDIA_SELECTOR).matches
: initialCriticalSettingsState.appearance.dark.userDarkMode,
initialCriticalSettingsState.appearance.dark.pureBlack,
initialCriticalSettingsState.appearance.theme,
);
const fixedDeviceFontCss = css`
--ion-dynamic-font: initial;
`;
interface GlobalStylesProps {
children: React.ReactNode;
}
export default function GlobalStyles({ children }: GlobalStylesProps) {
const isDark = useComputeIsDark();
const { fontSizeMultiplier, useSystemFontSize } = useAppSelector(
(state) => state.settings.appearance.font,
);
const { usingSystemDarkMode, pureBlack } = useAppSelector(
(state) => state.settings.appearance.dark,
);
const theme = useAppSelector((state) => state.settings.appearance.theme);
useLayoutEffect(() => {
if (isNative()) {
StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light });
}
}, [isDark]);
useLayoutEffect(() => {
if (useSystemFontSize) {
document.documentElement.classList.remove(fixedDeviceFontCss);
document.documentElement.style.fontSize = "";
} else {
document.documentElement.classList.add(fixedDeviceFontCss);
document.documentElement.style.fontSize = `${fontSizeMultiplier}rem`;
}
}, [useSystemFontSize, fontSizeMultiplier]);
useLayoutEffect(() => {
updateDocumentTheme(isDark, pureBlack, theme);
}, [theme, pureBlack, isDark]);
useEffect(() => {
if (!isNative()) return;
const keyboardStyle = (() => {
if (usingSystemDarkMode) return KeyboardStyle.Default;
if (isDark) return KeyboardStyle.Dark;
return KeyboardStyle.Light;
})();
Keyboard.setStyle({ style: keyboardStyle });
}, [isDark, usingSystemDarkMode]);
return <DarkContext.Provider value={isDark}>{children}</DarkContext.Provider>;
}
function useComputeIsDark(): boolean {
const systemDarkMode = useSystemDarkMode(); // sets up document listeners
const { userDarkMode, usingSystemDarkMode } = useAppSelector(
(state) => state.settings.appearance.dark,
);
return usingSystemDarkMode ? systemDarkMode : userDarkMode;
}
// Cached
export function useIsDark() {
return useContext(DarkContext);
}
const DarkContext = createContext(false);

View File

@@ -0,0 +1,59 @@
import React, {
MutableRefObject,
createContext,
useEffect,
useMemo,
useRef,
} from "react";
import { useLocation } from "react-router";
interface ITabContext {
tabRef: MutableRefObject<string> | undefined;
}
export const TabContext = createContext<ITabContext>({
tabRef: undefined,
});
/**
* The reason for this, instead of useLocation() in components directly to get tab name,
* is that it does not trigger a rerender on navigation changes.
*/
export function TabContextProvider({
children,
}: {
children: React.ReactNode;
}) {
const location = useLocation();
const tab = location.pathname.split("/")[1]!;
const memoized = useMemo(
() => (
<TabContextProviderInternals tab={tab}>
{children}
</TabContextProviderInternals>
),
[tab, children],
);
return memoized;
}
function TabContextProviderInternals({
tab,
children,
}: {
tab: string;
children: React.ReactNode;
}) {
const tabRef = useRef(tab);
useEffect(() => {
tabRef.current = tab;
}, [tab]);
const value = useMemo(() => ({ tabRef }), []);
return <TabContext.Provider value={value}>{children}</TabContext.Provider>;
}

View File

@@ -0,0 +1,325 @@
import { css } from "@linaria/core";
import "./syntaxHighlightCss";
export default css`
:global() {
:root {
--sat: env(safe-area-inset-top);
--sar: env(safe-area-inset-right);
--sab: env(safe-area-inset-bottom);
--sal: env(safe-area-inset-left);
}
html {
-webkit-text-size-adjust: 100%; /* Prevent font scaling in landscape while allowing user zoom */
}
.ReactCollapse--collapse {
transition: height 200ms;
}
ion-tab-button {
opacity: 1 !important;
}
ion-router-outlet > .ion-page > ion-header {
// Header doesn't support dynamic font size
ion-button,
ion-back-button,
ion-title {
font-size: 17px;
}
// Ionic's default font size for icons in header is a bit too large
ion-button.in-toolbar ion-icon {
font-size: 1.35em !important;
}
ion-back-button.md::part(text) {
display: none;
}
}
ion-router-outlet ion-list.list-inset ion-item {
--background: var(
--ion-tab-bar-background,
var(--ion-background-color-step-50, #fff)
);
}
.left-align-buttons
.action-sheet-button:not(.action-sheet-cancel)
.action-sheet-button-inner.sc-ion-action-sheet-ios:not(
.action-sheet-group-cancel
) {
justify-content: flex-start;
}
.mod .action-sheet-button:not(.action-sheet-destructive),
.mod .action-sheet-button:hover:not(.action-sheet-destructive),
.mod.action-sheet-button:not(.action-sheet-destructive),
.mod.action-sheet-button:hover:not(.action-sheet-destructive) {
--color: var(--ion-color-success-shade);
color: var(--color);
}
.mod.alert-button {
color: var(--ion-color-success-shade);
}
.admin-local .action-sheet-button,
.admin-local .action-sheet-button:hover,
.admin-local.action-sheet-button,
.admin-local.action-sheet-button:hover,
.admin-remote .action-sheet-button,
.admin-remote .action-sheet-button:hover,
.admin-remote.action-sheet-button,
.admin-remote.action-sheet-button:hover {
--color: var(--ion-color-danger-shade);
color: var(--color);
}
.report-reasons .action-sheet-title {
white-space: pre;
}
/* https://github.com/ionic-team/ionic-framework/issues/27777#issuecomment-1631522283 */
.action-sheet-height-fix {
--height: 100%;
}
ion-modal.small {
--height: 50%;
--width: 85%;
--max-width: 400px;
--max-height: 500px;
--border-radius: 16px;
--box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1),
0 4px 6px -4px rgb(0 0 0 / 0.1);
}
ion-modal.small ion-header ion-toolbar:first-of-type {
padding-top: 0px;
}
ion-modal.transparent-scroll {
--background: none;
--height: auto;
--box-shadow: none;
&.show-modal {
display: flex;
flex-direction: column;
}
}
ion-modal.transparent-scroll .ion-page {
overflow: auto;
}
ion-alert.preserve-newlines {
white-space: pre-line;
}
.pswp__img {
-webkit-touch-callout: default;
}
// gets in the way of non-post preview more actions button
.pswp__preloader {
display: none;
}
ion-action-sheet .detail::before {
content: "";
z-index: 1;
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
/* Ion chevron right icon */
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path fill="none" stroke="%2392949c44" stroke-linecap="round" stroke-linejoin="round" stroke-width="48" d="M184 112l144 144-144 144"/></svg>');
width: 24px;
height: 24px;
}
ion-action-sheet .action-sheet-selected {
font-weight: normal !important;
}
ion-action-sheet .action-sheet-selected.action-sheet-selected:after {
background: none;
}
/* Double class to override ionic specificity */
ion-action-sheet .action-sheet-selected .action-sheet-button-inner::after {
content: "";
z-index: 1;
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
/* Ion check icon */
background-image: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" class="ionicon" viewBox="0 0 512 512"><path fill="none" stroke="%233880ff" stroke-linecap="round" stroke-linejoin="round" stroke-width="32" d="M416 128L192 384l-96-96"/></svg>');
width: 24px;
height: 24px;
}
ion-action-sheet
.action-sheet-selected.detail
.action-sheet-button-inner::after {
right: 40px;
}
ion-action-sheet ion-icon {
flex-shrink: 0;
margin-inline-end: 16px !important;
}
.ios .action-sheet-button:not(.action-sheet-cancel) {
padding-inline-end: 0 !important;
padding-inline-start: 0;
}
.ios .left-align-buttons .action-sheet-button:not(.action-sheet-cancel) {
padding-inline-start: 14px;
}
.action-sheet-button-inner {
mask-image: linear-gradient(
90deg,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 1) calc(100% - 16px),
transparent 100%
);
}
/* photoswipe */
.pswp__top-bar {
top: env(safe-area-inset-top, 0);
}
/* Let IonActionSheet be on top */
.pswp {
--pswp-root-z-index: 100;
}
.ion-content-scroll-host {
overflow-y: auto;
}
.virtual-scroller {
overflow-x: hidden !important;
}
.ios {
.ion-content-scroll-host::before,
.ion-content-scroll-host::after {
position: absolute;
width: 1px;
height: 1px;
content: "";
}
.ion-content-scroll-host::before {
top: -1px;
}
.ion-content-scroll-host::after {
bottom: -1px;
}
}
ion-fab-button {
--background-focused: var(--ion-color-primary);
--background-activated: var(--ion-color-primary);
--background-hover: var(--ion-color-primary);
}
ion-fab.fab-vertical-top {
top: 15vh;
}
/**
* This patch reverts the following Ionic change:
* https://github.com/ionic-team/ionic-framework/pull/28246/files#diff-8371750710eecf6a609337a240b7db295073f746f1cda13248f9780cc6f6ff24L159
*
* Private discord discussion: https://discord.com/channels/520266681499779082/1128001453676568637/1162812415054983198
*
* Fixes #780
*/
.ion-page {
overflow: hidden;
}
ion-toast {
font-weight: 600;
--height: 45px;
}
/* Center toast message and icon */
ion-toast.center::part(container) {
display: inline-flex;
transform: translateX(-50%);
left: 50%;
position: relative;
}
/* Make header buttons closer together (room for mod button) */
.sc-ion-buttons-ios-s ion-button {
margin: 0;
}
/* Add minimum padding for top dismiss area of action sheet */
.action-sheet-group.sc-ion-action-sheet-ios:first-child {
margin-top: min(15vh, 70px);
}
#share-as-image-root {
font-size: 16px;
--width: 330px;
color: var(--ion-text-color);
position: absolute;
width: var(--width);
right: calc(var(--width) * -1);
a {
/* Hack for long links breaking */
word-break: break-word;
}
}
#share-as-image-root ion-item {
font-size: 16px;
}
ion-modal.save-as-image-modal {
--height: auto;
--max-width: 470px;
}
#share-as-image-root video {
display: none;
}
.collapse-md-margins {
> *:first-child,
> *:first-child > *:first-child,
> *:first-child > *:first-child > *:first-child {
margin-top: 0;
}
> *:last-child,
> *:last-child > *:last-child,
> *:last-child > *:last-child > *:last-child {
margin-bottom: 0;
}
}
}
`;

View File

@@ -0,0 +1,35 @@
import { App } from "@capacitor/app";
import { BackButtonEventDetail } from "@ionic/core";
import { useEffect } from "react";
import { useOptimizedIonRouter } from "../../helpers/useOptimizedIonRouter";
export default function AndroidBackButton() {
const router = useOptimizedIonRouter();
// Back button handling for Android native app
useEffect(() => {
const backButtonHandler = (ev: CustomEvent<BackButtonEventDetail>) => {
ev.detail.register(-1, () => {
// pswp is the gallery component. It pushes state, but the router isn't aware.
// So if that's open, don't close the app just yet
if (!router.canGoBack() && !document.querySelector(".pswp--open")) {
App.exitApp();
}
});
};
document.addEventListener(
"ionBackButton",
backButtonHandler as EventListener,
);
return () => {
document.removeEventListener(
"ionBackButton",
backButtonHandler as EventListener,
);
};
}, [router]);
return null;
}

View File

@@ -0,0 +1,57 @@
import { App } from "@capacitor/app";
import { useEffect, useRef } from "react";
import useLemmyUrlHandler from "../../features/shared/useLemmyUrlHandler";
import useEvent from "../../helpers/useEvent";
import { useAppSelector } from "../../store";
import useAppToast from "../../helpers/useAppToast";
import { deepLinkFailed } from "../../helpers/toastMessages";
import { normalizeObjectUrl } from "../../features/resolve/resolveSlice";
export default function AppUrlListener() {
const { redirectToLemmyObjectIfNeeded } = useLemmyUrlHandler();
const knownInstances = useAppSelector(
(state) => state.instances.knownInstances,
);
const connectedInstance = useAppSelector(
(state) => state.auth.connectedInstance,
);
const deepLinkReady = useAppSelector((state) => state.deepLinkReady.ready);
const appUrlToOpen = useRef<string | undefined>();
const presentToast = useAppToast();
const notReady =
!knownInstances ||
knownInstances === "pending" ||
!connectedInstance ||
!deepLinkReady;
const onAppUrl = useEvent(async (url: string) => {
if (notReady) {
appUrlToOpen.current = url;
return;
}
// wait for router to get into a good state before pushing
// (needed for pushing user profiles from app startup)
const resolved = await redirectToLemmyObjectIfNeeded(url);
if (!resolved) presentToast(deepLinkFailed);
});
useEffect(() => {
App.addListener("appUrlOpen", (event) => {
onAppUrl(normalizeObjectUrl(event.url));
});
}, [onAppUrl]);
useEffect(() => {
if (notReady) return;
if (!appUrlToOpen.current) return;
onAppUrl(appUrlToOpen.current);
appUrlToOpen.current = undefined;
}, [notReady, onAppUrl]);
return null;
}

View File

@@ -0,0 +1,36 @@
import { useEffect } from "react";
import useHapticFeedback from "../../helpers/useHapticFeedback";
import { ImpactStyle } from "@capacitor/haptics";
export default function HapticsListener() {
const vibrate = useHapticFeedback();
useEffect(() => {
const handleToggleChange = (e: Event) => {
if (!(e.target instanceof HTMLElement)) return;
if (e.target.tagName !== "ION-TOGGLE") return;
vibrate({ style: ImpactStyle.Light });
};
const handleActionSheetWillPresent = () => {
vibrate({ style: ImpactStyle.Light });
};
document.addEventListener("ionChange", handleToggleChange);
document.addEventListener(
"ionActionSheetWillPresent",
handleActionSheetWillPresent,
);
return () => {
document.removeEventListener("ionChange", handleToggleChange);
document.removeEventListener(
"ionActionSheetWillPresent",
handleActionSheetWillPresent,
);
};
}, [vibrate]);
return null;
}

View File

@@ -0,0 +1,32 @@
import { isAndroid, isNative } from "../../helpers/device";
import { SafeArea, SafeAreaInsets } from "capacitor-plugin-safe-area";
import { StatusBar } from "@capacitor/status-bar";
import { Keyboard } from "@capacitor/keyboard";
// Android safe area inset management is bad, we have to do it manually
if (isNative() && isAndroid()) {
let keyboardShowing = false;
const updateInsets = ({ insets }: SafeAreaInsets) => {
for (const [key, value] of Object.entries(insets)) {
document.documentElement.style.setProperty(
`--ion-safe-area-${key}`,
// if keyboard open, assume no safe area inset
`${keyboardShowing && key === "bottom" ? 0 : value}px`,
);
}
};
SafeArea.getSafeAreaInsets().then(updateInsets);
SafeArea.addListener("safeAreaChanged", updateInsets);
StatusBar.setOverlaysWebView({ overlay: true });
Keyboard.addListener("keyboardWillShow", () => {
keyboardShowing = true;
SafeArea.getSafeAreaInsets().then(updateInsets);
});
Keyboard.addListener("keyboardWillHide", () => {
keyboardShowing = false;
SafeArea.getSafeAreaInsets().then(updateInsets);
});
}

View File

@@ -0,0 +1,5 @@
import "./androidSafeArea";
import "./keyboardPageResizer";
import "./statusTap";
import "./network/listener";
import "./ionActivatable";

View File

@@ -0,0 +1,15 @@
import { stopIonicTapClick } from "../../helpers/ionic";
/**
* This prevents the `ion-activatable` tap highlight
* when tapping buttons and other things within the activatable ion-item
**/
function onPreventIonicTapClick(e: MouseEvent | TouchEvent) {
if (!(e.target instanceof HTMLElement)) return;
if (!e.target.closest("ion-button,a,img,input,button")) return;
stopIonicTapClick();
}
document.addEventListener("touchstart", onPreventIonicTapClick);
document.addEventListener("mousedown", onPreventIonicTapClick);

View File

@@ -0,0 +1,28 @@
import { isPlatform } from "@ionic/core";
import { Keyboard } from "@capacitor/keyboard";
import { isNative } from "../../helpers/device";
// Code from:
// https://github.com/ionic-team/capacitor/issues/1540#issuecomment-735221275
//
// Once the following issue is fixed:
// https://github.com/ionic-team/capacitor-plugins/issues/1904
//
// 1. Remove this code
// 2. Change capacitor.config.ts keyboard resize = "none" to "ionic"
if (isNative() && isPlatform("ios")) {
Keyboard.addListener("keyboardWillShow", (e) => {
const app = document.querySelector("ion-app");
if (!(app instanceof HTMLElement)) return;
app.style.marginBottom = `${e.keyboardHeight}px`;
});
Keyboard.addListener("keyboardWillHide", () => {
const app = document.querySelector("ion-app");
if (!(app instanceof HTMLElement)) return;
app.style.marginBottom = "0px";
});
}

View File

@@ -0,0 +1,14 @@
import store from "../../../store";
import { getConnectionType, updateConnectionType } from "./networkSlice";
import { Network } from "@capacitor/network";
import { isNative } from "../../../helpers/device";
(async () => {
if (!isNative()) return;
await store.dispatch(getConnectionType());
Network.addListener("networkStatusChange", ({ connectionType }) => {
store.dispatch(updateConnectionType(connectionType));
});
})();

View File

@@ -0,0 +1,38 @@
import { PayloadAction, createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { ConnectionType, Network } from "@capacitor/network";
interface NetworkState {
connectionType: ConnectionType;
}
const initialState: NetworkState = {
connectionType: "unknown",
};
export const networkSlice = createSlice({
name: "network",
initialState,
reducers: {
updateConnectionType(state, action: PayloadAction<ConnectionType>) {
state.connectionType = action.payload;
},
},
extraReducers: (builder) => {
builder.addCase(getConnectionType.fulfilled, (state, action) => {
state.connectionType = action.payload;
});
},
});
export const { updateConnectionType } = networkSlice.actions;
export default networkSlice.reducer;
export const getConnectionType = createAsyncThunk(
"network/getConnectionType",
async () => {
const { connectionType } = await Network.getStatus();
return connectionType;
},
);

View File

@@ -0,0 +1,20 @@
import { OAutoplayMediaType } from "../../../services/db";
import { useAppSelector } from "../../../store";
export default function useShouldAutoplay() {
const autoplayMedia = useAppSelector(
(state) => state.settings.general.posts.autoplayMedia,
);
const connectionType = useAppSelector(
(state) => state.network.connectionType,
);
switch (autoplayMedia) {
case OAutoplayMediaType.Always:
return true;
case OAutoplayMediaType.Never:
return false;
case OAutoplayMediaType.WifiOnly:
return connectionType === "wifi";
}
}

View File

@@ -0,0 +1,63 @@
import { useEffect } from "react";
import { findCurrentPage } from "../../helpers/ionic";
import { Browser } from "@capacitor/browser";
import { useLocation } from "react-router";
let savedScrollTop = 0;
/**
* statusTap is emitted with open full screen browser
*/
let browserOpen = false;
export function notifyStatusTapThatBrowserWasOpened() {
browserOpen = true;
}
Browser.addListener("browserFinished", () => {
browserOpen = false;
});
// statusTap is a capacitor (native app), iOS-only event
// https://capacitorjs.com/docs/apis/status-bar#example
//
// This custom implementation allows scrolling back down in
// the feed by tapping again after initially tapping to
// scroll to top
window.addEventListener("statusTap", () => {
if (browserOpen) return;
const page = findCurrentPage();
if (!page) return;
// TODO this logic is semi-duplicated in scrollUpIfNeeded.ts
// and should probably be abstracted
const scroll =
page.querySelector(".virtual-scroller") ??
page.shadowRoot?.querySelector(".inner-scroll");
if (!scroll) return;
if (scroll.scrollTop) {
savedScrollTop = scroll.scrollTop;
scroll.scrollTo({ top: 0, behavior: "smooth" });
} else {
scroll.scrollTo({ top: savedScrollTop, behavior: "smooth" });
}
});
export function resetSavedStatusTap() {
savedScrollTop = 0;
}
export function ResetStatusTap() {
const location = useLocation();
useEffect(() => {
resetSavedStatusTap();
}, [location]);
return null;
}

View File

@@ -0,0 +1,170 @@
import { css } from "@linaria/core";
export default css`
:global() {
html.ion-palette-dark {
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
color: #ff7b72;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: #d2a8ff;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
color: #79c0ff;
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
color: #a5d6ff;
}
.hljs-built_in,
.hljs-symbol {
color: #ffa657;
}
.hljs-comment,
.hljs-code,
.hljs-formula {
color: #8b949e;
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
color: #7ee787;
}
.hljs-subst {
color: #c9d1d9;
}
.hljs-section {
color: #1f6feb;
font-weight: bold;
}
.hljs-bullet {
color: #f2cc60;
}
.hljs-emphasis {
color: #c9d1d9;
font-style: italic;
}
.hljs-strong {
color: #c9d1d9;
font-weight: bold;
}
.hljs-addition {
color: #aff5b4;
background-color: #033a16;
}
.hljs-deletion {
color: #ffdcd7;
background-color: #67060c;
}
}
html:not(.ion-palette-dark) {
.hljs-doctag,
.hljs-keyword,
.hljs-meta .hljs-keyword,
.hljs-template-tag,
.hljs-template-variable,
.hljs-type,
.hljs-variable.language_ {
color: #d73a49;
}
.hljs-title,
.hljs-title.class_,
.hljs-title.class_.inherited__,
.hljs-title.function_ {
color: #6f42c1;
}
.hljs-attr,
.hljs-attribute,
.hljs-literal,
.hljs-meta,
.hljs-number,
.hljs-operator,
.hljs-variable,
.hljs-selector-attr,
.hljs-selector-class,
.hljs-selector-id {
color: #005cc5;
}
.hljs-regexp,
.hljs-string,
.hljs-meta .hljs-string {
color: #032f62;
}
.hljs-built_in,
.hljs-symbol {
color: #e36209;
}
.hljs-comment,
.hljs-code,
.hljs-formula {
color: #6a737d;
}
.hljs-name,
.hljs-quote,
.hljs-selector-tag,
.hljs-selector-pseudo {
color: #22863a;
}
.hljs-subst {
color: #24292e;
}
.hljs-section {
color: #005cc5;
font-weight: bold;
}
.hljs-bullet {
color: #735c0f;
}
.hljs-emphasis {
color: #24292e;
font-style: italic;
}
.hljs-strong {
color: #24292e;
font-weight: bold;
}
.hljs-addition {
color: #22863a;
background-color: #f0fff4;
}
.hljs-deletion {
color: #b31d28;
background-color: #ffeef0;
}
}
}
`;

View File

@@ -0,0 +1,130 @@
import { AppThemeType } from "../../services/db";
interface Theme {
light: Colors;
dark: Colors;
}
interface Colors {
primary: string;
background?: string;
insetItemBackground?: string;
tabBarBackground?: string;
}
export function getTheme(appTheme: AppThemeType): Theme {
switch (appTheme) {
case "default":
return {
light: {
primary: "#3880ff",
},
dark: {
primary: "#428cff",
},
};
case "mario":
return {
light: {
primary: "color(display-p3 1 0 0)",
},
dark: {
primary: "#db1f1f",
},
};
case "pistachio":
return {
light: {
primary: "color(display-p3 0 0.7 0)",
},
dark: {
primary: "color(display-p3 0 0.6 0)",
},
};
case "pumpkin":
return {
light: {
primary: "color(display-p3 1 0.5 0)",
},
dark: {
primary: "#DF6F0E",
},
};
case "uv":
return {
light: {
primary: "color(display-p3 0.5 0 1)",
},
dark: {
primary: "#942AD4",
},
};
case "mint":
return {
light: {
primary: "#36BB97",
},
dark: {
primary: "#53C391",
},
};
case "dracula":
return {
light: {
primary: "#AD81FF",
},
dark: {
primary: "#AD81FF",
background: "#1A1D29",
insetItemBackground: "#12141C",
tabBarBackground: "#12141C",
},
};
case "tangerine":
return {
light: {
primary: "#FF4500",
},
dark: {
primary: "#FF4500",
},
};
case "sunset":
return {
light: {
primary: "#FE6C09",
background: "#FFE2D0",
insetItemBackground: "#F1D8C7",
tabBarBackground: "#F1D8C7",
},
dark: {
primary: "#FE7C00",
background: "#000E29",
insetItemBackground: "#11213C",
tabBarBackground: "#000A1F",
},
};
case "outrun":
return {
light: {
primary: "#C400A5",
background: "#BAC1D1",
insetItemBackground: "#CFD7E8",
tabBarBackground: "#C1C8D9",
},
dark: {
primary: "#F335C5",
background: "#081D47",
insetItemBackground: "#061636",
tabBarBackground: "#041129",
},
};
}
}
export function getThemeByStyle(
appTheme: AppThemeType,
style: "light" | "dark",
): Colors {
return getTheme(appTheme)[style];
}

View File

@@ -0,0 +1,378 @@
import { css } from "@linaria/core";
export const baseVariables = css`
:global() {
:root {
--ion-text-color: #000;
/** primary **/
--ion-color-primary: #3880ff;
--ion-color-primary-rgb: 56, 128, 255;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #3171e0;
--ion-color-primary-tint: #4c8dff;
/** secondary **/
--ion-color-secondary: #3dc2ff;
--ion-color-secondary-rgb: 61, 194, 255;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #36abe0;
--ion-color-secondary-tint: #50c8ff;
/** tertiary **/
--ion-color-tertiary: #5260ff;
--ion-color-tertiary-rgb: 82, 96, 255;
--ion-color-tertiary-contrast: #ffffff;
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
--ion-color-tertiary-shade: #4854e0;
--ion-color-tertiary-tint: #6370ff;
/** success **/
--ion-color-success: #07be02;
--ion-color-success-rgb: 7, 190, 2;
--ion-color-success-contrast: #ffffff;
--ion-color-success-contrast-rgb: 255, 255, 255;
--ion-color-success-shade: #06a702;
--ion-color-success-tint: #20c51b;
/** warning **/
--ion-color-warning: #ffc409;
--ion-color-warning-rgb: 255, 196, 9;
--ion-color-warning-contrast: #000000;
--ion-color-warning-contrast-rgb: 0, 0, 0;
--ion-color-warning-shade: #e0ac08;
--ion-color-warning-tint: #ffca22;
/** danger **/
--ion-color-danger: #eb445a;
--ion-color-danger-rgb: 235, 68, 90;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #cf3c4f;
--ion-color-danger-tint: #ed576b;
/** dark **/
--ion-color-dark: #222428;
--ion-color-dark-rgb: 34, 36, 40;
--ion-color-dark-contrast: #ffffff;
--ion-color-dark-contrast-rgb: 255, 255, 255;
--ion-color-dark-shade: #1e2023;
--ion-color-dark-tint: #383a3e;
/** medium **/
--ion-color-medium: #92949c;
--ion-color-medium-rgb: 146, 148, 156;
--ion-color-medium-contrast: #ffffff;
--ion-color-medium-contrast-rgb: 255, 255, 255;
--ion-color-medium-shade: #808289;
--ion-color-medium-tint: #9d9fa6;
/** light **/
--ion-color-light: #f4f5f8;
--ion-color-light-rgb: 244, 245, 248;
--ion-color-light-contrast: #000000;
--ion-color-light-contrast-rgb: 0, 0, 0;
--ion-color-light-shade: #d7d8da;
--ion-color-light-tint: #f5f6f9;
--ion-color-medium2: var(--ion-color-medium);
--lightroom-bg: rgba(0, 0, 0, 0.047);
--thick-separator-color: var(--ion-background-color-step-50, #f2f2f7);
--ion-background-color-step-100: #f3f3f3;
--unread-item-background-color: #e3f1ff;
--ion-color-text-aside: rgba(0, 0, 0, 0.55);
--read-color: rgba(0, 0, 0, 0.45);
--read-color-medium: rgba(0, 0, 0, 0.4);
--share-img-drop-shadow: none;
--ion-color-reddit-upvote: #ff5c01;
&.theme-has-custom-background {
&.ios body,
&.md body {
--ion-background-color: var(--app-background) !important;
--ion-item-background: var(--app-background);
--ion-background-color-step-50: var(--app-inset-item-background);
--ion-background-color-step-100: var(--app-tab-bar-background);
--ion-tab-bar-background: var(--app-tab-bar-background);
--ion-toolbar-background: var(--app-tab-bar-background);
--thick-separator-color: var(--app-inset-item-background);
}
ion-router-outlet ion-list.list-inset ion-item {
--background: var(--app-inset-item-background);
}
&.ios ion-modal:not(.transparent-scroll) {
--ion-background-color: var(--app-background);
--ion-toolbar-background: var(--app-tab-bar-background);
--ion-toolbar-border-color: var(--ion-background-color-step-150);
--ion-item-background: var(--ion-background-color);
}
}
}
.ios body {
--ion-background-color: #fff;
}
.ios ion-modal {
--ion-background-color: var(--ion-background-color-step-50, #f2f2f7);
--ion-item-background: var(--app-background, #fff);
}
.ion-color-primary-fixed {
--ion-color-base: var(--ion-color-primary-fixed);
}
.ion-color-reddit-upvote {
--ion-color-base: var(--ion-color-reddit-upvote);
}
}
`;
export const lightVariables = css`
:global() {
html:not(.ion-palette-dark) {
&:root {
--ion-color-primary: var(--app-primary);
--ion-color-primary-fixed: #3880ff; // always blue always blue!
ion-item {
// default ionic light mode opacity is too harsh
--background-activated-opacity: 0.06;
}
&.ios .grey-bg {
--ion-background-color: var(--ion-background-color-step-50, #f2f2f7);
ion-header {
--opacity: 0;
}
ion-modal ion-content {
--background: #fff;
}
ion-item {
--ion-background-color: #fff;
}
ion-item-sliding {
background: #fff;
}
}
&.theme-has-custom-background {
ion-router-outlet .grey-bg ion-list.list-inset ion-item {
--background: var(--app-background);
}
}
}
}
}
`;
export const darkVariables = css`
:global() {
html.ion-palette-dark {
// Dark Colors
&:root {
--ion-color-primary-fixed: #428cff; // always blue always blue!
--lightroom-bg: rgba(255, 255, 255, 0.08);
--ion-item-border-color: #333;
}
body {
--ion-color-primary: var(--app-primary);
--ion-color-primary-rgb: 66, 140, 255;
--ion-color-primary-contrast: #ffffff;
--ion-color-primary-contrast-rgb: 255, 255, 255;
--ion-color-primary-shade: #3a7be0;
--ion-color-primary-tint: #5598ff;
--ion-color-secondary: #50c8ff;
--ion-color-secondary-rgb: 80, 200, 255;
--ion-color-secondary-contrast: #ffffff;
--ion-color-secondary-contrast-rgb: 255, 255, 255;
--ion-color-secondary-shade: #46b0e0;
--ion-color-secondary-tint: #62ceff;
--ion-color-tertiary: #6a64ff;
--ion-color-tertiary-rgb: 106, 100, 255;
--ion-color-tertiary-contrast: #ffffff;
--ion-color-tertiary-contrast-rgb: 255, 255, 255;
--ion-color-tertiary-shade: #5d58e0;
--ion-color-tertiary-tint: #7974ff;
--ion-color-success: #00940c;
--ion-color-success-rgb: 0, 148, 12;
--ion-color-success-contrast: #ffffff;
--ion-color-success-contrast-rgb: 255, 255, 255;
--ion-color-success-shade: #00820b;
--ion-color-success-tint: #1a9f24;
--ion-color-warning: #eac200;
--ion-color-warning-rgb: 234, 194, 0;
--ion-color-warning-contrast: #000000;
--ion-color-warning-contrast-rgb: 0, 0, 0;
--ion-color-warning-shade: #ceab00;
--ion-color-warning-tint: #ecc81a;
--ion-color-danger: #ff4961;
--ion-color-danger-rgb: 255, 73, 97;
--ion-color-danger-contrast: #ffffff;
--ion-color-danger-contrast-rgb: 255, 255, 255;
--ion-color-danger-shade: #e04055;
--ion-color-danger-tint: #ff5b71;
--ion-color-dark: #f4f5f8;
--ion-color-dark-rgb: 244, 245, 248;
--ion-color-dark-contrast: #000000;
--ion-color-dark-contrast-rgb: 0, 0, 0;
--ion-color-dark-shade: #d7d8da;
--ion-color-dark-tint: #f5f6f9;
--ion-color-medium: #989aa2;
--ion-color-medium-rgb: 152, 154, 162;
--ion-color-medium-contrast: #000000;
--ion-color-medium-contrast-rgb: 0, 0, 0;
--ion-color-medium-shade: #86888f;
--ion-color-medium-tint: #a2a4ab;
--ion-color-medium2: rgb(112, 113, 120);
--thick-separator-color: rgba(255, 255, 255, 0.08);
--unread-item-background-color: #162f4a;
--ion-color-text-aside: rgba(255, 255, 255, 0.65);
--read-color: rgba(255, 255, 255, 0.6);
--read-color-medium: rgba(255, 255, 255, 0.4);
--share-img-drop-shadow: drop-shadow(0 0 8px black);
--ion-color-reddit-upvote: #f26700;
}
// iOS Dark Theme
&.ios body {
/* --ion-text-color: #ddd;
--ion-text-color-rgb: 255, 255, 255; */
--ion-item-background: var(--ion-background-color);
--ion-card-background: #1c1c1d;
--ion-toolbar-background: var(--ion-background-color);
}
&.ios ion-modal {
--ion-background-color: var(--ion-background-color-step-100);
--ion-toolbar-background: var(--ion-background-color-step-150);
--ion-toolbar-border-color: var(--ion-background-color-step-250);
--ion-item-background: var(--ion-background-color-step-50);
}
// Material Design Dark Theme
@media (max-width: 767px) {
&.ios ion-modal:not(.small, .transparent-scroll) {
--ion-background-color: #000;
--ion-toolbar-background: var(--ion-background-color);
--ion-toolbar-border-color: var(--ion-background-color-step-150);
}
}
// TODO test other themes
ion-modal.transparent-scroll.dark {
--ion-background-color: inherit;
}
}
}
`;
export const darkBlackModifierVariables = css`
:global() {
html.ion-palette-dark {
&:not(.theme-pure-black) {
&.ios {
body {
--ion-background-color: #22252f;
--ion-background-color-rgb: 34, 37, 47;
--ion-tab-bar-background: rgba(0, 0, 0, 0.2);
--ion-toolbar-border-color: #444;
ion-tab-bar {
--ion-tab-bar-background: var(--ion-background-color);
--ion-tab-bar-border-color: #444;
}
}
}
&.md {
body {
--ion-background-color: #1e1e1e;
--ion-background-color-rgb: 18, 18, 18;
--ion-text-color: #ffffff;
--ion-text-color-rgb: 255, 255, 255;
--ion-border-color: #222222;
--ion-item-background: #1e1e1e;
--ion-toolbar-background: #1f1f1f;
--ion-tab-bar-background: #1f1f1f;
--ion-card-background: #1e1e1e;
}
}
}
&.theme-pure-black {
&.ios {
body {
--ion-background-color: #000000;
--ion-background-color-rgb: 0, 0, 0;
}
}
&:not(.theme-has-custom-background) {
&.md {
body {
--ion-item-background: black;
--ion-toolbar-background: #121212;
--ion-tab-bar-background: #121212;
}
}
}
&.md {
body {
--ion-background-color: black;
--ion-background-color-rgb: 18, 18, 18;
--ion-text-color: #ffffff;
--ion-text-color-rgb: 255, 255, 255;
--ion-border-color: #222222;
--ion-card-background: black;
}
}
}
}
}
`;