Textpad for Expo
What it is
This is Textpad, HashBin's example text editor, rebuilt as an iOS and Android app with Expo and React Native. It does the same things as the web version, and this page explains the parts that work differently on a phone. There is also a Flutter version.
- Open: a link such as
org.hashbin.textpad.expo://open?256t=<256t string>opens Textpad with that text. Users can also paste a 256t string, a 256t.us link or a web Textpad link into the Open field. - Edit and save: Save publishes the text to the user's HashBin account. Copy link then copies a link that opens the text in any browser.
- Pay with the user's account: the user approves Textpad once on hashbin.org, inside the app, and stays connected.
- Explain problems: a malformed 256t string, one with nothing stored under it, or content that isn't text each gets its own message.
The project is in the HashBin repository at examples/textpad-expo. The code is in three files:
textpad.js | 256t strings, links and text decoding. Plain JavaScript with no React Native, so it is tested under Node. |
hashbin.js | A small HashBin OAuth client: sign-in, token storage and renewal, and API calls. Copy it into your own app. |
App.js | The screen, and what happens when the user taps something. |
Try it
Install the app on a phone or simulator first. These links then open in it; short texts need no account, because they are stored inside the 256t string itself.
| Link | What you'll see |
|---|---|
| A short text | "Hello from Textpad! Edit me, then click Save." Edit it and tap Save to try saving. |
| A malformed 256t string | An error saying the string isn't a valid 256t string. |
| Content that isn't text | An error saying the content isn't text. |
In the simulator you can open a link from a terminal:
xcrun simctl openurl booted "org.hashbin.textpad.expo://open?256t=…" on iOS, or
adb shell am start -a android.intent.action.VIEW -d "org.hashbin.textpad.expo://open?256t=…"
on Android.
Signing in from an app
A phone app signs in to HashBin with the same OAuth 2.0 and PKCE flow as a web page. Four things are different:
-
The approval page opens in an in-app browser. On iOS this is an
ASWebAuthenticationSession, on Android a Chrome Custom Tab. The user sees the real hashbin.org address, and it shares the browser's sign-in, so they often don't need to sign in again. iOS first asks "Textpad Wants to Use hashbin.org to Sign In"; that prompt comes from the system, not from HashBin. -
The redirect URI uses the app's own URL scheme:
org.hashbin.textpad.expo://oauth. The scheme is set inapp.json, and the system hands links that use it to this app. HashBin accepts redirect URIs like this when the scheme is named after a domain, written backwards (RFC 8252). A plain name such astextpad://is refused, because any app could claim it. -
Tokens are kept in secure storage, the iOS Keychain or the Android
Keystore, through
expo-secure-store. A web page has to uselocalStorage. - Nothing has to survive a page change. The app keeps running while the browser is open, so unlike the web version it doesn't save the user's text before signing in.
Everything else matches the web version. Textpad asks for content:write and
balance:read. Each save is charged to the user's balance and kept for the
default retention period set on their account page, and texts
of 64 bytes or less are free. Disconnect revokes the app's access on the
server, and users can also revoke Textpad from their account page.
Messages and what causes them
| Situation | Textpad shows |
|---|---|
The string has characters outside A–Z a–z 0–9 - _, or its length doesn't match the length it encodes (for example, a link cut off when copied) | "… isn't a valid 256t string. Check that the link was copied completely." |
The link ends in 256t= with nothing after it | "This link has "256t=" but no 256t string after it." |
| Something pasted into Open is a link with no 256t string in it | "That link doesn't contain a 256t string." |
| 256t.us answers 404: never saved, expired, or deleted | "Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved." |
| 256t.us answers 451: the content is under dispute | "This text is unavailable while a dispute about it is reviewed." |
| 256t.us answers 429: the text has used up its download allowance | "HashBin is limiting how often this text can be downloaded. Try again later." |
| The content isn't valid UTF-8, or contains NUL characters (binary data) | "This 256t string points to content that isn't text, so Textpad can't open it." |
| The network request fails | "Couldn't reach HashBin to load this text…" |
| The user taps Deny on the HashBin approval page | "You didn't approve Textpad on HashBin, so nothing was saved." Their edits are kept. |
| The user closes the approval page | Nothing; the text stays as it was, unsaved. |
| The balance is too low to save | HashBin's explanation, with an Add funds link. |
| The connection was revoked or has expired | "Your HashBin connection has ended. Tap Save to connect again." |
The code, step by step
The excerpts below are copied exactly from the app; the full source is at the end of this page.
1. Configuration
App.js starts by naming the app (its client ID), where HashBin is, and the
redirect URI. EXPO_PUBLIC_ environment variables let a developer point the
app at a local HashBin server without editing the code. Expo copies them into the app when
it is bundled, so a published app always uses hashbin.org.
// The HashBin service, and the domain that serves published content.
// Development only: EXPO_PUBLIC_HASHBIN_URL points the app at a local
// HashBin server (npm run dev:local), which serves content itself.
const HASHBIN_URL = process.env.EXPO_PUBLIC_HASHBIN_URL || "https://hashbin.org";
const CONTENT_URL = process.env.EXPO_PUBLIC_HASHBIN_URL || "https://256t.us";
// The client ID from registering Textpad at https://hashbin.org/developers,
// or EXPO_PUBLIC_TEXTPAD_CLIENT_ID for an app registered on a local server.
const CLIENT_ID = process.env.EXPO_PUBLIC_TEXTPAD_CLIENT_ID || "app_1a74e62b-d752-4a3c-81af-5c850af18c5b";
// HashBin sends the user back to this link after they approve Textpad. Its
// scheme is the app's own ("scheme" in app.json), so the system hands the
// link to this app. It must match a registered redirect URI exactly.
const REDIRECT_URI = "org.hashbin.textpad.expo://oauth";
// What Textpad asks the user to allow: publishing (billed to their
// balance) and reading their balance so it can be shown.
const SCOPES = ["content:write", "balance:read"];
const account = new HashBinAccount({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
baseUrl: HASHBIN_URL,
scopes: SCOPES
});
The redirect URI's scheme is the one in app.json:
"scheme": "org.hashbin.textpad.expo",
2. Finding the 256t string in a link
Textpad accepts its own deep links, and also the links people are likely to have: a web Textpad link, a raw 256t.us link, or just the string. It ignores links that hold no 256t string, such as the one that ends a sign-in.
// Textpad opens a 256t string from any of these:
// - its own deep link: org.hashbin.textpad.expo://open?256t=<cid>
// - a web Textpad link: https://hashbin.org/docs/textpad/app#256t=<cid>
// - a raw content link: https://256t.us/<cid>
// - the bare 256t string: <cid>
// It returns null for a link that holds no 256t string, such as the
// "org.hashbin.textpad.expo://oauth?code=…" link that ends a sign-in.
export function cidFromText(text) {
const trimmed = text.trim();
const param = trimmed.match(/[#?&]256t=([^&#]*)/);
if (param) {
try {
return decodeURIComponent(param[1]);
} catch {
return param[1]; // A stray "%" can't be decoded; isValid256t rejects it.
}
}
const contentLink = trimmed.match(/^https:\/\/256t\.us\/([^/?#]*)/);
if (contentLink) {
return contentLink[1];
}
return trimmed.includes("://") ? null : trimmed;
}
3. Checking a 256t string
A 256t string has two parts, both in base64url. The first 8 characters hold the content's length in bytes. The rest is the content itself when it is 64 bytes or less, or else its SHA-512 hash, which is always 86 characters. Checking that the two parts agree catches typos and truncated links before any network request is made:
// A 256t string is base64url text in two parts:
// - 8 characters holding the content's length in bytes
// (a 6-byte big-endian number);
// - then the content itself if it is 64 bytes or less, or else the
// content's SHA-512 hash (always 86 characters).
// Checking this up front gives a clear message for a mistyped or cut-off
// link instead of a confusing "not found".
export function contentLength(cid) {
return base64UrlToBytes(cid.slice(0, 8)).reduce((total, byte) => total * 256 + byte, 0);
}
export function isValid256t(cid) {
if (!/^[A-Za-z0-9_-]{8,94}$/.test(cid)) {
return false;
}
const length = contentLength(cid);
const expectedRest = length <= 64 ? Math.ceil((length * 4) / 3) : 86;
return cid.length - 8 === expectedRest;
}
4. Turning bytes into text
HashBin stores bytes, not text, and React Native has no strict UTF-8 decoder. JavaScript's
decodeURIComponent is one, though: given the bytes as %XX escapes,
it throws on anything that isn't valid UTF-8.
// Content on HashBin is just bytes. Textpad only opens bytes that are valid
// UTF-8 text. React Native has no strict UTF-8 decoder, but
// decodeURIComponent is one: given the bytes as %XX escapes, it throws on
// anything that isn't valid UTF-8 instead of inserting replacement
// characters. It also keeps a leading byte order mark, so saving unchanged
// text gives back exactly the same bytes and 256t string.
export function decodeText(bytes) {
let escaped = "";
for (const byte of bytes) {
escaped += byte < 16 ? `%0${byte.toString(16)}` : `%${byte.toString(16)}`;
}
const text = decodeURIComponent(escaped);
// Valid UTF-8 can still be binary data; a NUL character is a reliable
// sign of that, and real text practically never contains one.
if (text.includes("\0")) {
throw new TypeError("Content contains NUL characters");
}
return text;
}
5. Opening a text
Opening checks the string, then either decodes a short text straight from it or downloads a
longer one from https://256t.us/<string>. Downloading needs no account.
Each failure gets its own message, and the editor is emptied first, so nothing on screen
can be mistaken for the content of a broken link.
async function openCid(cid) {
// Whatever happens below, don't leave the previous text on screen as if
// it belonged to this link.
setDocument(null, "");
clearMessage();
if (cid === "") {
showMessage("This link has \"256t=\" but no 256t string after it.", { error: true });
return;
}
if (!isValid256t(cid)) {
const shown = cid.length > 100 ? `${cid.slice(0, 100)}…` : cid;
showMessage(`"${shown}" isn't a valid 256t string. Check that the link was copied completely.`, { error: true });
return;
}
// Short texts are inside the 256t string itself.
const inline = inlineBytes(cid);
if (inline) {
showText(cid, inline);
return;
}
setBusy(true);
showMessage("Loading…");
try {
let response;
try {
response = await fetch(`${CONTENT_URL}/${cid}`);
} catch {
showMessage("Couldn't reach HashBin to load this text. Check your connection and try again.", { error: true });
return;
}
if (response.status === 404) {
showMessage("Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved.", { error: true });
return;
}
if (response.status === 451) {
showMessage("This text is unavailable while a dispute about it is reviewed.", { error: true });
return;
}
// Each stored text has a download allowance; when it runs out, 256t.us answers 429.
if (response.status === 429) {
showMessage("HashBin is limiting how often this text can be downloaded. Try again later.", { error: true });
return;
}
if (!response.ok) {
showMessage(`HashBin couldn't return this text (HTTP ${response.status}). Try again later.`, { error: true });
return;
}
showText(cid, new Uint8Array(await response.arrayBuffer()));
} finally {
setBusy(false);
}
}
6. Signing in
connect() in hashbin.js builds the authorization link with a PKCE
challenge, opens it with WebBrowser.openAuthSessionAsync, and waits. When
HashBin redirects to org.hashbin.textpad.expo://oauth?code=…, the browser closes
and hands that link back. The app checks the state, then trades the code for
tokens.
// Opens HashBin's approval page in an in-app browser and waits for the
// user to come back. Resolves to "connected", "denied" (the user clicked
// Deny) or "cancelled" (the user closed the browser).
async connect() {
// PKCE: a random secret (the verifier) stays in the app, and only its
// SHA-256 hash (the challenge) goes into the sign-in link. Whoever
// exchanges the code for tokens must know the verifier, so a code
// intercepted on the way back to the app is useless to anyone else.
const verifier = bytesToBase64Url(Crypto.getRandomBytes(32));
const challenge = (await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
verifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// "state" ties the reply to this request.
const state = bytesToBase64Url(Crypto.getRandomBytes(16));
const query = {
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: "code",
scope: this.scopes.join(" "),
state,
code_challenge: challenge,
code_challenge_method: "S256"
};
const authorizeUrl = `${this.baseUrl}/oauth/authorize?` + Object.entries(query)
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
.join("&");
// On iOS this is an ASWebAuthenticationSession, on Android a Chrome
// Custom Tab. It closes by itself when HashBin redirects to
// redirectUri, and hands that link back here.
const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, this.redirectUri);
if (result.type !== "success") {
return "cancelled";
}
const params = queryParams(result.url);
if (params.state !== state) {
throw new Error("The reply from HashBin doesn't match this sign-in.");
}
if (params.error === "access_denied") {
return "denied";
}
if (params.error || !params.code) {
throw new Error(params.error || "HashBin didn't send an authorization code.");
}
// Trade the one-time code (plus the verifier) for tokens.
const response = await fetch(`${this.baseUrl}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: this.clientId,
redirect_uri: this.redirectUri,
code: params.code,
code_verifier: verifier
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.message || body.error || `HTTP ${response.status}`);
}
await this.saveTokens(body);
return "connected";
}
7. Renewing access
Access tokens last an hour. Every API call goes through account.fetch(), which
renews an expired token first. Refresh tokens work only once, so calls that need a new
token at the same moment share a single renewal. A refused refresh token, or a 401 from
the API, means the user has to approve Textpad again.
// Access tokens last an hour. A refresh token gets a new one, and is
// itself replaced: each refresh token works only once. Calls that need a
// new token at the same moment share one refresh, because a second
// refresh with the same (by then used) token would fail.
refreshAccessToken() {
if (!this.refreshing) {
this.refreshing = this.renewTokens().finally(() => {
this.refreshing = null;
});
}
return this.refreshing;
}
async renewTokens() {
const response = await fetch(`${this.baseUrl}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ grant_type: "refresh_token", refresh_token: this.tokens.refreshToken })
});
const body = await response.json().catch(() => ({}));
if (body.error === "invalid_grant") {
// Expired or revoked: the user has to approve the app again.
await this.clearTokens();
throw new NotConnectedError();
}
if (!response.ok) {
throw new Error(body.message || body.error || `HTTP ${response.status}`);
}
await this.saveTokens(body);
}
// fetch() for HashBin API paths, as the signed-in user.
async fetch(path, options = {}) {
if (!this.isConnected()) {
throw new NotConnectedError();
}
if (Date.now() > this.tokens.expiresAt - EXPIRY_MARGIN_MS) {
await this.refreshAccessToken();
if (!this.isConnected()) {
throw new NotConnectedError(); // Disconnected while renewing.
}
}
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: { ...options.headers, authorization: `Bearer ${this.tokens.accessToken}` }
});
if (response.status === 401) {
await this.clearTokens();
throw new NotConnectedError();
}
return response;
}
8. Saving text
Save connects first if needed, then sends one POST /api/content whose body is
the text. The response holds the new 256t string (cid).
async function save() {
if (busy || !hasUnsavedChanges || text === "") {
return;
}
// The editor is read-only while busy, so this is what gets saved.
const textToSave = text;
setBusy(true);
try {
// The first save asks the user to approve Textpad. Unlike a web page,
// the app keeps running while the browser is open, so nothing has to
// be stored to survive the trip.
if (!account.isConnected()) {
showMessage("Waiting for you to approve Textpad on HashBin…");
const outcome = await account.connect();
showAccount();
if (outcome === "denied") {
showMessage("You didn't approve Textpad on HashBin, so nothing was saved.", { error: true });
return;
}
if (outcome === "cancelled") {
clearMessage();
return;
}
}
showMessage("Saving…");
// The request body is the text itself. Uploads made with an OAuth
// app are kept for the user's default retention period (set on their
// HashBin account page) and charged to their balance. Texts of 64
// bytes or less are stored inside the 256t string and are free.
const response = await account.fetch("/api/content", {
method: "POST",
headers: { "content-type": "text/plain; charset=utf-8" },
body: textToSave
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (result.error === "insufficient_balance") {
showMessage(result.message || "Your HashBin balance is too low to save this.", {
error: true,
actions: [{ label: "Add funds", onPress: () => Linking.openURL(`${HASHBIN_URL}/deposit.html`) }]
});
} else {
showMessage(`Saving failed: ${result.message || result.error || `HTTP ${response.status}`}`, { error: true });
}
return;
}
setCurrentCid(result.cid);
setSavedText(textToSave);
const expires = result.expires_at
? ` Kept until ${new Date(result.expires_at).toLocaleDateString()}.`
: "";
showMessage(`Saved.${expires}`, {
actions: [
{ label: "Copy link", onPress: () => copyLink(result.cid) },
{ label: "View raw text", onPress: () => Linking.openURL(`${CONTENT_URL}/${result.cid}`) }
]
});
showAccount();
} catch (error) {
if (error instanceof NotConnectedError) {
showAccount();
showMessage("Your HashBin connection has ended. Tap Save to connect again.", { error: true });
} else {
showMessage(`Saving failed: ${error.message}`, { error: true });
}
} finally {
setBusy(false);
}
}
9. Receiving links
React Native's Linking module gives the link that launched the app, and then
any link opened while it runs. The listener is set up once, so it calls the newest
openLink through a ref; otherwise it would see the text as it was when the app
started.
// The link listener below is set up once, but openLink changes with
// every render (it reads the current text). The ref always holds the
// latest one.
const openLinkRef = useRef(openLink);
openLinkRef.current = openLink;
useEffect(() => {
// Start up: read saved tokens, then open the link that launched the
// app, if any.
(async () => {
await account.load();
showAccount();
const initialLink = await Linking.getInitialURL();
if (initialLink) {
openLinkRef.current(initialLink);
}
})();
// A 256t link opened while the app is already running.
const subscription = Linking.addEventListener("url", ({ url }) => openLinkRef.current(url));
return () => subscription.remove();
}, []);
Run it
You need Node.js, plus Xcode for iOS or Android Studio for Android. Textpad uses its own URL scheme, so it needs a development build; it can't run in Expo Go, which has a scheme of its own.
git clone https://github.com/curtcox/hashbin.org.git
cd hashbin.org/examples/textpad-expo
npm install
npx expo run:ios # or: npx expo run:android
The first run builds the native app, which takes a few minutes. After that, edits to the JavaScript reload instantly.
Make your own app from it
-
Pick a URL scheme from a domain you control, written backwards, for example
com.example.notes. Set it asscheme,ios.bundleIdentifierandandroid.packageinapp.json. -
Register an app on the Developers page with the redirect URI
com.example.notes://oauth. - In
App.js, setREDIRECT_URIto that address andCLIENT_IDto your app's client ID. - Rebuild the native app (
npx expo prebuild --clean, thennpx expo run:ios), since the scheme is part of it.
Against a local HashBin server
Start the server with npm run dev:local in the repository root. On its
Developers page, register an app with the
redirect URI org.hashbin.textpad.expo://oauth. Then run:
EXPO_PUBLIC_HASHBIN_URL=http://127.0.0.1:8787 \
EXPO_PUBLIC_TEXTPAD_CLIENT_ID=<client ID> \
npx expo run:ios
In the Android emulator, the computer is 10.0.2.2 instead of
127.0.0.1. Expo copies these variables into the app when it bundles the
JavaScript, so restart with npx expo start --clear after changing them.
Things to know
- Copied links point to the web. Copy link copies a link to the web version of Textpad, which opens in any browser on any device; pasting it into this app's Open field works too.
- Saving the same text again. Save stays disabled until the text changes. If edited text happens to match something already on HashBin, HashBin keeps the existing copy and extends how long it is kept, which is charged like a new save.
- Saves use the account's default retention. Apps cannot choose how long content is kept; users set their default on the account page.
- Android can close a backgrounded app. If the phone is very short of memory while the approval page is open, Android may close Textpad, and unsaved text would be lost. A production app could keep a draft in storage to guard against this.
Full source
textpad.js
// 256t strings, links and text decoding for Textpad.
//
// Nothing in this file uses React Native, so the same code runs in the app
// and in plain Node.js tests (see src/docs-textpad-native.test.js in the
// hashbin.org repository).
// ----------------------------------------------------------------------
// Finding a 256t string in a link
// ----------------------------------------------------------------------
// Textpad opens a 256t string from any of these:
// - its own deep link: org.hashbin.textpad.expo://open?256t=<cid>
// - a web Textpad link: https://hashbin.org/docs/textpad/app#256t=<cid>
// - a raw content link: https://256t.us/<cid>
// - the bare 256t string: <cid>
// It returns null for a link that holds no 256t string, such as the
// "org.hashbin.textpad.expo://oauth?code=…" link that ends a sign-in.
export function cidFromText(text) {
const trimmed = text.trim();
const param = trimmed.match(/[#?&]256t=([^&#]*)/);
if (param) {
try {
return decodeURIComponent(param[1]);
} catch {
return param[1]; // A stray "%" can't be decoded; isValid256t rejects it.
}
}
const contentLink = trimmed.match(/^https:\/\/256t\.us\/([^/?#]*)/);
if (contentLink) {
return contentLink[1];
}
return trimmed.includes("://") ? null : trimmed;
}
// The query parameters of a link, such as the "code" and "state" that
// HashBin adds when it sends the user back after sign-in. (React Native's
// URL class doesn't support searchParams, so this is done by hand.)
export function queryParams(link) {
const query = link.split("#")[0].split("?")[1] || "";
const params = {};
for (const pair of query.split("&").filter(Boolean)) {
const [name, value = ""] = pair.split("=");
params[decodeURIComponent(name)] = decodeURIComponent(value.replace(/\+/g, " "));
}
return params;
}
// ----------------------------------------------------------------------
// Checking a 256t string
// ----------------------------------------------------------------------
export function base64UrlToBytes(text) {
const base64 = text.replace(/-/g, "+").replace(/_/g, "/");
return Uint8Array.from(atob(base64), (char) => char.charCodeAt(0));
}
export function bytesToBase64Url(bytes) {
const base64 = btoa(String.fromCharCode(...bytes));
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
// A 256t string is base64url text in two parts:
// - 8 characters holding the content's length in bytes
// (a 6-byte big-endian number);
// - then the content itself if it is 64 bytes or less, or else the
// content's SHA-512 hash (always 86 characters).
// Checking this up front gives a clear message for a mistyped or cut-off
// link instead of a confusing "not found".
export function contentLength(cid) {
return base64UrlToBytes(cid.slice(0, 8)).reduce((total, byte) => total * 256 + byte, 0);
}
export function isValid256t(cid) {
if (!/^[A-Za-z0-9_-]{8,94}$/.test(cid)) {
return false;
}
const length = contentLength(cid);
const expectedRest = length <= 64 ? Math.ceil((length * 4) / 3) : 86;
return cid.length - 8 === expectedRest;
}
// Short texts are inside the 256t string itself, so there is nothing to
// download: this returns their bytes, or null for longer content.
export function inlineBytes(cid) {
return contentLength(cid) <= 64 ? base64UrlToBytes(cid.slice(8)) : null;
}
// ----------------------------------------------------------------------
// Turning bytes into text
// ----------------------------------------------------------------------
// Content on HashBin is just bytes. Textpad only opens bytes that are valid
// UTF-8 text. React Native has no strict UTF-8 decoder, but
// decodeURIComponent is one: given the bytes as %XX escapes, it throws on
// anything that isn't valid UTF-8 instead of inserting replacement
// characters. It also keeps a leading byte order mark, so saving unchanged
// text gives back exactly the same bytes and 256t string.
export function decodeText(bytes) {
let escaped = "";
for (const byte of bytes) {
escaped += byte < 16 ? `%0${byte.toString(16)}` : `%${byte.toString(16)}`;
}
const text = decodeURIComponent(escaped);
// Valid UTF-8 can still be binary data; a NUL character is a reliable
// sign of that, and real text practically never contains one.
if (text.includes("\0")) {
throw new TypeError("Content contains NUL characters");
}
return text;
}
hashbin.js
// A small HashBin OAuth client for React Native.
//
// It signs the user in with OAuth 2.0 and PKCE in the system browser, keeps
// the tokens in the device's secure storage (the iOS Keychain or Android
// Keystore), renews the access token when it expires, and calls the HashBin
// API as the user. Copy this file into your own app and change the
// options passed to the constructor.
import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import { bytesToBase64Url, queryParams } from "./textpad";
// Raised when the stored sign-in no longer works: it expired, or the user
// revoked the app on their HashBin account page.
export class NotConnectedError extends Error {}
const TOKENS_KEY = "hashbin.tokens";
// Renew the access token this long before it expires, so a request never
// sets out with a token that runs out on the way.
const EXPIRY_MARGIN_MS = 60 * 1000;
export class HashBinAccount {
constructor({ clientId, redirectUri, baseUrl, scopes }) {
this.clientId = clientId;
this.redirectUri = redirectUri;
this.baseUrl = baseUrl;
this.scopes = scopes;
this.tokens = null; // { accessToken, refreshToken, expiresAt }
this.refreshing = null;
}
// Reads the tokens saved by an earlier run of the app.
async load() {
const saved = await SecureStore.getItemAsync(TOKENS_KEY);
this.tokens = saved ? JSON.parse(saved) : null;
}
isConnected() {
return Boolean(this.tokens?.refreshToken);
}
async saveTokens(response) {
this.tokens = {
accessToken: response.access_token,
refreshToken: response.refresh_token,
expiresAt: Date.now() + response.expires_in * 1000
};
await SecureStore.setItemAsync(TOKENS_KEY, JSON.stringify(this.tokens));
}
async clearTokens() {
this.tokens = null;
await SecureStore.deleteItemAsync(TOKENS_KEY);
}
// --------------------------------------------------------------------
// Signing in
// --------------------------------------------------------------------
// Opens HashBin's approval page in an in-app browser and waits for the
// user to come back. Resolves to "connected", "denied" (the user clicked
// Deny) or "cancelled" (the user closed the browser).
async connect() {
// PKCE: a random secret (the verifier) stays in the app, and only its
// SHA-256 hash (the challenge) goes into the sign-in link. Whoever
// exchanges the code for tokens must know the verifier, so a code
// intercepted on the way back to the app is useless to anyone else.
const verifier = bytesToBase64Url(Crypto.getRandomBytes(32));
const challenge = (await Crypto.digestStringAsync(
Crypto.CryptoDigestAlgorithm.SHA256,
verifier,
{ encoding: Crypto.CryptoEncoding.BASE64 }
)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
// "state" ties the reply to this request.
const state = bytesToBase64Url(Crypto.getRandomBytes(16));
const query = {
client_id: this.clientId,
redirect_uri: this.redirectUri,
response_type: "code",
scope: this.scopes.join(" "),
state,
code_challenge: challenge,
code_challenge_method: "S256"
};
const authorizeUrl = `${this.baseUrl}/oauth/authorize?` + Object.entries(query)
.map(([name, value]) => `${name}=${encodeURIComponent(value)}`)
.join("&");
// On iOS this is an ASWebAuthenticationSession, on Android a Chrome
// Custom Tab. It closes by itself when HashBin redirects to
// redirectUri, and hands that link back here.
const result = await WebBrowser.openAuthSessionAsync(authorizeUrl, this.redirectUri);
if (result.type !== "success") {
return "cancelled";
}
const params = queryParams(result.url);
if (params.state !== state) {
throw new Error("The reply from HashBin doesn't match this sign-in.");
}
if (params.error === "access_denied") {
return "denied";
}
if (params.error || !params.code) {
throw new Error(params.error || "HashBin didn't send an authorization code.");
}
// Trade the one-time code (plus the verifier) for tokens.
const response = await fetch(`${this.baseUrl}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: this.clientId,
redirect_uri: this.redirectUri,
code: params.code,
code_verifier: verifier
})
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body.message || body.error || `HTTP ${response.status}`);
}
await this.saveTokens(body);
return "connected";
}
// Ends the app's access: forgets the tokens here and revokes the refresh
// token on the server, so a copy of it can't be used either.
async disconnect() {
const refreshToken = this.tokens?.refreshToken;
await this.clearTokens();
if (refreshToken) {
await fetch(`${this.baseUrl}/oauth/revoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ token: refreshToken, token_type_hint: "refresh_token" })
}).catch(() => {});
}
}
// --------------------------------------------------------------------
// Calling the API
// --------------------------------------------------------------------
// Access tokens last an hour. A refresh token gets a new one, and is
// itself replaced: each refresh token works only once. Calls that need a
// new token at the same moment share one refresh, because a second
// refresh with the same (by then used) token would fail.
refreshAccessToken() {
if (!this.refreshing) {
this.refreshing = this.renewTokens().finally(() => {
this.refreshing = null;
});
}
return this.refreshing;
}
async renewTokens() {
const response = await fetch(`${this.baseUrl}/oauth/token`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ grant_type: "refresh_token", refresh_token: this.tokens.refreshToken })
});
const body = await response.json().catch(() => ({}));
if (body.error === "invalid_grant") {
// Expired or revoked: the user has to approve the app again.
await this.clearTokens();
throw new NotConnectedError();
}
if (!response.ok) {
throw new Error(body.message || body.error || `HTTP ${response.status}`);
}
await this.saveTokens(body);
}
// fetch() for HashBin API paths, as the signed-in user.
async fetch(path, options = {}) {
if (!this.isConnected()) {
throw new NotConnectedError();
}
if (Date.now() > this.tokens.expiresAt - EXPIRY_MARGIN_MS) {
await this.refreshAccessToken();
if (!this.isConnected()) {
throw new NotConnectedError(); // Disconnected while renewing.
}
}
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: { ...options.headers, authorization: `Bearer ${this.tokens.accessToken}` }
});
if (response.status === 401) {
await this.clearTokens();
throw new NotConnectedError();
}
return response;
}
}
App.js
// Textpad for Expo: open text from a 256t link, edit it, and save it to
// the user's HashBin account.
//
// A walkthrough of this code is at https://hashbin.org/docs/textpad-expo
import * as Clipboard from "expo-clipboard";
import { StatusBar } from "expo-status-bar";
import { useEffect, useRef, useState } from "react";
import {
Alert,
KeyboardAvoidingView,
Linking,
Platform,
Pressable,
StyleSheet,
Text,
TextInput,
View,
useColorScheme
} from "react-native";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { HashBinAccount, NotConnectedError } from "./hashbin";
import { cidFromText, decodeText, inlineBytes, isValid256t } from "./textpad";
// ----------------------------------------------------------------------
// 1. Configuration
// ----------------------------------------------------------------------
// The HashBin service, and the domain that serves published content.
// Development only: EXPO_PUBLIC_HASHBIN_URL points the app at a local
// HashBin server (npm run dev:local), which serves content itself.
const HASHBIN_URL = process.env.EXPO_PUBLIC_HASHBIN_URL || "https://hashbin.org";
const CONTENT_URL = process.env.EXPO_PUBLIC_HASHBIN_URL || "https://256t.us";
// The client ID from registering Textpad at https://hashbin.org/developers,
// or EXPO_PUBLIC_TEXTPAD_CLIENT_ID for an app registered on a local server.
const CLIENT_ID = process.env.EXPO_PUBLIC_TEXTPAD_CLIENT_ID || "app_1a74e62b-d752-4a3c-81af-5c850af18c5b";
// HashBin sends the user back to this link after they approve Textpad. Its
// scheme is the app's own ("scheme" in app.json), so the system hands the
// link to this app. It must match a registered redirect URI exactly.
const REDIRECT_URI = "org.hashbin.textpad.expo://oauth";
// What Textpad asks the user to allow: publishing (billed to their
// balance) and reading their balance so it can be shown.
const SCOPES = ["content:write", "balance:read"];
const account = new HashBinAccount({
clientId: CLIENT_ID,
redirectUri: REDIRECT_URI,
baseUrl: HASHBIN_URL,
scopes: SCOPES
});
export default function App() {
return (
<SafeAreaProvider>
<Textpad />
</SafeAreaProvider>
);
}
function Textpad() {
// --------------------------------------------------------------------
// 2. Screen state
// --------------------------------------------------------------------
// The text in the editor, and that text exactly as it was opened or last
// saved under the 256t string currentCid. Comparing the two tells us
// whether there are unsaved changes. savedText and currentCid are null
// for a new, unsaved text.
const [text, setText] = useState("");
const [savedText, setSavedText] = useState(null);
const [currentCid, setCurrentCid] = useState(null);
// Set while a load or save is running, to ignore extra taps.
const [busy, setBusy] = useState(false);
// The message above the editor: { text, error, actions: [{ label, onPress }] }
const [message, setMessage] = useState(null);
const [connected, setConnected] = useState(false);
const [accountLabel, setAccountLabel] = useState("");
const [linkField, setLinkField] = useState("");
const hasUnsavedChanges = text !== (savedText ?? "");
function showMessage(messageText, { error = false, actions = [] } = {}) {
setMessage({ text: messageText, error, actions });
}
function clearMessage() {
setMessage(null);
}
// Shows text in the editor as the saved version of cid.
function setDocument(cid, newText) {
setCurrentCid(cid);
setText(newText);
setSavedText(newText);
}
// --------------------------------------------------------------------
// 3. Opening a 256t link
// --------------------------------------------------------------------
// Asks before throwing away unsaved changes. Resolves to true to go on.
function confirmDiscard() {
if (!hasUnsavedChanges) {
return Promise.resolve(true);
}
return new Promise((resolve) => {
Alert.alert("Discard your unsaved changes?", undefined, [
{ text: "Keep editing", style: "cancel", onPress: () => resolve(false) },
{ text: "Discard", style: "destructive", onPress: () => resolve(true) }
]);
});
}
// Opens a deep link, a web Textpad link, a 256t.us link or a bare 256t
// string. fromUser is true when it came from the "Open" field, where a
// link without a 256t string deserves a message rather than silence.
async function openLink(link, { fromUser = false } = {}) {
const cid = cidFromText(link);
if (cid === null) {
if (fromUser) {
showMessage("That link doesn't contain a 256t string.", { error: true });
}
return;
}
if (busy || !(await confirmDiscard())) {
return;
}
setLinkField("");
await openCid(cid);
}
async function openCid(cid) {
// Whatever happens below, don't leave the previous text on screen as if
// it belonged to this link.
setDocument(null, "");
clearMessage();
if (cid === "") {
showMessage("This link has \"256t=\" but no 256t string after it.", { error: true });
return;
}
if (!isValid256t(cid)) {
const shown = cid.length > 100 ? `${cid.slice(0, 100)}…` : cid;
showMessage(`"${shown}" isn't a valid 256t string. Check that the link was copied completely.`, { error: true });
return;
}
// Short texts are inside the 256t string itself.
const inline = inlineBytes(cid);
if (inline) {
showText(cid, inline);
return;
}
setBusy(true);
showMessage("Loading…");
try {
let response;
try {
response = await fetch(`${CONTENT_URL}/${cid}`);
} catch {
showMessage("Couldn't reach HashBin to load this text. Check your connection and try again.", { error: true });
return;
}
if (response.status === 404) {
showMessage("Nothing is stored under this 256t string. It may have expired or been deleted, or it was never saved.", { error: true });
return;
}
if (response.status === 451) {
showMessage("This text is unavailable while a dispute about it is reviewed.", { error: true });
return;
}
// Each stored text has a download allowance; when it runs out, 256t.us answers 429.
if (response.status === 429) {
showMessage("HashBin is limiting how often this text can be downloaded. Try again later.", { error: true });
return;
}
if (!response.ok) {
showMessage(`HashBin couldn't return this text (HTTP ${response.status}). Try again later.`, { error: true });
return;
}
showText(cid, new Uint8Array(await response.arrayBuffer()));
} finally {
setBusy(false);
}
}
function showText(cid, bytes) {
try {
setDocument(cid, decodeText(bytes));
clearMessage();
} catch {
showMessage("This 256t string points to content that isn't text, so Textpad can't open it.", { error: true });
}
}
// --------------------------------------------------------------------
// 4. The user's HashBin account
// --------------------------------------------------------------------
async function showAccount() {
const isConnected = account.isConnected();
setConnected(isConnected);
if (!isConnected) {
setAccountLabel("Not connected to HashBin");
return;
}
setAccountLabel("Connected to HashBin");
try {
const response = await account.fetch("/api/balance");
if (response.ok) {
const { balance_cents } = await response.json();
setAccountLabel(`HashBin balance: $${(balance_cents / 100).toFixed(2)}`);
}
} catch (error) {
if (error instanceof NotConnectedError) {
showAccount();
}
}
}
async function disconnect() {
await account.disconnect();
showAccount();
}
// --------------------------------------------------------------------
// 5. Saving text
// --------------------------------------------------------------------
async function save() {
if (busy || !hasUnsavedChanges || text === "") {
return;
}
// The editor is read-only while busy, so this is what gets saved.
const textToSave = text;
setBusy(true);
try {
// The first save asks the user to approve Textpad. Unlike a web page,
// the app keeps running while the browser is open, so nothing has to
// be stored to survive the trip.
if (!account.isConnected()) {
showMessage("Waiting for you to approve Textpad on HashBin…");
const outcome = await account.connect();
showAccount();
if (outcome === "denied") {
showMessage("You didn't approve Textpad on HashBin, so nothing was saved.", { error: true });
return;
}
if (outcome === "cancelled") {
clearMessage();
return;
}
}
showMessage("Saving…");
// The request body is the text itself. Uploads made with an OAuth
// app are kept for the user's default retention period (set on their
// HashBin account page) and charged to their balance. Texts of 64
// bytes or less are stored inside the 256t string and are free.
const response = await account.fetch("/api/content", {
method: "POST",
headers: { "content-type": "text/plain; charset=utf-8" },
body: textToSave
});
const result = await response.json().catch(() => ({}));
if (!response.ok) {
if (result.error === "insufficient_balance") {
showMessage(result.message || "Your HashBin balance is too low to save this.", {
error: true,
actions: [{ label: "Add funds", onPress: () => Linking.openURL(`${HASHBIN_URL}/deposit.html`) }]
});
} else {
showMessage(`Saving failed: ${result.message || result.error || `HTTP ${response.status}`}`, { error: true });
}
return;
}
setCurrentCid(result.cid);
setSavedText(textToSave);
const expires = result.expires_at
? ` Kept until ${new Date(result.expires_at).toLocaleDateString()}.`
: "";
showMessage(`Saved.${expires}`, {
actions: [
{ label: "Copy link", onPress: () => copyLink(result.cid) },
{ label: "View raw text", onPress: () => Linking.openURL(`${CONTENT_URL}/${result.cid}`) }
]
});
showAccount();
} catch (error) {
if (error instanceof NotConnectedError) {
showAccount();
showMessage("Your HashBin connection has ended. Tap Save to connect again.", { error: true });
} else {
showMessage(`Saving failed: ${error.message}`, { error: true });
}
} finally {
setBusy(false);
}
}
// A link to the web version of Textpad opens the text in any browser,
// and pasting it into this app's "Open" field works too.
async function copyLink(cid) {
await Clipboard.setStringAsync(`${HASHBIN_URL}/docs/textpad/app#256t=${cid}`);
showMessage("Link copied. Anyone with it can read this text.");
}
async function newDocument() {
if (busy || !(await confirmDiscard())) {
return;
}
setDocument(null, "");
clearMessage();
}
// --------------------------------------------------------------------
// 6. Wiring it together
// --------------------------------------------------------------------
// The link listener below is set up once, but openLink changes with
// every render (it reads the current text). The ref always holds the
// latest one.
const openLinkRef = useRef(openLink);
openLinkRef.current = openLink;
useEffect(() => {
// Start up: read saved tokens, then open the link that launched the
// app, if any.
(async () => {
await account.load();
showAccount();
const initialLink = await Linking.getInitialURL();
if (initialLink) {
openLinkRef.current(initialLink);
}
})();
// A 256t link opened while the app is already running.
const subscription = Linking.addEventListener("url", ({ url }) => openLinkRef.current(url));
return () => subscription.remove();
}, []);
// --------------------------------------------------------------------
// 7. The screen
// --------------------------------------------------------------------
const colors = useColorScheme() === "dark" ? darkColors : lightColors;
const styles = makeStyles(colors);
let savedState = "";
if (currentCid && !hasUnsavedChanges) {
savedState = `Saved as 256t:${currentCid.slice(0, 12)}…`;
} else if (currentCid) {
savedState = "Unsaved changes";
} else if (text) {
savedState = "Not saved yet";
}
return (
<SafeAreaView style={styles.screen}>
<StatusBar style="auto" />
<KeyboardAvoidingView style={styles.screen} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<View style={styles.header}>
<Text style={styles.title}>Textpad</Text>
<Text style={styles.muted} numberOfLines={1}>{accountLabel}</Text>
{connected && <Button styles={styles} label="Disconnect" onPress={disconnect} />}
</View>
<View style={styles.row}>
<TextInput
style={[styles.input, styles.linkField]}
value={linkField}
onChangeText={setLinkField}
placeholder="Paste a 256t string or link"
placeholderTextColor={colors.muted}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="go"
onSubmitEditing={() => openLink(linkField, { fromUser: true })}
/>
<Button
styles={styles}
label="Open"
disabled={busy || !linkField.trim()}
onPress={() => openLink(linkField, { fromUser: true })}
/>
</View>
{message && (
<View style={[styles.message, message.error && styles.error]} accessibilityLiveRegion="polite">
<Text style={styles.messageText}>{message.text}</Text>
{message.actions.length > 0 && (
<View style={styles.actions}>
{message.actions.map((action) => (
<Pressable key={action.label} onPress={action.onPress} accessibilityRole="link">
<Text style={styles.link}>{action.label}</Text>
</Pressable>
))}
</View>
)}
</View>
)}
<TextInput
style={[styles.input, styles.editor]}
value={text}
onChangeText={setText}
editable={!busy}
multiline
textAlignVertical="top"
placeholder="Type or paste text here."
placeholderTextColor={colors.muted}
accessibilityLabel="Text"
/>
<View style={styles.row}>
<Text style={[styles.muted, styles.savedState]} numberOfLines={1}>{savedState}</Text>
<Button styles={styles} label="New" disabled={busy} onPress={newDocument} />
<Button
styles={styles}
label="Save"
primary
disabled={busy || !hasUnsavedChanges || text === ""}
onPress={save}
/>
</View>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
function Button({ styles, label, onPress, disabled = false, primary = false }) {
return (
<Pressable
onPress={onPress}
disabled={disabled}
accessibilityRole="button"
accessibilityState={{ disabled }}
style={({ pressed }) => [
styles.button,
primary && styles.primaryButton,
(disabled || pressed) && styles.dimmed
]}
>
<Text style={[styles.buttonText, primary && styles.primaryButtonText]}>{label}</Text>
</Pressable>
);
}
const lightColors = {
background: "#ffffff",
text: "#1f2328",
muted: "#656d76",
border: "#d0d7de",
accent: "#0969da",
error: "#cf222e",
errorBackground: "#ffebe9",
messageBackground: "#f6f8fa"
};
const darkColors = {
background: "#0d1117",
text: "#e6edf3",
muted: "#8d96a0",
border: "#30363d",
accent: "#4493f8",
error: "#f85149",
errorBackground: "#2d1214",
messageBackground: "#161b22"
};
function makeStyles(colors) {
return StyleSheet.create({
screen: { flex: 1, backgroundColor: colors.background },
header: { flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 16, paddingVertical: 8 },
title: { fontSize: 20, fontWeight: "700", color: colors.text },
muted: { flex: 1, color: colors.muted },
row: { flexDirection: "row", alignItems: "center", gap: 8, paddingHorizontal: 16, paddingVertical: 8 },
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: 6,
paddingHorizontal: 10,
paddingVertical: 8,
color: colors.text,
fontSize: 16
},
linkField: { flex: 1 },
editor: { flex: 1, marginHorizontal: 16, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace" },
message: {
marginHorizontal: 16,
marginBottom: 8,
padding: 10,
borderRadius: 6,
backgroundColor: colors.messageBackground,
gap: 8
},
error: { backgroundColor: colors.errorBackground },
messageText: { color: colors.text },
actions: { flexDirection: "row", gap: 16 },
link: { color: colors.accent, fontWeight: "600" },
savedState: { fontSize: 13 },
button: { borderWidth: 1, borderColor: colors.border, borderRadius: 6, paddingHorizontal: 14, paddingVertical: 8 },
primaryButton: { backgroundColor: colors.accent, borderColor: colors.accent },
buttonText: { color: colors.text, fontWeight: "600" },
primaryButtonText: { color: "#ffffff" },
dimmed: { opacity: 0.5 }
});
}
app.json
{
"expo": {
"name": "Textpad",
"slug": "textpad-expo",
"version": "1.0.0",
"scheme": "org.hashbin.textpad.expo",
"orientation": "portrait",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
"ios": {
"supportsTablet": true,
"bundleIdentifier": "org.hashbin.textpad.expo"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
"backgroundImage": "./assets/android-icon-background.png",
"monochromeImage": "./assets/android-icon-monochrome.png"
},
"package": "org.hashbin.textpad.expo"
},
"web": {
"favicon": "./assets/favicon.png"
},
"plugins": [
"expo-web-browser",
"expo-secure-store"
]
}
}
package.json
{
"name": "textpad-expo",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"expo": "~57.0.31",
"expo-clipboard": "~57.0.2",
"expo-crypto": "~57.0.4",
"expo-secure-store": "~57.0.4",
"expo-status-bar": "~57.0.1",
"expo-web-browser": "~57.0.3",
"react": "19.2.3",
"react-native": "0.86.3",
"react-native-safe-area-context": "~5.7.0"
},
"scripts": {
"start": "expo start",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web"
},
"private": true
}