Skip to content

Commit a6a2a6d

Browse files
authoredMar 6, 2023
Merge pull request #10 from jaesung-0o0/feature/jaesung/message-menu
[Release/1.0.0-beta.2] Message Menu
2 parents f9450da + 381b191 commit a6a2a6d

14 files changed

+584
-43
lines changed
 

‎Documentation/Chat_in_Channel/MessageList.md

+32
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,35 @@ var body: some View {
9797
isKeyboardShown = isShown
9898
}
9999
```
100+
101+
## How to show message menu on long press gesture
102+
103+
You can add message menus to display when a `rowContent`(such as `MessageRow`) is on long press gesture by setting `menuContent` parameter of the `MessageList` initializer.
104+
105+
`MessageMenu` and `MessageMenubuttonStyle` allow you to create message menu more easily. Here is an example:
106+
107+
```swift
108+
MessageList(messages) { message in
109+
// row content
110+
MessageRow(message: message)
111+
.padding(.top, 12)
112+
} menuContent: { highlightMessage in
113+
// menu content
114+
MessageMenu {
115+
Button("Copy", action: copy)
116+
.buttonStyle(MessageMenuButtonStyle(symbol: "doc.on.doc"))
117+
118+
Divider()
119+
120+
Button("Reply", action: reply)
121+
.buttonStyle(MessageMenuButtonStyle(symbol: "arrowshape.turn.up.right"))
122+
123+
Divider()
124+
125+
Button("Delete", action: delete)
126+
.buttonStyle(MessageMenuButtonStyle(symbol: "trash"))
127+
}
128+
.padding(.top, 12)
129+
}
130+
```
131+

‎README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ If you have any feature you want, please let me know via *Issue* or *Discussion*
104104
- [x] MessageList: Dimiss keyboard when tap outside
105105
- [ ] MessageList: Date view
106106
- [ ] MessageList: Publisher for retrieving more message while scrolling
107-
- [ ] MessageRow: Message Menu
107+
- [x] MessageList: Message Menu
108+
- [x] MessageList: Message reaction publisher
108109
- [ ] MessageRow: placement (e.g., Both, leftOnly, rightOnly)
109110
- [ ] MessageField: CameraCapturer
110111
- [ ] Giphy: Resize body with GIF frame size

‎Sources/ChatUI/ChatInChannel/MessageList.swift

+163-2
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import SwiftUI
9+
import Combine
910

1011
/**
1112
The view that lists message objects.
@@ -18,7 +19,7 @@ import SwiftUI
1819

1920
- **NOTE:** The order of the messages: sending → failed → sent → delivered → seen
2021
*/
21-
public struct MessageList<MessageType: MessageProtocol & Identifiable, RowContent: View>: View {
22+
public struct MessageList<MessageType: MessageProtocol & Identifiable, RowContent: View, MenuContent: View>: View {
2223

2324
@EnvironmentObject var configuration: ChatConfiguration
2425

@@ -27,12 +28,17 @@ public struct MessageList<MessageType: MessageProtocol & Identifiable, RowConten
2728
@State private var isKeyboardShown = false
2829
@State private var scrollOffset: CGFloat = 0
2930
@State private var showsScrollButton: Bool = false
31+
@State private var highlightMessage: MessageType? = nil
32+
@State private var isMessageMenuPresented: Bool = false
3033

3134
/// The latest message goes very first.
3235
let showsDate: Bool
3336
let rowContent: (_ message: MessageType) -> RowContent
3437
let listName = "name.list.message"
3538

39+
let messageMenuContent: ((_ message: MessageType) -> MenuContent)?
40+
let reactionItems: [String]
41+
3642
let sendingMessages: [MessageType]
3743
let failedMessages: [MessageType]
3844
let sentMessages: [MessageType]
@@ -56,24 +62,36 @@ public struct MessageList<MessageType: MessageProtocol & Identifiable, RowConten
5662
LazyVStack(spacing: 0) {
5763
ForEach(sendingMessages) { message in
5864
rowContent(message)
65+
.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { anchor in
66+
[message.id: anchor]
67+
}
5968
.padding(.horizontal, 12)
6069
.effect(.flipped)
6170
}
6271

6372
ForEach(failedMessages) { message in
6473
rowContent(message)
74+
.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { anchor in
75+
[message.id: anchor]
76+
}
6577
.padding(.horizontal, 12)
6678
.effect(.flipped)
6779
}
6880

6981
ForEach(sentMessages) { message in
7082
rowContent(message)
83+
.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { anchor in
84+
[message.id: anchor]
85+
}
7186
.padding(.horizontal, 12)
7287
.effect(.flipped)
7388
}
7489

7590
ForEach(deliveredMessages) { message in
7691
rowContent(message)
92+
.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { anchor in
93+
[message.id: anchor]
94+
}
7795
.padding(.horizontal, 12)
7896
.effect(.flipped)
7997
}
@@ -93,6 +111,9 @@ public struct MessageList<MessageType: MessageProtocol & Identifiable, RowConten
93111
.padding(.trailing, 21)
94112
}
95113
}
114+
.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { anchor in
115+
[message.id: anchor]
116+
}
96117
.padding(.horizontal, 12)
97118
.effect(.flipped)
98119
}
@@ -149,12 +170,117 @@ public struct MessageList<MessageType: MessageProtocol & Identifiable, RowConten
149170
.padding(.bottom, 8)
150171
}
151172
}
173+
.overlay {
174+
// MARK: blur
175+
if highlightMessage != nil {
176+
Rectangle()
177+
.fill(.ultraThinMaterial)
178+
.ignoresSafeArea()
179+
.onTapGesture {
180+
withAnimation {
181+
isMessageMenuPresented = false
182+
highlightMessage = nil
183+
}
184+
}
185+
}
186+
}
187+
.overlayPreferenceValue(BoundsPreferenceKey.self) { values in
188+
// MARK: Detects which message row is tapped
189+
if let highlightMessage = highlightMessage, let preference = values.first(where: { item in
190+
item.key == highlightMessage.id
191+
}) {
192+
GeometryReader { proxy in
193+
let rect = proxy[preference.value]
194+
// presenting view as an overlay, so that it will look like custom context
195+
VStack(alignment: configuration.userID == highlightMessage.sender.id ? .trailing : .leading, spacing: 0) {
196+
// MARK: Message Reaction
197+
if highlightMessage.readReceipt == .seen {
198+
ReactionSelector(isPresented: $isMessageMenuPresented, message: highlightMessage, items: reactionItems) { reactionItem in
199+
// MARK: Close Highlight
200+
withAnimation(.easeInOut) {
201+
isMessageMenuPresented = false
202+
self.highlightMessage = nil
203+
}
204+
205+
// Reaction Publisher
206+
withAnimation(.easeInOut.delay(0.3)) {
207+
let _ = Empty<Void, Never>()
208+
.sink { _ in
209+
messageReactionPublisher.send((reactionItem, highlightMessage.id))
210+
} receiveValue: { _ in }
211+
}
212+
}
213+
.padding(.top, rect.minY > 0 ? rect.minY - 36.5 : 0)
214+
} else {
215+
Spacer()
216+
.frame(maxHeight: rect.minY > 0 ? rect.minY : 0)
217+
}
218+
219+
// MARK: Message Row
220+
rowContent(highlightMessage)
221+
.frame(width: rect.width, height: rect.height)
222+
223+
// MARK: Message Menu
224+
if let messageMenuContent = messageMenuContent {
225+
messageMenuContent(highlightMessage)
226+
}
227+
228+
Spacer()
229+
}
230+
.id(highlightMessage.id)
231+
.offset(x: rect.minX)
232+
}
233+
.transition(.asymmetric(insertion: .identity, removal: .offset(x: 1)))
234+
}
235+
}
236+
.onReceive(highlightMessagePublisher) { highlightMessage in
237+
withAnimation(.easeInOut) {
238+
self.highlightMessage = highlightMessage as? MessageType
239+
self.isMessageMenuPresented = true
240+
}
241+
}
152242
}
153243

244+
/// The view that lists `messageData` which is an array of the objects that conform to ``MessageProtocol``
245+
///
246+
/// - Parameters:
247+
/// - messageData: The array of objects that conform to ``MessageProtocol``
248+
/// - showsDate: The boolean value that indicates whether shows date or not.
249+
/// - reactionItems: The array of reaction item that is type of `String`. e.g., `["❤️", "👍", "👎", "😆", "🎉"]`
250+
/// - rowContent: The row content for the message list. Each row represent one `message`. It's recommended that uses ``MessageRow``.
251+
/// - menuContent: The menu content for `message`. It's recommended that uses ``MessageMenu`` and ``MessageMenuButtonStyle``
252+
///
253+
/// Example usage:
254+
/// ```swift
255+
/// MessageList(messages, reactionItems: ["❤️", "👍", "👎", "😆", "🎉"]) { message in
256+
/// MessageRow(message: message)
257+
/// } menuContent: { highlightMessage in
258+
/// MessageMenu {
259+
/// Button("Copy", action: copy)
260+
/// .buttonStyle(MessageMenuButtonStyle(symbol: "doc.on.doc"))
261+
///
262+
/// Divider()
263+
///
264+
/// Button("Reply", action: reply)
265+
/// .buttonStyle(MessageMenuButtonStyle(symbol: "arrowshape.turn.up.right"))
266+
///
267+
/// Divider()
268+
///
269+
/// Button("Delete", action: delete)
270+
/// .buttonStyle(MessageMenuButtonStyle(symbol: "trash"))
271+
/// }
272+
/// .padding(.top, 12)
273+
/// }
274+
/// .onReceive(messageReactionPublisher) { (reactionItem, messageID) in
275+
/// // handle message with reactionItem
276+
/// }
277+
/// ```
154278
public init(
155279
_ messageData: [MessageType],
156280
showsDate: Bool = false, // TODO: Not Supported yet
157-
@ViewBuilder rowContent: @escaping (_ message: MessageType) -> RowContent
281+
reactionItems: [String] = [],
282+
@ViewBuilder rowContent: @escaping (_ message: MessageType) -> RowContent,
283+
@ViewBuilder menuContent: @escaping (_ message: MessageType) -> MenuContent
158284
) {
159285
var sendingMessages: [MessageType] = []
160286
var failedMessages: [MessageType] = []
@@ -185,5 +311,40 @@ public struct MessageList<MessageType: MessageProtocol & Identifiable, RowConten
185311

186312
self.showsDate = showsDate
187313
self.rowContent = rowContent
314+
self.reactionItems = reactionItems
315+
self.messageMenuContent = menuContent
316+
}
317+
318+
/// The view that lists `messageData` which is an array of the objects that conform to ``MessageProtocol``
319+
///
320+
/// - Parameters:
321+
/// - messageData: The array of objects that conform to ``MessageProtocol``
322+
/// - showsDate: The boolean value that indicates whether shows date or not.
323+
/// - reactionItems: The array of reaction item that is type of `String`. e.g., `["❤️", "👍", "👎", "😆", "🎉"]`
324+
/// - rowContent: The row content for the message list. Each row represent one `message`. It's recommended that uses ``MessageRow``.
325+
///
326+
/// Example usage:
327+
/// ```swift
328+
/// MessageList(messages, reactionItems: ["❤️", "👍", "👎", "😆", "🎉"]) { message in
329+
/// MessageRow(message: message)
330+
/// }
331+
/// .onReceive(messageReactionPublisher) { (reactionItem, messageID) in
332+
/// // handle message with reactionItem
333+
/// }
334+
/// ```
335+
public init(
336+
_ messageData: [MessageType],
337+
showsDate: Bool = false, // TODO: Not Supported yet
338+
reactionItems: [String] = [],
339+
@ViewBuilder rowContent: @escaping (_ message: MessageType) -> RowContent
340+
) where MenuContent == EmptyView {
341+
self.init(
342+
messageData,
343+
showsDate: showsDate,
344+
reactionItems: reactionItems,
345+
rowContent: rowContent
346+
) { _ in
347+
EmptyView()
348+
}
188349
}
189350
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//
2+
// MessageMenu.swift
3+
//
4+
//
5+
// Created by Jaesung Lee on 2023/03/06.
6+
//
7+
8+
import SwiftUI
9+
10+
/// The menu for the message
11+
public struct MessageMenu<Content: View>: View {
12+
@Environment(\.appearance) var appearance
13+
@ViewBuilder let content: () -> Content
14+
15+
public var body: some View {
16+
VStack(spacing: 0) {
17+
content()
18+
}
19+
.frame(width: 240)
20+
.background(appearance.remoteMessageBackground)
21+
.cornerRadius(14)
22+
}
23+
24+
public init(@ViewBuilder content: @escaping () -> Content) {
25+
self.content = content
26+
}
27+
}
28+
29+
public struct MessageMenuButtonStyle: ButtonStyle {
30+
@Environment(\.appearance) var appearance
31+
let symbol: String
32+
33+
public func makeBody(configuration: Configuration) -> some View {
34+
HStack {
35+
configuration.label
36+
Spacer()
37+
Image(systemName: symbol)
38+
}
39+
.padding(.horizontal, 16)
40+
.foregroundColor(appearance.primary)
41+
.background(configuration.isPressed ? appearance.secondaryBackground : Color.clear)
42+
.frame(height: 44)
43+
}
44+
45+
public init(symbol: String) {
46+
self.symbol = symbol
47+
}
48+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
//
2+
// ReactionEffectView.swift
3+
//
4+
//
5+
// Created by Jaesung Lee on 2023/03/05.
6+
//
7+
8+
import SwiftUI
9+
10+
// - INFORMATION: [Reference - Youtube](https://www.youtube.com/watch?v=S7hhHc9FgnY)
11+
public struct ReactionEffectView: View {
12+
@Environment(\.appearance) var appearance
13+
14+
@State var animationValues: [Bool] = Array(repeating: false, count: 6)
15+
16+
var item: String
17+
var effectTint: Color
18+
19+
public var body: some View {
20+
ZStack {
21+
Text(item)
22+
.font(appearance.title)
23+
.padding(6)
24+
.background {
25+
effectTint
26+
.clipShape(Circle())
27+
}
28+
.scaleEffect(animationValues[2] ? 1 : 0)
29+
.overlay {
30+
Circle()
31+
.stroke(effectTint, lineWidth: animationValues[1] ? 0 : 100)
32+
.clipShape(Circle())
33+
.scaleEffect(animationValues[0] ? 1.6 : 0.01)
34+
}
35+
// MARK: Random Circles
36+
.overlay {
37+
ZStack {
38+
ForEach(1...20, id: \.self) { index in
39+
Circle()
40+
.fill(effectTint)
41+
.frame(width: .random(in: 3...5), height: .random(in: 3...5))
42+
.offset(x: .random(in: -5...5), y: .random(in: -5...5))
43+
.offset(x: animationValues[3] ? 45 : 10)
44+
.rotationEffect(.init(degrees: Double(index) * 18.0))
45+
.scaleEffect(animationValues[2] ? 1 : 0.01)
46+
.opacity(animationValues[4] ? 0 : 1)
47+
}
48+
}
49+
}
50+
}
51+
.onAppear {
52+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
53+
withAnimation(.easeInOut(duration: 0.35)) {
54+
animationValues[0] = true
55+
}
56+
withAnimation(.easeInOut(duration: 0.45).delay(0.06)) {
57+
animationValues[1] = true
58+
}
59+
withAnimation(.easeInOut(duration: 0.35).delay(0.3)) {
60+
animationValues[2] = true
61+
}
62+
withAnimation(.easeInOut(duration: 0.35).delay(0.4)) {
63+
animationValues[3] = true
64+
}
65+
withAnimation(.easeInOut(duration: 0.55).delay(0.55)) {
66+
animationValues[4] = true
67+
}
68+
}
69+
}
70+
}
71+
72+
public init(item: String, effectTint: Color) {
73+
self.item = item
74+
self.effectTint = effectTint
75+
}
76+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
//
2+
// ReactionSelector.swift
3+
//
4+
//
5+
// Created by Jaesung Lee on 2023/03/05.
6+
//
7+
8+
import SwiftUI
9+
10+
public struct ReactionSelector<MessageType: MessageProtocol>: View {
11+
@Environment(\.appearance) var appearance
12+
13+
@Binding var isPresented: Bool
14+
15+
let message: MessageType
16+
let items: [String]
17+
let onReaction: (String) -> ()
18+
19+
20+
// update the count based on your reaction item array size
21+
@State var effectItem: [Bool] = Array(repeating: false, count: 5)
22+
@State var isEffectAnimated: Bool = false
23+
24+
public var body: some View {
25+
HStack(spacing: 12) {
26+
ForEach(Array(items.enumerated()), id: \.element) { index, item in
27+
Text(item)
28+
.font(appearance.title)
29+
.scaleEffect(effectItem[index] ? 1 : 0.1)
30+
.onAppear {
31+
// animate
32+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
33+
withAnimation(.easeInOut.delay(Double(index) * 0.1)) {
34+
effectItem[index] = true
35+
}
36+
}
37+
}
38+
.onTapGesture {
39+
onReaction(items[index])
40+
}
41+
}
42+
}
43+
.padding(.horizontal, 15)
44+
.padding(.vertical, 8)
45+
.background {
46+
Capsule()
47+
.fill(appearance.secondaryBackground)
48+
.mask {
49+
Capsule()
50+
.scaleEffect(isEffectAnimated ? 1 : 0.1, anchor: .leading)
51+
}
52+
}
53+
.onAppear {
54+
withAnimation {
55+
isEffectAnimated = true
56+
}
57+
}
58+
.onChange(of: isPresented) { newValue in
59+
if !newValue {
60+
withAnimation(.easeInOut(duration: 0.2).delay(0.15)) {
61+
isEffectAnimated = true
62+
}
63+
64+
for index in items.indices {
65+
withAnimation(.easeInOut) {
66+
effectItem[index] = false
67+
}
68+
}
69+
}
70+
}
71+
}
72+
73+
init(isPresented: Binding<Bool>, message: MessageType, items: [String], onReaction: @escaping (String) -> Void) {
74+
self._isPresented = isPresented
75+
self.message = message
76+
self.items = items
77+
self.onReaction = onReaction
78+
self.effectItem = effectItem
79+
self.isEffectAnimated = isEffectAnimated
80+
}
81+
}

