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:
@@ -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(' ')
|
||||
}
|
||||
Reference in New Issue
Block a user