React Native is not a browser
My next #notai generated post is about another day of developing the kiesel app. The post composer for kiesel.app was nearly finished and I was going to upload my very first image. This post is about 3 bugs, which appeared in React Native, but would have worked flawlessly in the browser.
Redirect URIs in Expo
To get my Mastodon OAuth working and the POST to /api/v1/apps happy, it requires a redirect_uri. Since I am using expo.dev, the Linking.createURL will give me a exp:// prefixed url.
That worked for a while. But as soon as I added TestFlight builds and tested there: the uri scheme changed to kiesel:// - but the redirect uri didn't. The error:
invalid redirect URI
complained.
My new check includes:
async function getOrCreateApp(instanceUrl: string): Promise {
const key = ${MASTODON_APP_KEY_PREFIX}${new URL(instanceUrl).hostname};
const redirectUri = Linking.createURL("mastodon-callback");
// Check if we have a stored app with matching redirect URI const stored = await SecureStore.getItemAsync(key); if (stored) { const app = JSON.parse(stored) as MastodonApp; if (app.redirect_uri === redirectUri) return app; // Redirect URI changed (e.g. Expo Go → EAS build), re-register }
const response = await fetch(${instanceUrl}/api/v1/apps, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_name: "Example",
redirect_uris: redirectUri,
scopes: "read write",
website: "https://example.org",
}),
});
if (!response.ok) {
throw new Error(Failed to register app: ${response.status});
}
const data = await response.json(); const app: MastodonApp = { client_id: data.client_id, client_secret: data.client_secret, redirect_uri: redirectUri, };
await SecureStore.setItemAsync(key, JSON.stringify(app)); return app; } and if it changes - we will get a fresh client_id and client_secret - otherwise the SecureStore has an easy copy of it. For the sake of copy n' paste I replaced Kiesel with Example and kiesel.app with example.org to make sure nobody adds kiesel's client_name or website to their own app. blob.arrayBuffer is undefined My first attempt in the atproto-adapter to fetch the local file was: const response = await fetch(localUri); const blob = await response.blob(); const uint8 = new Uint8Array(await blob.arrayBuffer()); const uploaded = await this.agent.uploadBlob(uint8, { encoding: blob.type || "image/jpeg", }); and it worked well in Expo Go - but failed in React Native - because arrayBuffer did not exist. So I worked around this, by reading the local file as base64 with plain old XMLHttpRequest (will give fetch another try in a next iteration, though!): const fileData = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.onload = () => { const reader = new FileReader(); reader.onloadend = () => { const base64 = (reader.result as string).split(",")[1]; const binary = atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } resolve(bytes); }; reader.onerror = reject; reader.readAsDataURL(xhr.response); }; xhr.onerror = reject; xhr.open("GET", localUri); xhr.responseType = "blob"; xhr.send(); });
const uploaded = await this.agent.uploadBlob(fileData, { encoding: "image/jpeg", }); And sending FormData also failed with this: const response = await fetch(localUri); const blob = await response.blob(); const formData = new FormData(); formData.append("file", blob, "image.jpg"); because React Native's FormData expects another structure, so I changed it to a way where the local file is directly used as source: const formData = new FormData(); formData.append("file", { uri: localUri, type: "image/jpeg", name: "image.jpg", } as unknown as Blob); Don't guess image size I learned that ATproto has a filesize upload limit of 976.56 KB. My silly attempt at the beginning: try to upload the file with 70% quality + same width and that's it. But it failed, since the file might still be too big. My approach: reduce target width and run manipulateAsync function from expo-image-manipulator and see if it fits into the maximum size. If not, try again. But don't fall below 256px width. // Compress images to stay under ATproto's 976KB limit const MAX_SIZE = 950_000; // 950KB to leave margin let width = 2048; let compress = 0.8;
while (width >= 256) { const result = await manipulateAsync( uri, [{ resize: { width } }], { compress, format: SaveFormat.JPEG } );
// Check file size via fetch const response = await fetch(result.uri); const blob = await response.blob(); if (blob.size <= MAX_SIZE) { return result.uri; }
// Reduce quality first, then size if (compress > 0.3) { compress -= 0.15; } else { width = Math.floor(width * 0.7); compress = 0.7; } }
// Last resort: smallest possible const result = await manipulateAsync( uri, [{ resize: { width: 256 } }], { compress: 0.3, format: SaveFormat.JPEG } ); return result.uri; Now the uploaded image is always in "best" quality for the respective ATproto limits. Three bugs in one evening, three places where React Native looked like a browser just long enough to compile. See you in the next dev day post!
Discussion in the ATmosphere