Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Message Reactions 🥳 #140

Merged
merged 53 commits into from
Feb 21, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
45035a8
Moved MessageMenu.swift into a MessageMenu subfolder
btoms20 Feb 16, 2025
f5b82f6
Moved MessageMenu.swift into the MessageMenu subfolder
btoms20 Feb 16, 2025
134b765
Added Reaction Model
btoms20 Feb 16, 2025
62af29c
Extended Message to support Reactions
btoms20 Feb 16, 2025
6614383
Added reaction equality check (we should probably drop message.text a…
btoms20 Feb 16, 2025
c9ec30d
Added a Reaction Selection view (essentially just a hstack of emojis).
btoms20 Feb 16, 2025
c3e6859
Added a ReactionOverview view that associates message reactions with …
btoms20 Feb 16, 2025
9ee94f7
Extended MessageView with a MessageReactionView for rendering reactio…
btoms20 Feb 16, 2025
20ea97a
Extended MessageView to support reactions
btoms20 Feb 16, 2025
5988d68
Added mock reactions to the preview.
btoms20 Feb 16, 2025
06493a2
Modified the subscribeKeyboardNotifications subscription to log the k…
btoms20 Feb 16, 2025
88ec302
Added a `viewWidth` method that's similar to `viewSize` but sets the …
btoms20 Feb 16, 2025
a8f1822
Added an EmojiTextField. This view behaves similar to the standard Te…
btoms20 Feb 16, 2025
1721cff
Added a ReactionDelegate protocol and an internal DefaultReactionConf…
btoms20 Feb 16, 2025
d82a487
Added a maxHeightGetter View extension that behaves similarly to the …
btoms20 Feb 16, 2025
395ff90
These values are a rough estimate of a single Emoji rendered at the F…
btoms20 Feb 16, 2025
172d45c
Moved MessageMenu into sub folder
btoms20 Feb 16, 2025
dda70ba
Added a `messageFrame` var for logging a more precise frame for our m…
btoms20 Feb 16, 2025
36ae3af
The new MessageMenu results in not needing swiftui-introspection and …
btoms20 Feb 16, 2025
2ade186
Using the new ImpactGenerator to provide haptic feedback to the user …
btoms20 Feb 16, 2025
5d39876
Added optional reactionDelegate param to the partial template initial…
btoms20 Feb 16, 2025
760d37a
Removed unused imports
btoms20 Feb 16, 2025
a02552c
Removed unused @State vars associated with the old MessageMenu implem…
btoms20 Feb 16, 2025
db9e07b
Added reactionDelegate to the ChatView and it's initializer. Included…
btoms20 Feb 16, 2025
c384733
Added ChatView modifiers for setting a ReactionDelegate or configurin…
btoms20 Feb 16, 2025
a4a01b0
Added a remove(messageID:) method for deleting message from our data …
btoms20 Feb 16, 2025
0445b52
Added the remove and add methods to conform to the updated ChatIntera…
btoms20 Feb 16, 2025
ec63d36
Modified the generateNewMessage and generateStartMessages to include …
btoms20 Feb 16, 2025
62f693b
Added reactions to the MockMessage
btoms20 Feb 16, 2025
025a200
Added reaction support to MockChatData, a function for generating a r…
btoms20 Feb 16, 2025
d77a5d3
Made our ChatExampleViewModel conform to the ReactionDelegate protoco…
btoms20 Feb 16, 2025
3459793
Added our viewModel as a message reaction delegate using the new API.
btoms20 Feb 16, 2025
8940b6f
Instead of just printing a delete message, actually delete the messag…
btoms20 Feb 16, 2025
32d1c1e
Merge branch 'main'
btoms20 Feb 16, 2025
875ea37
Fix merge conflicts
btoms20 Feb 16, 2025
6be50fb
Fix merge conflicts
btoms20 Feb 16, 2025
d8f0260
Added comment regarding possible future Reaction types
btoms20 Feb 16, 2025
a258a08
Merged main
btoms20 Feb 17, 2025
5766ce2
Using PositionInUserGroup to get the exact top padding on a given mes…
btoms20 Feb 18, 2025
58866d4
Updated ReactionOverview to handle leading safe area insets correctly…
btoms20 Feb 18, 2025
96243c7
Adjusted the emojiBackgroundView color to be more visible in more sit…
btoms20 Feb 18, 2025
b36a543
Updated ReactionSelectionView to handle leading safeAreaInsets correctly
btoms20 Feb 18, 2025
b7aa088
Adjusted the inner shadow to be less intrusive.
btoms20 Feb 18, 2025
e079383
Added a reactionOverviewWidth param instead of calculating it indepen…
btoms20 Feb 18, 2025
d598c30
Include the height of the ReactionOverview in our content height check.
btoms20 Feb 18, 2025
7784530
Updated the reactionsView layout to not require a sizeGetter and fram…
btoms20 Feb 18, 2025
0ed009e
Removed unused code
btoms20 Feb 18, 2025
bef7204
Formatting
btoms20 Feb 18, 2025
d8e2ea0
Instead of altering the opacity of the friendsMessageColor we mix it …
btoms20 Feb 18, 2025
95d585f
Actually use the alignment provided in our initializer instead of che…
btoms20 Feb 18, 2025
f93e696
Moved the scaleAndFade definition above the preview code
btoms20 Feb 18, 2025
dbc9e5e
Merge branch 'main'
btoms20 Feb 18, 2025
ad75470
Altered mock reaction generation values, increased send time, decreas…
btoms20 Feb 18, 2025
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
7 changes: 7 additions & 0 deletions .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,6 @@
"version" : "1.3.0"
}
},
{
"identity" : "giphy-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Giphy/giphy-ios-sdk",
"state" : {
"revision" : "5afbf8ac89273a14ead2bd494b498a3fa46c6c56",
"version" : "2.2.14"
}
},
{
"identity" : "libwebp-xcode",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SDWebImage/libwebp-Xcode",
"state" : {
"revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2",
"version" : "1.5.0"
}
},
{
"identity" : "mediapicker",
"kind" : "remoteSourceControl",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ protocol ChatInteractorProtocol {
var otherSenders: [MockUser] { get }

func send(draftMessage: ExyteChat.DraftMessage)
func remove(messageID: String)

func add(draftReaction: DraftReaction, to messageID: String)

func connect()
func disconnect()
Expand Down
96 changes: 93 additions & 3 deletions Example/ChatExample/ChatInteractor/MockChatInteractor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,82 @@ final class MockChatInteractor: ChatInteractorProtocol {
}
}
}

