git.iko.soy/rowtype-yoga/purescript-yoga-react-native
on main help sign in
Add + button with system emoji picker via NSButtonCell insertText
bf230fdMark
example-macos/macos/YogaReactExample-macOS/RCTMacOSControlsManager.m+19−6
@@ -42,7 +42,7 @@
42}
42}
43@end
43@end
44
44
45−@interface RCTNativeButtonView : NSView
45+@interface RCTNativeButtonView : NSView <NSTextInputClient>
46@property (nonatomic, strong) NSButton *button;
46@property (nonatomic, strong) NSButton *button;
47@property (nonatomic, assign) BOOL borderlessHover;
47@property (nonatomic, assign) BOOL borderlessHover;
48@property (nonatomic, copy) RCTDirectEventBlock onPressButton;
48@property (nonatomic, copy) RCTDirectEventBlock onPressButton;
@@ -78,15 +78,28 @@
78 if (_onPressButton) _onPressButton(@{});
78 if (_onPressButton) _onPressButton(@{});
79}
79}
80
80
81+// NSTextInputClient required methods
81−- (void)insertText:(id)string {
82+- (void)insertText:(id)string replacementRange:(NSRange)replacementRange {
82− if (_onEmojiPick && [string isKindOfClass:[NSString class]]) {
83+ NSString *text = [string isKindOfClass:[NSAttributedString class]]
84+ ? [(NSAttributedString *)string string] : string;
85+ if (_onEmojiPick && text.length > 0) {
83− _onEmojiPick(@{@"emoji": string});
86+ _onEmojiPick(@{@"emoji": text});
84 }
87 }
85}
88}
86
89
87−- (void)doCommandBySelector:(SEL)selector {
90+- (void)doCommandBySelector:(SEL)selector {}
88− // Required by NSTextInputClient protocol — ignore commands
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];
89}
101}
102+- (NSUInteger)characterIndexForPoint:(NSPoint)point { return NSNotFound; }
90
103
91- (void)mouseEntered:(NSEvent *)event {
104- (void)mouseEntered:(NSEvent *)event {
92 if (_borderlessHover) {
105 if (_borderlessHover) {
example-macos/src/Demo/Chat.purs+53−26
@@ -30,10 +30,11 @@ import Yoga.React.Native.MacOS.ScrollView (nativeScrollView)
30import Yoga.React.Native.MacOS.Sidebar (sidebarLayout)
30import Yoga.React.Native.MacOS.Sidebar (sidebarLayout)
31import Yoga.React.Native.MacOS.TextField (nativeTextField)
31import Yoga.React.Native.MacOS.TextField (nativeTextField)
32import Yoga.React.Native.MacOS.Types as T
32import Yoga.React.Native.MacOS.Types as T
33+import Yoga.React.Native.Pressable (pressable)
33import Yoga.React.Native.Reanimated as R
34import Yoga.React.Native.Reanimated as R
34import Yoga.React.Native.Style as Style
35import Yoga.React.Native.Style as Style
35
36
36−type Reaction = { emoji :: String, count :: Int }
37+type Reaction = { emoji :: String, count :: Int, mine :: Boolean }
37
38
38type Message =
39type Message =
39 { sender :: String
40 { sender :: String
@@ -90,8 +91,12 @@ customEmojiMap = emojiMap
90
91
91addReaction :: String -> Array Reaction -> Array Reaction
92addReaction :: String -> Array Reaction -> Array Reaction
92addReaction emoji rs = case filter (\r -> r.emoji == emoji) rs of
93addReaction emoji rs = case filter (\r -> r.emoji == emoji) rs of
93− [] -> snoc rs { emoji, count: 1 }
94+ [] -> snoc rs { emoji, count: 1, mine: true }
94− _ -> map (\r -> if r.emoji == emoji then r { count = r.count + 1 } else r) rs
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
95
100
96mockRooms :: Array Room
101mockRooms :: Array Room
97mockRooms =
102mockRooms =
@@ -107,40 +112,43 @@ mockMessages room = map (\m -> m { body = replaceEmoji m.body }) (mockMessagesRa
107noReactions :: Array Reaction
112noReactions :: Array Reaction
108noReactions = []
113noReactions = []
109
114
115+mkReaction :: String -> Int -> Reaction
116+mkReaction emoji count = { emoji, count, mine: false }
117+
110mockMessagesRaw :: String -> Array Message
118mockMessagesRaw :: String -> Array Message
111mockMessagesRaw = case _ of
119mockMessagesRaw = case _ of
112 "general" ->
120 "general" ->
113− [ { sender: "Alice", body: "Hey everyone! Welcome to the chat demo :wave:", isMine: false, reactions: [ { emoji: "👋", count: 3 } ], replyTo: Nothing }
121+ [ { sender: "Alice", body: "Hey everyone! Welcome to the chat demo :wave:", isMine: false, reactions: [ mkReaction "👋" 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 }
122+ , { sender: "Bob", body: "This is built with :ps: + React Native macOS", isMine: false, reactions: [ mkReaction "🔥" 2, mkReaction "❤️" 1 ], replyTo: Nothing }
115 , { sender: "You", body: "That's awesome! :fire:", isMine: true, reactions: noReactions, replyTo: Just 1 }
123 , { sender: "You", body: "That's awesome! :fire:", isMine: true, reactions: noReactions, replyTo: Just 1 }
116 , { sender: "Alice", body: "Try typing :rocket: or :heart: in a message", isMine: false, reactions: noReactions, replyTo: Nothing }
124 , { 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 }
118 , { sender: "Bob", body: "Click the smiley to react!", isMine: false, reactions: noReactions, replyTo: Just 4 }
126 , { 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 }
127+ , { sender: "Alice", body: ":party: :shipit:", isMine: false, reactions: [ mkReaction "🎉" 5 ], replyTo: Nothing }
120− , { sender: "Bob", body: ":parrot: Party Parrot! :parrot:", isMine: false, reactions: [ { emoji: "🦜", count: 3 } ], replyTo: Nothing }
128+ , { sender: "Bob", body: ":parrot: Party Parrot! :parrot:", isMine: false, reactions: [ mkReaction "🦜" 3 ], replyTo: Nothing }
121 , { sender: "You", body: ":dealwithit: deal with it :doge:", isMine: true, reactions: noReactions, replyTo: Nothing }
129 , { sender: "You", body: ":dealwithit: deal with it :doge:", isMine: true, reactions: noReactions, replyTo: Nothing }
122 , { sender: "Alice", body: ":typingcat:", isMine: false, reactions: noReactions, replyTo: Nothing }
130 , { sender: "Alice", body: ":typingcat:", isMine: false, reactions: noReactions, replyTo: Nothing }
123 ]
131 ]
124 "purescript" ->
132 "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 }
126 , { sender: "You", body: "Yes! The build times are much better", isMine: true, reactions: noReactions, replyTo: Just 0 }
134 , { 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 }
128 , { sender: "Jordan", body: "The ecosystem keeps getting better", isMine: false, reactions: noReactions, replyTo: Nothing }
136 , { 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 }
137+ , { sender: "You", body: "Agreed, especially for React Native", isMine: true, reactions: [ mkReaction "🔥" 1 ], replyTo: Just 2 }
130− , { sender: "Jordan", body: ":opensourceparrot: :ps: :haskell: :opensourceparrot:", isMine: false, reactions: [ { emoji: "❤️", count: 4 } ], replyTo: Nothing }
138+ , { sender: "Jordan", body: ":opensourceparrot: :ps: :haskell: :opensourceparrot:", isMine: false, reactions: [ mkReaction "❤️" 4 ], replyTo: Nothing }
131 ]
139 ]
132 "react-native" ->
140 "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 }
134 , { sender: "You", body: "Native controls feel right at home", isMine: true, reactions: noReactions, replyTo: Just 0 }
142 , { sender: "You", body: "Native controls feel right at home", isMine: true, reactions: noReactions, replyTo: Just 0 }
135 , { sender: "Christoph", body: "NSOutlineView, NSSplitView, toolbars...", isMine: false, reactions: noReactions, replyTo: Nothing }
143 , { 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 }
144+ , { sender: "You", body: "Even the vibrancy effects work", isMine: true, reactions: [ mkReaction "😮" 2 ], replyTo: Nothing }
137− , { sender: "Christoph", body: "🚀", isMine: false, reactions: [ { emoji: "🚀", count: 7 } ], replyTo: Nothing }
145+ , { sender: "Christoph", body: "🚀", isMine: false, reactions: [ mkReaction "🚀" 7 ], replyTo: Nothing }
138− , { sender: "You", body: ":fastparrot: :congaparrot: :ultraparrot: conga line!", isMine: true, reactions: [ { emoji: "🎉", count: 3 } ], replyTo: Nothing }
146+ , { sender: "You", body: ":fastparrot: :congaparrot: :ultraparrot: conga line!", isMine: true, reactions: [ mkReaction "🎉" 3 ], replyTo: Nothing }
139 ]
147 ]
140 "random" ->
148 "random" ->
141 [ { sender: "Eve", body: "Anyone here?", isMine: false, reactions: noReactions, replyTo: Nothing }
149 [ { sender: "Eve", body: "Anyone here?", isMine: false, reactions: noReactions, replyTo: Nothing }
142− , { sender: "You", body: "👋", isMine: true, reactions: [ { emoji: "👋", count: 1 } ], replyTo: Just 0 }
150+ , { sender: "You", body: "👋", isMine: true, reactions: [ mkReaction "👋" 1 ], replyTo: Just 0 }
143− , { sender: "Eve", body: ":partyblob: :partyblob: :partyblob:", isMine: false, reactions: [ { emoji: "🎉", count: 2 } ], replyTo: Nothing }
151+ , { sender: "Eve", body: ":partyblob: :partyblob: :partyblob:", isMine: false, reactions: [ mkReaction "🎉" 2 ], replyTo: Nothing }
144 ]
152 ]
145 _ -> []
153 _ -> []
146
154
@@ -260,16 +268,35 @@ chatDemo = component "ChatDemo" \dp -> React.do
260 ]
268 ]
261 )
269 )
262
270
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+
263− reactionPills msg =
285+ reactionPills idx msg =
264 if length msg.reactions == 0 then mempty
286 if length msg.reactions == 0 then mempty
265 else view { style: tw "flex-row mt-1" <> Style.style { alignSelf: if msg.isMine then "flex-end" else "flex-start" } }
287 else view { style: tw "flex-row mt-1" <> Style.style { alignSelf: if msg.isMine then "flex-end" else "flex-start" } }
266− ( msg.reactions <#> \r ->
288+ ( msg.reactions <#> \rx ->
267− view
289+ pressable
268− { style: tw "flex-row items-center rounded-full px-2 py-0.5 mr-1"
290+ { onPress: handler_ (toggleReaction idx rx.emoji)
269− <> Style.style { backgroundColor: reactionBg }
270 }
291 }
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+ }
271− [ text { style: Style.style { fontSize: 12.0 } } r.emoji
297+ [ text { style: Style.style { fontSize: 12.0 } } rx.emoji
272− , text { style: tw "text-xs ml-1" <> Style.style { color: dp.dimFg } } (show r.count)
298+ , text { style: tw "text-xs ml-1" <> Style.style { color: if rx.mine then "#FFFFFF" else dp.dimFg } } (show rx.count)
299+ ]
273 ]
300 ]
274 )
301 )
275
302
@@ -405,7 +432,7 @@ chatDemo = component "ChatDemo" \dp -> React.do
405 ]
432 ]
406 , if not msg.isMine then reactionPicker idx else mempty
433 , if not msg.isMine then reactionPicker idx else mempty
407 ]
434 ]
408− , reactionPills msg
435+ , reactionPills idx msg
409 ]
436 ]
410 ]
437 ]
411 ]
438 ]