Skip to content

Commit dc20884

Browse files
committed
Add reaction
1 parent 2cadef1 commit dc20884

File tree

15 files changed

+1579
-41
lines changed

15 files changed

+1579
-41
lines changed

apple/InlineIOS/Chat/UIMessageView.swift

+117-31
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import InlineKit
22
import SwiftUI
33
import UIKit
44

5+
struct Reaction2: Equatable {
6+
let emoji: String
7+
var count: Int
8+
var hasReacted: Bool
9+
}
10+
511
class UIMessageView: UIView {
612
// MARK: - Properties
713

@@ -40,19 +46,26 @@ class UIMessageView: UIView {
4046
return stack
4147
}()
4248

49+
private var reactions: [Reaction2] = []
50+
private let reactionHeight: CGFloat = 24
51+
52+
private lazy var reactionsContainer: UIStackView = {
53+
let stack = UIStackView()
54+
stack.axis = .horizontal
55+
stack.spacing = 4
56+
stack.alignment = .center
57+
stack.distribution = .fillProportionally
58+
stack.translatesAutoresizingMaskIntoConstraints = false
59+
return stack
60+
}()
61+
4362
private var leadingConstraint: NSLayoutConstraint?
4463
private var trailingConstraint: NSLayoutConstraint?
4564
private var fullMessage: FullMessage
4665

4766
private let horizontalPadding: CGFloat = 12
4867
private let verticalPadding: CGFloat = 8
4968

50-
// private let embedView: MessageEmbedView = {
51-
// let view = MessageEmbedView(repliedToMessage: nil)
52-
// view.translatesAutoresizingMaskIntoConstraints = false
53-
// return view
54-
// }()
55-
5669
// MARK: - Initialization
5770

5871
init(fullMessage: FullMessage) {
@@ -76,12 +89,6 @@ class UIMessageView: UIView {
7689

7790
bubbleView.addSubview(contentStack)
7891

79-
// Add embed view if there's a reply
80-
// if let replyMessage = fullMessage.repliedToMessage {
81-
// contentStack.addArrangedSubview(embedView)
82-
// embedView.repliedToMessage = replyMessage
83-
// }
84-
8592
leadingConstraint = bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8)
8693
trailingConstraint = bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8)
8794

@@ -112,12 +119,6 @@ class UIMessageView: UIView {
112119
contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
113120
shortMessageStack.arrangedSubviews.forEach { $0.removeFromSuperview() }
114121

115-
// Add embed view first if there's a reply
116-
// if let replyMessage = fullMessage.repliedToMessage {
117-
// contentStack.addArrangedSubview(embedView)
118-
// embedView.repliedToMessage = replyMessage
119-
// }
120-
121122
let messageLength = fullMessage.message.text?.count ?? 0
122123
let messageText = fullMessage.message.text ?? ""
123124
let hasLineBreak = messageText.contains("\n")
@@ -147,10 +148,16 @@ class UIMessageView: UIView {
147148
shortMessageStack.addArrangedSubview(metadataView)
148149
contentStack.addArrangedSubview(shortMessageStack)
149150
}
151+
152+
// Add reactions container if there are reactions
153+
if !reactions.isEmpty {
154+
contentStack.addArrangedSubview(reactionsContainer)
155+
}
150156
}
151157

152158
private func configureForMessage() {
153-
messageLabel.text = fullMessage.message.text
159+
messageLabel.text =
160+
"\(fullMessage.message.text) \(fullMessage.message.messageId) \(fullMessage.message.chatId)"
154161

155162
if fullMessage.message.out == true {
156163
bubbleView.backgroundColor = ColorManager.shared.selectedColor
@@ -188,15 +195,77 @@ class UIMessageView: UIView {
188195
super.layoutSubviews()
189196
}
190197

191-
// override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
192-
// super.traitCollectionDidChange(previousTraitCollection)
193-
//
194-
// if previousTraitCollection?.preferredContentSizeCategory
195-
// != traitCollection.preferredContentSizeCategory
196-
// {
197-
// setNeedsLayout()
198-
// }
199-
// }
198+
// MARK: - Reaction2 Handling
199+
200+
private func handleReaction2(_ emoji: String) {
201+
if let index = reactions.firstIndex(where: { $0.emoji == emoji }) {
202+
// Toggle existing reaction
203+
var reaction = reactions[index]
204+
reaction.hasReacted.toggle()
205+
reaction.count += reaction.hasReacted ? 1 : -1
206+
207+
if reaction.count > 0 {
208+
reactions[index] = reaction
209+
} else {
210+
reactions.remove(at: index)
211+
}
212+
} else {
213+
// Add new reaction
214+
reactions.append(Reaction2(emoji: emoji, count: 1, hasReacted: true))
215+
}
216+
217+
updateReaction2Views()
218+
}
219+
220+
private func updateReaction2Views() {
221+
// Clear existing reaction views
222+
reactionsContainer.arrangedSubviews.forEach { $0.removeFromSuperview() }
223+
224+
// Add reaction bubbles
225+
for reaction in reactions {
226+
let reactionView = createReaction2Bubble(for: reaction)
227+
reactionsContainer.addArrangedSubview(reactionView)
228+
}
229+
230+
updateMetadataLayout()
231+
}
232+
233+
private func createReaction2Bubble(for reaction: Reaction2) -> UIView {
234+
let container = UIView()
235+
container.backgroundColor =
236+
reaction.hasReacted ? ColorManager.shared.selectedColor.withAlphaComponent(0.1) : .systemGray6
237+
container.layer.cornerRadius = reactionHeight / 2
238+
239+
let label = UILabel()
240+
label.text = "\(reaction.emoji) \(reaction.count)"
241+
label.font = .systemFont(ofSize: 12)
242+
label.textAlignment = .center
243+
244+
container.addSubview(label)
245+
label.translatesAutoresizingMaskIntoConstraints = false
246+
247+
NSLayoutConstraint.activate([
248+
container.heightAnchor.constraint(equalToConstant: reactionHeight),
249+
label.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 8),
250+
label.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -8),
251+
label.centerYAnchor.constraint(equalTo: container.centerYAnchor),
252+
])
253+
254+
// Add tap gesture
255+
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(reactionTapped(_:)))
256+
container.addGestureRecognizer(tapGesture)
257+
container.tag = reactions.firstIndex(where: { $0.emoji == reaction.emoji }) ?? 0
258+
259+
return container
260+
}
261+
262+
@objc private func reactionTapped(_ gesture: UITapGestureRecognizer) {
263+
guard let view = gesture.view,
264+
reactions.indices.contains(view.tag)
265+
else { return }
266+
267+
handleReaction2(reactions[view.tag].emoji)
268+
}
200269
}
201270

