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 URIcomplained.
My new check includes:
async function getOrCreateApp(instanceUrl: string): Promise<MastodonApp> {
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<Uint8Array>((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!