From f658896b53bdda9a3ec55a80608a4a902e19a9f8 Mon Sep 17 00:00:00 2001 From: Dena Sohrabi Date: Sun, 15 Dec 2024 13:31:24 +0330 Subject: [PATCH] WIP reply --- apple/InlineIOS/Chat/ChatView.swift | 134 +- apple/InlineIOS/Chat/ComposeView.swift | 4 +- apple/InlineIOS/Chat/TopComposeView.swift | 171 ++- .../Sources/InlineKit/ApiClient.swift | 18 +- .../Sources/InlineKit/DataManager.swift | 70 +- .../Sources/InlineKit/Database.swift | 69 +- .../Sources/InlineKit/Models/Message.swift | 29 +- .../drizzle/0017_add_replying_to_message.sql | 1 + server/drizzle/meta/0017_snapshot.json | 1107 +++++++++++++++++ server/drizzle/meta/_journal.json | 7 + server/src/db/schema/messages.ts | 3 + server/src/methods/sendMessage.ts | 7 +- server/src/models/index.ts | 1 + 13 files changed, 1414 insertions(+), 207 deletions(-) create mode 100644 server/drizzle/0017_add_replying_to_message.sql create mode 100644 server/drizzle/meta/0017_snapshot.json diff --git a/apple/InlineIOS/Chat/ChatView.swift b/apple/InlineIOS/Chat/ChatView.swift index 2ddaa3c4..c52e2067 100644 --- a/apple/InlineIOS/Chat/ChatView.swift +++ b/apple/InlineIOS/Chat/ChatView.swift @@ -12,6 +12,15 @@ class ChatContainerView: UIView { private let contentView = UIView() private let messagesView: MessagesCollectionView private let composeView = ComposeView() + private let topComposeView: UIHostingController + private let composeStack: UIStackView = { + let stack = UIStackView() + stack.axis = .vertical + stack.spacing = 0 + stack.alignment = .fill + return stack + }() + private let text: Binding var onSendMessage: (() -> Void)? @@ -20,24 +29,15 @@ class ChatContainerView: UIView { init(frame: CGRect, fullMessages: [FullMessage], text: Binding) { self.text = text self.messagesView = MessagesCollectionView(fullMessages: fullMessages) + self.topComposeView = UIHostingController( + rootView: TopComposeView(replyingMessageId: ChatState.shared.replyingMessageId ?? 0) + ) super.init(frame: frame) setupViews() - - composeView.onTextChange = { [weak self] newText in - - self?.text.wrappedValue = newText - } - composeView.onSend = { [weak self] in - print("Send called") - self?.onSendMessage?() - } - - composeView.text = text.wrappedValue - - contentView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) - contentView.setContentHuggingPriority(.defaultLow, for: .horizontal) + setupChatStateObserver() + setupComposeView() } @available(*, unavailable) @@ -45,17 +45,7 @@ class ChatContainerView: UIView { fatalError("init(coder:) has not been implemented") } - private var didSetupConstraints = false - - override func didMoveToWindow() { - super.didMoveToWindow() - - // Only setup once when we have a valid window/hierarchy - guard !didSetupConstraints else { return } - didSetupConstraints = true - - setupViews() - } + // MARK: - Setup Methods private func setupViews() { // Add contentView to main view @@ -64,12 +54,17 @@ class ChatContainerView: UIView { // Add views to contentView contentView.addSubview(messagesView) - contentView.addSubview(composeView) + contentView.addSubview(composeStack) + + // Setup compose stack + composeStack.translatesAutoresizingMaskIntoConstraints = false + composeStack.addArrangedSubview(topComposeView.view) + composeStack.addArrangedSubview(composeView) messagesView.translatesAutoresizingMaskIntoConstraints = false + topComposeView.view.translatesAutoresizingMaskIntoConstraints = false composeView.translatesAutoresizingMaskIntoConstraints = false - // Setup constraints NSLayoutConstraint.activate([ // ContentView constraints contentView.topAnchor.constraint(equalTo: topAnchor), @@ -81,24 +76,80 @@ class ChatContainerView: UIView { messagesView.topAnchor.constraint(equalTo: contentView.topAnchor), messagesView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), messagesView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - messagesView.bottomAnchor.constraint(equalTo: composeView.topAnchor), + messagesView.bottomAnchor.constraint(equalTo: composeStack.topAnchor), - // ComposeView constraints - composeView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - composeView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - composeView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + // ComposeStack constraints + composeStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), + composeStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), + composeStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), + + // Fixed height for topComposeView + topComposeView.view.heightAnchor.constraint(equalToConstant: 58) ]) + + // Initial state + topComposeView.view.isHidden = ChatState.shared.replyingMessageId == nil } - override func layoutSubviews() { - super.layoutSubviews() - print("ContentView frame: \(contentView.frame)") - print("Compose frame: \(composeView.frame)") + private func setupComposeView() { + composeView.onTextChange = { [weak self] newText in + self?.text.wrappedValue = newText + } + composeView.onSend = { [weak self] in + self?.onSendMessage?() + } + composeView.text = text.wrappedValue + } + + private func setupChatStateObserver() { + Task { @MainActor in + for await _ in ChatState.shared.$replyingMessageId.values { + updateTopComposeView() + } + } + } + + // MARK: - Update Methods + + private func updateTopComposeView() { + if let replyingId = ChatState.shared.replyingMessageId { + print("Showing reply view for message: \(replyingId)") + topComposeView.rootView = TopComposeView(replyingMessageId: replyingId) + + if topComposeView.view.isHidden { + topComposeView.view.alpha = 0 + topComposeView.view.isHidden = false + + UIView.animate(withDuration: 0.3) { + self.topComposeView.view.alpha = 1 + self.layoutIfNeeded() + } + } + } else { + print("Hiding reply view") + guard !topComposeView.view.isHidden else { return } + + UIView.animate(withDuration: 0.3) { + self.topComposeView.view.alpha = 0 + } completion: { _ in + self.topComposeView.view.isHidden = true + self.layoutIfNeeded() + } + } } func updateMessages(_ messages: [FullMessage]) { messagesView.updateMessages(messages) } + + // MARK: - Layout + + override func layoutSubviews() { + super.layoutSubviews() + print("ContentView frame: \(contentView.frame)") + print("TopComposeView frame: \(topComposeView.view.frame)") + print("Compose frame: \(composeView.frame)") + } } // MARK: - ChatContainerViewRepresentable (SwiftUI Bridge) @@ -107,7 +158,6 @@ struct ChatContainerViewRepresentable: UIViewRepresentable { var fullMessages: [FullMessage] var text: Binding var onSendMessage: () -> Void - func makeUIView(context: Context) -> ChatContainerView { let view = ChatContainerView( frame: .zero, @@ -145,12 +195,12 @@ struct ChatContainerViewRepresentable: UIViewRepresentable { struct ChatView: View { var peer: Peer - @State var text: String = "" @EnvironmentStateObject var fullChatViewModel: FullChatViewModel @EnvironmentObject var nav: Navigation @EnvironmentObject var dataManager: DataManager + @Environment(\.appDatabase) var database @Environment(\.scenePhase) var scenePhase @@ -244,9 +294,11 @@ struct ChatView: View { peerThreadId: peerThreadId, chatId: chatId, out: true, - status: .sending + status: .sending, + repliedToMessageId: ChatState.shared.replyingMessageId ) + print("Sending message with repliedToMessageId: \(message.repliedToMessageId) \(message)") // Save message to database try await database.dbWriter.write { db in try message.save(db) @@ -259,8 +311,10 @@ struct ChatView: View { peerThreadId: peerThreadId, text: messageText, peerId: peer, - randomId: randomId + randomId: randomId, + repliedToMessageId: message.repliedToMessageId ) + ChatState.shared.clearReplyingMessageId() } catch { Log.shared.error("Failed to send message", error: error) } diff --git a/apple/InlineIOS/Chat/ComposeView.swift b/apple/InlineIOS/Chat/ComposeView.swift index fa4d9248..7befd9fa 100644 --- a/apple/InlineIOS/Chat/ComposeView.swift +++ b/apple/InlineIOS/Chat/ComposeView.swift @@ -180,7 +180,9 @@ final class OptimizedTextStorage: NSTextStorage { override var string: String { storage.string } - override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key: Any] { + override func attributes(at location: Int, effectiveRange range: NSRangePointer?) + -> [NSAttributedString.Key: Any] + { storage.attributes(at: location, effectiveRange: range) } diff --git a/apple/InlineIOS/Chat/TopComposeView.swift b/apple/InlineIOS/Chat/TopComposeView.swift index 27352f7a..b277794e 100644 --- a/apple/InlineIOS/Chat/TopComposeView.swift +++ b/apple/InlineIOS/Chat/TopComposeView.swift @@ -1,88 +1,83 @@ -// import GRDB -// import InlineKit -// import UIKit -// -// class TopComposeView: UIView { -// private let contentStack: UIStackView = { -// let stack = UIStackView() -// stack.axis = .horizontal -// stack.spacing = 8 -// stack.alignment = .center -// return stack -// }() -// -// private let messageLabel: UILabel = { -// let label = UILabel() -// label.numberOfLines = 2 -// label.font = .systemFont(ofSize: 14) -// label.textColor = .secondaryLabel -// return label -// }() -// -// private let closeButton: UIButton = { -// let button = UIButton() -// button.setImage(UIImage(systemName: "xmark.circle.fill"), for: .normal) -// button.tintColor = .secondaryLabel -// return button -// }() -// -// var onClose: (() -> Void)? -// private let messageId: Int64 -// -// init(messageId: Int64, frame: CGRect = .zero) { -// self.messageId = messageId -// super.init(frame: frame) -// setupView() -// configure() -// } -// -// @available(*, unavailable) -// required init?(coder: NSCoder) { -// fatalError("init(coder:) has not been implemented") -// } -// -// private func setupView() { -// backgroundColor = .secondarySystemBackground -// -// addSubview(contentStack) -// contentStack.translatesAutoresizingMaskIntoConstraints = false -// -// contentStack.addArrangedSubview(messageLabel) -// contentStack.addArrangedSubview(closeButton) -// -// closeButton.addTarget(self, action: #selector(closeTapped), for: .touchUpInside) -// -// NSLayoutConstraint.activate([ -// contentStack.topAnchor.constraint(equalTo: topAnchor, constant: 8), -// contentStack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), -// contentStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), -// contentStack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), -// -// closeButton.widthAnchor.constraint(equalToConstant: 24), -// closeButton.heightAnchor.constraint(equalToConstant: 24) -// ]) -// } -// -// private func configure() { -// Task { -// do { -// try await AppDatabase.shared.dbWriter.read { db in -// if let message = try Message.fetchOne(db, id: messageId) { -// await MainActor.run { -// messageLabel.text = "Replying to: \(message.text)" -// } -// } -// } -// } catch { -// await MainActor.run { -// messageLabel.text = "Message not found" -// } -// print("Error fetching message: \(error)") -// } -// } -// } -// -// @objc private func closeTapped() { -// onClose?() -// } -// } +import GRDB +import InlineKit +import SwiftUI + +struct TopComposeView: View { + var replyingMessageId: Int64 + @Environment(\.appDatabase) var db + @State private var repliedMessage: FullMessage? + + init(replyingMessageId: Int64) { + self.replyingMessageId = replyingMessageId + print("TopComposeView init with id: \(replyingMessageId)") + } + + var body: some View { + ZStack { + if let message = repliedMessage { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(message.user?.fullName ?? "Deleted User") + .font(.callout) + .foregroundStyle(.tertiary) + .fontWeight(.medium) + + Spacer() + + Button { + ChatState.shared.clearReplyingMessageId() + } label: { + Image(systemName: "xmark") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + + Text((message.message.text ?? "") + .replacingOccurrences(of: "\r\n", with: " ") + .replacingOccurrences(of: "\n", with: " ")) + .font(.callout) + .lineLimit(2) + .foregroundStyle(.secondary) + } + .padding(.horizontal) + .frame(maxWidth: .infinity) + .frame(height: 58) + .background(.clear) + .overlay(alignment: .top) { + Divider() + } + .overlay(alignment: .bottom) { + Divider() + } + } + } + .onAppear { + fetchRepliedMessage() + } + .onChange(of: replyingMessageId) { + fetchRepliedMessage() + } + } + + private func fetchRepliedMessage() { + print("Fetching replied message: \(replyingMessageId)") + Task { + do { + let message = try await db.dbWriter.read { db in + try Message + .filter(Column("messageId") == replyingMessageId) + .including(optional: Message.from) + .asRequest(of: FullMessage.self) + .fetchOne(db) + } + await MainActor.run { + self.repliedMessage = message + print("Fetched message: \(String(describing: message))") + } + } catch { + Log.shared.error("Failed to fetch replied message", error: error) + } + } + } +} diff --git a/apple/InlineKit/Sources/InlineKit/ApiClient.swift b/apple/InlineKit/Sources/InlineKit/ApiClient.swift index 03b13615..f1ffe116 100644 --- a/apple/InlineKit/Sources/InlineKit/ApiClient.swift +++ b/apple/InlineKit/Sources/InlineKit/ApiClient.swift @@ -246,10 +246,13 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { // ) // } - public func sendMessage(peerUserId: Int64?, peerThreadId: Int64?, text: String, randomId: Int64?) - async throws - -> SendMessage - { + public func sendMessage( + peerUserId: Int64?, + peerThreadId: Int64?, + text: String, + randomId: Int64?, + repliedToMessageId: Int64? + ) async throws -> SendMessage { var queryItems: [URLQueryItem] = [ URLQueryItem(name: "text", value: text), ] @@ -265,6 +268,9 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { if let randomId = randomId { queryItems.append(URLQueryItem(name: "randomId", value: "\(randomId)")) } + if let repliedToMessageId = repliedToMessageId { + queryItems.append(URLQueryItem(name: "repliedToMessageId", value: "\(repliedToMessageId)")) + } return try await request( .sendMessage, @@ -310,7 +316,9 @@ public final class ApiClient: ObservableObject, @unchecked Sendable { ) } - public func sendComposeAction(peerId: Peer, action: ApiComposeAction?) async throws -> EmptyPayload { + public func sendComposeAction(peerId: Peer, action: ApiComposeAction?) async throws + -> EmptyPayload + { try await request( .sendComposeAction, queryItems: [ diff --git a/apple/InlineKit/Sources/InlineKit/DataManager.swift b/apple/InlineKit/Sources/InlineKit/DataManager.swift index 82bea87d..62086df5 100644 --- a/apple/InlineKit/Sources/InlineKit/DataManager.swift +++ b/apple/InlineKit/Sources/InlineKit/DataManager.swift @@ -337,11 +337,14 @@ public class DataManager: ObservableObject { } public func sendMessage( - chatId: Int64, peerUserId: Int64?, peerThreadId: Int64?, text: String, peerId: Peer?, - randomId: Int64? // for now nilable - ) - async throws - { + chatId: Int64, + peerUserId: Int64?, + peerThreadId: Int64?, + text: String, + peerId: Peer?, + randomId: Int64?, + repliedToMessageId: Int64? + ) async throws { let finalPeerUserId: Int64? let finalPeerThreadId: Int64? @@ -367,7 +370,8 @@ public class DataManager: ObservableObject { peerUserId: finalPeerUserId, peerThreadId: finalPeerThreadId, text: text, - randomId: randomId + randomId: randomId, + repliedToMessageId: repliedToMessageId ) Task { @MainActor in @@ -416,33 +420,33 @@ public class DataManager: ObservableObject { peerUserId: finalPeerUserId, peerThreadId: finalPeerThreadId ) -// try await database.dbWriter.write { db in -// let pendingMessages = -// try Message -// .filter(Column("out") == true) -// .filter(Column("status") == MessageSendingStatus.sending.rawValue) -// .filter(Column("peerUserId") == finalPeerUserId) -// .filter(Column("peerThreadId") == finalPeerThreadId) -// .fetchAll(db) -// -// for var message in pendingMessages { -// message.status = .failed -// try message.save(db, onConflict: .replace) -// } -// -// let unsentMessages = -// try Message -// .filter(Column("out") == true) -// .filter(Column("status") == nil) -// .filter(Column("peerUserId") == finalPeerUserId) -// .filter(Column("peerThreadId") == finalPeerThreadId) -// .fetchAll(db) -// -// for var message in unsentMessages { -// message.status = .sent -// try message.save(db, onConflict: .replace) -// } -// } + // try await database.dbWriter.write { db in + // let pendingMessages = + // try Message + // .filter(Column("out") == true) + // .filter(Column("status") == MessageSendingStatus.sending.rawValue) + // .filter(Column("peerUserId") == finalPeerUserId) + // .filter(Column("peerThreadId") == finalPeerThreadId) + // .fetchAll(db) + // + // for var message in pendingMessages { + // message.status = .failed + // try message.save(db, onConflict: .replace) + // } + // + // let unsentMessages = + // try Message + // .filter(Column("out") == true) + // .filter(Column("status") == nil) + // .filter(Column("peerUserId") == finalPeerUserId) + // .filter(Column("peerThreadId") == finalPeerThreadId) + // .fetchAll(db) + // + // for var message in unsentMessages { + // message.status = .sent + // try message.save(db, onConflict: .replace) + // } + // } try await database.dbWriter.write { db in for apiMessage in result.messages { diff --git a/apple/InlineKit/Sources/InlineKit/Database.swift b/apple/InlineKit/Sources/InlineKit/Database.swift index 611ad370..eee1798b 100644 --- a/apple/InlineKit/Sources/InlineKit/Database.swift +++ b/apple/InlineKit/Sources/InlineKit/Database.swift @@ -5,9 +5,10 @@ import GRDB public final class AppDatabase: Sendable { public let dbWriter: any DatabaseWriter - static let log = Log.scoped("AppDatabase", - // Enable tracing for seeing all SQL statements - enableTracing: false) + static let log = Log.scoped( + "AppDatabase", + // Enable tracing for seeing all SQL statements + enableTracing: false) public init(_ dbWriter: any GRDB.DatabaseWriter) throws { self.dbWriter = dbWriter @@ -17,8 +18,8 @@ public final class AppDatabase: Sendable { // MARK: - Migrations -public extension AppDatabase { - var migrator: DatabaseMigrator { +extension AppDatabase { + public var migrator: DatabaseMigrator { var migrator = DatabaseMigrator() #if DEBUG @@ -102,7 +103,7 @@ public extension AppDatabase { migrator.registerMigration("v2") { db in // Message table try db.alter(table: "message") { t in - t.add(column: "randomId", .integer) // .unique() + t.add(column: "randomId", .integer) // .unique() } } @@ -119,15 +120,21 @@ public extension AppDatabase { } } + migrator.registerMigration("repliedToMessageId") { db in + try db.alter(table: "message") { t in + t.add(column: "repliedToMessageId", .integer) + } + } + return migrator } } // MARK: - Database Configuration -public extension AppDatabase { +extension AppDatabase { /// - parameter base: A base configuration. - static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration { + public static func makeConfiguration(_ base: Configuration = Configuration()) -> Configuration { var config = base config.prepareDatabase { db in @@ -146,7 +153,7 @@ public extension AppDatabase { return config } - static func authenticated() async throws { + public static func authenticated() async throws { if let token = Auth.shared.getToken() { try AppDatabase.changePassphrase(token) } else { @@ -154,7 +161,7 @@ public extension AppDatabase { } } - static func clearDB() throws { + public static func clearDB() throws { _ = try AppDatabase.shared.dbWriter.write { db in // Disable foreign key checks temporarily @@ -164,11 +171,11 @@ public extension AppDatabase { let tables = try String.fetchAll( db, sql: """ - SELECT name FROM sqlite_master - WHERE type = 'table' - AND name NOT LIKE 'sqlite_%' - AND name NOT LIKE 'grdb_%' - """) + SELECT name FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name NOT LIKE 'grdb_%' + """) // Delete all rows from each table for table in tables { @@ -189,7 +196,7 @@ public extension AppDatabase { log.info("Database successfully cleared.") } - static func loggedOut() throws { + public static func loggedOut() throws { try clearDB() // Reset the database passphrase to a default value @@ -215,8 +222,8 @@ public extension AppDatabase { } } -public extension AppDatabase { - static func deleteDatabaseFile() throws { +extension AppDatabase { + public static func deleteDatabaseFile() throws { let fileManager = FileManager.default let databaseUrl = getDatabaseUrl() let databasePath = databaseUrl.path @@ -232,18 +239,18 @@ public extension AppDatabase { // MARK: - Database Access: Reads -public extension AppDatabase { +extension AppDatabase { /// Provides a read-only access to the database. - var reader: any GRDB.DatabaseReader { + public var reader: any GRDB.DatabaseReader { dbWriter } } // MARK: - The database for the application -public extension AppDatabase { +extension AppDatabase { /// The database for the application - static let shared = makeShared() + public static let shared = makeShared() private static func getDatabaseUrl() -> URL { do { @@ -320,14 +327,14 @@ public extension AppDatabase { } /// Creates an empty database for SwiftUI previews - static func empty() -> AppDatabase { + public static func empty() -> AppDatabase { // Connect to an in-memory database // Refrence https://swiftpackageindex.com/groue/grdb.swift/documentation/grdb/databaseconnections let dbQueue = try! DatabaseQueue(configuration: AppDatabase.makeConfiguration()) return try! AppDatabase(dbQueue) } - static func emptyWithSpaces() -> AppDatabase { + public static func emptyWithSpaces() -> AppDatabase { let db = AppDatabase.empty() do { try db.dbWriter.write { db in @@ -343,7 +350,7 @@ public extension AppDatabase { return db } - static func emptyWithChat() -> AppDatabase { + public static func emptyWithChat() -> AppDatabase { let db = AppDatabase.empty() do { try db.dbWriter.write { db in @@ -356,7 +363,7 @@ public extension AppDatabase { } /// Used for previews - static func populated() -> AppDatabase { + public static func populated() -> AppDatabase { let db = AppDatabase.empty() // Populate with test data @@ -427,13 +434,13 @@ public extension AppDatabase { // Create dialogs for quick access let dialogs: [Dialog] = [ // DM dialogs - Dialog(id: 2, peerUserId: 2, spaceId: nil), // Dialog with Alice - Dialog(id: 3, peerUserId: 3, spaceId: nil), // Dialog with Bob + Dialog(id: 2, peerUserId: 2, spaceId: nil), // Dialog with Alice + Dialog(id: 3, peerUserId: 3, spaceId: nil), // Dialog with Bob // Thread dialogs - Dialog(id: -3, peerThreadId: 3, spaceId: 1), // Engineering/General - Dialog(id: -4, peerThreadId: 4, spaceId: 1), // Engineering/Random - Dialog(id: -5, peerThreadId: 5, spaceId: 2), // Design/Design System + Dialog(id: -3, peerThreadId: 3, spaceId: 1), // Engineering/General + Dialog(id: -4, peerThreadId: 4, spaceId: 1), // Engineering/Random + Dialog(id: -5, peerThreadId: 5, spaceId: 2), // Design/Design System ] try dialogs.forEach { try $0.save(db) } } diff --git a/apple/InlineKit/Sources/InlineKit/Models/Message.swift b/apple/InlineKit/Sources/InlineKit/Models/Message.swift index 1d6e454f..fb9b80eb 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Message.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Message.swift @@ -13,6 +13,7 @@ public struct ApiMessage: Codable, Hashable, Sendable { public var out: Bool? public var editDate: Int? public var date: Int + public var repliedToMessageId: Int64? } public enum MessageSendingStatus: Int64, Codable, DatabaseValueConvertible, Sendable { @@ -21,7 +22,8 @@ public enum MessageSendingStatus: Int64, Codable, DatabaseValueConvertible, Send case failed } -public struct Message: FetchableRecord, Identifiable, Codable, Hashable, PersistableRecord, TableRecord, +public struct Message: FetchableRecord, Identifiable, Codable, Hashable, PersistableRecord, + TableRecord, Sendable, Equatable { // Locally autoincremented id @@ -64,6 +66,8 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist public var status: MessageSendingStatus? + public var repliedToMessageId: Int64? + public static let chat = belongsTo(Chat.self) public var chat: QueryInterfaceRequest { request(for: Message.chat) @@ -74,6 +78,13 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist request(for: Message.from) } + public static let repliedToMessage = belongsTo( + Message.self, using: ForeignKey(["repliedToMessageId"], to: ["messageId"]) + ) + public var repliedToMessage: QueryInterfaceRequest { + request(for: Message.repliedToMessage) + } + public init( messageId: Int64, randomId: Int64? = nil, @@ -87,7 +98,8 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist mentioned: Bool? = nil, pinned: Bool? = nil, editDate: Date? = nil, - status: MessageSendingStatus? = nil + status: MessageSendingStatus? = nil, + repliedToMessageId: Int64? = nil ) { self.messageId = messageId self.randomId = randomId @@ -102,6 +114,7 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist self.mentioned = mentioned self.pinned = pinned self.status = status + self.repliedToMessageId = repliedToMessageId if peerUserId == nil && peerThreadId == nil { fatalError("One of peerUserId or peerThreadId must be set") } @@ -120,7 +133,8 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist mentioned: from.mentioned, pinned: from.pinned, editDate: from.editDate.map { Date(timeIntervalSince1970: TimeInterval($0)) }, - status: from.out == true ? MessageSendingStatus.sent : nil + status: from.out == true ? MessageSendingStatus.sent : nil, + repliedToMessageId: from.repliedToMessageId ) } @@ -138,7 +152,9 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist // MARK: Helpers public extension Message { - mutating func saveMessage(_ db: Database, onConflict: Database.ConflictResolution = .abort) throws { + mutating func saveMessage(_ db: Database, onConflict: Database.ConflictResolution = .abort) + throws + { if self.globalId == nil { // Alternative: // if let existing = try Message @@ -150,8 +166,9 @@ public extension Message { // message.globalId = existing.globalId // } - if let existing = try? Message - .fetchOne(db, key: ["messageId": self.messageId, "chatId": self.chatId]) + if let existing = + try? Message + .fetchOne(db, key: ["messageId": self.messageId, "chatId": self.chatId]) { self.globalId = existing.globalId } diff --git a/server/drizzle/0017_add_replying_to_message.sql b/server/drizzle/0017_add_replying_to_message.sql new file mode 100644 index 00000000..74931f3f --- /dev/null +++ b/server/drizzle/0017_add_replying_to_message.sql @@ -0,0 +1 @@ +ALTER TABLE "messages" ADD COLUMN "replied_to_message_id" integer; \ No newline at end of file diff --git a/server/drizzle/meta/0017_snapshot.json b/server/drizzle/meta/0017_snapshot.json new file mode 100644 index 00000000..bf371194 --- /dev/null +++ b/server/drizzle/meta/0017_snapshot.json @@ -0,0 +1,1107 @@ +{ + "id": "aa1d865a-e96e-43c1-92d7-6536096e654f", + "prevId": "77d439c3-9c69-455f-9c0d-66f09afd16ed", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "verified": { + "name": "verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.there_users": { + "name": "there_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "there_users_email_unique": { + "name": "there_users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "default": "nextval('user_id')" + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "phone_verified": { + "name": "phone_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "deleted": { + "name": "deleted", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "online": { + "name": "online", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_online": { + "name": "last_online", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "users_username_unique": { + "name": "users_username_unique", + "columns": [ + { + "expression": "lower(\"username\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_phone_number_unique": { + "name": "users_phone_number_unique", + "nullsNotDistinct": false, + "columns": [ + "phone_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "revoked": { + "name": "revoked", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "last_active": { + "name": "last_active", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "personal_data_encrypted": { + "name": "personal_data_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "personal_data_iv": { + "name": "personal_data_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "personal_data_tag": { + "name": "personal_data_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "applePushToken": { + "name": "applePushToken", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_encrypted": { + "name": "apple_push_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_iv": { + "name": "apple_push_token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "apple_push_token_tag": { + "name": "apple_push_token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "client_type": { + "name": "client_type", + "type": "client_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "clientVersion": { + "name": "clientVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "osVersion": { + "name": "osVersion", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.login_codes": { + "name": "login_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "phone_number": { + "name": "phone_number", + "type": "varchar(15)", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "smallint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "login_codes_email_unique": { + "name": "login_codes_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "login_codes_phone_number_unique": { + "name": "login_codes_phone_number_unique", + "nullsNotDistinct": false, + "columns": [ + "phone_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.spaces": { + "name": "spaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(256)", + "primaryKey": false, + "notNull": true + }, + "handle": { + "name": "handle", + "type": "varchar(32)", + "primaryKey": false, + "notNull": false + }, + "creatorId": { + "name": "creatorId", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted": { + "name": "deleted", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "spaces_creatorId_users_id_fk": { + "name": "spaces_creatorId_users_id_fk", + "tableFrom": "spaces", + "tableTo": "users", + "columnsFrom": [ + "creatorId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "spaces_handle_unique": { + "name": "spaces_handle_unique", + "nullsNotDistinct": false, + "columns": [ + "handle" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.members": { + "name": "members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "member_roles", + "typeSchema": "public", + "primaryKey": false, + "notNull": false, + "default": "'member'" + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "members_user_id_users_id_fk": { + "name": "members_user_id_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "members_space_id_spaces_id_fk": { + "name": "members_space_id_spaces_id_fk", + "tableFrom": "members", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "members_user_id_space_id_unique": { + "name": "members_user_id_space_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "space_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chats": { + "name": "chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "chats_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "chat_types", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(150)", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "emoji": { + "name": "emoji", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "last_msg_id": { + "name": "last_msg_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "public_thread": { + "name": "public_thread", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "thread_number": { + "name": "thread_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "min_user_id": { + "name": "min_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "max_user_id": { + "name": "max_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chats_space_id_spaces_id_fk": { + "name": "chats_space_id_spaces_id_fk", + "tableFrom": "chats", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_min_user_id_users_id_fk": { + "name": "chats_min_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "min_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chats_max_user_id_users_id_fk": { + "name": "chats_max_user_id_users_id_fk", + "tableFrom": "chats", + "tableTo": "users", + "columnsFrom": [ + "max_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "last_msg_id_fk": { + "name": "last_msg_id_fk", + "tableFrom": "chats", + "tableTo": "messages", + "columnsFrom": [ + "id", + "last_msg_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_ids_unique": { + "name": "user_ids_unique", + "nullsNotDistinct": false, + "columns": [ + "min_user_id", + "max_user_id" + ] + }, + "space_thread_number_unique": { + "name": "space_thread_number_unique", + "nullsNotDistinct": false, + "columns": [ + "space_id", + "thread_number" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_ids_check": { + "name": "user_ids_check", + "value": "\"chats\".\"min_user_id\" <= \"chats\".\"max_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "global_id": { + "name": "global_id", + "type": "bigserial", + "primaryKey": true, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "random_id": { + "name": "random_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_encrypted": { + "name": "text_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "text_iv": { + "name": "text_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "text_tag": { + "name": "text_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_id": { + "name": "from_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "edit_date": { + "name": "edit_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "replied_to_message_id": { + "name": "replied_to_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "msg_id_per_chat_index": { + "name": "msg_id_per_chat_index", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_chat_id_chats_id_fk": { + "name": "messages_chat_id_chats_id_fk", + "tableFrom": "messages", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_from_id_users_id_fk": { + "name": "messages_from_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "msg_id_per_chat_unique": { + "name": "msg_id_per_chat_unique", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "chat_id" + ] + }, + "random_id_per_sender_unique": { + "name": "random_id_per_sender_unique", + "nullsNotDistinct": false, + "columns": [ + "random_id", + "from_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.dialogs": { + "name": "dialogs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "peer_user_id": { + "name": "peer_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "read_inbox_max_id": { + "name": "read_inbox_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "read_outbox_max_id": { + "name": "read_outbox_max_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "dialogs_user_id_users_id_fk": { + "name": "dialogs_user_id_users_id_fk", + "tableFrom": "dialogs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_chat_id_chats_id_fk": { + "name": "dialogs_chat_id_chats_id_fk", + "tableFrom": "dialogs", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "dialogs_peer_user_id_users_id_fk": { + "name": "dialogs_peer_user_id_users_id_fk", + "tableFrom": "dialogs", + "tableTo": "users", + "columnsFrom": [ + "peer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "dialogs_space_id_spaces_id_fk": { + "name": "dialogs_space_id_spaces_id_fk", + "tableFrom": "dialogs", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "chat_id_user_id_unique": { + "name": "chat_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.client_type": { + "name": "client_type", + "schema": "public", + "values": [ + "ios", + "macos", + "web" + ] + }, + "public.member_roles": { + "name": "member_roles", + "schema": "public", + "values": [ + "owner", + "admin", + "member" + ] + }, + "public.chat_types": { + "name": "chat_types", + "schema": "public", + "values": [ + "private", + "thread" + ] + } + }, + "schemas": {}, + "sequences": { + "public.user_id": { + "name": "user_id", + "schema": "public", + "increment": "3", + "startWith": "1000", + "minValue": "1000", + "maxValue": "9223372036854775807", + "cache": "100", + "cycle": false + } + }, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index c588aa5e..e1886520 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -120,6 +120,13 @@ "when": 1734083987450, "tag": "0016_online", "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1734198503295, + "tag": "0017_add_replying_to_message", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema/messages.ts b/server/src/db/schema/messages.ts index 6e97b1c7..757d6579 100644 --- a/server/src/db/schema/messages.ts +++ b/server/src/db/schema/messages.ts @@ -42,6 +42,9 @@ export const messages = pgTable( editDate: timestamp("edit_date", { mode: "date", precision: 3 }), date: creationDate, + + /** optional, message it is replying to */ + repliedToMessageId: integer("replied_to_message_id"), }, (table) => ({ messageIdPerChatUnique: unique("msg_id_per_chat_unique").on(table.messageId, table.chatId), diff --git a/server/src/methods/sendMessage.ts b/server/src/methods/sendMessage.ts index db1d0705..0960e276 100644 --- a/server/src/methods/sendMessage.ts +++ b/server/src/methods/sendMessage.ts @@ -28,7 +28,7 @@ import { isProd } from "@in/server/env" export const Input = Type.Object({ peerId: Optional(TInputPeerInfo), text: Type.String(), - + repliedToMessageId: Optional(TInputId), peerUserId: Optional(TInputId), peerThreadId: Optional(TInputId), @@ -51,7 +51,7 @@ export const handler = async (input: Input, context: HandlerContext): Promise