202271
// MARK: - Context Menu
@@ -206,18 +275,35 @@ extension UIMessageView: UIContextMenuInteractionDelegate {
206275
_ interaction: UIContextMenuInteraction,
207276
configurationForMenuAtLocation location: CGPoint
208277
) -> UIContextMenuConfiguration? {
209-
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in
278+
return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in
210279
let copyAction = UIAction(title: "Copy") { [weak self] _ in
211280
UIPasteboard.general.string = self?.fullMessage.message.text
212281
}
213282

214283
let replyAction = UIAction(title: "Reply") { [weak self] _ in
215284
ChatState.shared.setReplyingMessageId(
216-
chatId: self?.fullMessage.message.chatId ?? 0, id: self?.fullMessage.message.id ?? 0
285+
chatId: self?.fullMessage.message.chatId ?? 0,
286+
id: self?.fullMessage.message.id ?? 0
217287
)
218288
}
219289

220-
return UIMenu(children: [copyAction, replyAction])
290+
// Create reaction submenu
291+
let reactionActions = self?.createReaction2Actions() ?? []
292+
let reactMenu = UIMenu(
293+
title: "React", image: UIImage(systemName: "face.smiling"), children: reactionActions
294+
)
295+
296+
return UIMenu(children: [copyAction, replyAction, reactMenu])
297+
}
298+
}
299+
300+
private func createReaction2Actions() -> [UIAction] {
301+
let commonEmojis = ["👍", "❤️", "😂", "🎉", "🤔", "👀"]
302+
303+
return commonEmojis.map { emoji in
304+
UIAction(title: emoji) { [weak self] _ in
305+
self?.handleReaction2(emoji)
306+
}
221307
}
222308
}
223309
}

apple/InlineKit/Sources/InlineKit/ApiClient.swift

+23-4
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ public enum Path: String {
4040
case savePushNotification
4141
case updateStatus
4242
case sendComposeAction
43+
case addReaction
4344
}
4445

