diff --git a/.vscode/settings.json b/.vscode/settings.json index f8c89d55..809840b7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,8 +3,6 @@ "editor.defaultFormatter": "sweetpad.sweetpad", "editor.formatOnSave": true }, - "lldb.library": "/Applications/Xcode-16.0.0.app/Contents/SharedFrameworks/LLDB.framework/Versions/A/LLDB", - "lldb.launch.expressions": "native", "cSpell.words": [ "elysia", "elysiajs", diff --git a/apple/Inline.xcodeproj/project.pbxproj b/apple/Inline.xcodeproj/project.pbxproj index 78a432db..94d45514 100644 --- a/apple/Inline.xcodeproj/project.pbxproj +++ b/apple/Inline.xcodeproj/project.pbxproj @@ -14,6 +14,8 @@ C35EC9F42CC4176D0067979B /* SwiftUIIntrospect in Frameworks */ = {isa = PBXBuildFile; productRef = C35EC9F32CC4176D0067979B /* SwiftUIIntrospect */; }; C38C905E2CB9431900B78B8A /* InlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = C38C905D2CB9431900B78B8A /* InlineKit */; }; C38C90602CB9432200B78B8A /* InlineKit in Frameworks */ = {isa = PBXBuildFile; productRef = C38C905F2CB9432200B78B8A /* InlineKit */; }; + C3AE72D02D1B0ED0009F70D8 /* ContextMenuAuxiliaryPreview in Frameworks */ = {isa = PBXBuildFile; productRef = C3AE72CF2D1B0ED0009F70D8 /* ContextMenuAuxiliaryPreview */; }; + C3AE72D32D1B1716009F70D8 /* EmojiPicker in Frameworks */ = {isa = PBXBuildFile; productRef = C3AE72D22D1B1716009F70D8 /* EmojiPicker */; }; C3CC99192CCE9AF7007F7F77 /* InlineUI in Frameworks */ = {isa = PBXBuildFile; productRef = C3CC99182CCE9AF7007F7F77 /* InlineUI */; }; C3CC991B2CCE9AFF007F7F77 /* InlineUI in Frameworks */ = {isa = PBXBuildFile; productRef = C3CC991A2CCE9AFF007F7F77 /* InlineUI */; }; C3E6D5CB2CAE82B00048D2D8 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = C3E6D5CA2CAE82B00048D2D8 /* GRDB */; }; @@ -76,6 +78,8 @@ buildActionMask = 2147483647; files = ( C35EC9F42CC4176D0067979B /* SwiftUIIntrospect in Frameworks */, + C3AE72D32D1B1716009F70D8 /* EmojiPicker in Frameworks */, + C3AE72D02D1B0ED0009F70D8 /* ContextMenuAuxiliaryPreview in Frameworks */, CD27856F2CFB053C008A0EEF /* InlineConfig in Frameworks */, C35EC8222CC2A24A0067979B /* InlineKit in Frameworks */, C3E6D5CB2CAE82B00048D2D8 /* GRDB in Frameworks */, @@ -163,6 +167,8 @@ C35EC9F32CC4176D0067979B /* SwiftUIIntrospect */, C3CC99182CCE9AF7007F7F77 /* InlineUI */, CD27856E2CFB053C008A0EEF /* InlineConfig */, + C3AE72CF2D1B0ED0009F70D8 /* ContextMenuAuxiliaryPreview */, + C3AE72D22D1B1716009F70D8 /* EmojiPicker */, ); productName = InlineIOS; productReference = C38A28372CA5AB8F00D3DFB8 /* InlineIOS.app */; @@ -244,6 +250,8 @@ C35092D32CAEE8EA00AE3E23 /* XCRemoteSwiftPackageReference "keychain-swift" */, CDE960142CB94C4F004C541C /* XCRemoteSwiftPackageReference "swiftui-introspect" */, CD0A3CD82CD536DF0081AD69 /* XCRemoteSwiftPackageReference "GRDBQuery" */, + C3AE72CE2D1B0ED0009F70D8 /* XCRemoteSwiftPackageReference "ContextMenuAuxiliaryPreview" */, + C3AE72D12D1B1716009F70D8 /* XCRemoteSwiftPackageReference "EmojiPicker" */, ); preferredProjectObjectVersion = 77; productRefGroup = CD2661052CA01CAE00EB186F /* Products */; @@ -738,6 +746,22 @@ minimumVersion = 24.0.0; }; }; + C3AE72CE2D1B0ED0009F70D8 /* XCRemoteSwiftPackageReference "ContextMenuAuxiliaryPreview" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/dominicstop/ContextMenuAuxiliaryPreview"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.5.2; + }; + }; + C3AE72D12D1B1716009F70D8 /* XCRemoteSwiftPackageReference "EmojiPicker" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/htmlprogrammist/EmojiPicker"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.0.9; + }; + }; C3E6D5C92CAE82B00048D2D8 /* XCRemoteSwiftPackageReference "GRDB" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/inlinehq/GRDB.swift"; @@ -796,6 +820,16 @@ isa = XCSwiftPackageProductDependency; productName = InlineKit; }; + C3AE72CF2D1B0ED0009F70D8 /* ContextMenuAuxiliaryPreview */ = { + isa = XCSwiftPackageProductDependency; + package = C3AE72CE2D1B0ED0009F70D8 /* XCRemoteSwiftPackageReference "ContextMenuAuxiliaryPreview" */; + productName = ContextMenuAuxiliaryPreview; + }; + C3AE72D22D1B1716009F70D8 /* EmojiPicker */ = { + isa = XCSwiftPackageProductDependency; + package = C3AE72D12D1B1716009F70D8 /* XCRemoteSwiftPackageReference "EmojiPicker" */; + productName = EmojiPicker; + }; C3CC99182CCE9AF7007F7F77 /* InlineUI */ = { isa = XCSwiftPackageProductDependency; productName = InlineUI; diff --git a/apple/Inline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/apple/Inline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8fcc049b..cdc7fe48 100644 --- a/apple/Inline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/apple/Inline.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,33 @@ { - "originHash" : "522f2e44c2c8cc72252ad6814d68806fb904e3b9dd5464b3506d3ceb85bb62e4", + "originHash" : "59ab21ff2530925af1d1e03a7dacb575d22d5474b0325c5b418802b10aaf5e11", "pins" : [ + { + "identity" : "contextmenuauxiliarypreview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/dominicstop/ContextMenuAuxiliaryPreview", + "state" : { + "revision" : "6b92806b13716f3c46f39d09f2270de18994c31f", + "version" : "0.5.2" + } + }, + { + "identity" : "dgswiftutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/dominicstop/DGSwiftUtilities", + "state" : { + "revision" : "1e37402c7ef2c16990d846cc479c74aa044b0d71", + "version" : "0.37.0" + } + }, + { + "identity" : "emojipicker", + "kind" : "remoteSourceControl", + "location" : "https://github.com/htmlprogrammist/EmojiPicker", + "state" : { + "revision" : "6018bd985b01d2355a635569cc702e8a337ed0a1", + "version" : "3.0.9" + } + }, { "identity" : "grdb.swift", "kind" : "remoteSourceControl", diff --git a/apple/InlineIOS/Chat/UIMessageView.swift b/apple/InlineIOS/Chat/UIMessageView.swift index 2ebf3dff..c15a7da2 100644 --- a/apple/InlineIOS/Chat/UIMessageView.swift +++ b/apple/InlineIOS/Chat/UIMessageView.swift @@ -1,10 +1,41 @@ +import ContextMenuAuxiliaryPreview import InlineKit import SwiftUI import UIKit +struct GroupedReaction { + let emoji: String + let count: Int + let isFromCurrentUser: Bool +} + +extension String { + var isRTL: Bool { + guard let firstChar = first else { return false } + let earlyRTL = firstChar.unicodeScalars.first?.properties.generalCategory == .otherLetter + && firstChar.unicodeScalars.first != nil + && firstChar.unicodeScalars.first!.value >= 0x0590 + && firstChar.unicodeScalars.first!.value <= 0x08FF + + if earlyRTL { return true } + + let language = CFStringTokenizerCopyBestStringLanguage( + self as CFString, + CFRange(location: 0, length: count) + ) + if let language = language { + return NSLocale.characterDirection(forLanguage: language as String) == .rightToLeft + } + return false + } +} + class UIMessageView: UIView { // MARK: - Properties - + + private var interaction: UIContextMenuInteraction? + private var contextMenuManager: ContextMenuManager? + private let messageLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 @@ -12,18 +43,18 @@ class UIMessageView: UIView { label.textAlignment = .natural return label }() - + private let bubbleView: UIView = { let view = UIView() view.layer.cornerRadius = 18 return view }() - + private let metadataView: MessageMetadata = { let metadata = MessageMetadata(date: Date(), status: nil, isOutgoing: false) return metadata }() - + private lazy var contentStack: UIStackView = { let stack = UIStackView() stack.axis = .vertical @@ -31,7 +62,7 @@ class UIMessageView: UIView { stack.translatesAutoresizingMaskIntoConstraints = false return stack }() - + private lazy var shortMessageStack: UIStackView = { let stack = UIStackView() stack.axis = .horizontal @@ -39,119 +70,222 @@ class UIMessageView: UIView { stack.alignment = .center return stack }() - + + private let reactionsContainer: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + return view + }() + + private let reactionsStackView: UIStackView = { + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 4 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + return stack + }() + private var leadingConstraint: NSLayoutConstraint? private var trailingConstraint: NSLayoutConstraint? private var fullMessage: FullMessage - + private let horizontalPadding: CGFloat = 12 private let verticalPadding: CGFloat = 8 - -// private let embedView: MessageEmbedView = { -// let view = MessageEmbedView(repliedToMessage: nil) -// view.translatesAutoresizingMaskIntoConstraints = false -// return view -// }() - + // MARK: - Initialization - + init(fullMessage: FullMessage) { self.fullMessage = fullMessage super.init(frame: .zero) - + setupViews() configureForMessage() } - + @available(*, unavailable) required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + // MARK: - Setup - + private func setupViews() { addSubview(bubbleView) bubbleView.translatesAutoresizingMaskIntoConstraints = false - + bubbleView.addSubview(contentStack) - - // Add embed view if there's a reply -// if let replyMessage = fullMessage.repliedToMessage { -// contentStack.addArrangedSubview(embedView) -// embedView.repliedToMessage = replyMessage -// } - + + setupReactionsView() + leadingConstraint = bubbleView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8) trailingConstraint = bubbleView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8) - - // Base constraints + NSLayoutConstraint.activate([ bubbleView.topAnchor.constraint(equalTo: topAnchor), bubbleView.bottomAnchor.constraint(equalTo: bottomAnchor), bubbleView.widthAnchor.constraint(lessThanOrEqualTo: widthAnchor, multiplier: 0.9), - + contentStack.topAnchor.constraint(equalTo: bubbleView.topAnchor, constant: verticalPadding), - contentStack.leadingAnchor.constraint( - equalTo: bubbleView.leadingAnchor, constant: horizontalPadding - ), - contentStack.trailingAnchor.constraint( - equalTo: bubbleView.trailingAnchor, constant: -horizontalPadding - ), - contentStack.bottomAnchor.constraint( - equalTo: bubbleView.bottomAnchor, constant: -verticalPadding - ), + contentStack.leadingAnchor.constraint(equalTo: bubbleView.leadingAnchor, constant: horizontalPadding), + contentStack.trailingAnchor.constraint(equalTo: bubbleView.trailingAnchor, constant: -horizontalPadding), + contentStack.bottomAnchor.constraint(equalTo: bubbleView.bottomAnchor, constant: -verticalPadding), ]) - + + setupContextMenu() + } + + private func setupContextMenu() { let interaction = UIContextMenuInteraction(delegate: self) + self.interaction = interaction bubbleView.addInteraction(interaction) + + let contextMenuManager = ContextMenuManager( + contextMenuInteraction: interaction, + menuTargetView: self + ) + + self.contextMenuManager = contextMenuManager + contextMenuManager.delegate = self + + contextMenuManager.auxiliaryPreviewConfig = AuxiliaryPreviewConfig( + verticalAnchorPosition: .automatic, + horizontalAlignment: fullMessage.message.out == true ? .targetTrailing : .targetLeading, + preferredWidth: .constant(320), + preferredHeight: .constant(46), + marginInner: 16, + marginOuter: 12, + transitionConfigEntrance: .syncedToMenuEntranceTransition(), + transitionExitPreset: .fade + ) } - + + private func setupReactionsView() { + reactionsContainer.addSubview(reactionsStackView) + + NSLayoutConstraint.activate([ + reactionsStackView.topAnchor.constraint(equalTo: reactionsContainer.topAnchor), + reactionsStackView.leadingAnchor.constraint(equalTo: reactionsContainer.leadingAnchor), + reactionsStackView.trailingAnchor.constraint(equalTo: reactionsContainer.trailingAnchor), + reactionsStackView.bottomAnchor.constraint(equalTo: reactionsContainer.bottomAnchor), + ]) + } + private func updateMetadataLayout() { - // Remove existing arrangement contentStack.arrangedSubviews.forEach { $0.removeFromSuperview() } shortMessageStack.arrangedSubviews.forEach { $0.removeFromSuperview() } - - // Add embed view first if there's a reply -// if let replyMessage = fullMessage.repliedToMessage { -// contentStack.addArrangedSubview(embedView) -// embedView.repliedToMessage = replyMessage -// } - + let messageLength = fullMessage.message.text?.count ?? 0 let messageText = fullMessage.message.text ?? "" let hasLineBreak = messageText.contains("\n") + if messageLength > 22 || hasLineBreak { - // Long message layout: Vertical stack with metadata at bottom contentStack.addArrangedSubview(messageLabel) - contentStack.addArrangedSubview(metadataView) - - // Align metadata to trailing - metadataView.setContentHuggingPriority(.required, for: .horizontal) - metadataView.setContentCompressionResistancePriority(.required, for: .horizontal) - + updateReactions() + let metadataContainer = UIView() metadataContainer.addSubview(metadataView) metadataView.translatesAutoresizingMaskIntoConstraints = false - + NSLayoutConstraint.activate([ metadataView.trailingAnchor.constraint(equalTo: metadataContainer.trailingAnchor), metadataView.topAnchor.constraint(equalTo: metadataContainer.topAnchor), metadataView.bottomAnchor.constraint(equalTo: metadataContainer.bottomAnchor), ]) - + contentStack.addArrangedSubview(metadataContainer) } else { - // Short message layout: Horizontal stack shortMessageStack.addArrangedSubview(messageLabel) shortMessageStack.addArrangedSubview(metadataView) contentStack.addArrangedSubview(shortMessageStack) + updateReactions() } } - + + private func updateReactions() { + reactionsStackView.arrangedSubviews.forEach { $0.removeFromSuperview() } + + let groupedReactions = groupReactions(fullMessage.reactions) + + for reaction in groupedReactions { + let reactionView = createReactionView(for: reaction) + reactionsStackView.addArrangedSubview(reactionView) + } + + if !groupedReactions.isEmpty { + contentStack.addArrangedSubview(reactionsContainer) + } else { + reactionsContainer.removeFromSuperview() + } + } + + private func groupReactions(_ reactions: [Reaction]) -> [GroupedReaction] { + var groupedDict: [String: (count: Int, fromCurrentUser: Bool)] = [:] + + for reaction in reactions { + let current = groupedDict[reaction.emoji] ?? (0, false) + let isFromCurrentUser = reaction.userId == Auth.shared.getCurrentUserId() + groupedDict[reaction.emoji] = ( + current.count + 1, + current.fromCurrentUser || isFromCurrentUser + ) + } + + return groupedDict.map { emoji, info in + GroupedReaction( + emoji: emoji, + count: info.count, + isFromCurrentUser: info.fromCurrentUser + ) + }.sorted { $0.count > $1.count } + } + + private func createReactionView(for reaction: GroupedReaction) -> UIView { + let container = UIView() + container.backgroundColor = reaction.isFromCurrentUser ? + ColorManager.shared.selectedColor.withAlphaComponent(0.1) : + UIColor.systemGray5.withAlphaComponent(0.5) + container.layer.cornerRadius = 12 + + let stack = UIStackView() + stack.axis = .horizontal + stack.spacing = 4 + stack.alignment = .center + stack.translatesAutoresizingMaskIntoConstraints = false + + let emojiLabel = UILabel() + emojiLabel.text = reaction.emoji + emojiLabel.font = .systemFont(ofSize: 14) + + let countLabel = UILabel() + countLabel.text = "\(reaction.count)" + countLabel.font = .systemFont(ofSize: 12, weight: .medium) + countLabel.textColor = reaction.isFromCurrentUser ? + ColorManager.shared.selectedColor : + .secondaryLabel + + stack.addArrangedSubview(emojiLabel) + stack.addArrangedSubview(countLabel) + + container.addSubview(stack) + + NSLayoutConstraint.activate([ + container.heightAnchor.constraint(equalToConstant: 24), + stack.topAnchor.constraint(equalTo: container.topAnchor, constant: 4), + stack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 6), + stack.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -6), + stack.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -4), + ]) + + container.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(reactionTapped(_:)))) + container.tag = reaction.emoji.hashValue + + return container + } + private func configureForMessage() { messageLabel.text = fullMessage.message.text - + if fullMessage.message.out == true { bubbleView.backgroundColor = ColorManager.shared.selectedColor leadingConstraint?.isActive = false @@ -173,71 +307,191 @@ class UIMessageView: UIView { isOutgoing: false ) } - + updateMetadataLayout() } - - func updateLayout() { - setNeedsLayout() - layoutIfNeeded() + + // MARK: - Actions + + @objc private func reactionTapped(_ gesture: UITapGestureRecognizer) { + guard let view = gesture.view, + let emoji = groupReactions(fullMessage.reactions) + .first(where: { $0.emoji.hashValue == view.tag })?.emoji + else { + return + } + + UIView.animate(withDuration: 0.1, animations: { + view.transform = CGAffineTransform(scaleX: 0.8, y: 0.8) + }) { _ in + UIView.animate(withDuration: 0.1) { + view.transform = .identity + } + } + + Task { + do { + try await DataManager.shared.addReaction( + messageId: fullMessage.message.messageId, + chatId: fullMessage.message.chatId, + emoji: emoji + ) + } catch { + print("Error toggling reaction: \(error)") + } + } } - - // MARK: - Layout - - override func layoutSubviews() { - super.layoutSubviews() + + @objc private func buttonTouchDown(_ sender: UIButton) { + UIView.animate(withDuration: 0.1) { + sender.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + sender.alpha = 0.7 + } + } + + @objc private func buttonTouchUp(_ sender: UIButton) { + UIView.animate(withDuration: 0.1) { + sender.transform = .identity + sender.alpha = 1.0 + } + } + + @objc private func reactionButtonTapped(_ sender: UIButton) { + guard let emoji = sender.title(for: .normal) else { return } + + Task { + do { + try await DataManager.shared.addReaction( + messageId: fullMessage.message.messageId, + chatId: fullMessage.message.chatId, + emoji: emoji + ) + } catch { + print("Error adding reaction: \(error)") + } + } } - - // override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { - // super.traitCollectionDidChange(previousTraitCollection) - // - // if previousTraitCollection?.preferredContentSizeCategory - // != traitCollection.preferredContentSizeCategory - // { - // setNeedsLayout() - // } - // } } // MARK: - Context Menu -extension UIMessageView: UIContextMenuInteractionDelegate { +extension UIMessageView: UIContextMenuInteractionDelegate, ContextMenuManagerDelegate { func contextMenuInteraction( _ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint ) -> UIContextMenuConfiguration? { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in - let copyAction = UIAction(title: "Copy") { [weak self] _ in - UIPasteboard.general.string = self?.fullMessage.message.text + contextMenuManager?.notifyOnContextMenuInteraction( + interaction, + configurationForMenuAtLocation: location + ) + + return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { [weak self] _ in + guard let self else { return nil } + + let copyAction = UIAction(title: "Copy") { _ in + UIPasteboard.general.string = self.fullMessage.message.text } - - let replyAction = UIAction(title: "Reply") { [weak self] _ in + + let replyAction = UIAction(title: "Reply") { _ in ChatState.shared.setReplyingMessageId( - chatId: self?.fullMessage.message.chatId ?? 0, id: self?.fullMessage.message.id ?? 0 + chatId: self.fullMessage.message.chatId ?? 0, + id: self.fullMessage.message.id ?? 0 ) } - - return UIMenu(children: [copyAction, replyAction]) + + return UIMenu(children: [copyAction]) } } -} - -extension String { - var isRTL: Bool { - guard let firstChar = first else { return false } - let earlyRTL = - firstChar.unicodeScalars.first?.properties.generalCategory == .otherLetter - && firstChar.unicodeScalars.first != nil && firstChar.unicodeScalars.first!.value >= 0x0590 - && firstChar.unicodeScalars.first!.value <= 0x08FF - - if earlyRTL { return true } - - let language = CFStringTokenizerCopyBestStringLanguage( - self as CFString, CFRange(location: 0, length: count) + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willDisplayMenuFor configuration: UIContextMenuConfiguration, + animator: UIContextMenuInteractionAnimating? + ) { + contextMenuManager?.notifyOnContextMenuInteraction( + interaction, + willDisplayMenuFor: configuration, + animator: animator ) - if let language = language { - return NSLocale.characterDirection(forLanguage: language as String) == .rightToLeft + } + + func contextMenuInteraction( + _ interaction: UIContextMenuInteraction, + willEndFor configuration: UIContextMenuConfiguration, + animator: UIContextMenuInteractionAnimating? + ) { + contextMenuManager?.notifyOnContextMenuInteraction( + interaction, + willEndFor: configuration, + animator: animator + ) + } + + func onRequestMenuAuxiliaryPreview(sender: ContextMenuManager) -> UIView? { + let previewView = UIView() + previewView.backgroundColor = .clear + previewView.layer.cornerRadius = 25 + + let blurEffect = UIBlurEffect(style: .systemMaterial) + let blurView = UIVisualEffectView(effect: blurEffect) + blurView.layer.cornerRadius = 25 + blurView.clipsToBounds = true + blurView.translatesAutoresizingMaskIntoConstraints = false + previewView.addSubview(blurView) + + let contentView = blurView.contentView + + let reactions = ["👍", "👎", "❤️", "🥸", "🔥", "🥹", "👋", "🤩"] + let stackView = UIStackView() + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.spacing = 12 + stackView.translatesAutoresizingMaskIntoConstraints = false + + for reaction in reactions { + let container = UIView() + container.translatesAutoresizingMaskIntoConstraints = false + + let button = UIButton() + button.setTitle(reaction, for: .normal) + button.titleLabel?.font = .systemFont(ofSize: 24) + button.addTarget(self, action: #selector(reactionButtonTapped(_:)), for: .touchUpInside) + button.addTarget(self, action: #selector(buttonTouchDown(_:)), for: .touchDown) + button.addTarget(self, action: #selector(buttonTouchUp(_:)), for: [.touchUpInside, .touchUpOutside, .touchCancel]) + button.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(button) + + NSLayoutConstraint.activate([ + button.centerXAnchor.constraint(equalTo: container.centerXAnchor), + button.centerYAnchor.constraint(equalTo: container.centerYAnchor), + button.widthAnchor.constraint(equalToConstant: 40), + button.heightAnchor.constraint(equalToConstant: 40), + ]) + + stackView.addArrangedSubview(container) } - return false + + contentView.addSubview(stackView) + + NSLayoutConstraint.activate([ + blurView.topAnchor.constraint(equalTo: previewView.topAnchor), + blurView.leadingAnchor.constraint(equalTo: previewView.leadingAnchor), + blurView.trailingAnchor.constraint(equalTo: previewView.trailingAnchor), + blurView.bottomAnchor.constraint(equalTo: previewView.bottomAnchor), + + stackView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 8), + stackView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 8), + stackView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -8), + stackView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -8), + + previewView.heightAnchor.constraint(equalToConstant: 46), + ]) + + previewView.layer.shadowColor = UIColor.black.cgColor + previewView.layer.shadowOffset = CGSize(width: 0, height: 2) + previewView.layer.shadowRadius = 4 + previewView.layer.shadowOpacity = 0.1 + + return previewView } } diff --git a/apple/InlineKit/Sources/InlineKit/Database.swift b/apple/InlineKit/Sources/InlineKit/Database.swift index ef7446bf..5130e13e 100644 --- a/apple/InlineKit/Sources/InlineKit/Database.swift +++ b/apple/InlineKit/Sources/InlineKit/Database.swift @@ -131,15 +131,15 @@ public extension AppDatabase { t.primaryKey("id", .integer).notNull().unique() t.column("messageId", .integer) .notNull() - .unique() + t.column("userId", .integer) .references("user", column: "id", onDelete: .cascade) .notNull() - .unique() + t.column("chatId", .integer) .references("chat", column: "id", onDelete: .cascade) .notNull() - .unique() + t.column("emoji", .text) .notNull() @@ -148,6 +148,9 @@ public extension AppDatabase { t.foreignKey( ["chatId", "messageId"], references: "message", columns: ["chatId", "messageId"], onDelete: .cascade, onUpdate: .cascade, deferred: true) + t.uniqueKey([ + "chatId", "messageId", "userId", "emoji", + ]) } } diff --git a/apple/InlineKit/Sources/InlineKit/Models/Message.swift b/apple/InlineKit/Sources/InlineKit/Models/Message.swift index d6ceafd0..a73f2a9e 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Message.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Message.swift @@ -83,7 +83,8 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist } public static let repliedToMessage = belongsTo( - Message.self, key: "repliedToMessage", using: ForeignKey(["messageId"])) + Message.self, key: "repliedToMessage", using: ForeignKey(["messageId"]) + ) public var repliedToMessage: QueryInterfaceRequest { request(for: Message.repliedToMessage) } @@ -159,8 +160,8 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist // MARK: Helpers -extension Message { - public mutating func saveMessage(_ db: Database, onConflict: Database.ConflictResolution = .abort) +public extension Message { + mutating func saveMessage(_ db: Database, onConflict: Database.ConflictResolution = .abort) throws { if self.globalId == nil { @@ -176,7 +177,7 @@ extension Message { if let existing = try? Message - .fetchOne(db, key: ["messageId": self.messageId, "chatId": self.chatId]) + .fetchOne(db, key: ["messageId": self.messageId, "chatId": self.chatId]) { self.globalId = existing.globalId } diff --git a/apple/InlineKit/Sources/InlineKit/Models/Reaction.swift b/apple/InlineKit/Sources/InlineKit/Models/Reaction.swift index 2c21e79f..49c714af 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Reaction.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Reaction.swift @@ -10,7 +10,10 @@ public struct ApiReaction: Codable, Sendable { public var date: Int } -public struct Reaction: Codable, FetchableRecord, PersistableRecord, Identifiable, Sendable { +public struct Reaction: FetchableRecord, Identifiable, Codable, Hashable, PersistableRecord, + TableRecord, + Sendable, Equatable +{ public var id: Int64 public var messageId: Int64 public var userId: Int64 @@ -32,8 +35,7 @@ public struct Reaction: Codable, FetchableRecord, PersistableRecord, Identifiabl case chatId } - public init(id: Int64, messageId: Int64, userId: Int64, emoji: String, date: Date, chatId: Int64) - { + public init(id: Int64, messageId: Int64, userId: Int64, emoji: String, date: Date, chatId: Int64) { self.id = id self.messageId = messageId self.userId = userId @@ -43,8 +45,8 @@ public struct Reaction: Codable, FetchableRecord, PersistableRecord, Identifiabl } } -extension Reaction { - public init(from: ApiReaction) { +public extension Reaction { + init(from: ApiReaction) { self.id = from.id self.messageId = from.messageId self.userId = from.userId @@ -54,8 +56,8 @@ extension Reaction { } } -extension Reaction { - public static func fromTimestamp(from: Int) -> Date { +public extension Reaction { + static func fromTimestamp(from: Int) -> Date { return Date(timeIntervalSince1970: Double(from) / 1000) } } diff --git a/apple/InlineKit/Sources/InlineKit/ViewModels/FullChat.swift b/apple/InlineKit/Sources/InlineKit/ViewModels/FullChat.swift index c20dba5d..01ef8b94 100644 --- a/apple/InlineKit/Sources/InlineKit/ViewModels/FullChat.swift +++ b/apple/InlineKit/Sources/InlineKit/ViewModels/FullChat.swift @@ -1,12 +1,13 @@ import Combine import GRDB -public struct FullMessage: Codable, FetchableRecord, PersistableRecord, Sendable, Hashable, - Identifiable, Equatable +public struct FullMessage: FetchableRecord, Identifiable, Codable, Hashable, PersistableRecord, + TableRecord, + Sendable, Equatable { public var user: User? public var message: Message - + public var reactions: [Reaction] // stable id public var id: Int64 { message.globalId ?? message.id @@ -26,32 +27,32 @@ public final class FullChatViewModel: ObservableObject, @unchecked Sendable { @Published public private(set) var chatItem: SpaceChatItem? @Published public private(set) var fullMessages: [FullMessage] = [] @Published public private(set) var messagesInSections: [FullChatSection] = [] - + public var messageIdToGlobalId: [Int64: Int64] = [:] - + public var chat: Chat? { chatItem?.chat } - + public var peerUser: User? { chatItem?.user } - + public var topMessage: FullMessage? { reversed ? fullMessages.first : fullMessages.last } - + private var chatCancellable: AnyCancellable? private var messagesCancellable: AnyCancellable? private var peerUserCancellable: AnyCancellable? - + private var db: AppDatabase public var peer: Peer public var limit: Int? - + // Descending order (newest first) if true private var reversed: Bool - + public init(db: AppDatabase, peer: Peer, reversed: Bool = true, limit: Int? = nil) { self.db = db self.peer = peer @@ -60,122 +61,124 @@ public final class FullChatViewModel: ObservableObject, @unchecked Sendable { fetchChat() fetchMessages() } - + func fetchChat() { let peerId = peer chatCancellable = ValueObservation - .tracking { db in - switch peerId { - case .user: - // Fetch private chat - try Dialog - .filter(id: Dialog.getDialogId(peerId: peerId)) - .including( - optional: Dialog.peerUser - .including( - optional: User.chat - .including(optional: Chat.lastMessage)) - ) - .asRequest(of: SpaceChatItem.self) - .fetchAll(db) - - case .thread: - // Fetch thread chat - try Dialog - .filter(id: Dialog.getDialogId(peerId: peerId)) - .including( - optional: Dialog.peerThread - .including(optional: Chat.lastMessage) - ) - .asRequest(of: SpaceChatItem.self) - .fetchAll(db) - } + .tracking { db in + switch peerId { + case .user: + // Fetch private chat + try Dialog + .filter(id: Dialog.getDialogId(peerId: peerId)) + .including( + optional: Dialog.peerUser + .including( + optional: User.chat + .including(optional: Chat.lastMessage)) + ) + .asRequest(of: SpaceChatItem.self) + .fetchAll(db) + + case .thread: + // Fetch thread chat + try Dialog + .filter(id: Dialog.getDialogId(peerId: peerId)) + .including( + optional: Dialog.peerThread + .including(optional: Chat.lastMessage) + ) + .asRequest(of: SpaceChatItem.self) + .fetchAll(db) } - .publisher(in: db.dbWriter, scheduling: .immediate) - .sink( - receiveCompletion: { Log.shared.error("Failed to get full chat \($0)") }, - receiveValue: { [weak self] chats in - self?.chatItem = chats.first - } - ) + } + .publisher(in: db.dbWriter, scheduling: .immediate) + .sink( + receiveCompletion: { Log.shared.error("Failed to get full chat \($0)") }, + receiveValue: { [weak self] chats in + self?.chatItem = chats.first + } + ) } - + func fetchMessages() { let peer = self.peer messagesCancellable = ValueObservation - .tracking { db in - - if case .thread(let id) = peer { + .tracking { db in + + if case .thread(let id) = peer { + return + try Message + .filter(Column("peerThreadId") == id) + .including(optional: Message.from) + .asRequest(of: FullMessage.self) + .order(Column("date").asc) + .fetchAll(db) + + } else if case .user(let id) = peer { + if let limit = self.limit { return try Message - .filter(Column("peerThreadId") == id) - .including(optional: Message.from) - .asRequest(of: FullMessage.self) - .order(Column("date").asc) - .fetchAll(db) - - } else if case .user(let id) = peer { - if let limit = self.limit { - return - try Message - .filter(Column("peerUserId") == id) - .including(optional: Message.from) - .asRequest(of: FullMessage.self) - .order(Column("date").desc) - .limit(limit) - .fetchAll(db) - } else { - return - try Message - .filter(Column("peerUserId") == id) - .including(optional: Message.from) - .asRequest(of: FullMessage.self) - .order(Column("date").asc) - - .fetchAll(db) - } + .filter(Column("peerUserId") == id) + .including(optional: Message.from) + .including(all: Message.reactions) + .asRequest(of: FullMessage.self) + .order(Column("date").desc) + .limit(limit) + .fetchAll(db) } else { - return [] + return + try Message + .filter(Column("peerUserId") == id) + .including(optional: Message.from) + .asRequest(of: FullMessage.self) + .order(Column("date").asc) + + .fetchAll(db) } + } else { + return [] } - .publisher(in: db.dbWriter, scheduling: .immediate) - .sink( - receiveCompletion: { error in - Log.shared.error("Failed to get messages \(error)") - }, - receiveValue: { [weak self] messages in - - if self?.reversed == true { - self?.fullMessages = messages.reversed() - } else { - self?.fullMessages = messages - } + } + .publisher(in: db.dbWriter, scheduling: .immediate) + .sink( + receiveCompletion: { error in + Log.shared.error("Failed to get messages \(error)") + }, + receiveValue: { [weak self] messages in + + print("messages are \(messages)") + if self?.reversed == true { + self?.fullMessages = messages.reversed() + } else { + self?.fullMessages = messages } - ) + } + ) } - + public func getGlobalId(forMessageId messageId: Int64) -> Int64? { messageIdToGlobalId[messageId] } - + // Send message public func sendMessage(text: String) { guard let chatId = chat?.id else { Log.shared.warning("Chat ID is nil, cannot send message") return } - + Task { let messageText = text.trimmingCharacters(in: .whitespacesAndNewlines) do { guard !messageText.isEmpty else { return } - + let peerUserId: Int64? = if case .user(let id) = peer { id } else { nil } let peerThreadId: Int64? = if case .thread(let id) = peer { id } else { nil } - - let randomId = Int64.random(in: Int64.min ... Int64.max) + + let randomId = Int64.random(in: Int64.min...Int64.max) let message = Message( messageId: -randomId, randomId: randomId, @@ -187,13 +190,13 @@ public final class FullChatViewModel: ObservableObject, @unchecked Sendable { chatId: chatId, out: true ) - + try await db.dbWriter.write { db in try message.save(db) } - + // TODO: Scroll to bottom - + try await DataManager.shared.sendMessage( chatId: chatId, peerUserId: peerUserId, @@ -203,7 +206,7 @@ public final class FullChatViewModel: ObservableObject, @unchecked Sendable { randomId: randomId, repliedToMessageId: nil ) - + } catch { Log.shared.error("Failed to send message", error: error) // Optionally show error to user diff --git a/server/drizzle/0018_add_reaction.sql b/server/drizzle/0018_add_reaction.sql index 53102afd..f5f35016 100644 --- a/server/drizzle/0018_add_reaction.sql +++ b/server/drizzle/0018_add_reaction.sql @@ -4,7 +4,8 @@ CREATE TABLE IF NOT EXISTS "reactions" ( "chat_id" integer NOT NULL, "user_id" integer NOT NULL, "emoji" text NOT NULL, - "date" timestamp (3) DEFAULT now() NOT NULL + "date" timestamp (3) DEFAULT now() NOT NULL, + CONSTRAINT "unique_reaction_per_emoji" UNIQUE("chat_id","message_id","user_id","emoji") ); --> statement-breakpoint DO $$ BEGIN diff --git a/server/drizzle/meta/0018_snapshot.json b/server/drizzle/meta/0018_snapshot.json index f9538d81..4dc0efec 100644 --- a/server/drizzle/meta/0018_snapshot.json +++ b/server/drizzle/meta/0018_snapshot.json @@ -1,5 +1,5 @@ { - "id": "c8f38689-0bf4-4aaf-a64f-4a929ceed36c", + "id": "d8d209f2-655d-49c1-9427-e33d9df1835d", "prevId": "aa1d865a-e96e-43c1-92d7-6536096e654f", "version": "7", "dialect": "postgresql", @@ -1126,7 +1126,18 @@ } }, "compositePrimaryKeys": {}, - "uniqueConstraints": {}, + "uniqueConstraints": { + "unique_reaction_per_emoji": { + "name": "unique_reaction_per_emoji", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "message_id", + "user_id", + "emoji" + ] + } + }, "policies": {}, "checkConstraints": {}, "isRLSEnabled": false diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 8b6837c1..d1528f82 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -131,7 +131,7 @@ { "idx": 18, "version": "7", - "when": 1735039100997, + "when": 1735054252362, "tag": "0018_add_reaction", "breakpoints": true } diff --git a/server/src/db/schema/reactions.ts b/server/src/db/schema/reactions.ts index 200a9dd2..6785817a 100644 --- a/server/src/db/schema/reactions.ts +++ b/server/src/db/schema/reactions.ts @@ -8,23 +8,34 @@ import { messages } from "./messages" import { relations } from "drizzle-orm" import { creationDate } from "./common" -export const reactions = pgTable("reactions", { - id: serial().primaryKey(), +export const reactions = pgTable( + "reactions", + { + id: serial().primaryKey(), - messageId: integer("message_id").notNull(), - chatId: integer("chat_id") - .notNull() - .references((): AnyPgColumn => chats.id, { - onDelete: "cascade", - }), - userId: integer("user_id") - .notNull() - .references((): AnyPgColumn => users.id, { - onDelete: "cascade", - }), - emoji: text("emoji").notNull(), - date: creationDate, -}) + messageId: integer("message_id").notNull(), + chatId: integer("chat_id") + .notNull() + .references((): AnyPgColumn => chats.id, { + onDelete: "cascade", + }), + userId: integer("user_id") + .notNull() + .references((): AnyPgColumn => users.id, { + onDelete: "cascade", + }), + emoji: text("emoji").notNull(), + date: creationDate, + }, + (table) => ({ + uniqueReactionPerEmoji: unique("unique_reaction_per_emoji").on( + table.chatId, + table.messageId, + table.userId, + table.emoji, + ), + }), +) export const reactionRelations = relations(reactions, ({ one }) => ({ message: one(messages, {