Compare commits

...
4 Commits
13 changed files with 164 additions and 16 deletions
+26
View File
@@ -36,6 +36,7 @@ import { useAuthStore } from './src/stores/auth-store';
import { useCalendarStore } from './src/stores/calendar-store';
import { useContactsStore } from './src/stores/contacts-store';
import { useEmailStore } from './src/stores/email-store';
import { useHasCalendar, useHasContacts, useHasFiles } from './src/lib/capabilities';
import { useSettingsStore } from './src/stores/settings-store';
import { useLocaleStore } from './src/stores/locale-store';
import { useNetworkStore } from './src/stores/network-store';
@@ -88,6 +89,10 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
const mailboxes = useEmailStore((state) => state.mailboxes);
const logout = useAuthStore((state) => state.logout);
const inboxUnreadCount = mailboxes.find((mailbox) => mailbox.role === 'inbox')?.unreadEmails ?? 0;
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const disabledTabStyle = { opacity: 0.4 } as const;
return (
<View style={{ flex: 1, backgroundColor: c.background }}>
@@ -149,6 +154,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={CalendarScreen}
options={{
tabBarIcon: ({ color, size }) => <Calendar size={size} color={color} />,
tabBarItemStyle: hasCalendar ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasCalendar ? 'Calendar' : 'Calendar (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasCalendar) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -156,6 +168,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={ContactsScreen}
options={{
tabBarIcon: ({ color, size }) => <BookUser size={size} color={color} />,
tabBarItemStyle: hasContacts ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasContacts ? 'Contacts' : 'Contacts (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasContacts) e.preventDefault();
},
}}
/>
<Tab.Screen
@@ -163,6 +182,13 @@ function MainTabsNavigator({ navigation }: NativeStackScreenProps<RootStackParam
component={FilesScreen}
options={{
tabBarIcon: ({ color, size }) => <HardDrive size={size} color={color} />,
tabBarItemStyle: hasFiles ? undefined : disabledTabStyle,
tabBarAccessibilityLabel: hasFiles ? 'Files' : 'Files (unavailable)',
}}
listeners={{
tabPress: (e) => {
if (!hasFiles) e.preventDefault();
},
}}
/>
<Tab.Screen
+1 -1
View File
@@ -1 +1 @@
0.1.30
0.1.32
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "bulwark-mobile",
"version": "0.1.30",
"version": "0.1.32",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "bulwark-mobile",
"version": "0.1.30",
"version": "0.1.32",
"license": "AGPL-3.0-only",
"dependencies": {
"@react-native-async-storage/async-storage": "2.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "bulwark-mobile",
"version": "0.1.30",
"version": "0.1.32",
"license": "AGPL-3.0-only",
"main": "index.ts",
"scripts": {
+35
View File
@@ -0,0 +1,35 @@
import { CAPABILITIES } from '../api/types';
import { useAuthStore } from '../stores/auth-store';
// When the session is null (cold start, offline restore) we assume features
// are available so they don't flicker off mid-restore. Once the live session
// arrives, the real capability set takes over.
function sessionHasCapability(capability: string): boolean {
const session = useAuthStore.getState().session;
if (!session) return true;
return capability in session.capabilities;
}
export function hasCalendarCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CALENDARS);
}
export function hasContactsCapability(): boolean {
return sessionHasCapability(CAPABILITIES.CONTACTS);
}
export function hasFilesCapability(): boolean {
return sessionHasCapability(CAPABILITIES.FILES);
}
export function useHasCalendar(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CALENDARS in s.session.capabilities : true));
}
export function useHasContacts(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.CONTACTS in s.session.capabilities : true));
}
export function useHasFiles(): boolean {
return useAuthStore((s) => (s.session ? CAPABILITIES.FILES in s.session.capabilities : true));
}
+21 -3
View File
@@ -36,6 +36,7 @@ import { LanguageSettings } from '../components/settings/LanguageSettings';
import { ComposingSettings } from '../components/settings/ComposingSettings';
import { LayoutSettings } from '../components/settings/LayoutSettings';
import { useLocaleStore } from '../stores/locale-store';
import { useHasCalendar, useHasContacts, useHasFiles } from '../lib/capabilities';
type Tab =
| 'account' | 'language' | 'notifications'
@@ -155,6 +156,16 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
// Subscribe to locale so labels re-render when the user picks a different language.
const locale = useLocaleStore((s) => s.locale);
const t = useLocaleStore((s) => s.t);
const hasCalendar = useHasCalendar();
const hasContacts = useHasContacts();
const hasFiles = useHasFiles();
const unavailableTabs = React.useMemo(() => {
const set = new Set<Tab>();
if (!hasCalendar) set.add('calendar');
if (!hasContacts) set.add('contacts');
if (!hasFiles) set.add('files');
return set;
}, [hasCalendar, hasContacts, hasFiles]);
const groupedTabs = React.useMemo(() => {
void locale; // dependency: re-translate on locale change
return groupTabs().map((g) => ({
@@ -178,6 +189,7 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
const handleTabPress = (tab: TabDef) => {
if (!tab.implemented) return;
if (unavailableTabs.has(tab.id)) return;
setSelectedTab(tab.id);
onTabSelect?.(tab.id);
};
@@ -241,7 +253,13 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
{group.items.map((tab) => {
const Icon = tab.icon;
const disabled = !tab.implemented;
const unavailable = unavailableTabs.has(tab.id);
const disabled = !tab.implemented || unavailable;
const badgeLabel = !tab.implemented
? 'Not implemented'
: unavailable
? 'Unavailable'
: null;
return (
<Pressable
key={tab.id}
@@ -269,9 +287,9 @@ export default function SettingsScreen({ onLogout, onBack, onTabSelect }: Settin
</View>
)}
</View>
{disabled ? (
{badgeLabel ? (
<View style={styles.notWorkingBadge}>
<Text style={styles.notWorkingText}>Not implemented</Text>
<Text style={styles.notWorkingText}>{badgeLabel}</Text>
</View>
) : (
<ChevronRight size={16} color={c.mutedForeground} />
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
isConnected: true,
},
}));
@@ -20,6 +20,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
accountId: 'acc-1',
isConnected: true,
},
}));
+12 -1
View File
@@ -17,7 +17,18 @@ vi.mock('../../api/email', () => ({
// trip on react-native's Flow-typed entrypoint under vitest. The store only
// reads archiveMode in archiveEmail, so a minimal stub is enough.
vi.mock('../settings-store', () => ({
useSettingsStore: { getState: () => ({ archiveMode: 'single' }) },
useSettingsStore: { getState: () => ({ archiveMode: 'single', emailsPerPage: 25 }) },
}));
// email-store now early-returns from fetch actions when jmapClient.isConnected
// is false (cold-start guard). The tests exercise those actions, so present
// a connected stub.
vi.mock('../../api/jmap-client', () => ({
jmapClient: {
isConnected: true,
accountId: 'acc-1',
currentSession: { apiUrl: 'https://mail.example.com/jmap/' },
},
}));
import * as emailApi from '../../api/email';
+33 -8
View File
@@ -225,17 +225,42 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({ isLoading: true, error: null });
jmapClient.reset();
resetFeatureStores();
const ok = await jmapClient.loadAccount(accountId);
if (!ok) {
// Credentials missing - evict stale entry and surface error
accountStore.removeAccount(accountId);
set({ isLoading: false, error: 'Session expired for this account' });
// Load the new account's session BEFORE clearing jmapClient or feature
// stores. The previous order (reset → resetFeatureStores → loadAccount)
// left jmapClient with no accountId during the loadAccount await, and
// any fetch fired by a screen effect reacting to the emptied stores
// (e.g. EmailListScreen retrying fetchMailboxes when mailboxes goes to
// []) threw "Not authenticated - call connect() first". loadAccount
// overwrites credentials/session/_accountId itself, so the pre-reset
// is unnecessary.
try {
const ok = await jmapClient.loadAccount(accountId);
if (!ok) {
// Credentials missing - evict stale entry and surface error
accountStore.removeAccount(accountId);
set({ isLoading: false, error: 'Session expired for this account' });
return;
}
} catch (err) {
if (err instanceof AuthenticationError) {
await jmapClient.clearAccountCredentials(accountId).catch(() => undefined);
accountStore.removeAccount(accountId);
set({ isLoading: false, error: 'Session expired for this account' });
return;
}
// NetworkError or anything else - keep the previous active account
// intact instead of stranding the user on a half-switched state.
set({
isLoading: false,
error: err instanceof Error ? err.message : 'Failed to switch account',
});
return;
}
// Session is in place under the new account. Safe now to wipe the
// previous account's cached feature data; the refetch below repopulates.
resetFeatureStores();
accountStore.setActiveAccount(accountId);
accountStore.updateAccount(accountId, {
isConnected: true,
+7
View File
@@ -89,6 +89,11 @@ export const useCalendarStore = create<CalendarState>()(
},
fetchCalendars: async () => {
// CalendarScreen fires this on mount; on cold start that happens before
// restoreSession has connected jmapClient. Bail rather than surfacing
// a "Not authenticated" error - the refetch driven by the auth-store
// will run this again once the session is live.
if (!jmapClient.isConnected) return;
try {
const calendars = (await fetchCalendars()) ?? [];
set({ calendars });
@@ -98,6 +103,7 @@ export const useCalendarStore = create<CalendarState>()(
},
fetchEvents: async (calendarIds, after, before) => {
if (!jmapClient.isConnected) return;
set({ loading: true, error: null });
try {
const ids = (await queryEvents(calendarIds, after, before)) ?? [];
@@ -142,6 +148,7 @@ export const useCalendarStore = create<CalendarState>()(
},
handleStateChange: async (change) => {
if (!jmapClient.isConnected) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
+6
View File
@@ -87,6 +87,10 @@ export const useContactsStore = create<ContactsState>()(
},
fetchAddressBooks: async () => {
// No-op until a JMAP session exists. ContactsScreen fires this from a
// mount-time useEffect, which on cold start runs before restoreSession
// has set up the client.
if (!jmapClient.isConnected) return;
try {
const addressBooks = (await fetchAddressBooks()) ?? [];
set({ addressBooks });
@@ -96,6 +100,7 @@ export const useContactsStore = create<ContactsState>()(
},
fetchContacts: async (filter) => {
if (!jmapClient.isConnected) return;
set({ loading: true, error: null });
try {
const ids = (await queryContacts(filter)) ?? [];
@@ -111,6 +116,7 @@ export const useContactsStore = create<ContactsState>()(
},
handleStateChange: async (change) => {
if (!jmapClient.isConnected) return;
const accountId = jmapClient.accountId;
const accountChanges = change.changed?.[accountId];
if (!accountChanges) return;
+18
View File
@@ -129,6 +129,13 @@ export const useEmailStore = create<EmailState>()(
pendingUndo: null,
fetchMailboxes: async () => {
// Skip silently when there's no live session. Screens fire this from
// mount-time useEffects, and on cold start App.tsx renders MainTabs
// before restoreSession() finishes; without this guard the underlying
// API call would throw "Not authenticated - call connect() first" and
// surface as a user-visible error before the real fetch (kicked off by
// refetchFeatureStores() once the session is live) replaces it.
if (!jmapClient.isConnected) return;
try {
const mailboxes = await fetchMailboxes();
set({ mailboxes });
@@ -171,6 +178,15 @@ export const useEmailStore = create<EmailState>()(
}
}
// Stop here if there's no live session yet — the cached seed already
// gave the user something to look at, and the refetch driven by
// restoreSession() / switchAccount will run the network half once the
// client is connected.
if (!jmapClient.isConnected) {
set({ loading: false });
return;
}
try {
const filter = buildJmapFilter(mailboxId, '', {});
const { ids, total } = await queryEmails(mailboxId, { limit, filter });
@@ -195,6 +211,7 @@ export const useEmailStore = create<EmailState>()(
loadMoreEmails: async () => {
const { currentMailboxId, emails, totalEmails, loading, searchQuery, filters } = get();
if (!currentMailboxId || loading || emails.length >= totalEmails) return;
if (!jmapClient.isConnected) return;
set({ loading: true });
try {
@@ -215,6 +232,7 @@ export const useEmailStore = create<EmailState>()(
refreshEmails: async () => {
const { currentMailboxId, searchQuery, filters, emails: existing } = get();
if (!currentMailboxId) return;
if (!jmapClient.isConnected) return;
// Keep existing emails visible while refreshing - swap atomically once
// the new list is ready so pull-to-refresh doesn't flash an empty list.
set({ loading: true, error: null });