4546
public final class ApiClient: ObservableObject, @unchecked Sendable {
@@ -92,7 +93,7 @@ public final class ApiClient: ObservableObject, @unchecked Sendable {
9293
}
9394

9495
switch httpResponse.statusCode {
95-
case 200 ... 299:
96+
case 200...299:
9697
let apiResponse = try decoder.decode(APIResponse<T>.self, from: data)
9798
switch apiResponse {
9899
case let .success(data):
@@ -254,7 +255,7 @@ public final class ApiClient: ObservableObject, @unchecked Sendable {
254255
repliedToMessageId: Int64?
255256
) async throws -> SendMessage {
256257
var queryItems: [URLQueryItem] = [
257-
URLQueryItem(name: "text", value: text),
258+
URLQueryItem(name: "text", value: text)
258259
]
259260

260261
if let peerUserId = peerUserId {
@@ -299,7 +300,7 @@ public final class ApiClient: ObservableObject, @unchecked Sendable {
299300
try await request(
300301
.savePushNotification,
301302
queryItems: [
302-
URLQueryItem(name: "applePushToken", value: pushToken),
303+
URLQueryItem(name: "applePushToken", value: pushToken)
303304

304305
],
305306
includeToken: true
@@ -310,7 +311,7 @@ public final class ApiClient: ObservableObject, @unchecked Sendable {
310311
try await request(
311312
.updateStatus,
312313
queryItems: [
313-
URLQueryItem(name: "online", value: online ? "true" : "false"),
314+
URLQueryItem(name: "online", value: online ? "true" : "false")
314315
],
315316
includeToken: true
316317
)
@@ -332,6 +333,20 @@ public final class ApiClient: ObservableObject, @unchecked Sendable {
332333
includeToken: true
333334
)
334335
}
336+
337+
public func addReaction(messageId: Int64, chatId: Int64, emoji: String) async throws
338+
-> AddReaction
339+
{
340+
try await request(
341+
.addReaction,
342+
queryItems: [
343+
URLQueryItem(name: "messageId", value: "\(messageId)"),
344+
URLQueryItem(name: "chatId", value: "\(chatId)"),
345+
URLQueryItem(name: "emoji", value: emoji),
346+
],
347+
includeToken: true
348+
)
349+
}
335350
}
336351

337352
/// Example
@@ -448,6 +463,10 @@ public struct SendMessage: Codable, Sendable {
448463
public let message: ApiMessage
449464
}
450465

466+
public struct AddReaction: Codable, Sendable {
467+
public let reaction: ApiReaction
468+
}
469+
451470
public struct GetDialogs: Codable, Sendable {
452471
// Threads
453472
public let chats: [ApiChat]

apple/InlineKit/Sources/InlineKit/DataManager.swift

+12-1
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ public class DataManager: ObservableObject {
296296

297297
var chat = Chat(from: chat)
298298
// to avoid foriegn key constraint
299-
chat.lastMsgId = nil // TODO: fix
299+
chat.lastMsgId = nil // TODO: fix
300300

301301
return chat
302302
}
@@ -463,6 +463,17 @@ public class DataManager: ObservableObject {
463463
}
464464
}
465465

466+
public func addReaction(messageId: Int64, chatId: Int64, emoji: String) async throws {
467+
let result = try await ApiClient.shared.addReaction(
468+
messageId: messageId, chatId: chatId, emoji: emoji)
469+
470+
try await database.dbWriter.write { db in
471+
let reaction = Reaction(from: result.reaction)
472+
try reaction.save(db, onConflict: .replace)
473+
print("saved reaction: \(reaction)")
474+
}
475+
}
476+
466477
public func updateStatus(online: Bool) async throws {
467478
log.debug("updateStatus")
468479
let _ = try await ApiClient.shared.updateStatus(online: online)

apple/InlineKit/Sources/InlineKit/Database.swift

+21
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,27 @@ public extension AppDatabase {
9898
t.column("readOutboxMaxId", .integer)
9999
t.column("pinned", .boolean)
100100
}
101+
102+
try db.create(table: "reaction") { t in
103+
t.primaryKey("id", .integer).notNull().unique()
104+
t.column("messageId", .integer)
105+
.references(
106+
"message", column: "messageId", onDelete: .cascade)
107+
.notNull()
108+
.unique()
109+
t.column("userId", .integer)
110+
.references("user", column: "id", onDelete: .cascade)
111+
.notNull()
112+
.unique()
113+
t.column("chatId", .integer)
114+
.references("chat", column: "id", onDelete: .cascade)
115+
.notNull()
116+
.unique()
117+
t.column("emoji", .text)
118+
.notNull()
119+
120+
t.column("date", .datetime).notNull()
121+
}
101122
}
102123

103124
migrator.registerMigration("v2") { db in

apple/InlineKit/Sources/InlineKit/Models/Message.swift

+10-4
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,17 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist
7777
request(for: Message.from)
7878
}
7979

80-
public static let repliedToMessage = belongsTo(Message.self, key: "repliedToMessage", using: ForeignKey(["messageId"]))
80+
public static let repliedToMessage = belongsTo(
81+
Message.self, key: "repliedToMessage", using: ForeignKey(["messageId"]))
8182
public var repliedToMessage: QueryInterfaceRequest<Message> {
8283
request(for: Message.repliedToMessage)
8384
}
8485

86+
public static let reactions = hasMany(Reaction.self)
87+
public var reactions: QueryInterfaceRequest<Reaction> {
88+
request(for: Message.reactions)
89+
}
90+
8591
public init(
8692
messageId: Int64,
8793
randomId: Int64? = nil,
@@ -148,8 +154,8 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist
148154

149155
// MARK: Helpers
150156

151-
public extension Message {
152-
mutating func saveMessage(_ db: Database, onConflict: Database.ConflictResolution = .abort)
157+
extension Message {
158+
public mutating func saveMessage(_ db: Database, onConflict: Database.ConflictResolution = .abort)
153159
throws
154160
{
155161
if self.globalId == nil {
@@ -165,7 +171,7 @@ public extension Message {
165171

166172
if let existing =
167173
try? Message
168-
.fetchOne(db, key: ["messageId": self.messageId, "chatId": self.chatId])
174+
.fetchOne(db, key: ["messageId": self.messageId, "chatId": self.chatId])
169175
{
170176
self.globalId = existing.globalId
171177
}

0 commit comments

Comments
 (0)