('a[href="https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9"]');
@@ -217,6 +227,7 @@ describe('InterfaceSettings', () => {
it('checks for app updates when the check button is pressed', async () => {
render(createElement(InterfaceSettings));
+ await settleLazyImports();
const button = findButtonByText('Check');
expect(button).toBeTruthy();
@@ -242,6 +253,7 @@ describe('InterfaceSettings', () => {
});
render(createElement(InterfaceSettings));
+ await settleLazyImports();
const button = findButtonByText('Download');
expect(button).toBeTruthy();
@@ -255,7 +267,7 @@ describe('InterfaceSettings', () => {
expect(testState.alertMock).not.toHaveBeenCalled();
});
- it('disables the update button while an app update is already being applied', () => {
+ it('disables the update button while an app update is already being applied', async () => {
useAppUpdateStore.setState({
availableUpdate: {
runtime: 'web',
@@ -266,6 +278,7 @@ describe('InterfaceSettings', () => {
});
render(createElement(InterfaceSettings));
+ await settleLazyImports();
expect(findButtonByText('Download')?.disabled).toBe(true);
});
@@ -283,6 +296,7 @@ describe('InterfaceSettings', () => {
});
render(createElement(InterfaceSettings));
+ await settleLazyImports();
const button = findButtonByText('Download');
expect(button).toBeTruthy();
diff --git a/src/components/settings-modal/interface-settings/app-update-setting.tsx b/src/components/settings-modal/interface-settings/app-update-setting.tsx
new file mode 100644
index 00000000..3bef0c95
--- /dev/null
+++ b/src/components/settings-modal/interface-settings/app-update-setting.tsx
@@ -0,0 +1,61 @@
+import { useTranslation } from 'react-i18next';
+import capitalize from 'lodash/capitalize';
+import styles from './interface-settings.module.css';
+import useAppUpdateStore from '../../../stores/use-app-update-store';
+
+const UpdateButton = () => {
+ const { t } = useTranslation();
+ const availableUpdate = useAppUpdateStore((state) => state.availableUpdate);
+ const isApplyingUpdate = useAppUpdateStore((state) => state.isApplyingUpdate);
+ const isCheckingForUpdate = useAppUpdateStore((state) => state.isCheckingForUpdate);
+ const applyAppUpdate = useAppUpdateStore((state) => state.applyAppUpdate);
+ const refreshAvailableUpdate = useAppUpdateStore((state) => state.refreshAvailableUpdate);
+
+ const handleUpdateAction = async () => {
+ try {
+ if (availableUpdate) {
+ await applyAppUpdate();
+ return;
+ }
+
+ await refreshAvailableUpdate();
+ } catch (error) {
+ alert(String(error));
+ }
+ };
+ const buttonLabel = availableUpdate ? t('download') : t('check');
+ const isBusy = isApplyingUpdate || isCheckingForUpdate;
+
+ return (
+ <>
+
+ {isCheckingForUpdate && (
+
+ {t('checking_for_updates')}
+
+ )}
+ {!isCheckingForUpdate && availableUpdate && (
+
+ {t('new_version_found')}:
+
+ v{availableUpdate.targetVersion}
+
+
+ )}
+ >
+ );
+};
+
+const AppUpdateSetting = () => {
+ const { t } = useTranslation();
+
+ return (
+
+ {capitalize(t('update'))}:
+
+ );
+};
+
+export default AppUpdateSetting;
diff --git a/src/components/settings-modal/interface-settings/interface-settings.tsx b/src/components/settings-modal/interface-settings/interface-settings.tsx
index df9e30ff..915ec830 100644
--- a/src/components/settings-modal/interface-settings/interface-settings.tsx
+++ b/src/components/settings-modal/interface-settings/interface-settings.tsx
@@ -1,4 +1,4 @@
-import { memo } from 'react';
+import { lazy, memo, Suspense } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './interface-settings.module.css';
import capitalize from 'lodash/capitalize';
@@ -7,52 +7,9 @@ import useFeedViewSettingsStore from '../../../stores/use-feed-view-settings-sto
import Version from '../../version';
import StyleSelector from '../../style-selector/style-selector';
import { INTERFACE_LANGUAGE_STORAGE_KEY, SUPPORTED_INTERFACE_LANGUAGES } from '../../../lib/constants';
-import useAppUpdateStore from '../../../stores/use-app-update-store';
-const UpdateButton = () => {
- const { t } = useTranslation();
- const availableUpdate = useAppUpdateStore((state) => state.availableUpdate);
- const isApplyingUpdate = useAppUpdateStore((state) => state.isApplyingUpdate);
- const isCheckingForUpdate = useAppUpdateStore((state) => state.isCheckingForUpdate);
- const applyAppUpdate = useAppUpdateStore((state) => state.applyAppUpdate);
- const refreshAvailableUpdate = useAppUpdateStore((state) => state.refreshAvailableUpdate);
-
- const handleUpdateAction = async () => {
- try {
- if (availableUpdate) {
- await applyAppUpdate();
- return;
- }
-
- await refreshAvailableUpdate();
- } catch (error) {
- alert(String(error));
- }
- };
- const buttonLabel = availableUpdate ? t('download') : t('check');
- const isBusy = isApplyingUpdate || isCheckingForUpdate;
-
- return (
- <>
-
- {isCheckingForUpdate && (
-
- {t('checking_for_updates')}
-
- )}
- {!isCheckingForUpdate && availableUpdate && (
-
- {t('new_version_found')}:
-
- v{availableUpdate.targetVersion}
-
-
- )}
- >
- );
-};
+const shouldRenderAppUpdateSetting = import.meta.env.VITE_APP_DISTRIBUTION !== 'fdroid';
+const AppUpdateSetting = shouldRenderAppUpdateSetting ? lazy(() => import('./app-update-setting')) : null;
const InterfaceLanguage = () => {
const { i18n } = useTranslation();
@@ -87,9 +44,11 @@ const InterfaceSettings = () => {
{capitalize(t('version'))}:
-
- {capitalize(t('update'))}:
-
+ {AppUpdateSetting && (
+
+
+
+ )}
{capitalize(t('interface_language'))}:
diff --git a/src/env.d.ts b/src/env.d.ts
index 84d2f5b5..93620ad0 100644
--- a/src/env.d.ts
+++ b/src/env.d.ts
@@ -3,6 +3,8 @@
declare interface ImportMetaEnv {
readonly VITE_COMMIT_REF: string;
+ readonly VITE_APP_VERSION?: string;
+ readonly VITE_APP_DISTRIBUTION?: string;
}
declare interface ImportMeta {
diff --git a/src/index.tsx b/src/index.tsx
index ed375379..09a36e30 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -8,13 +8,13 @@ import './index.css';
import './themes.css';
import AppUpdateRegistration from './components/app-update-registration';
import { App as CapacitorApp } from '@capacitor/app';
-import { Analytics } from '@vercel/analytics/react';
import { configureP2PBrowserPkcOptions } from './lib/p2p-browser-config';
// Only enable analytics on 5chan.app (Vercel deployment)
// Exclude Electron (file:// or localhost), Capacitor/APK (capacitor:// or localhost), and IPFS (ipfs:// or different domain)
const isVercelDeployment =
typeof window !== 'undefined' && (window.location.hostname === '5chan.app' || window.location.hostname === 'www.5chan.app') && !window.isElectron;
+const shouldLoadAnalytics = import.meta.env.VITE_APP_DISTRIBUTION !== 'fdroid' && isVercelDeployment;
const e2eStartHash = import.meta.env.VITE_E2E_START_HASH?.trim();
const requestedE2EHarness = import.meta.env.DEV && typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('e2e') : null;
@@ -27,6 +27,7 @@ configureP2PBrowserPkcOptions();
const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
const renderRoot = async () => {
let e2eHarness: React.ComponentType | null = null;
+ let Analytics: React.ComponentType | null = null;
if (requestedE2EHarness === 'thread-auto-update') {
e2eHarness = (await import('./e2e/thread-auto-update-harness')).default;
@@ -40,13 +41,16 @@ const renderRoot = async () => {
}
const App = (await import('./app')).default;
+ if (shouldLoadAnalytics) {
+ Analytics = (await import('@vercel/analytics/react')).Analytics;
+ }
root.render(
- {isVercelDeployment && }
+ {Analytics && }
,
);
diff --git a/src/lib/__tests__/app-update.test.ts b/src/lib/__tests__/app-update.test.ts
index 10b4a215..ccc96e45 100644
--- a/src/lib/__tests__/app-update.test.ts
+++ b/src/lib/__tests__/app-update.test.ts
@@ -43,6 +43,7 @@ describe('app-update', () => {
testState.electronDownloadAndInstallUpdateMock.mockReset();
testState.electronGetPlatformMock.mockReset();
testState.fetchMock.mockReset();
+ vi.stubEnv('VITE_APP_VERSION', '0.8.1');
vi.stubGlobal('fetch', testState.fetchMock);
window.electronApi = undefined;
Object.defineProperty(navigator, 'serviceWorker', {
@@ -91,6 +92,27 @@ describe('app-update', () => {
expect(result).toBeNull();
});
+ it('disables update checks for F-Droid builds', async () => {
+ vi.stubEnv('VITE_APP_DISTRIBUTION', 'fdroid');
+ testState.capacitorPlatform = 'android';
+
+ const { applyAvailableAppUpdate, isAppUpdateEnabled, resolveAvailableAppUpdate } = await loadModule();
+
+ await expect(resolveAvailableAppUpdate()).resolves.toBeNull();
+ await expect(
+ applyAvailableAppUpdate({
+ runtime: 'android',
+ targetVersion: '9.9.9',
+ assetName: '5chan-9.9.9.apk',
+ downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
+ releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
+ }),
+ ).rejects.toThrow('App updates are disabled for this build');
+ expect(isAppUpdateEnabled).toBe(false);
+ expect(testState.fetchMock).not.toHaveBeenCalled();
+ expect(testState.androidDownloadAndInstallUpdateMock).not.toHaveBeenCalled();
+ });
+
it('selects the matching electron release asset for the current desktop platform', async () => {
window.electronApi = {
isElectron: true,
diff --git a/src/lib/app-distribution.ts b/src/lib/app-distribution.ts
new file mode 100644
index 00000000..38e0af7f
--- /dev/null
+++ b/src/lib/app-distribution.ts
@@ -0,0 +1,4 @@
+const appDistribution = import.meta.env.VITE_APP_DISTRIBUTION?.trim().toLowerCase();
+const isAppUpdateEnabled = appDistribution !== 'fdroid';
+
+export { isAppUpdateEnabled };
diff --git a/src/lib/app-update.ts b/src/lib/app-update.ts
index b362bb83..acc3c0fa 100644
--- a/src/lib/app-update.ts
+++ b/src/lib/app-update.ts
@@ -1,5 +1,5 @@
import { Capacitor } from '@capacitor/core';
-import AppUpdater from '../plugins/app-updater';
+import { isAppUpdateEnabled } from './app-distribution';
import { currentAppVersion } from './app-version';
import { getDefaultReleaseUrl, getReleaseApiUrl, isAllowedDownloadUrl } from './app-update-config';
@@ -180,6 +180,10 @@ const fetchLatestReleaseUpdate = async (runtime: Extract => {
+ if (!isAppUpdateEnabled) {
+ return null;
+ }
+
const runtime = getAppRuntime();
if (runtime === 'web') {
@@ -201,6 +205,10 @@ const resolveAvailableAppUpdate = async (): Promise =
};
const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise => {
+ if (!isAppUpdateEnabled) {
+ throw new Error('App updates are disabled for this build');
+ }
+
if (update.runtime === 'web') {
await refreshServiceWorkerRegistration().catch((error) => {
console.error('Failed to refresh service worker registration', error);
@@ -221,6 +229,7 @@ const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise {
const configuredVersion = import.meta.env.VITE_APP_VERSION;
@@ -7,7 +5,7 @@ const resolveCurrentAppVersion = (): string => {
return configuredVersion.trim();
}
- return packageJson.version;
+ return '0.0.0';
};
const currentAppVersion = resolveCurrentAppVersion();
diff --git a/vite.config.js b/vite.config.js
index e3d70e9a..ba72e8fc 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -7,6 +7,7 @@ import { VitePWA } from 'vite-plugin-pwa';
const { version: packageVersion } = JSON.parse(readFileSync(new URL('./package.json', import.meta.url), 'utf8'));
const appVersion = `${process.env.VITE_APP_VERSION || packageVersion}`.trim() || packageVersion;
+process.env.VITE_APP_VERSION = appVersion;
const publicBase = process.env.PUBLIC_URL || '/';
const buildOutDir = 'build';
const basePathPrefix = (() => {
@@ -373,6 +374,7 @@ export default defineConfig({
include: ['ethers', 'assert', 'buffer', 'process', 'util', 'stream-browserify', 'isomorphic-fetch', 'workbox-core', 'workbox-precaching'],
},
define: {
+ 'import.meta.env.VITE_APP_VERSION': JSON.stringify(appVersion),
'process.env.VITE_COMMIT_REF': JSON.stringify(process.env.COMMIT_REF),
'process.version': JSON.stringify(''),
global: 'globalThis',