diff --git a/App.tsx b/App.tsx index 808fd873..529120c3 100644 --- a/App.tsx +++ b/App.tsx @@ -34,6 +34,8 @@ import { useCalendarStore } from './src/stores/calendar-store'; import { useContactsStore } from './src/stores/contacts-store'; import { useEmailStore } from './src/stores/email-store'; import { useSettingsStore } from './src/stores/settings-store'; +import { useUpdatesStore } from './src/stores/updates-store'; +import { UpdateBanner } from './src/components/UpdateBanner'; import { colors, spacing, typography } from './src/theme/tokens'; const Stack = createNativeStackNavigator(); @@ -64,6 +66,8 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps mailbox.role === 'inbox')?.unreadEmails ?? 0; return ( + + } + ); } @@ -171,6 +176,19 @@ export default function App() { void useSettingsStore.getState().hydrate(); }, []); + React.useEffect(() => { + let cancelled = false; + void (async () => { + const store = useUpdatesStore.getState(); + await store.hydrate(); + if (cancelled) return; + if (useUpdatesStore.getState().autoCheck) { + await useUpdatesStore.getState().checkNow(); + } + })(); + return () => { cancelled = true; }; + }, []); + // Listen for FCM messages so the app can refresh state even when woken // from the background — the FirebaseMessagingService also posts the system // notification so the user sees it regardless of RN runtime state. diff --git a/src/api/updates.ts b/src/api/updates.ts new file mode 100644 index 00000000..c6eaea9e --- /dev/null +++ b/src/api/updates.ts @@ -0,0 +1,43 @@ +export const UPDATE_REPO = 'bulwarkmail/native'; + +export interface ReleaseAsset { + name: string; + browser_download_url: string; + size: number; +} + +export interface LatestRelease { + tag: string; + name: string; + htmlUrl: string; + publishedAt: string; + body: string; + apkAsset: ReleaseAsset | null; +} + +export async function fetchLatestRelease(repo: string = UPDATE_REPO): Promise { + const res = await fetch(`https://api.github.com/repos/${repo}/releases/latest`, { + headers: { Accept: 'application/vnd.github+json' }, + }); + if (!res.ok) { + if (res.status === 404) return null; + throw new Error(`GitHub API ${res.status}`); + } + const json = (await res.json()) as { + tag_name: string; + name: string; + html_url: string; + published_at: string; + body: string; + assets: ReleaseAsset[]; + }; + const apkAsset = json.assets.find((a) => a.name.endsWith('.apk')) ?? null; + return { + tag: json.tag_name, + name: json.name || json.tag_name, + htmlUrl: json.html_url, + publishedAt: json.published_at, + body: json.body || '', + apkAsset, + }; +} diff --git a/src/components/UpdateBanner.tsx b/src/components/UpdateBanner.tsx new file mode 100644 index 00000000..14958b9d --- /dev/null +++ b/src/components/UpdateBanner.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; +import { Download, X } from 'lucide-react-native'; +import { useUpdatesStore } from '../stores/updates-store'; +import { colors, spacing, radius, typography } from '../theme/tokens'; + +export function UpdateBanner(): React.ReactElement | null { + const cachedLatest = useUpdatesStore((s) => s.cachedLatest); + const dismissedTag = useUpdatesStore((s) => s.dismissedTag); + const installing = useUpdatesStore((s) => s.installing); + const installLatest = useUpdatesStore((s) => s.installLatest); + const dismissCurrent = useUpdatesStore((s) => s.dismissCurrent); + const hasUpdate = useUpdatesStore((s) => s.hasUpdate); + + if (!hasUpdate()) return null; + if (cachedLatest && dismissedTag === cachedLatest.tag) return null; + if (!cachedLatest?.apkAsset) return null; + + return ( + + + + Update available + + v{cachedLatest.tag} is ready to install. + + + void installLatest()} + disabled={installing} + > + {installing ? '…' : 'Install'} + + + + + + ); +} + +const styles = StyleSheet.create({ + banner: { + flexDirection: 'row', + alignItems: 'center', + gap: spacing.md, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + backgroundColor: colors.primary, + }, + title: { ...typography.bodyMedium, color: colors.primaryForeground }, + subtitle: { ...typography.caption, color: colors.primaryForeground, opacity: 0.85 }, + installButton: { + paddingHorizontal: spacing.md, + paddingVertical: 6, + borderRadius: radius.sm, + backgroundColor: 'rgba(255,255,255,0.2)', + }, + installText: { ...typography.captionMedium, color: colors.primaryForeground }, + dismiss: { padding: 4 }, +}); diff --git a/src/components/settings/UpdatesSettings.tsx b/src/components/settings/UpdatesSettings.tsx new file mode 100644 index 00000000..68fe43eb --- /dev/null +++ b/src/components/settings/UpdatesSettings.tsx @@ -0,0 +1,124 @@ +import React, { useEffect } from 'react'; +import { View, Text, StyleSheet, Linking, Alert } from 'react-native'; +import { SettingsSection, SettingItem, ToggleSwitch } from './settings-section'; +import Button from '../Button'; +import { colors, spacing, radius, typography } from '../../theme/tokens'; +import { useUpdatesStore } from '../../stores/updates-store'; + +function formatTime(ts: number): string { + if (!ts) return 'never'; + const date = new Date(ts); + return date.toLocaleString(); +} + +export function UpdatesSettings() { + const hydrated = useUpdatesStore((s) => s.hydrated); + const hydrate = useUpdatesStore((s) => s.hydrate); + const checking = useUpdatesStore((s) => s.checking); + const installing = useUpdatesStore((s) => s.installing); + const error = useUpdatesStore((s) => s.error); + const lastCheckedAt = useUpdatesStore((s) => s.lastCheckedAt); + const cachedLatest = useUpdatesStore((s) => s.cachedLatest); + const autoCheck = useUpdatesStore((s) => s.autoCheck); + const setAutoCheck = useUpdatesStore((s) => s.setAutoCheck); + const checkNow = useUpdatesStore((s) => s.checkNow); + const installLatest = useUpdatesStore((s) => s.installLatest); + const currentVersion = useUpdatesStore((s) => s.currentVersion); + const hasUpdate = useUpdatesStore((s) => s.hasUpdate); + + useEffect(() => { + if (!hydrated) void hydrate(); + }, [hydrated, hydrate]); + + const updateAvailable = hasUpdate(); + const apkAsset = cachedLatest?.apkAsset ?? null; + + const onInstall = () => { + if (!apkAsset) { + if (cachedLatest?.htmlUrl) { + void Linking.openURL(cachedLatest.htmlUrl); + } + return; + } + void installLatest(); + }; + + return ( + + + + v{currentVersion()} + + + + {cachedLatest ? cachedLatest.tag : '—'} + + + + {formatTime(lastCheckedAt)} + + + + + + + + {updateAvailable ? ( + + ) : ( + + )} + + + {cachedLatest?.body ? ( + + Release notes + + {cachedLatest.body.trim()} + + + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + container: { gap: spacing.xxxl }, + value: { ...typography.bodyMedium, color: colors.text }, + notesBox: { + padding: spacing.md, + borderRadius: radius.md, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.muted, + gap: spacing.sm, + }, + notesTitle: { ...typography.caption, color: colors.mutedForeground, textTransform: 'uppercase' }, + notesBody: { ...typography.body, color: colors.text, lineHeight: 20 }, +}); diff --git a/src/lib/install-update.ts b/src/lib/install-update.ts new file mode 100644 index 00000000..23aa51d1 --- /dev/null +++ b/src/lib/install-update.ts @@ -0,0 +1,21 @@ +import { Platform } from 'react-native'; +import { File, Paths } from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import type { ReleaseAsset } from '../api/updates'; + +export async function downloadAndInstallApk(asset: ReleaseAsset): Promise { + if (Platform.OS !== 'android') { + throw new Error('APK install is only supported on Android'); + } + const dest = new File(Paths.cache, asset.name); + if (dest.exists) dest.delete(); + const downloaded = await File.downloadFileAsync(asset.browser_download_url, dest); + if (!(await Sharing.isAvailableAsync())) { + throw new Error('Sharing is not available on this device'); + } + await Sharing.shareAsync(downloaded.uri, { + mimeType: 'application/vnd.android.package-archive', + dialogTitle: 'Install update', + UTI: 'com.android.package-archive', + }); +} diff --git a/src/lib/version-compare.ts b/src/lib/version-compare.ts new file mode 100644 index 00000000..0c36a19d --- /dev/null +++ b/src/lib/version-compare.ts @@ -0,0 +1,17 @@ +export function parseVersion(v: string): [number, number, number] { + const cleaned = v.replace(/^v/, '').split(/[-+]/)[0]; + const parts = cleaned.split('.').map((n) => parseInt(n, 10)); + return [parts[0] || 0, parts[1] || 0, parts[2] || 0]; +} + +export function compareVersions(a: string, b: string): number { + const [a1, a2, a3] = parseVersion(a); + const [b1, b2, b3] = parseVersion(b); + if (a1 !== b1) return a1 - b1; + if (a2 !== b2) return a2 - b2; + return a3 - b3; +} + +export function isNewer(latest: string, current: string): boolean { + return compareVersions(latest, current) > 0; +} diff --git a/src/screens/SettingsScreenNew2.tsx b/src/screens/SettingsScreenNew2.tsx index 06e8db96..21b0c34b 100644 --- a/src/screens/SettingsScreenNew2.tsx +++ b/src/screens/SettingsScreenNew2.tsx @@ -5,7 +5,7 @@ import { ArrowLeft, LogOut, Settings, ChevronRight, Palette, Mail, User, Shield, UserPen, Palmtree, Calendar, Filter, FileText, FolderOpen, Tags, HardDrive, Wrench, - BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, + BookUser, KeyRound, PanelLeftClose, Bell, Puzzle, RefreshCw, type LucideIcon, } from 'lucide-react-native'; import { colors, spacing, radius, typography, componentSizes } from '../theme/tokens'; @@ -16,13 +16,14 @@ import { IdentitySettings } from '../components/settings/IdentitySettings'; import { VacationSettings } from '../components/settings/VacationSettings'; import { FolderSettings } from '../components/settings/FolderSettings'; import { AdvancedSettings } from '../components/settings/AdvancedSettings'; +import { UpdatesSettings } from '../components/settings/UpdatesSettings'; type Tab = | 'appearance' | 'email' | 'notifications' | 'account' | 'security' | 'identities' | 'encryption' | 'vacation' | 'filters' | 'templates' | 'folders' | 'keywords' | 'calendar' | 'contacts' | 'files' | 'sidebar_apps' - | 'themes' | 'plugins' | 'advanced'; + | 'themes' | 'plugins' | 'updates' | 'advanced'; type TabGroup = 'general' | 'account' | 'organization' | 'apps' | 'system'; @@ -64,6 +65,7 @@ const TABS: TabDef[] = [ { id: 'sidebar_apps', label: 'Sidebar Apps', icon: PanelLeftClose, group: 'apps', implemented: false }, { id: 'themes', label: 'Themes', icon: Palette, group: 'system', experimental: true, implemented: false }, { id: 'plugins', label: 'Plugins', icon: Puzzle, group: 'system', experimental: true, implemented: false }, + { id: 'updates', label: 'Updates', icon: RefreshCw, group: 'system', implemented: true }, { id: 'advanced', label: 'Advanced', icon: Wrench, group: 'system', implemented: true }, ]; @@ -74,6 +76,7 @@ const TAB_COMPONENTS: Partial>> = { identities: IdentitySettings, vacation: VacationSettings, folders: FolderSettings, + updates: UpdatesSettings, advanced: AdvancedSettings, }; diff --git a/src/stores/updates-store.ts b/src/stores/updates-store.ts new file mode 100644 index 00000000..6b45d8e5 --- /dev/null +++ b/src/stores/updates-store.ts @@ -0,0 +1,132 @@ +import { create } from 'zustand'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import Constants from 'expo-constants'; +import { fetchLatestRelease, type LatestRelease } from '../api/updates'; +import { isNewer } from '../lib/version-compare'; +import { downloadAndInstallApk } from '../lib/install-update'; + +const STORAGE_KEY = 'webmail:updates:v1'; +const MIN_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000; + +interface PersistedUpdates { + autoCheck: boolean; + lastCheckedAt: number; + cachedLatest: LatestRelease | null; + dismissedTag: string | null; +} + +const DEFAULT_PERSISTED: PersistedUpdates = { + autoCheck: true, + lastCheckedAt: 0, + cachedLatest: null, + dismissedTag: null, +}; + +export interface UpdatesState extends PersistedUpdates { + hydrated: boolean; + checking: boolean; + installing: boolean; + error: string | null; + + hydrate: () => Promise; + setAutoCheck: (enabled: boolean) => void; + checkNow: (opts?: { force?: boolean }) => Promise; + installLatest: () => Promise; + dismissCurrent: () => void; + currentVersion: () => string; + hasUpdate: () => boolean; +} + +function persist(state: PersistedUpdates): void { + void AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(state)).catch((err) => { + console.warn('[updates-store] persist failed', err); + }); +} + +export const useUpdatesStore = create((set, get) => ({ + ...DEFAULT_PERSISTED, + hydrated: false, + checking: false, + installing: false, + error: null, + + hydrate: async () => { + try { + const raw = await AsyncStorage.getItem(STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw) as Partial; + set({ ...DEFAULT_PERSISTED, ...parsed, hydrated: true }); + } else { + set({ hydrated: true }); + } + } catch (err) { + console.warn('[updates-store] hydrate failed', err); + set({ hydrated: true }); + } + }, + + setAutoCheck: (enabled) => { + set({ autoCheck: enabled }); + const s = get(); + persist({ + autoCheck: enabled, + lastCheckedAt: s.lastCheckedAt, + cachedLatest: s.cachedLatest, + dismissedTag: s.dismissedTag, + }); + }, + + checkNow: async (opts) => { + const s = get(); + if (s.checking) return; + const now = Date.now(); + if (!opts?.force && now - s.lastCheckedAt < MIN_CHECK_INTERVAL_MS) return; + set({ checking: true, error: null }); + try { + const latest = await fetchLatestRelease(); + const next: PersistedUpdates = { + autoCheck: s.autoCheck, + lastCheckedAt: now, + cachedLatest: latest, + dismissedTag: s.dismissedTag, + }; + set({ ...next, checking: false }); + persist(next); + } catch (err) { + set({ checking: false, error: err instanceof Error ? err.message : String(err) }); + } + }, + + installLatest: async () => { + const s = get(); + if (s.installing || !s.cachedLatest?.apkAsset) return; + set({ installing: true, error: null }); + try { + await downloadAndInstallApk(s.cachedLatest.apkAsset); + set({ installing: false }); + } catch (err) { + set({ installing: false, error: err instanceof Error ? err.message : String(err) }); + } + }, + + dismissCurrent: () => { + const s = get(); + const tag = s.cachedLatest?.tag ?? null; + set({ dismissedTag: tag }); + persist({ + autoCheck: s.autoCheck, + lastCheckedAt: s.lastCheckedAt, + cachedLatest: s.cachedLatest, + dismissedTag: tag, + }); + }, + + currentVersion: () => Constants.expoConfig?.version ?? '0.0.0', + + hasUpdate: () => { + const s = get(); + if (!s.cachedLatest) return false; + const current = Constants.expoConfig?.version ?? '0.0.0'; + return isNewer(s.cachedLatest.tag, current); + }, +}));