{
  "$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"
}