func remove(messageID: String) {
DispatchQueue.main.async { [weak self] in
self?.chatState.value.removeAll(where: { $0.uid == messageID })
}
}

/// Adds a reaction to an existing message
func add(draftReaction: ExyteChat.DraftReaction, to messageID: String) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
if let matchIndex = self.chatState.value.firstIndex(where: { $0.uid == messageID }) {
let originalMessage = self.chatState.value[matchIndex]
let reaction = Reaction(user: self.chatData.tim.toChatUser(), type: draftReaction.type)
let newMessage = MockMessage(
uid: originalMessage.uid,
sender: originalMessage.sender,
createdAt: originalMessage.createdAt,
status: originalMessage.status,
text: originalMessage.text,
images: originalMessage.images,
videos: originalMessage.videos,
reactions: originalMessage.reactions + [reaction],
recording: originalMessage.recording,
replyMessage: originalMessage.replyMessage
)
print("Setting Reaction")
self.chatState.value[matchIndex] = newMessage

// Update our message reaction status after a random delay...
delayUpdateReactionStatus(messageID: messageID, reactionID: reaction.id)

} else {
print("No Match for Reaction")
}
}
}

/// Updates a reaction's status after a random amount of time
func delayUpdateReactionStatus(messageID: String, reactionID: String) {
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(.random(in: 1000...3500))) { [weak self] in
guard let self else { return }
if let matchIndex = self.chatState.value.firstIndex(where: { $0.uid == messageID }) {
let originalMessage = self.chatState.value[matchIndex]
if let reactionIndex = originalMessage.reactions.firstIndex(where: { $0.id == reactionID }) {
let originalReaction = originalMessage.reactions[reactionIndex]

var reactions = originalMessage.reactions
var status:Reaction.Status = .sent
if Int.random(min: 0, max: 20) == 0 {
status = .error(.init(id: originalReaction.id, messageID: originalMessage.uid, createdAt: originalReaction.createdAt, type: originalReaction.type))
}
reactions[reactionIndex] = .init(id: originalReaction.id, user: originalReaction.user, createdAt: originalReaction.createdAt, type: originalReaction.type, status: status)

let newMessage = MockMessage(
uid: originalMessage.uid,
sender: originalMessage.sender,
createdAt: originalMessage.createdAt,
status: originalMessage.status,
text: originalMessage.text,
images: originalMessage.images,
videos: originalMessage.videos,
reactions: reactions,
recording: originalMessage.recording,
replyMessage: originalMessage.replyMessage
)

self.chatState.value[matchIndex] = newMessage
} else {
print("No Match for Reaction")
}
} else {
print("No Match for Message")
}
}
}

