mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 08:06:13 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
886a64171d | ||
|
|
760bf01f4d | ||
|
|
b6d87093b9 | ||
|
|
2a3b1f90dc | ||
|
|
2ed9c29bd5 | ||
|
|
455f26e90a | ||
|
|
e9b93bc6e5 | ||
|
|
c21661ac7a |
@@ -19,6 +19,7 @@ import com.facebook.react.bridge.ReadableMap
|
||||
import com.facebook.react.bridge.WritableMap
|
||||
import com.facebook.react.modules.core.DeviceEventManagerModule.RCTDeviceEventEmitter
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
Generated
+12
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
@@ -17,6 +17,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
@@ -5216,6 +5217,15 @@
|
||||
"react-native": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-document-picker": {
|
||||
"version": "14.0.8",
|
||||
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
|
||||
"integrity": "sha512-3tyQKpPqWWFlI8p9RiMX1+T1Zge5mEKeBuXWp1h8PEItFMUDSiOJbQ112sfdC6Hxt8wSxreV9bCRl/NgBdt+fA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"expo": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/expo-file-system": {
|
||||
"version": "19.0.21",
|
||||
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "bulwark-mobile",
|
||||
"version": "0.1.24",
|
||||
"version": "0.1.27",
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"@react-navigation/native-stack": "^7.14.10",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-document-picker": "~14.0.8",
|
||||
"expo-file-system": "~19.0.21",
|
||||
"expo-image-picker": "~17.0.10",
|
||||
"expo-intent-launcher": "~13.0.8",
|
||||
|
||||
+71
-8
@@ -1,19 +1,35 @@
|
||||
import { jmapClient } from './jmap-client';
|
||||
import { CAPABILITIES } from './types';
|
||||
import type { FileNode } from './types';
|
||||
import { getDownloadUrl, uploadBlob } from './blob';
|
||||
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.MAIL];
|
||||
const USING = [CAPABILITIES.CORE, CAPABILITIES.FILES];
|
||||
|
||||
export async function getFileNodes(parentId?: string | null): Promise<FileNode[]> {
|
||||
// Stalwart and other JMAP servers use one of these strings on FileNode.type to
|
||||
// mark a directory; everything else is a MIME type.
|
||||
const DIRECTORY_TYPES = new Set([
|
||||
'd',
|
||||
'application/x-directory',
|
||||
'text/directory',
|
||||
'httpd/unix-directory',
|
||||
'inode/directory',
|
||||
]);
|
||||
|
||||
export function isDirectory(node: Pick<FileNode, 'type'>): boolean {
|
||||
return DIRECTORY_TYPES.has(node.type) || node.type.includes('directory');
|
||||
}
|
||||
|
||||
// Stalwart's FileNode/get does not support a parentId filter — fetching with
|
||||
// `ids: null` returns the whole tree, which the caller filters client-side.
|
||||
// Mirrors the webmail file-store; keeping the API uniform avoids a divergence
|
||||
// later when we add other JMAP servers.
|
||||
export async function getAllFileNodes(): Promise<FileNode[]> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (parentId !== undefined) filter.parentId = parentId;
|
||||
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/get', { accountId, filter }, '0']],
|
||||
[['FileNode/get', { accountId, ids: null }, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].list;
|
||||
return (res.methodResponses[0][1].list ?? []) as FileNode[];
|
||||
}
|
||||
|
||||
export async function createFileNode(
|
||||
@@ -27,13 +43,60 @@ export async function createFileNode(
|
||||
}, '0']],
|
||||
USING,
|
||||
);
|
||||
return res.methodResponses[0][1].created['new-file'];
|
||||
const created = res.methodResponses[0][1].created?.['new-file'];
|
||||
if (!created) {
|
||||
const err = res.methodResponses[0][1].notCreated?.['new-file'];
|
||||
throw new Error(err?.description || 'Create failed');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
|
||||
export async function createFolder(
|
||||
name: string,
|
||||
parentId: string | null,
|
||||
): Promise<FileNode> {
|
||||
return createFileNode({ name, parentId, type: 'd', blobId: null, size: 0 });
|
||||
}
|
||||
|
||||
export async function updateFileNode(
|
||||
id: string,
|
||||
updates: Partial<Pick<FileNode, 'name' | 'parentId'>>,
|
||||
): Promise<void> {
|
||||
const accountId = jmapClient.accountId;
|
||||
const res = await jmapClient.request(
|
||||
[['FileNode/set', { accountId, update: { [id]: updates } }, '0']],
|
||||
USING,
|
||||
);
|
||||
const notUpdated = res.methodResponses[0][1].notUpdated?.[id];
|
||||
if (notUpdated) throw new Error(notUpdated.description || 'Update failed');
|
||||
}
|
||||
|
||||
export async function deleteFileNodes(ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) return;
|
||||
const accountId = jmapClient.accountId;
|
||||
await jmapClient.request(
|
||||
[['FileNode/set', { accountId, destroy: ids }, '0']],
|
||||
USING,
|
||||
);
|
||||
}
|
||||
|
||||
export function getFileNodeDownloadUrl(node: FileNode): string {
|
||||
if (!node.blobId) throw new Error('Folders cannot be downloaded');
|
||||
return getDownloadUrl(node.blobId, node.name, node.type);
|
||||
}
|
||||
|
||||
export async function uploadFileNode(
|
||||
uri: string,
|
||||
name: string,
|
||||
mimeType: string,
|
||||
parentId: string | null,
|
||||
): Promise<FileNode> {
|
||||
const blob = await uploadBlob(uri, mimeType);
|
||||
return createFileNode({
|
||||
name,
|
||||
parentId,
|
||||
type: blob.type || mimeType || 'application/octet-stream',
|
||||
blobId: blob.blobId,
|
||||
size: blob.size,
|
||||
});
|
||||
}
|
||||
|
||||
+2
-1
@@ -429,7 +429,7 @@ export interface FileNode {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
type: string;
|
||||
blobId?: string;
|
||||
blobId?: string | null;
|
||||
size?: number;
|
||||
created?: string;
|
||||
updated?: string;
|
||||
@@ -465,4 +465,5 @@ export const CAPABILITIES = {
|
||||
CALENDARS: 'urn:ietf:params:jmap:calendars',
|
||||
SIEVE: 'urn:ietf:params:jmap:sieve',
|
||||
QUOTA: 'urn:ietf:params:jmap:quota',
|
||||
FILES: 'urn:ietf:params:jmap:filenode',
|
||||
} as const;
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, Pressable } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { CloudDownload, X } from 'lucide-react-native';
|
||||
import { useOfflineCacheStore } from '../stores/offline-cache-store';
|
||||
import { useUpdatesStore } from '../stores/updates-store';
|
||||
import { formatBytes } from '../lib/offline-sync';
|
||||
import { spacing, radius, typography, type ThemePalette } from '../theme/tokens';
|
||||
import { useColors } from '../theme/colors';
|
||||
@@ -12,9 +14,18 @@ import { useColors } from '../theme/colors';
|
||||
export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const c = useColors();
|
||||
const styles = React.useMemo(() => makeStyles(c), [c]);
|
||||
const insets = useSafeAreaInsets();
|
||||
const sync = useOfflineCacheStore((s) => s.sync);
|
||||
const requestAbort = useOfflineCacheStore((s) => s.requestAbort);
|
||||
const resetSync = useOfflineCacheStore((s) => s.resetSync);
|
||||
// When UpdateBanner is stacked above us it already absorbs the status-bar
|
||||
// inset, so we only add it when we're the topmost banner.
|
||||
const cachedLatest = useUpdatesStore((s) => s.cachedLatest);
|
||||
const dismissedTag = useUpdatesStore((s) => s.dismissedTag);
|
||||
const hasUpdate = useUpdatesStore((s) => s.hasUpdate);
|
||||
const updateBannerVisible =
|
||||
hasUpdate() && cachedLatest?.apkAsset != null && dismissedTag !== cachedLatest.tag;
|
||||
const topInset = updateBannerVisible ? 0 : insets.top;
|
||||
const [hideTimer, setHideTimer] = React.useState<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Auto-dismiss the "done" state after a few seconds so the bar isn't a
|
||||
@@ -71,7 +82,7 @@ export function OfflineCacheBanner(): React.ReactElement | null {
|
||||
const isError = sync.phase === 'error';
|
||||
|
||||
return (
|
||||
<View style={[styles.banner, isError && styles.bannerError]}>
|
||||
<View style={[styles.banner, isError && styles.bannerError, { paddingTop: spacing.sm + topInset }]}>
|
||||
<CloudDownload size={16} color={c.primaryForeground} />
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.title}>{title}</Text>
|
||||
|
||||
+890
-134
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user