‎Sources/ChatUI/ChatInChannel/MessageRow.swift

+33-37
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
//
77

88
import SwiftUI
9+
import Combine
910

1011
public struct MessageRow<M: MessageProtocol>: View {
1112

@@ -18,6 +19,8 @@ public struct MessageRow<M: MessageProtocol>: View {
1819
let showsProfileImage: Bool
1920
let showsReadReceiptStatus: Bool
2021

22+
@State private var isSelected: Bool = false
23+
2124
var isMyMessage: Bool {
2225
message.sender.id == configuration.userID
2326
}
@@ -57,46 +60,36 @@ public struct MessageRow<M: MessageProtocol>: View {
5760
.padding(.horizontal, 8)
5861
}
5962

60-
switch message.style {
61-
case .text(let text):
62-
let markdown = LocalizedStringKey(text)
63-
Text(markdown)
64-
.tint(isMyMessage ? appearance.prominentLink : appearance.link)
65-
.messageStyle(isMyMessage ? .localBody : .remoteBody)
66-
case .media(let mediaType):
67-
switch mediaType {
68-
case .emoji(let key):
69-
Text(key)
70-
.messageStyle(isMyMessage ? .localBody : .remoteBody)
71-
case .gif(let key):
72-
GiphyStyleView(id: key)
73-
case .photo(let data):
74-
PhotoStyleView(data: data)
75-
case .video(let data):
76-
Text("\(data)")
77-
.lineLimit(5)
78-
.messageStyle(isMyMessage ? .localBody : .remoteBody)
79-
case .document(let data):
80-
Text("\(data)")
81-
.lineLimit(5)
82-
.messageStyle(isMyMessage ? .localBody : .remoteBody)
83-
case .contact(let contact):
84-
let markdown = """
85-
Name: **\(contact.givenName) \(contact.familyName)**
86-
Phone: \(contact.phoneNumbers)
87-
"""
88-
Text(.init(markdown))
89-
.messageStyle(isMyMessage ? .localBody : .remoteBody)
90-
case .location(let latitude, let longitude):
91-
LocationStyleView(
92-
latitude: latitude,
93-
longitude: longitude
63+
if let reactableMessage = message as? MessageReactable {
64+
switch reactableMessage.reaction {
65+
case .none:
66+
EmptyView()
67+
case .reacted(let reactionItem):
68+
ReactionEffectView(
69+
item: reactionItem,
70+
effectTint: isMyMessage
71+
? appearance.remoteMessageBackground
72+
: appearance.localMessageBackground
9473
)
74+
.offset(x: isMyMessage ? 15 : -15)
75+
.padding(.bottom, -25)
76+
.zIndex(1)
77+
.opacity(isSelected ? 0 : 1)
9578
}
96-
case .voice(let data):
97-
VoiceStyleView(data: data)
98-
.messageStyle(isMyMessage ? .localBody : .remoteBody)
79+
9980
}
81+
82+
// MARK: Message bubble
83+
MessageView(style: message.style, isMyMessage: isMyMessage)
84+
.zIndex(0)
85+
.onLongPressGesture {
86+
withAnimation(.easeInOut) {
87+
let _ = Empty<Void, Never>()
88+
.sink { _ in
89+
highlightMessagePublisher.send(message)
90+
} receiveValue: { _ in }
91+
}
92+
}
10093
}
10194

10295
if !isMyMessage {
@@ -144,6 +137,9 @@ public struct MessageRow<M: MessageProtocol>: View {
144137

145138
}
146139
}
140+
.onReceive(highlightMessagePublisher) { highlightMessage in
141+
isSelected = message.id == highlightMessage.id
142+
}
147143
}
148144

149145
public init(
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//
2+
// MessageView.swift
3+
//
4+
//
5+
// Created by Jaesung Lee on 2023/03/05.
6+
//
7+
8+
import SwiftUI
9+
10+
struct MessageView: View {
11+
@Environment(\.appearance) var appearance
12+
13+
let style: MessageStyle
14+
let isMyMessage: Bool
15+
16+
var body: some View {
17+
switch style {
18+
case .text(let text):
19+
let markdown = LocalizedStringKey(text)
20+
Text(markdown)
21+
.tint(isMyMessage ? appearance.prominentLink : appearance.link)
22+
.messageStyle(isMyMessage ? .localBody : .remoteBody)
23+
case .media(let mediaType):
24+
switch mediaType {
25+
case .emoji(let key):
26+
Text(key)
27+
.messageStyle(isMyMessage ? .localBody : .remoteBody)
28+
case .gif(let key):
29+
GiphyStyleView(id: key)
30+
case .photo(let data):
31+
PhotoStyleView(data: data)
32+
case .video(let data):
33+
Text("\(data)")
34+
.lineLimit(5)
35+
.messageStyle(isMyMessage ? .localBody : .remoteBody)
36+
case .document(let data):
37+
Text("\(data)")
38+
.lineLimit(5)
39+
.messageStyle(isMyMessage ? .localBody : .remoteBody)
40+
case .contact(let contact):
41+
let markdown = """
42+
Name: **\(contact.givenName) \(contact.familyName)**
43+
Phone: \(contact.phoneNumbers)
44+
"""
45+
Text(.init(markdown))
46+
.messageStyle(isMyMessage ? .localBody : .remoteBody)
47+
case .location(let latitude, let longitude):
48+
LocationStyleView(
49+
latitude: latitude,
50+
longitude: longitude
51+
)
52+
}
53+
case .voice(let data):
54+
VoiceStyleView(data: data)
55+
.messageStyle(isMyMessage ? .localBody : .remoteBody)
56+
}
57+
}
58+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
//
2+
// BoundsPreferenceKey.swift
3+
//
4+
//
5+
// Created by Jaesung Lee on 2023/03/05.
6+
//
7+
8+
import SwiftUI
9+
10+
/// The preference key to detect anchors of the bounds as `CGRect`
11+
public struct BoundsPreferenceKey: PreferenceKey {
12+
public internal(set) static var defaultValue: [String: Anchor<CGRect>] = [:]
13+
14+
public static func reduce(value: inout [String : Anchor<CGRect>], nextValue: () -> [String : Anchor<CGRect>]) {
15+
value.merge(nextValue()) { $1 }
16+
}
17+
}

‎Sources/ChatUI/PreferenceKeys/ScrollViewOffsetPreferenceKey.swift

+4-3
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77

88
import SwiftUI
99

10-
struct ScrollViewOffsetPreferenceKey: PreferenceKey {
11-
static var defaultValue: CGFloat? = nil
10+
/// The preference key to detect scroll view offeset
11+
public struct ScrollViewOffsetPreferenceKey: PreferenceKey {
12+
public internal(set) static var defaultValue: CGFloat? = nil
1213

13-
static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
14+
public static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) {
1415
value = value ?? nextValue()
1516
}
1617
}

‎Sources/ChatUI/Previews/ChatInChannel/MessageList.Previews.swift

+25
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,31 @@ struct MessageList_Previews: PreviewProvider {
3232
}
3333
}
3434
.previewDisplayName("Message List")
35+
36+
MessageList(
37+
[Message.message1, Message.message2, Message.message3],
38+
reactionItems: ["❤️", "👍", "👎", "😆", "🎉"]
39+
) { message in
40+
MessageRow(message: message, showsUsername: false, showsProfileImage: false)
41+
.padding(.top, 12)
42+
} menuContent: { highlightMessage in
43+
MessageMenu {
44+
Button("Copy", action: {})
45+
.buttonStyle(MessageMenuButtonStyle(symbol: "doc.on.doc"))
46+
47+
Divider()
48+
Button("Reply", action: {})
49+
.buttonStyle(MessageMenuButtonStyle(symbol: "arrowshape.turn.up.right"))
50+
Divider()
51+
Button("Delete", action: {})
52+
.buttonStyle(MessageMenuButtonStyle(symbol: "trash"))
53+
}
54+
.padding(.top, 12)
55+
}
56+
.onReceive(messageReactionPublisher) { (item, messageID) in
57+
print(item)
58+
}
59+
.previewDisplayName("Message Menu")
3560
}
3661
.environmentObject(
3762
ChatConfiguration(

‎Sources/ChatUI/Protocols/MessageProtocol.swift

+17
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,20 @@ public protocol MessageProtocol: Hashable {
1919
var readReceipt: ReadReceipt { get }
2020
var style: MessageStyle { get }
2121
}
22+
23+
/// The protocol to support message reaction features.
24+
/// - IMPORTANT: Currently, it supports single reaction item only that is type of `String`. It's recommeded that uses text emoji such as `"❤️"`, `"🙂"`, `"👍"` and so on.
25+
public protocol MessageReactable: Hashable {
26+
/// The reaction status.
27+
/// - SeeAlso: ``ReactionStatus``
28+
var reaction: ReactionStatus { get set }
29+
}
30+
31+
/// The enumeration for message reaction status.
32+
/// - IMPORTANT: Currently, it supports single reaction item only that is type of `String`. It's recommeded that uses text emoji such as `"❤️"`, `"🙂"`, `"👍"` and so on.
33+
public enum ReactionStatus: Equatable, Hashable {
34+
/// There is no reaction item in which the message has.
35+
case none
36+
/// There is a single reaction item on the message.
37+
case reacted(_ item: String)
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
//
2+
// HighlightMessagePublisher.swift
3+
//
4+
//
5+
// Created by Jaesung Lee on 2023/03/05.
6+
//
7+
8+
import Combine
9+
10+
/**
11+
The publisher that send highlight message.
12+
*/
13+
public var highlightMessagePublisher = PassthroughSubject<any MessageProtocol, Never>()
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
//
2+
// MessageReactionPublisher.swift
3+
//
4+
//
5+
// Created by Jaesung Lee on 2023/03/05.
6+
//
7+
8+
import Combine
9+
10+
/**
11+
The publisher that send reaction item and message ID.
12+
- IMPORTANT: The first parameter is the reaction item.
13+
*/
14+
public var messageReactionPublisher = PassthroughSubject<(String, String), Never>()
15+

0 commit comments

Comments
 (0)
Please sign in to comment.