func connect() {
Timer.publish(every: 2, on: .main, in: .default)
Expand Down Expand Up @@ -100,16 +176,30 @@ private extension MockChatInteractor {
}
return (0...10)
.map { index in
chatData.randomMessage(senders: senders, date: lastDate.randomTime())
// Generate a random message
var msg = chatData.randomMessage(senders: senders, date: lastDate.randomTime())
// 20% of the time, generate a random reaction to the message
if Int.random(in: 0...4) == 0 { msg = chatData.reactToMessage(msg, senders: senders) }
// Return the message
return msg
}
.sorted { lhs, rhs in
lhs.createdAt < rhs.createdAt
}
}

func generateNewMessage() {
let message = chatData.randomMessage(senders: otherSenders)
chatState.value.append(message)
let idx = Int.random(min: 1, max: 10)
// 30% of the time, lets react to a previous and recent message
if idx <= 3, chatState.value.count >= idx {
let msgIndex = chatState.value.count - idx
let message = chatData.reactToMessage(chatState.value[msgIndex], senders: otherSenders)
chatState.value[msgIndex] = message
} else {
// 70% of the time just create a new random message
let message = chatData.randomMessage(senders: otherSenders)
chatState.value.append(message)
}
}

func updateSendingStatuses() {
Expand Down
27 changes: 27 additions & 0 deletions Example/ChatExample/DataGeneration/MockChatData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ final class MockChatData {
text: shouldGenerateText ? Lorem.sentence(nbWords: Int.random(in: 3...10), useMarkdown: true) : "",
images: images,
videos: [],
reactions: [],
recording: nil,
replyMessage: nil
)
Expand Down Expand Up @@ -73,6 +74,31 @@ final class MockChatData {
.map { _ in randomHexChar() }
.joined()
}

func randomReaction(senders: [MockUser]) -> Reaction {
let sampleEmojis:[String] = ["👍", "👎", "❤️", "🤣", "‼️", "❓", "🥳", "💪", "🔥", "💔", "😭"]
return Reaction(
user: senders.random()!.toChatUser(),
createdAt: Date.now,
type: .emoji(sampleEmojis.random()!),
status: .sent
)
}

func reactToMessage(_ msg: MockMessage, senders: [MockUser]) -> MockMessage {
return MockMessage(
uid: msg.uid,
sender: msg.sender,
createdAt: msg.createdAt,
status: msg.status,
text: msg.text,
images: msg.images,
videos: msg.videos,
reactions: msg.reactions + [randomReaction(senders: senders)],
recording: msg.recording,
replyMessage: msg.replyMessage
)
}
}

