git.iko.soy/rowtype-yoga/purescript-yoga-react-native
on main help sign in
Add + button with system emoji picker via NSButtonCell insertText
commitbf230fd0ceb39dc356ce2cf50472d42be4159017 authorMark <mark.eibes@gmail.com> dateThu 19 Feb 2026, 14:26 (7 months ago) parent75fac64
2 files changed+72−32
example-macos/macos/YogaReactExample-macOS/RCTMacOSControlsManager.m+19−6 example-macos/src/Demo/Chat.purs+53−26
example-macos/macos/YogaReactExample-macOS/RCTMacOSControlsManager.m+19−6
@@ -42,7 +42,7 @@
4242}
4343@end
4444
45−@interface RCTNativeButtonView : NSView
45+@interface RCTNativeButtonView : NSView <NSTextInputClient>
4646@property (nonatomic, strong) NSButton *button;
4747@property (nonatomic, assign) BOOL borderlessHover;
4848@property (nonatomic, copy) RCTDirectEventBlock onPressButton;
@@ -78,15 +78,28 @@
7878 if (_onPressButton) _onPressButton(@{});
7979}
8080
81−- (void)insertText:(id)string {
82− if (_onEmojiPick && [string isKindOfClass:[NSString class]]) {
83− _onEmojiPick(@{@"emoji": string});
81+// NSTextInputClient required methods
82+- (void)insertText:(id)string replacementRange:(NSRange)replacementRange {
83+ NSString *text = [string isKindOfClass:[NSAttributedString class]]
84+ ? [(NSAttributedString *)string string] : string;
85+ if (_onEmojiPick && text.length > 0) {
86+ _onEmojiPick(@{@"emoji": text});
8487 }
8588}
8689
87−- (void)doCommandBySelector:(SEL)selector {
88− // Required by NSTextInputClient protocol — ignore commands
90+- (void)doCommandBySelector:(SEL)selector {}
91+- (void)setMarkedText:(id)string selectedRange:(NSRange)selectedRange replacementRange:(NSRange)replacementRange {}
92+- (void)unmarkText {}
93+- (NSRange)selectedRange { return NSMakeRange(NSNotFound, 0); }
94+- (NSRange)markedRange { return NSMakeRange(NSNotFound, 0); }
95+- (BOOL)hasMarkedText { return NO; }
96+- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(NSRangePointer)actualRange { return nil; }
97+- (NSArray<NSAttributedStringKey> *)validAttributesForMarkedText { return @[]; }
98+- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actualRange {
99+ NSRect r = [self convertRect:self.bounds toView:nil];
100+ return [self.window convertRectToScreen:r];
89101}
102+- (NSUInteger)characterIndexForPoint:(NSPoint)point { return NSNotFound; }
90103
91104- (void)mouseEntered:(NSEvent *)event {
92105 if (_borderlessHover) {
example-macos/src/Demo/Chat.purs+53−26
@@ -30,10 +30,11 @@ import Yoga.React.Native.MacOS.ScrollView (nativeScrollView)
3030import Yoga.React.Native.MacOS.Sidebar (sidebarLayout)
3131import Yoga.React.Native.MacOS.TextField (nativeTextField)
3232import Yoga.React.Native.MacOS.Types as T
33+import Yoga.React.Native.Pressable (pressable)
3334import Yoga.React.Native.Reanimated as R
3435import Yoga.React.Native.Style as Style
3536
36−type Reaction = { emoji :: String, count :: Int }
37+type Reaction = { emoji :: String, count :: Int, mine :: Boolean }
3738
3839type Message =
3940 { sender :: String
@@ -90,8 +91,12 @@ customEmojiMap = emojiMap
9091
9192addReaction :: String -> Array Reaction -> Array Reaction
9293addReaction emoji rs = case filter (\r -> r.emoji == emoji) rs of
93− [] -> snoc rs { emoji, count: 1 }
94− _ -> map (\r -> if r.emoji == emoji then r { count = r.count + 1 } else r) rs
94+ [] -> snoc rs { emoji, count: 1, mine: true }
95+ _ -> map (\r -> if r.emoji == emoji then r { count = r.count + 1, mine = true } else r) rs
96+
97+removeReaction :: String -> Array Reaction -> Array Reaction
98+removeReaction emoji rs = filter (\r -> r.count > 0) $
99+ map (\r -> if r.emoji == emoji && r.mine then r { count = r.count - 1, mine = false } else r) rs
95100
96101mockRooms :: Array Room
97102mockRooms =
@@ -107,40 +112,43 @@ mockMessages room = map (\m -> m { body = replaceEmoji m.body }) (mockMessagesRa
107112noReactions :: Array Reaction
108113noReactions = []
109114
115+mkReaction :: String -> Int -> Reaction
116+mkReaction emoji count = { emoji, count, mine: false }
117+
110118mockMessagesRaw :: String -> Array Message
111119mockMessagesRaw = case _ of
112120 "general" ->
113− [ { sender: "Alice", body: "Hey everyone! Welcome to the chat demo :wave:", isMine: false, reactions: [ { emoji: "👋", count: 3 } ], replyTo: Nothing }
114− , { sender: "Bob", body: "This is built with :ps: + React Native macOS", isMine: false, reactions: [ { emoji: "🔥", count: 2 }, { emoji: "❤️", count: 1 } ], replyTo: Nothing }
121+ [ { sender: "Alice", body: "Hey everyone! Welcome to the chat demo :wave:", isMine: false, reactions: [ mkReaction "👋" 3 ], replyTo: Nothing }
122+ , { sender: "Bob", body: "This is built with :ps: + React Native macOS", isMine: false, reactions: [ mkReaction "🔥" 2, mkReaction "❤️" 1 ], replyTo: Nothing }
115123 , { sender: "You", body: "That's awesome! :fire:", isMine: true, reactions: noReactions, replyTo: Just 1 }
116124 , { sender: "Alice", body: "Try typing :rocket: or :heart: in a message", isMine: false, reactions: noReactions, replyTo: Nothing }
117− , { sender: "You", body: "The bubbles look great :sparkles:", isMine: true, reactions: [ { emoji: "👍", count: 2 } ], replyTo: Nothing }
125+ , { sender: "You", body: "The bubbles look great :sparkles:", isMine: true, reactions: [ mkReaction "👍" 2 ], replyTo: Nothing }
118126 , { sender: "Bob", body: "Click the smiley to react!", isMine: false, reactions: noReactions, replyTo: Just 4 }
119− , { sender: "Alice", body: ":party: :shipit:", isMine: false, reactions: [ { emoji: "🎉", count: 5 } ], replyTo: Nothing }
120− , { sender: "Bob", body: ":parrot: Party Parrot! :parrot:", isMine: false, reactions: [ { emoji: "🦜", count: 3 } ], replyTo: Nothing }
127+ , { sender: "Alice", body: ":party: :shipit:", isMine: false, reactions: [ mkReaction "🎉" 5 ], replyTo: Nothing }
128+ , { sender: "Bob", body: ":parrot: Party Parrot! :parrot:", isMine: false, reactions: [ mkReaction "🦜" 3 ], replyTo: Nothing }
121129 , { sender: "You", body: ":dealwithit: deal with it :doge:", isMine: true, reactions: noReactions, replyTo: Nothing }
122130 , { sender: "Alice", body: ":typingcat:", isMine: false, reactions: noReactions, replyTo: Nothing }
123131 ]
124132 "purescript" ->
125− [ { sender: "Phil", body: "Has anyone tried the new compiler release?", isMine: false, reactions: [ { emoji: "👍", count: 4 } ], replyTo: Nothing }
133+ [ { sender: "Phil", body: "Has anyone tried the new compiler release?", isMine: false, reactions: [ mkReaction "👍" 4 ], replyTo: Nothing }
126134 , { sender: "You", body: "Yes! The build times are much better", isMine: true, reactions: noReactions, replyTo: Just 0 }
127− , { sender: "Phil", body: "Row polymorphism makes FFI so clean", isMine: false, reactions: [ { emoji: "❤️", count: 3 } ], replyTo: Nothing }
135+ , { sender: "Phil", body: "Row polymorphism makes FFI so clean", isMine: false, reactions: [ mkReaction "❤️" 3 ], replyTo: Nothing }
128136 , { sender: "Jordan", body: "The ecosystem keeps getting better", isMine: false, reactions: noReactions, replyTo: Nothing }
129− , { sender: "You", body: "Agreed, especially for React Native", isMine: true, reactions: [ { emoji: "🔥", count: 1 } ], replyTo: Just 2 }
130− , { sender: "Jordan", body: ":opensourceparrot: :ps: :haskell: :opensourceparrot:", isMine: false, reactions: [ { emoji: "❤️", count: 4 } ], replyTo: Nothing }
137+ , { sender: "You", body: "Agreed, especially for React Native", isMine: true, reactions: [ mkReaction "🔥" 1 ], replyTo: Just 2 }
138+ , { sender: "Jordan", body: ":opensourceparrot: :ps: :haskell: :opensourceparrot:", isMine: false, reactions: [ mkReaction "❤️" 4 ], replyTo: Nothing }
131139 ]
132140 "react-native" ->
133− [ { sender: "Christoph", body: "macOS support is looking solid", isMine: false, reactions: [ { emoji: "🔥", count: 3 } ], replyTo: Nothing }
141+ [ { sender: "Christoph", body: "macOS support is looking solid", isMine: false, reactions: [ mkReaction "🔥" 3 ], replyTo: Nothing }
134142 , { sender: "You", body: "Native controls feel right at home", isMine: true, reactions: noReactions, replyTo: Just 0 }
135143 , { sender: "Christoph", body: "NSOutlineView, NSSplitView, toolbars...", isMine: false, reactions: noReactions, replyTo: Nothing }
136− , { sender: "You", body: "Even the vibrancy effects work", isMine: true, reactions: [ { emoji: "😮", count: 2 } ], replyTo: Nothing }
137− , { sender: "Christoph", body: "🚀", isMine: false, reactions: [ { emoji: "🚀", count: 7 } ], replyTo: Nothing }
138− , { sender: "You", body: ":fastparrot: :congaparrot: :ultraparrot: conga line!", isMine: true, reactions: [ { emoji: "🎉", count: 3 } ], replyTo: Nothing }
144+ , { sender: "You", body: "Even the vibrancy effects work", isMine: true, reactions: [ mkReaction "😮" 2 ], replyTo: Nothing }
145+ , { sender: "Christoph", body: "🚀", isMine: false, reactions: [ mkReaction "🚀" 7 ], replyTo: Nothing }
146+ , { sender: "You", body: ":fastparrot: :congaparrot: :ultraparrot: conga line!", isMine: true, reactions: [ mkReaction "🎉" 3 ], replyTo: Nothing }
139147 ]
140148 "random" ->
141149 [ { sender: "Eve", body: "Anyone here?", isMine: false, reactions: noReactions, replyTo: Nothing }
142− , { sender: "You", body: "👋", isMine: true, reactions: [ { emoji: "👋", count: 1 } ], replyTo: Just 0 }
143− , { sender: "Eve", body: ":partyblob: :partyblob: :partyblob:", isMine: false, reactions: [ { emoji: "🎉", count: 2 } ], replyTo: Nothing }
150+ , { sender: "You", body: "👋", isMine: true, reactions: [ mkReaction "👋" 1 ], replyTo: Just 0 }
151+ , { sender: "Eve", body: ":partyblob: :partyblob: :partyblob:", isMine: false, reactions: [ mkReaction "🎉" 2 ], replyTo: Nothing }
144152 ]
145153 _ -> []
146154
@@ -260,16 +268,35 @@ chatDemo = component "ChatDemo" \dp -> React.do
260268 ]
261269 )
262270
263− reactionPills msg =
271+ toggleReaction idx emoji = do
272+ setMessages \msgs -> fromMaybe msgs
273+ ( modifyAt idx
274+ ( \m ->
275+ let
276+ existing = filter (\rx -> rx.emoji == emoji) m.reactions
277+ in
278+ case existing !! 0 of
279+ Just rx | rx.mine -> m { reactions = removeReaction emoji m.reactions }
280+ _ -> m { reactions = addReaction emoji m.reactions }
281+ )
282+ msgs
283+ )
284+
285+ reactionPills idx msg =
264286 if length msg.reactions == 0 then mempty
265287 else view { style: tw "flex-row mt-1" <> Style.style { alignSelf: if msg.isMine then "flex-end" else "flex-start" } }
266− ( msg.reactions <#> \r ->
267− view
268− { style: tw "flex-row items-center rounded-full px-2 py-0.5 mr-1"
269− <> Style.style { backgroundColor: reactionBg }
288+ ( msg.reactions <#> \rx ->
289+ pressable
290+ { onPress: handler_ (toggleReaction idx rx.emoji)
270291 }
271− [ text { style: Style.style { fontSize: 12.0 } } r.emoji
272− , text { style: tw "text-xs ml-1" <> Style.style { color: dp.dimFg } } (show r.count)
292+ [ view
293+ { style: tw "flex-row items-center rounded-full px-2 py-0.5 mr-1"
294+ <> Style.style
295+ { backgroundColor: if rx.mine then sentBubbleBg else reactionBg }
296+ }
297+ [ text { style: Style.style { fontSize: 12.0 } } rx.emoji
298+ , text { style: tw "text-xs ml-1" <> Style.style { color: if rx.mine then "#FFFFFF" else dp.dimFg } } (show rx.count)
299+ ]
273300 ]
274301 )
275302
@@ -405,7 +432,7 @@ chatDemo = component "ChatDemo" \dp -> React.do
405432 ]
406433 , if not msg.isMine then reactionPicker idx else mempty
407434 ]
408− , reactionPills msg
435+ , reactionPills idx msg
409436 ]
410437 ]
411438 ]