Use butterfly glyphs in the Invite Friends QR code

Render each QR data module as a Bluesky butterfly instead of a rounded square, using react-native-qrcode-styled's renderCustomPieceItem hook. The finder eyes stay as rounded ring-and-dot shapes and the centered avatar is unchanged.

Butterfly modules cover less of each cell than solid squares, so error correction is raised to H to keep the code scannable. pieceSize is derived from the module count so the QR renders at a fixed size and keeps a consistent white margin inside the card for any handle length.
This commit is contained in:
vineyardbovines
2026-06-16 10:18:55 -04:00
parent 1c2fd5b280
commit 34bf65958a
2 changed files with 335 additions and 97 deletions
@@ -1,16 +1,23 @@
import {lazy} from 'react'
import {View} from 'react-native'
// @ts-expect-error missing types
import QRCode from 'react-native-qrcode-styled'
import QRCode, {useQRCodeData} from 'react-native-qrcode-styled'
import {Path} from 'react-native-svg'
import type ViewShot from 'react-native-view-shot'
import {Image} from 'expo-image'
import {LinearGradient} from 'expo-linear-gradient'
import {Logo} from '#/view/icons/Logo'
import {atoms as a, useTheme} from '#/alf'
import {hexToRgb, rgbToHex} from '#/alf/util/colorGeneration'
import {Text} from '#/components/Typography'
import {type InviteThemeVariant} from '../themes'
import {
BUTTERFLY_PIECE_SCALE,
butterflyPiecePath,
eyePath,
finderCornerAnchor,
isInFinderRegion,
} from './qrButterflies'
const LazyViewShot = lazy(
// @ts-expect-error dynamic import
@@ -19,9 +26,28 @@ const LazyViewShot = lazy(
const CARD_WIDTH = 278
const CARD_GRADIENT_PADDING = 12
// White panel the QR sits on.
const QR_AREA = CARD_WIDTH - CARD_GRADIENT_PADDING * 2
const QR_PIECE_SIZE = 7
const QR_INNER_PADDING = 4
// White quiet-zone ring between the QR and the gradient card border, on each
// side. A bit tighter than CARD_GRADIENT_PADDING so the QR reads as the focal
// element while still keeping a clear scan margin.
const QR_MARGIN = 10
// The QR's on-screen size. Fixed regardless of module count so longer handles
// (denser matrices) don't overflow the panel - we scale pieceSize to fit, the
// way the design prototype does, rather than letting the QR grow with the data.
const QR_RENDER_SIZE = QR_AREA - QR_MARGIN * 2
// Small internal quiet zone, in QR units. Also keeps the outermost butterflies
// (which overhang their module) from clipping at the SVG edge.
const QR_QUIET = 6
// High error correction keeps the butterfly modules (which cover less of each
// cell than solid squares) and the centered avatar scannable.
const ERROR_CORRECTION_LEVEL = 'H'
const QR_DATA_OPTIONS = {errorCorrectionLevel: ERROR_CORRECTION_LEVEL} as const
// react-native-qrcode-styled ships no resolvable types; type just what we use.
const useResolvedQrCodeData = useQRCodeData as (
data: string,
options: typeof QR_DATA_OPTIONS,
) => {qrCodeSize: number}
const AVATAR_WRAPPER = 72
const AVATAR_IMAGE = 60
const AVATAR_BORDER = 2
@@ -40,6 +66,12 @@ export function ThemedQrCard({
captureRef: React.Ref<ViewShot>
}) {
const t = useTheme()
// Derive pieceSize from the module count so the QR always renders at
// QR_RENDER_SIZE px (svgSize + 2*QR_QUIET == QR_RENDER_SIZE), independent of
// handle length.
const {qrCodeSize} = useResolvedQrCodeData(shareUrl, QR_DATA_OPTIONS)
const pieceSize =
qrCodeSize > 0 ? (QR_RENDER_SIZE - 2 * QR_QUIET) / qrCodeSize : 6
return (
<LazyViewShot ref={captureRef}>
<View
@@ -79,71 +111,15 @@ export function ThemedQrCard({
<QRCode
data={shareUrl}
style={{
width: QR_AREA,
height: QR_AREA,
width: QR_RENDER_SIZE,
height: QR_RENDER_SIZE,
backgroundColor: t.palette.white,
}}
pieceSize={QR_PIECE_SIZE}
padding={QR_INNER_PADDING}
pieceBorderRadius={3.5}
pieceSize={pieceSize}
padding={QR_QUIET}
errorCorrectionLevel={ERROR_CORRECTION_LEVEL}
{...qrGradient(variant.gradientFrom, variant.gradientTo)}
outerEyesOptions={{
topLeft: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
topRight: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
bottomLeft: {
borderRadius: 16,
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_BOTTOM_T,
),
},
}}
innerEyesOptions={{
topLeft: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
topRight: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_TOP_T,
),
},
bottomLeft: {
color: eyeColor(
variant.gradientFrom,
variant.gradientTo,
EYE_BOTTOM_T,
),
},
}}
logo={{
hidePieces: true,
padding: 2,
scale: 0.95,
href: avatarUri
? {uri: avatarUri}
: require('../../../../assets/logo.png'),
}}
renderCustomPieceItem={renderButterflyPiece}
/>
<View
style={{
@@ -200,10 +176,75 @@ export function ThemedQrCard({
}
/**
* Returns the `gradient` prop shape that react-native-qrcode-styled accepts
* on both pieces and eyes. Mirrors the card's top-to-bottom LinearGradient
* so the QR data appears as a continuous extension of the card gradient
* rather than a separate solid-color overlay.
* Per-module renderer for react-native-qrcode-styled. Returns a butterfly for
* each data module, a smooth rounded ring + dot for each finder eye, and
* nothing for the avatar area. Paths carry no fill so they inherit the canvas
* gradient from the library's wrapping group.
*/
function renderButterflyPiece({
x,
y,
pieceSize,
qrSize,
bitMatrix,
}: {
x: number
y: number
pieceSize: number
qrSize: number
bitMatrix: (0 | 1)[][]
}) {
const n = bitMatrix.length
const corner = finderCornerAnchor(x, y, n)
if (corner) {
return (
<Path
key={`eye-${corner}`}
d={eyePath(corner, n, pieceSize)}
fillRule="evenodd"
/>
)
}
if (isInFinderRegion(x, y, n)) return null
if (isInLogoArea(x, y, pieceSize, qrSize)) return null
if (bitMatrix[y]?.[x] !== 1) return null
return (
<Path
key={`b-${x}-${y}`}
d={butterflyPiecePath(
(x + 0.5) * pieceSize,
(y + 0.5) * pieceSize,
pieceSize * BUTTERFLY_PIECE_SCALE,
)}
/>
)
}
/**
* Clears a circular region in the QR center for the avatar overlay. The QR
* renders at QR_RENDER_SIZE px with a viewBox of qrSize + 2*QR_QUIET == that
* same size, so one display px equals one QR unit and the AVATAR_WRAPPER px
* overlay maps to a radius of AVATAR_WRAPPER / 2 QR units.
*/
function isInLogoArea(
x: number,
y: number,
pieceSize: number,
qrSize: number,
): boolean {
const clearRadius = AVATAR_WRAPPER / 2 + pieceSize * 0.5
const cx = (x + 0.5) * pieceSize
const cy = (y + 0.5) * pieceSize
const center = qrSize / 2
return Math.hypot(cx - center, cy - center) <= clearRadius
}
/**
* Returns the `gradient` prop shape that react-native-qrcode-styled accepts.
* Mirrors the card's top-to-bottom LinearGradient so the QR data appears as a
* continuous extension of the card gradient rather than a separate overlay.
*/
function qrGradient(from: string, to: string) {
return {
@@ -217,30 +258,3 @@ function qrGradient(from: string, to: string) {
},
}
}
// Vertical positions (0..1 along the QR) where the corner eye centers sit.
// QR is 33 modules square; eye centers are at module index 3 and 29.
const EYE_TOP_T = 3 / 33
const EYE_BOTTOM_T = 29 / 33
/**
* Returns the solid color the canvas-wide gradient would paint at vertical
* position `t` (0=top, 1=bottom). We use this to color each eye so that the
* eye blends seamlessly into the gradient on the surrounding data pieces.
* Eyes can't use a local `gradient` prop here - react-native-qrcode-styled
* 0.3.3 has an internal ID mismatch where the eye gradient <defs> are
* registered as `${pos}CornerSquareGradient` / `${pos}CornerDotGradient`
* but the Path fill references `url(#${pos}OuterEyeGradient)` /
* `url(#${pos}InnerEyeGradient)`, so per-eye gradients silently render
* with no fill.
*/
function eyeColor(from: string, to: string, t: number): string {
const fromRgb = hexToRgb(from)
const toRgb = hexToRgb(to)
if (!fromRgb || !toRgb) return from
return rgbToHex(
fromRgb.r + (toRgb.r - fromRgb.r) * t,
fromRgb.g + (toRgb.g - fromRgb.g) * t,
fromRgb.b + (toRgb.b - fromRgb.b) * t,
)
}
@@ -0,0 +1,224 @@
/**
* Geometry helpers for rendering the Invite Friends QR code with butterfly data
* modules (APP-2417).
*
* react-native-qrcode-styled draws every "on" module as a rounded square. To
* draw butterflies instead we take over per-module rendering via its
* `renderCustomPieceItem` hook, which bypasses the library's own eye + logo
* handling, so we reproduce all of it here as plain SVG path strings.
*
* All paths are emitted in absolute QR-canvas coordinates (no per-element
* transform) so the single canvas-wide `userSpaceOnUse` gradient maps
* continuously across butterflies and eyes alike. Applying a transform per
* element would instead remap the gradient into each element's local box.
*/
/** Finder ("eye") patterns are 7x7 modules in each non-bottom-right corner. */
export const FINDER_SIZE = 7
/**
* Scale of each butterfly relative to a module. >1 lets neighbours overlap into
* a denser, more legible silhouette. 1.2 matches the approved prototype and,
* with error-correction level H, scans as reliably as solid squares.
*/
export const BUTTERFLY_PIECE_SCALE = 1.2
// Eye corner radii, in module units. Outer ~= 2.3 modules keeps the current
// design's 16px outer-corner look (16 / 7px piece).
const EYE_RADIUS_OUTER = 2.3
const EYE_RADIUS_CUT = 1.4
const EYE_RADIUS_DOT = 1.1
export type FinderCorner = 'topLeft' | 'topRight' | 'bottomLeft'
/**
* The bsky butterfly outline (from the bsky-qr prototype), as a single closed
* path over a 600x530 viewBox.
*/
const BSKY_PATH_DATA =
'm135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z'
const BSKY_VIEW_W = 600
const BSKY_VIEW_H = 530
type BskyCmd =
| {type: 'M'; x: number; y: number}
| {
type: 'C'
x1: number
y1: number
x2: number
y2: number
x: number
y: number
}
| {type: 'Z'}
/**
* Parse the butterfly path once into normalized commands centered on (0,0) and
* scaled to a unit box (uniform scale on the larger viewBox dimension). The path
* only uses M/m, C/c and Z, so the parser handles just those.
*/
function parseBskyPath(): BskyCmd[] {
const cmds: BskyCmd[] = []
const tokens = BSKY_PATH_DATA.match(/[a-zA-Z]|-?(?:\d+\.?\d*|\.\d+)/g) ?? []
const scale = 1 / Math.max(BSKY_VIEW_W, BSKY_VIEW_H)
const offsetX = -(BSKY_VIEW_W * scale) / 2
const offsetY = -(BSKY_VIEW_H * scale) / 2
const norm = (x: number, y: number) => ({
x: x * scale + offsetX,
y: y * scale + offsetY,
})
let i = 0
let cx = 0
let cy = 0
let lastCmd = ''
while (i < tokens.length) {
const t = tokens[i]
if (/[a-zA-Z]/.test(t)) {
lastCmd = t
i++
if (lastCmd === 'Z' || lastCmd === 'z') cmds.push({type: 'Z'})
continue
}
if (lastCmd === 'M' || lastCmd === 'm') {
const x = parseFloat(tokens[i++])
const y = parseFloat(tokens[i++])
if (lastCmd === 'm') {
cx += x
cy += y
} else {
cx = x
cy = y
}
cmds.push({type: 'M', ...norm(cx, cy)})
lastCmd = lastCmd === 'm' ? 'l' : 'L'
} else if (lastCmd === 'C' || lastCmd === 'c') {
const x1 = parseFloat(tokens[i++])
const y1 = parseFloat(tokens[i++])
const x2 = parseFloat(tokens[i++])
const y2 = parseFloat(tokens[i++])
const x = parseFloat(tokens[i++])
const y = parseFloat(tokens[i++])
const rel = lastCmd === 'c'
const ax1 = rel ? cx + x1 : x1
const ay1 = rel ? cy + y1 : y1
const ax2 = rel ? cx + x2 : x2
const ay2 = rel ? cy + y2 : y2
const ax = rel ? cx + x : x
const ay = rel ? cy + y : y
const p1 = norm(ax1, ay1)
const p2 = norm(ax2, ay2)
const p = norm(ax, ay)
cmds.push({
type: 'C',
x1: p1.x,
y1: p1.y,
x2: p2.x,
y2: p2.y,
x: p.x,
y: p.y,
})
cx = ax
cy = ay
} else {
i++
}
}
return cmds
}
const BSKY_NORMALIZED = parseBskyPath()
/** A butterfly centered on (centerX, centerY), sized to `scale` canvas units. */
export function butterflyPiecePath(
centerX: number,
centerY: number,
scale: number,
): string {
const tx = (px: number) => centerX + px * scale
const ty = (py: number) => centerY + py * scale
let p = ''
for (const c of BSKY_NORMALIZED) {
if (c.type === 'M') {
p += `M${tx(c.x)} ${ty(c.y)}`
} else if (c.type === 'C') {
p += `C${tx(c.x1)} ${ty(c.y1)} ${tx(c.x2)} ${ty(c.y2)} ${tx(c.x)} ${ty(c.y)}`
} else {
p += 'Z'
}
}
return p
}
function roundedRectPath(
x: number,
y: number,
w: number,
h: number,
r: number,
): string {
const rr = Math.min(r, w / 2, h / 2)
return (
`M${x + rr} ${y} H${x + w - rr} A${rr} ${rr} 0 0 1 ${x + w} ${y + rr} ` +
`V${y + h - rr} A${rr} ${rr} 0 0 1 ${x + w - rr} ${y + h} ` +
`H${x + rr} A${rr} ${rr} 0 0 1 ${x} ${y + h - rr} ` +
`V${y + rr} A${rr} ${rr} 0 0 1 ${x + rr} ${y} Z`
)
}
/**
* Returns the finder corner whose top-left module is (x, y), or null. Used to
* emit each eye exactly once, at its anchor cell.
*/
export function finderCornerAnchor(
x: number,
y: number,
n: number,
): FinderCorner | null {
if (x === 0 && y === 0) return 'topLeft'
if (x === n - FINDER_SIZE && y === 0) return 'topRight'
if (x === 0 && y === n - FINDER_SIZE) return 'bottomLeft'
return null
}
/** Whether (x, y) falls inside any of the three finder boxes. */
export function isInFinderRegion(x: number, y: number, n: number): boolean {
return (
(x < FINDER_SIZE && y < FINDER_SIZE) ||
(x >= n - FINDER_SIZE && y < FINDER_SIZE) ||
(x < FINDER_SIZE && y >= n - FINDER_SIZE)
)
}
/**
* A single even-odd path for one eye: outer rounded ring + inner cut + center
* dot. Even-odd winding fills the ring band and the dot while leaving the gap
* between them empty, reproducing a finder pattern.
*/
export function eyePath(
corner: FinderCorner,
n: number,
pieceSize: number,
): string {
const left = corner === 'topRight' ? (n - FINDER_SIZE) * pieceSize : 0
const top = corner === 'bottomLeft' ? (n - FINDER_SIZE) * pieceSize : 0
const size = FINDER_SIZE * pieceSize
return [
roundedRectPath(left, top, size, size, EYE_RADIUS_OUTER * pieceSize),
roundedRectPath(
left + pieceSize,
top + pieceSize,
size - 2 * pieceSize,
size - 2 * pieceSize,
EYE_RADIUS_CUT * pieceSize,
),
roundedRectPath(
left + 2 * pieceSize,
top + 2 * pieceSize,
3 * pieceSize,
3 * pieceSize,
EYE_RADIUS_DOT * pieceSize,
),
].join(' ')
}