{
"$type": "site.standard.document",
"content": {
"$type": "blog.pckt.content",
"items": [
{
"$type": "blog.pckt.block.text",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#link",
"uri": "http://kiesel.app"
}
],
"index": {
"byteEnd": 113,
"byteStart": 103
}
}
],
"plaintext": "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."
},
{
"$type": "blog.pckt.block.heading",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#bold"
}
],
"index": {
"byteEnd": 21,
"byteStart": 0
}
}
],
"level": 2,
"plaintext": "Redirect URIs in Expo"
},
{
"$type": "blog.pckt.block.text",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 61,
"byteStart": 49
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 95,
"byteStart": 83
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#link",
"uri": "http://expo.dev"
},
{
"$type": "blog.pckt.richtext.facet#underline"
}
],
"index": {
"byteEnd": 122,
"byteStart": 114
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#link",
"uri": "https://docs.expo.dev/versions/latest/sdk/linking/#linkingcreateurlpath-namedparameters"
},
{
"$type": "blog.pckt.richtext.facet#underline"
}
],
"index": {
"byteEnd": 145,
"byteStart": 128
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 167,
"byteStart": 161
}
}
],
"plaintext": "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."
},
{
"$type": "blog.pckt.block.text",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 119,
"byteStart": 110
}
}
],
"plaintext": "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:"
},
{
"$type": "blog.pckt.block.codeBlock",
"attrs": [],
"plaintext": "invalid redirect URI"
},
{
"$type": "blog.pckt.block.text",
"plaintext": "complained."
},
{
"$type": "blog.pckt.block.text",
"plaintext": "My new check includes:"
},
{
"$type": "blog.pckt.block.codeBlock",
"attrs": {
"language": "typescript"
},
"plaintext": "async function getOrCreateApp(instanceUrl: string): Promise<MastodonApp> {\n const key = `${MASTODON_APP_KEY_PREFIX}${new URL(instanceUrl).hostname}`;\n const redirectUri = Linking.createURL(\"mastodon-callback\");\n\n // Check if we have a stored app with matching redirect URI\n const stored = await SecureStore.getItemAsync(key);\n if (stored) {\n const app = JSON.parse(stored) as MastodonApp;\n if (app.redirect_uri === redirectUri) return app;\n // Redirect URI changed (e.g. Expo Go → EAS build), re-register\n }\n\n const response = await fetch(`${instanceUrl}/api/v1/apps`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n client_name: \"Example\",\n redirect_uris: redirectUri,\n scopes: \"read write\",\n website: \"https://example.org\",\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to register app: ${response.status}`);\n }\n\n const data = await response.json();\n const app: MastodonApp = {\n client_id: data.client_id,\n client_secret: data.client_secret,\n redirect_uri: redirectUri,\n };\n\n await SecureStore.setItemAsync(key, JSON.stringify(app));\n return app;\n}"
},
{
"$type": "blog.pckt.block.text",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 49,
"byteStart": 40
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 67,
"byteStart": 54
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#link",
"uri": "http://kiesel.app"
}
],
"index": {
"byteEnd": 195,
"byteStart": 185
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#link",
"uri": "http://example.org"
}
],
"index": {
"byteEnd": 212,
"byteStart": 201
}
}
],
"plaintext": "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."
},
{
"$type": "blog.pckt.block.heading",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 16,
"byteStart": 0
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#bold"
}
],
"index": {
"byteEnd": 29,
"byteStart": 16
}
}
],
"level": 2,
"plaintext": "blob.arrayBuffer is undefined"
},
{
"$type": "blog.pckt.block.text",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 39,
"byteStart": 24
}
}
],
"plaintext": "My first attempt in the atproto-adapter to fetch the local file was:"
},
{
"$type": "blog.pckt.block.codeBlock",
"attrs": {
"language": "typescript"
},
"plaintext": "const response = await fetch(localUri);\nconst blob = await response.blob();\nconst uint8 = new Uint8Array(await blob.arrayBuffer());\nconst uploaded = await this.agent.uploadBlob(uint8, {\n encoding: blob.type || \"image/jpeg\",\n});"
},
{
"$type": "blog.pckt.block.text",
"plaintext": "and it worked well in Expo Go - but failed in React Native - because arrayBuffer did not exist."
},
{
"$type": "blog.pckt.block.text",
"plaintext": "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!):"
},
{
"$type": "blog.pckt.block.codeBlock",
"attrs": {
"language": "typescript"
},
"plaintext": "const fileData = await new Promise<Uint8Array>((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.onload = () => {\n const reader = new FileReader();\n reader.onloadend = () => {\n const base64 = (reader.result as string).split(\",\")[1];\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n resolve(bytes);\n };\n reader.onerror = reject;\n reader.readAsDataURL(xhr.response);\n };\n xhr.onerror = reject;\n xhr.open(\"GET\", localUri);\n xhr.responseType = \"blob\";\n xhr.send();\n});\n\nconst uploaded = await this.agent.uploadBlob(fileData, {\n encoding: \"image/jpeg\",\n});"
},
{
"$type": "blog.pckt.block.text",
"plaintext": "And sending FormData also failed with this:"
},
{
"$type": "blog.pckt.block.codeBlock",
"attrs": {
"language": "typescript"
},
"plaintext": "const response = await fetch(localUri);\nconst blob = await response.blob();\nconst formData = new FormData();\nformData.append(\"file\", blob, \"image.jpg\");"
},
{
"$type": "blog.pckt.block.text",
"plaintext": "because React Native's FormData expects another structure, so I changed it to a way where the local file is directly used as source:"
},
{
"$type": "blog.pckt.block.codeBlock",
"attrs": {
"language": "typescript"
},
"plaintext": "const formData = new FormData();\nformData.append(\"file\", {\n uri: localUri,\n type: \"image/jpeg\",\n name: \"image.jpg\",\n} as unknown as Blob);"
},
{
"$type": "blog.pckt.block.heading",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#bold"
}
],
"index": {
"byteEnd": 22,
"byteStart": 0
}
}
],
"level": 2,
"plaintext": "Don't guess image size"
},
{
"$type": "blog.pckt.block.text",
"plaintext": "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."
},
{
"$type": "blog.pckt.block.text",
"facets": [
{
"features": [
{
"$type": "blog.pckt.richtext.facet#code"
}
],
"index": {
"byteEnd": 110,
"byteStart": 95
}
},
{
"features": [
{
"$type": "blog.pckt.richtext.facet#link",
"uri": "https://docs.expo.dev/versions/latest/sdk/imagemanipulator/"
},
{
"$type": "blog.pckt.richtext.facet#underline"
}
],
"index": {
"byteEnd": 147,
"byteStart": 125
}
}
],
"plaintext": "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."
},
{
"$type": "blog.pckt.block.codeBlock",
"attrs": {
"language": "typescript"
},
"plaintext": "// Compress images to stay under ATproto's 976KB limit\nconst MAX_SIZE = 950_000; // 950KB to leave margin\nlet width = 2048;\nlet compress = 0.8;\n\nwhile (width >= 256) {\n const result = await manipulateAsync(\n uri,\n [{ resize: { width } }],\n { compress, format: SaveFormat.JPEG }\n );\n\n // Check file size via fetch\n const response = await fetch(result.uri);\n const blob = await response.blob();\n if (blob.size <= MAX_SIZE) {\n return result.uri;\n }\n\n // Reduce quality first, then size\n if (compress > 0.3) {\n compress -= 0.15;\n } else {\n width = Math.floor(width * 0.7);\n compress = 0.7;\n }\n}\n\n// Last resort: smallest possible\nconst result = await manipulateAsync(\n uri,\n [{ resize: { width: 256 } }],\n { compress: 0.3, format: SaveFormat.JPEG }\n);\nreturn result.uri;"
},
{
"$type": "blog.pckt.block.text",
"plaintext": "Now the uploaded image is always in \"best\" quality for the respective ATproto limits."
},
{
"$type": "blog.pckt.block.text",
"plaintext": "Three bugs in one evening, three places where React Native looked like a browser just long enough to compile."
},
{
"$type": "blog.pckt.block.text",
"plaintext": "See you in the next dev day post!"
}
]
},
"description": "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. 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 ...",
"path": "/react-native-is-not-a-browser-28prbg2",
"publishedAt": "2026-06-05T06:07:24+00:00",
"site": "at://did:plc:ks6l7qs37543awud4jyfl7o3/site.standard.publication/3mka7ezi7cjmw",
"tags": [],
"textContent": "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.\nRedirect URIs in Expo\nTo 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.\nThat 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:\ninvalid redirect URI\ncomplained.\nMy new check includes:\nasync function getOrCreateApp(instanceUrl: string): Promise<MastodonApp> {\n const key = `${MASTODON_APP_KEY_PREFIX}${new URL(instanceUrl).hostname}`;\n const redirectUri = Linking.createURL(\"mastodon-callback\");\n\n // Check if we have a stored app with matching redirect URI\n const stored = await SecureStore.getItemAsync(key);\n if (stored) {\n const app = JSON.parse(stored) as MastodonApp;\n if (app.redirect_uri === redirectUri) return app;\n // Redirect URI changed (e.g. Expo Go → EAS build), re-register\n }\n\n const response = await fetch(`${instanceUrl}/api/v1/apps`, {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({\n client_name: \"Example\",\n redirect_uris: redirectUri,\n scopes: \"read write\",\n website: \"https://example.org\",\n }),\n });\n\n if (!response.ok) {\n throw new Error(`Failed to register app: ${response.status}`);\n }\n\n const data = await response.json();\n const app: MastodonApp = {\n client_id: data.client_id,\n client_secret: data.client_secret,\n redirect_uri: redirectUri,\n };\n\n await SecureStore.setItemAsync(key, JSON.stringify(app));\n return app;\n}\nand 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.\nblob.arrayBuffer is undefined\nMy first attempt in the atproto-adapter to fetch the local file was:\nconst response = await fetch(localUri);\nconst blob = await response.blob();\nconst uint8 = new Uint8Array(await blob.arrayBuffer());\nconst uploaded = await this.agent.uploadBlob(uint8, {\n encoding: blob.type || \"image/jpeg\",\n});\nand it worked well in Expo Go - but failed in React Native - because arrayBuffer did not exist.\nSo 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!):\nconst fileData = await new Promise<Uint8Array>((resolve, reject) => {\n const xhr = new XMLHttpRequest();\n xhr.onload = () => {\n const reader = new FileReader();\n reader.onloadend = () => {\n const base64 = (reader.result as string).split(\",\")[1];\n const binary = atob(base64);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) {\n bytes[i] = binary.charCodeAt(i);\n }\n resolve(bytes);\n };\n reader.onerror = reject;\n reader.readAsDataURL(xhr.response);\n };\n xhr.onerror = reject;\n xhr.open(\"GET\", localUri);\n xhr.responseType = \"blob\";\n xhr.send();\n});\n\nconst uploaded = await this.agent.uploadBlob(fileData, {\n encoding: \"image/jpeg\",\n});\nAnd sending FormData also failed with this:\nconst response = await fetch(localUri);\nconst blob = await response.blob();\nconst formData = new FormData();\nformData.append(\"file\", blob, \"image.jpg\");\nbecause React Native's FormData expects another structure, so I changed it to a way where the local file is directly used as source:\nconst formData = new FormData();\nformData.append(\"file\", {\n uri: localUri,\n type: \"image/jpeg\",\n name: \"image.jpg\",\n} as unknown as Blob);\nDon't guess image size\nI 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.\nBut 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.\n// Compress images to stay under ATproto's 976KB limit\nconst MAX_SIZE = 950_000; // 950KB to leave margin\nlet width = 2048;\nlet compress = 0.8;\n\nwhile (width >= 256) {\n const result = await manipulateAsync(\n uri,\n [{ resize: { width } }],\n { compress, format: SaveFormat.JPEG }\n );\n\n // Check file size via fetch\n const response = await fetch(result.uri);\n const blob = await response.blob();\n if (blob.size <= MAX_SIZE) {\n return result.uri;\n }\n\n // Reduce quality first, then size\n if (compress > 0.3) {\n compress -= 0.15;\n } else {\n width = Math.floor(width * 0.7);\n compress = 0.7;\n }\n}\n\n// Last resort: smallest possible\nconst result = await manipulateAsync(\n uri,\n [{ resize: { width: 256 } }],\n { compress: 0.3, format: SaveFormat.JPEG }\n);\nreturn result.uri;\nNow the uploaded image is always in \"best\" quality for the respective ATproto limits.\nThree bugs in one evening, three places where React Native looked like a browser just long enough to compile.\nSee you in the next dev day post!",
"title": "React Native is not a browser",
"updatedAt": "2026-06-05T06:10:10+00:00"
}