Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 81 additions & 24 deletions EmojiLibrarySheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ struct EmojiLibrarySheet: View {
/// catalog + custom pack list.
@State private var unicodeMatches: [String] = []
@State private var customMatches: [(shortcode: String, url: String)] = []
/// The user's currently-preferred Fitzpatrick tone applied to every
/// tone-capable cell. Mirrored in `UserDefaults` via
/// `EmojiSkinTonePreference` and re-read when the inline tone
/// selector writes a new value so all visible cells re-render with
/// the new tone.
@State private var preferredTone: EmojiSkinTone = EmojiSkinTonePreference.current
/// When true, ignore scroll-driven tab updates briefly so a tab tap's
/// `scrollTo` animation doesn't ping-pong with the position observer
/// (the scroll lands on the target → observer sees it → tries to
Expand All @@ -59,6 +65,7 @@ struct EmojiLibrarySheet: View {
NavigationStack {
VStack(spacing: 0) {
searchField
skinToneSelector
if searchQuery.trimmingCharacters(in: .whitespaces).isEmpty {
tabStrip
Divider()
Expand Down Expand Up @@ -88,6 +95,11 @@ struct EmojiLibrarySheet: View {
debounceTask?.cancel()
suppressResetTask?.cancel()
}
.onReceive(NotificationCenter.default.publisher(
for: .emojiSkinTonePreferenceDidChange
)) { _ in
preferredTone = EmojiSkinTonePreference.current
}
}
}

Expand Down Expand Up @@ -285,15 +297,7 @@ struct EmojiLibrarySheet: View {
let cols = [GridItem(.adaptive(minimum: 42, maximum: 56), spacing: 4)]
return LazyVGrid(columns: cols, alignment: .leading, spacing: 4) {
ForEach(items, id: \.self) { e in
Button {
handleUnicodePick(e)
} label: {
Text(e)
.font(.system(size: 30))
.frame(maxWidth: .infinity)
.frame(height: 42)
}
.buttonStyle(.plain)
toneAwareCell(baseEmoji: e)
}
}
}
Expand Down Expand Up @@ -398,12 +402,11 @@ struct EmojiLibrarySheet: View {
let cols = [GridItem(.adaptive(minimum: 50, maximum: 64), spacing: 8)]
return LazyVGrid(columns: cols, alignment: .leading, spacing: 8) {
ForEach(items, id: \.shortcode) { item in
Button {
handleCustomPick(shortcode: item.shortcode, url: item.url)
} label: {
customImageCell(url: item.url, shortcode: item.shortcode)
}
.buttonStyle(.plain)
customImageCell(url: item.url, shortcode: item.shortcode)
.contentShape(Rectangle())
.onTapGesture {
handleCustomPick(shortcode: item.shortcode, url: item.url)
}
}
}
}
Expand Down Expand Up @@ -448,18 +451,71 @@ struct EmojiLibrarySheet: View {
.font(.headline)
LazyVGrid(columns: cols, alignment: .leading, spacing: 4) {
ForEach(cat.emojis, id: \.self) { e in
Button {
handleUnicodePick(e)
} label: {
Text(e)
.font(.system(size: 30))
.frame(maxWidth: .infinity)
.frame(height: 42)
}
.buttonStyle(.plain)
toneAwareCell(baseEmoji: e)
}
}
}
}

/// Single cell shared by the category and filtered grids. Renders the
/// emoji with the user's preferred Fitzpatrick tone applied if the base
/// is tone-capable; otherwise renders the base as-is. The tone is
/// chosen globally via `skinToneSelector` at the top of the sheet.
///
/// Uses `.onTapGesture` instead of `Button`. `Button` inside a
/// LazyVGrid inside a ScrollView only cancels its tap on substantial
/// movement, so a quick flick to scroll the long grid was registering
/// as a pick on release. `onTapGesture` cancels on any drag past the
/// hit-test threshold, which is what the user expects for a scroll.
@ViewBuilder
private func toneAwareCell(baseEmoji: String) -> some View {
let toneCapable = EmojiToneCapability.accepts(baseEmoji)
let rendered = toneCapable
? baseEmoji.applyingSkinTone(preferredTone)
: baseEmoji
Text(rendered)
.font(.system(size: 30))
.frame(maxWidth: .infinity)
.frame(height: 42)
.contentShape(Rectangle())
.onTapGesture {
handleUnicodePick(rendered)
}
}

/// Compact horizontal row of six tone swatches sitting above the tab
/// strip. Each swatch is the open-palm 👋 glyph rendered in that tone
/// so the user sees the actual color, not an abstract chip. Tapping a
/// swatch updates `EmojiSkinTonePreference` (persisted in
/// `UserDefaults`) and re-renders every tone-capable cell in the
/// sheet on the next body pass.
private var skinToneSelector: some View {
HStack(spacing: 6) {
Text("Skin tone")
.font(.caption)
.foregroundStyle(.secondary)
Spacer(minLength: 8)
ForEach(EmojiSkinTone.allCases) { tone in
Button {
EmojiSkinTonePreference.current = tone
preferredTone = tone
} label: {
Text(tone.previewSwatch)
.font(.system(size: 22))
.frame(width: 32, height: 32)
.background(
Circle()
.stroke(tone == preferredTone
? Color.accentColor
: Color.clear,
lineWidth: 2)
)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 12)
.padding(.bottom, 4)
}

// MARK: - Pick handlers
Expand Down Expand Up @@ -503,3 +559,4 @@ private struct SectionTopOffsetKey: PreferenceKey {
value.merge(nextValue(), uniquingKeysWith: { _, new in new })
}
}

174 changes: 174 additions & 0 deletions SkinTone.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import Foundation
import SwiftUI

/// Fitzpatrick skin-tone modifier applied to tone-capable emoji in the
/// picker. Mirrors the iOS system emoji keyboard's long-press popover:
/// pick once, the choice persists in `UserDefaults` and is applied to
/// every tone-capable cell going forward.
enum EmojiSkinTone: String, CaseIterable, Identifiable {
case `default`
case light
case mediumLight
case medium
case mediumDark
case dark

var id: String { rawValue }

/// Fitzpatrick modifier appended to the base codepoint. `nil` for the
/// default yellow rendering.
var modifier: String? {
switch self {
case .default: return nil
case .light: return "\u{1F3FB}"
case .mediumLight: return "\u{1F3FC}"
case .medium: return "\u{1F3FD}"
case .mediumDark: return "\u{1F3FE}"
case .dark: return "\u{1F3FF}"
}
}

/// Single-glyph preview shown in the popover swatch. Uses the open-palm
/// emoji (👋) under each tone so the user sees the actual rendered
/// color rather than an abstract swatch.
var previewSwatch: String {
let base = "\u{1F44B}" // 👋
guard let mod = modifier else { return base }
return base + mod
}
}

@MainActor
enum EmojiSkinTonePreference {
private static let key = "com.wisp.emoji.preferredSkinTone"

static var current: EmojiSkinTone {
get {
guard let raw = UserDefaults.standard.string(forKey: key),
let tone = EmojiSkinTone(rawValue: raw) else {
return .default
}
return tone
}
set {
if newValue == .default {
UserDefaults.standard.removeObject(forKey: key)
} else {
UserDefaults.standard.set(newValue.rawValue, forKey: key)
}
NotificationCenter.default.post(
name: .emojiSkinTonePreferenceDidChange, object: nil
)
}
}
}

extension Notification.Name {
/// Fired when `EmojiSkinTonePreference.current` is written. Picker views
/// listen so cells re-render with the new tone applied without needing
/// the user to dismiss and re-open the sheet.
static let emojiSkinTonePreferenceDidChange = Notification.Name(
"com.wisp.emoji.preferredSkinTone.didChange"
)
}

extension String {
/// Strip every Fitzpatrick modifier (U+1F3FB–U+1F3FF) so the base form
/// can be looked up in the tone-capable set, or so a tone can be
/// re-applied cleanly.
func removingSkinTones() -> String {
String(unicodeScalars.filter { scalar in
!(0x1F3FB...0x1F3FF).contains(scalar.value)
})
}

/// Apply `tone` to a tone-capable base emoji. For a simple single-glyph
/// emoji the modifier is appended (👋 + 🏽 → 👋🏽). For a ZWJ sequence
/// with a single human glyph at the start (e.g. 👨‍🚀) the modifier is
/// inserted between the base character and the first ZWJ joiner so the
/// renderer applies it to the human glyph rather than the trailing
/// component (👨‍🚀 → 👨🏽‍🚀). Returns the base unchanged for the default
/// (yellow) tone.
func applyingSkinTone(_ tone: EmojiSkinTone) -> String {
guard let modifier = tone.modifier else { return removingSkinTones() }
let base = removingSkinTones()
// Split on the first ZWJ (U+200D) — if present, inject the modifier
// before it; otherwise append.
if let zwjRange = base.range(of: "\u{200D}") {
// Skip any variation selector (FE0F) that sits right before the
// ZWJ — the modifier must come BEFORE the VS-16 to render
// correctly.
var injectAt = zwjRange.lowerBound
if injectAt > base.startIndex {
let prev = base.index(before: injectAt)
if base[prev] == "\u{FE0F}" {
injectAt = prev
}
}
return String(base[..<injectAt]) + modifier + String(base[injectAt...])
}
// Plain emoji: append (strip any trailing VS-16 first so the
// modifier sits flush against the base glyph).
let trimmed = base.hasSuffix("\u{FE0F}") ? String(base.dropLast()) : base
return trimmed + modifier
}
}

/// Set of base emoji that accept Fitzpatrick skin-tone modifiers. Sourced
/// from Unicode emoji-test.txt (lines tagged `; fully-qualified` with a
/// `.tone.` annotation). Keys are normalized (no VS-16, no existing tone)
/// so a lookup matches whether the picker stored the emoji with or without
/// the variation selector.
@MainActor
enum EmojiToneCapability {
/// Membership check. Strips the variation selector before lookup so
/// "👋" and "👋\u{FE0F}" both resolve.
static func accepts(_ emoji: String) -> Bool {
let key = emoji.removingSkinTones().replacingOccurrences(of: "\u{FE0F}", with: "")
return baseSet.contains(key)
}

/// Base emoji that accept the standard 5-tone Fitzpatrick modifier set.
/// Multi-person ZWJ sequences (couples, families) accept per-position
/// tones in principle but are intentionally excluded here — the picker
/// only offers a single global tone choice.
private static let baseSet: Set<String> = [
// Hands — left/right/individual fingers/gestures
"👋", "🤚", "🖐", "✋", "🖖",
"🫱", "🫲", "🫳", "🫴", "🫵",
"🫷", "🫸",
"👌", "🤌", "🤏", "✌", "🤞",
"🫰", "🤟", "🤘", "🤙",
"👈", "👉", "👆", "🖕", "👇",
"☝", "👍", "👎",
"✊", "👊", "🤛", "🤜",
"👏", "🙌", "🫶", "👐", "🤲",
"🙏", "✍",
// Body
"💅", "🦵", "🦶", "👂", "🦻", "👃",
// People — base glyphs (one per body)
"👶", "🧒", "👦", "👧",
"🧑", "👨", "👩",
"🧓", "👴", "👵",
// Gestures with body posture
"🙍", "🙎", "🙅", "🙆", "💁",
"🙋", "🧏", "🙇", "🤦", "🤷",
// Roles (single-glyph, no ZWJ)
"🤴", "👸", "👳", "👲", "🧕",
"🤵", "👰", "🤰", "🫃", "🫄", "🤱",
// Activity/motion
"🚶", "🧍", "🧎", "🏃",
"💃", "🕺", "🕴",
"🧖", "🧘",
"🛀", "🛌",
// Sport
"🏄", "🏊", "🚣", "🤽",
"🚴", "🚵", "🏇", "🏂",
"🏋", "🤸", "🤾", "⛹",
"🤹", "🤺",
// Fantasy
"🧚", "🧛", "🧜", "🧝", "🧞", "🧟",
// Nose/ear cleaning + Santa + Mrs Claus
"🎅", "🤶", "🧙",
]
}
4 changes: 4 additions & 0 deletions wisp.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
4CABCD010000000000030000 /* RetryingAsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CABCD000000000000030000 /* RetryingAsyncImage.swift */; };
4CABCD010000000000040000 /* DecodedImageCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CABCD000000000000040000 /* DecodedImageCache.swift */; };
4CABCD010000000000050000 /* BackChevronButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CABCD000000000000050000 /* BackChevronButton.swift */; };
4CABCD010000000000F30060 /* SkinTone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CABCD000000000000F30060 /* SkinTone.swift */; };
4CABCD010000000000060000 /* ProfileEditViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CABCD000000000000060000 /* ProfileEditViewModel.swift */; };
4CABCD010000000000070000 /* ProfileEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CABCD000000000000070000 /* ProfileEditView.swift */; };
4CABCD010000000000080000 /* FollowSender.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CABCD000000000000080000 /* FollowSender.swift */; };
Expand Down Expand Up @@ -369,6 +370,7 @@
4CABCD000000000000030000 /* RetryingAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryingAsyncImage.swift; sourceTree = "<group>"; };
4CABCD000000000000040000 /* DecodedImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedImageCache.swift; sourceTree = "<group>"; };
4CABCD000000000000050000 /* BackChevronButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackChevronButton.swift; sourceTree = "<group>"; };
4CABCD000000000000F30060 /* SkinTone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkinTone.swift; sourceTree = "<group>"; };
4CABCD000000000000060000 /* ProfileEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditViewModel.swift; sourceTree = "<group>"; };
4CABCD000000000000070000 /* ProfileEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditView.swift; sourceTree = "<group>"; };
4CABCD000000000000080000 /* FollowSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowSender.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1034,6 +1036,7 @@
4CDDDD000000000000000004 /* DmReactionPublisher.swift */,
4CABCD000000000000F30006 /* Dip03.swift */,
4CABCD000000000000050000 /* BackChevronButton.swift */,
4CABCD000000000000F30060 /* SkinTone.swift */,
4CABCD000000000000F30050 /* WispTopHeader.swift */,
4CABCD000000000000F2010 /* NewPostsPill.swift */,
4CABCE0000000000000F4607 /* Signer.swift */,
Expand Down Expand Up @@ -1543,6 +1546,7 @@
4CDDDD010000000000000004 /* DmReactionPublisher.swift in Sources */,
4CABCD010000000000F30006 /* Dip03.swift in Sources */,
4CABCD010000000000050000 /* BackChevronButton.swift in Sources */,
4CABCD010000000000F30060 /* SkinTone.swift in Sources */,
4CABCD010000000000F30050 /* WispTopHeader.swift in Sources */,
4CABCD010000000000F30051 /* AccountSwitcherSheet.swift in Sources */,
4CABCD010000000000F2010 /* NewPostsPill.swift in Sources */,
Expand Down