private extension MockChatData {
Expand Down Expand Up @@ -142,6 +168,7 @@ extension DraftMessage {
text: text,
images: await makeMockImages(),
videos: await makeMockVideos(),
reactions: [],
recording: recording,
replyMessage: replyMessage
)
Expand Down
2 changes: 2 additions & 0 deletions Example/ChatExample/Model/MockMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct MockMessage {
let text: String
let images: [MockImage]
let videos: [MockVideo]
let reactions: [Reaction]
let recording: Recording?
let replyMessage: ReplyMessage?
}
Expand All @@ -27,6 +28,7 @@ extension MockMessage {
createdAt: createdAt,
text: text,
attachments: images.map { $0.toChatAttachment() } + videos.map { $0.toChatAttachment() },
reactions: reactions,
recording: recording,
replyMessage: replyMessage
)
Expand Down
1 change: 1 addition & 0 deletions Example/ChatExample/Screens/ChatExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ struct ChatExampleView: View {
}
.messageUseMarkdown(true)
.setRecorderSettings(recorderSettings)
.messageReactionDelegate(viewModel)
.swipeActions(edge: .leading, performsFirstActionWithFullSwipe: true, items: [
SwipeAction(action: onReply, activeFor: { !$0.user.isCurrentUser }, background: .blue) {
VStack {
Expand Down
11 changes: 10 additions & 1 deletion Example/ChatExample/Screens/ChatExampleViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import Foundation
import Combine
import ExyteChat

final class ChatExampleViewModel: ObservableObject {
final class ChatExampleViewModel: ObservableObject, ReactionDelegate {

@Published var messages: [Message] = []

var chatTitle: String {
Expand All @@ -30,6 +31,14 @@ final class ChatExampleViewModel: ObservableObject {
interactor.send(draftMessage: draft)
}

func remove(messageID: String) {
interactor.remove(messageID: messageID)
}

func didReact(to message: Message, reaction draftReaction: DraftReaction) {
interactor.add(draftReaction: draftReaction, to: draftReaction.messageID)
}

func onStart() {
interactor.messages
.compactMap { messages in
Expand Down
7 changes: 5 additions & 2 deletions Example/ChatExample/Screens/CommentsExampleView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,14 @@ struct CommentsExampleView: View {
defaultActionClosure(message, .reply)
case .edit:
defaultActionClosure(message, .edit { editedText in
// update this message's text on your BE
// update this message's text in your datasource
print(editedText)
})
case .delete:
print("deleted")
// delete this message in your datasource
viewModel.messages.removeAll { msg in
msg.id == message.id
}
case .print:
print(message.text)
}
Expand Down
10 changes: 0 additions & 10 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,10 @@ let package = Package(
targets: ["ExyteChat"]),
],
dependencies: [
.package(
url: "https://github.com/siteline/swiftui-introspect",
from: "1.0.0"
),
.package(
url: "https://github.com/exyte/MediaPicker.git",
from: "2.0.0"
),
.package(
url: "https://github.com/exyte/FloatingButton",
from: "1.2.2"
),
.package(
url: "https://github.com/exyte/ActivityIndicatorView",
from: "1.0.0"
Expand All @@ -40,9 +32,7 @@ let package = Package(
.target(
name: "ExyteChat",
dependencies: [
.product(name: "SwiftUIIntrospect", package: "swiftui-introspect"),
.product(name: "ExyteMediaPicker", package: "MediaPicker"),
.product(name: "FloatingButton", package: "FloatingButton"),
.product(name: "ActivityIndicatorView", package: "ActivityIndicatorView"),
.product(name: "GiphyUISDK", package: "giphy-ios-sdk")
],
Expand Down
Loading