diff --git a/EmojiLibrarySheet.swift b/EmojiLibrarySheet.swift index 47e93935..04618123 100644 --- a/EmojiLibrarySheet.swift +++ b/EmojiLibrarySheet.swift @@ -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 @@ -59,6 +65,7 @@ struct EmojiLibrarySheet: View { NavigationStack { VStack(spacing: 0) { searchField + skinToneSelector if searchQuery.trimmingCharacters(in: .whitespaces).isEmpty { tabStrip Divider() @@ -88,6 +95,11 @@ struct EmojiLibrarySheet: View { debounceTask?.cancel() suppressResetTask?.cancel() } + .onReceive(NotificationCenter.default.publisher( + for: .emojiSkinTonePreferenceDidChange + )) { _ in + preferredTone = EmojiSkinTonePreference.current + } } } @@ -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) } } } @@ -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) + } } } } @@ -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 @@ -503,3 +559,4 @@ private struct SectionTopOffsetKey: PreferenceKey { value.merge(nextValue(), uniquingKeysWith: { _, new in new }) } } + diff --git a/SkinTone.swift b/SkinTone.swift new file mode 100644 index 00000000..8d435b01 --- /dev/null +++ b/SkinTone.swift @@ -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[.. 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 = [ + // 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 + "๐ŸŽ…", "๐Ÿคถ", "๐Ÿง™", + ] +} diff --git a/wisp.xcodeproj/project.pbxproj b/wisp.xcodeproj/project.pbxproj index eb1b3aba..a75225a7 100644 --- a/wisp.xcodeproj/project.pbxproj +++ b/wisp.xcodeproj/project.pbxproj @@ -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 */; }; @@ -369,6 +370,7 @@ 4CABCD000000000000030000 /* RetryingAsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RetryingAsyncImage.swift; sourceTree = ""; }; 4CABCD000000000000040000 /* DecodedImageCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodedImageCache.swift; sourceTree = ""; }; 4CABCD000000000000050000 /* BackChevronButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackChevronButton.swift; sourceTree = ""; }; + 4CABCD000000000000F30060 /* SkinTone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SkinTone.swift; sourceTree = ""; }; 4CABCD000000000000060000 /* ProfileEditViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditViewModel.swift; sourceTree = ""; }; 4CABCD000000000000070000 /* ProfileEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEditView.swift; sourceTree = ""; }; 4CABCD000000000000080000 /* FollowSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowSender.swift; sourceTree = ""; }; @@ -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 */, @@ -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 */,