I take a lot of screenshots of Bluesky posts to share in Slack. A few weeks ago I noticed something odd: a handful of them had a tiny butterfly logo tucked into a corner that I had genuinely never seen while actually using the app. Not a watermark I'd toggled on, not something in a share sheet, nothing in the settings menu that would explain it. It only showed up in the saved screenshot file itself.
My first assumption was that this was some kind of post-processing step, maybe triggered by the iOS screenshot notification, stamping the image after the fact. That theory fell apart the moment I actually thought about the mechanics of how screenshots get saved on iOS, which sent me down a genuinely fun rabbit hole into the Bluesky app's open source repo, a file literally named GrowthHack.tsx, and an Expo package most people have never heard of.
What I found wasn't clever image processing at all. It's a repurposing of an iOS privacy feature that was never designed for branding, and understanding it taught me more about how UIKit renders "secure" content than any tutorial I'd read before.
The Screenshot That Shouldn't Have Had a Logo
The obvious explanation for "something appears only in screenshots" is a screenshot listener. iOS has had UIApplication.userDidTakeScreenshotNotification since forever, and Expo wraps it as expo-screen-capture. The idea would be: detect the screenshot, then draw a logo on top before the user notices.
The problem is timing. By the time that notification fires, the image is already sitting in the user's Photos library. You can't retroactively edit a file the OS already wrote to disk on your behalf — you'd need to intercept the pixel buffer before it's committed, and Apple gives apps no hook that early in the capture pipeline. Whatever Bluesky was doing, it had to already be baked into the view hierarchy at the moment iOS takes its snapshot, not applied afterward.
That reframing is what sent me looking at the source instead of guessing. Bluesky's app is open, and the relevant code turned out to sit behind a dependency called expo-privacy-sensitive, built by one of their own engineers. The name alone was a pretty big hint about what was actually going on.
What "Secure Text Entry" Actually Protects
Every iOS app that's ever had a password field relies on a UIKit feature most developers touch once and forget about: UITextField.isSecureTextEntry. Set it to true and the field masks input with dots, sure, but there's a second, less advertised behavior baked into the same flag. When iOS captures a snapshot of your app — for the App Switcher card, for a screen recording, or for a screenshot — any secure text field gets its rendered content swapped out at the compositor level before the image is finalized.
This isn't something you opt into separately. It's automatic, and it exists so that a banking app's PIN field can't leak into a screenshot someone accidentally shares, or into the thumbnail iOS generates when you swipe up to switch apps. The redaction happens below the level your view controller code even runs at; you don't get a callback, you don't get a chance to intervene, the OS just does it.
Once I understood that the blanking happens at the layer level rather than as a rule about "text fields containing passwords," the rest clicked into place. It's not about controlling what appears when a layer gets redacted — nothing appears, the layer just goes blank. What you actually control is what you've stacked underneath it, waiting for exposure the moment the thing on top disappears.
Inside expo-privacy-sensitive: What's Actually Behind the Button
The part I originally got backwards in my head — and worth naming explicitly, since it's the whole trick — is which element lives inside the secure field. It's not the logo. It's the "Follow" button. The package creates a non-interactive UITextField with isSecureTextEntry set to true, reaches into its underlying CALayer, and renders the button's artwork into it. During ordinary use, that's exactly what the user sees: a normal, visible Follow button, stacked directly on top of a logo layer that renders the whole time but stays covered.
I don't have Bluesky's literal Swift source in front of me, so what follows is my own stripped-down reconstruction of the idea rather than a claim about their exact implementation, but it captures the mechanism:
final class ButterflyRevealView: ExpoView {
private let logoLayer = CALayer()
private let secureField = UITextField()
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
// The logo is a plain, always-rendered layer sitting behind
// everything else — nothing secure or special about it.
if let logoImage = UIImage(named: "bsky_butterfly") {
logoLayer.contents = logoImage.cgImage
logoLayer.frame = bounds
}
layer.addSublayer(logoLayer)
// The Follow button is what actually renders inside the secure
// field, covering the logo during ordinary use.
secureField.isSecureTextEntry = true
secureField.text = " "
secureField.isUserInteractionEnabled = false
if let followButtonImage = UIImage(named: "follow_button"),
let editingLayer = secureField.layer.sublayers?.first {
editingLayer.contents = followButtonImage.cgImage
}
addSubview(secureField)
}
}
The important line is the one rendering the Follow button into the field's internal editing sublayer rather than the logo. That sublayer is specifically the thing iOS blanks during a capture — so when a screenshot fires, the button that's been covering the logo the whole time simply disappears, and it's the layer that was there all along that the screenshot actually captures.
Reproducing a Minimal Version
I wanted to see this fail the "obvious" way before I understood why it worked, so I set up a throwaway Expo project and tried the screenshot-listener approach I mentioned earlier first, mostly to confirm my own assumption about the timing problem.
The setup was about as basic as it gets: a listener registered on mount, and a callback that flips some local state to show an overlay view once a screenshot fires.
import { addScreenshotListener } from 'expo-screen-capture';
addScreenshotListener(() => {
// By the time this fires, the screenshot is already
// saved to Photos — there's nothing left to modify.
showWatermarkOverlay();
});
That confirmed what I'd suspected: the overlay showed up fine on screen, a full render cycle after the actual capture, which is useless if your goal is for the logo to exist inside the saved image. The actual package's public API, once I went and read it, made the direction of the trick obvious in a way my first assumption hadn't: expo-privacy-sensitive exports a component called PrivacySensitive, and its whole job is to hide its children from screenshots — the README's own example wraps sensitive text like a card number in it. It's not a "reveal in screenshots" component at all. It's a "hide from screenshots" component, and Bluesky is using it to hide the thing covering the logo, not to hide the logo itself.
import { PrivacySensitive } from 'expo-privacy-sensitive';
export function ButterflyWithFollowButton() {
return (
);
}
From the JavaScript side there's nothing to detect and nothing to time — the logo just sits in the tree permanently, rendered like any other image. The Follow button sitting on top of it is the only thing wrapped in PrivacySensitive, and it's the button that vanishes the instant a screenshot fires. That inversion of control, where your code stops trying to react to the screenshot event at all and instead just decides what's allowed to disappear, is really the whole trick.
Why This Breaks in the App Switcher
Once I had a working version, I immediately tried to see it in the App Switcher preview by swiping up and holding, expecting the same redaction to blank the Follow button there too, since that's also a "secure snapshot" moment according to Apple's own documentation. It didn't. The card just looked like the normal, logo-covered screen — Follow button visible, logo hidden behind it, exactly like ordinary use.
My best explanation, after poking at it for longer than I'd like to admit, is that the App Switcher snapshot is captured continuously and cached the moment you start the switching gesture, using whatever the view hierarchy already looked like at that instant — not a fresh, live redraw pass through the secure-content pipeline. A real screenshot triggers a genuine re-composite of the live view tree, which is when the secure field's blanking actually runs. The App Switcher card is closer to a photo of a photo; there's no live UITextField instance for iOS to intervene on at that point, just an already-baked bitmap with the button still sitting right where it always was.
It's a good reminder that "screenshot" isn't one unified concept on iOS. Volume-and-power capture, the share-sheet screenshot editor, screen recording, and App Switcher previews all go through different code paths internally, and a trick that depends on one of them isn't guaranteed to behave consistently across the others.
Where This Pattern Is Actually Useful (and Where It Isn't)
Setting aside the branding use case, this is the mechanism's actual, intended use: hiding sensitive content from screenshots, full stop. Banking apps and password managers already lean on plain isSecureTextEntry fields, or wrappers like PrivacySensitive built on top of them, for exactly that reason. Bluesky isn't doing anything novel with the privacy mechanism itself — the only clever part is choosing to hide a Follow button instead of a PIN, so that what's left behind happens to be branding instead of blank space.
The tradeoff that matters most if you're considering this for your own app is platform scope. This entire mechanism is an iOS-specific behavior of UIKit's secure text rendering pipeline, and Android has no equivalent hook you can piggyback on in the same way, so you'd need a completely different (and almost certainly less elegant) approach there.
| Capture Method | iOS Behavior | Android Behavior |
|---|---|---|
| Volume + power screenshot | Secure layer (the Follow button) blanked live — the logo it was covering shows through | No equivalent OS-level redaction hook |
| App Switcher preview | Uses a cached snapshot from gesture start — button still covering the logo | Same limitation, no secure-field pipeline to exploit |
| Screen recording | Untested by me, but likely re-applies per frame, which could flicker | No comparable behavior |
There's also a practical review-risk angle worth naming plainly: this relies on the exact internal layer structure of UITextField, which is not a documented, stable API surface. Apple could change how the internal editing layer is structured in a future iOS release and quietly break this without ever calling it a deprecation, since from Apple's point of view they never promised that structure to third parties in the first place.
What I'd Actually Take Away From This
I don't think most teams should copy this verbatim — it's a fragile dependency on undocumented internals for a marketing win that a lot of product folks would consider marginal anyway. But the underlying idea, that you can lean on a privacy API's redaction behavior to control what appears in a snapshot without ever handling a screenshot event yourself, is worth remembering the next time you're stuck trying to solve a "detect X and react to it" problem where the detection always fires a beat too late. Sometimes the fix isn't a faster listener, it's finding the OS mechanism that already runs at the exact moment you need, and quietly riding along inside it.

Comments
No comments yet — be the first to share your thoughts.