diff --git a/apple/InlineIOS/Features/ChatInfo/ChatInfoView.swift b/apple/InlineIOS/Features/ChatInfo/ChatInfoView.swift index b32c421a..1508ea0c 100644 --- a/apple/InlineIOS/Features/ChatInfo/ChatInfoView.swift +++ b/apple/InlineIOS/Features/ChatInfo/ChatInfoView.swift @@ -11,6 +11,7 @@ struct ChatInfoView: View { let chatItem: SpaceChatItem @StateObject var participantsWithMembersViewModel: ChatParticipantsWithMembersViewModel @EnvironmentStateObject var documentsViewModel: ChatDocumentsViewModel + @EnvironmentStateObject var linksViewModel: ChatLinksViewModel @EnvironmentStateObject var mediaViewModel: ChatMediaViewModel @EnvironmentStateObject var spaceMembersViewModel: SpaceMembersViewModel @StateObject var spaceFullMembersViewModel: SpaceFullMembersViewModel @@ -38,10 +39,11 @@ struct ChatInfoView: View { case info = "Info" case media = "Media" case files = "Files" + case links = "Links" } var availableTabs: [ChatInfoTab] { - isDM ? [.media, .files] : [.info, .media, .files] + isDM ? [.media, .files, .links] : [.info, .media, .files, .links] } var currentChat: Chat? { @@ -101,6 +103,13 @@ struct ChatInfoView: View { ) } + _linksViewModel = EnvironmentStateObject { env in + ChatLinksViewModel( + db: env.appDatabase, + chatId: chatItem.chat?.id ?? 0 + ) + } + _mediaViewModel = EnvironmentStateObject { env in ChatMediaViewModel( db: env.appDatabase, @@ -218,16 +227,20 @@ struct ChatInfoView: View { } )) } + case .media: + MediaTabView( + mediaViewModel: mediaViewModel, + onShowInChat: showMessageInChat + ) case .files: DocumentsTabView( documentsViewModel: documentsViewModel, peerUserId: chatItem.dialog.peerUserId, peerThreadId: chatItem.dialog.peerThreadId ) - case .media: - MediaTabView( - mediaViewModel: mediaViewModel, - onShowInChat: showMessageInChat + case .links: + LinksTabView( + linksViewModel: linksViewModel ) } } diff --git a/apple/InlineIOS/Features/ChatInfo/LinksTabView.swift b/apple/InlineIOS/Features/ChatInfo/LinksTabView.swift new file mode 100644 index 00000000..89ef519b --- /dev/null +++ b/apple/InlineIOS/Features/ChatInfo/LinksTabView.swift @@ -0,0 +1,207 @@ +import InlineKit +import InlineUI +import SwiftUI +import UIKit + +struct LinksTabView: View { + @ObservedObject var linksViewModel: ChatLinksViewModel + + var body: some View { + VStack(spacing: 16) { + if linksViewModel.linkMessages.isEmpty { + VStack(spacing: 8) { + Text("No links found in this chat.") + + Text("Older links may not appear, will be fixed in an update.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(linksViewModel.groupedLinkMessages, id: \.date) { group in + Section { + ForEach(group.messages) { linkMessage in + LinkRow(linkMessage: linkMessage) + .padding(.bottom, 4) + .onAppear { + Task { + await linksViewModel.loadMoreIfNeeded(currentMessageId: linkMessage.message.messageId) + } + } + } + } header: { + HStack { + Text(formatDate(group.date)) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background( + Capsule() + .fill(Color(.systemBackground).opacity(0.95)) + ) + .padding(.leading, 16) + Spacer() + } + .padding(.top, 16) + .padding(.bottom, 8) + } + } + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .task { + await linksViewModel.loadInitial() + } + } + + private func formatDate(_ date: Date) -> String { + let calendar = Calendar.current + let now = Date() + + if calendar.isDateInToday(date) { + return "Today" + } else if calendar.isDateInYesterday(date) { + return "Yesterday" + } else if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE" + return formatter.string(from: date) + } else { + let formatter = DateFormatter() + formatter.dateFormat = "MMMM d, yyyy" + return formatter.string(from: date) + } + } +} + +private struct LinkRow: View { + let linkMessage: LinkMessage + + var body: some View { + HStack(alignment: .top, spacing: 9) { + linkIconCircle + linkData + Spacer() + } + .frame(maxWidth: .infinity) + .padding(.vertical, contentVPadding) + .padding(.horizontal, contentHPadding) + .background { + fileBackgroundRect + } + .padding(.horizontal, contentHMargin) + .contentShape(RoundedRectangle(cornerRadius: fileWrapperCornerRadius)) + .onTapGesture { + openLink() + } + } + + private var linkText: String { + if let urlString = linkMessage.urlPreview?.url, !urlString.isEmpty { + return urlString + } + if let text = linkMessage.message.text, + let url = firstLinkURL(from: text) + { + return url.absoluteString + } + if let text = linkMessage.message.text, !text.isEmpty { + return text + } + return "Link" + } + + private var linkURL: URL? { + URL(string: linkText) + } + + private func openLink() { + guard let url = linkURL else { return } + UIApplication.shared.open(url) + } + + private var linkIconCircle: some View { + ZStack(alignment: .top) { + RoundedRectangle(cornerRadius: linkIconCornerRadius) + .fill(fileCircleFill) + .frame(width: fileCircleSize, height: fileCircleSize) + + Image(systemName: "link") + .foregroundColor(linkIconColor) + .font(.system(size: 11)) + .padding(.top, linkIconTopPadding) + } + } + + private var linkData: some View { + Text(linkText) + .font(.body) + .foregroundColor(.blue) + .lineLimit(nil) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var fileCircleSize: CGFloat { + 25 + } + + private var linkIconCornerRadius: CGFloat { + 6 + } + + private var linkIconTopPadding: CGFloat { + 5 + } + + private var fileCircleFill: Color { + .primary.opacity(0.04) + } + + private var contentVPadding: CGFloat { + 14 + } + + private var contentHPadding: CGFloat { + 14 + } + + private var contentHMargin: CGFloat { + 16 + } + + private var fileWrapperCornerRadius: CGFloat { + 18 + } + + private var linkIconColor: Color { + .secondary + } + + private var fileBackgroundRect: some View { + RoundedRectangle(cornerRadius: fileWrapperCornerRadius) + .fill(fileBackgroundColor) + } + + private var fileBackgroundColor: Color { + Color(UIColor { traitCollection in + if traitCollection.userInterfaceStyle == .dark { + UIColor(hex: "#141414") ?? UIColor.systemGray6 + } else { + UIColor(hex: "#F8F8F8") ?? UIColor.systemGray6 + } + }) + } + + private func firstLinkURL(from text: String) -> URL? { + guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { + return nil + } + let range = NSRange(text.startIndex..., in: text) + return detector.firstMatch(in: text, options: [], range: range)?.url + } +} diff --git a/apple/InlineIOS/Localizable.xcstrings b/apple/InlineIOS/Localizable.xcstrings index bf8c0664..d15952d6 100644 --- a/apple/InlineIOS/Localizable.xcstrings +++ b/apple/InlineIOS/Localizable.xcstrings @@ -772,6 +772,9 @@ }, "No files found in this chat." : { + }, + "No links found in this chat." : { + }, "No media found in this chat." : { @@ -799,6 +802,9 @@ }, "Older files may not appear, will be fixed in an update." : { + }, + "Older links may not appear, will be fixed in an update." : { + }, "Only selected members" : { diff --git a/apple/InlineKit/Sources/InlineKit/Database.swift b/apple/InlineKit/Sources/InlineKit/Database.swift index 9e98fe16..5cabb6ad 100644 --- a/apple/InlineKit/Sources/InlineKit/Database.swift +++ b/apple/InlineKit/Sources/InlineKit/Database.swift @@ -602,6 +602,12 @@ public extension AppDatabase { } } + migrator.registerMigration("message has link") { db in + try db.alter(table: "message") { t in + t.add(column: "hasLink", .boolean) + } + } + /// TODOs: /// - Add indexes for performance /// - Add timestamp integer types instead of Date for performance and faster sort, less storage diff --git a/apple/InlineKit/Sources/InlineKit/Models/Message.swift b/apple/InlineKit/Sources/InlineKit/Models/Message.swift index 40d909eb..52e56e29 100644 --- a/apple/InlineKit/Sources/InlineKit/Models/Message.swift +++ b/apple/InlineKit/Sources/InlineKit/Models/Message.swift @@ -19,6 +19,7 @@ public struct ApiMessage: Codable, Hashable, Sendable { public var photo: [ApiPhoto]? public var replyToMsgId: Int64? public var isSticker: Bool? + public var hasLink: Bool? } public enum MessageSendingStatus: Int64, Codable, DatabaseValueConvertible, Sendable { @@ -87,8 +88,14 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist public var documentId: Int64? public var transactionId: String? public var isSticker: Bool? + public var hasLink: Bool? public var entities: MessageEntities? + private static let allowedLinkSchemes: Set = ["http", "https"] + private static let linkDetector: NSDataDetector? = { + try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + }() + public enum Columns { public static let globalId = Column(CodingKeys.globalId) public static let messageId = Column(CodingKeys.messageId) @@ -113,6 +120,7 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist public static let photoId = Column(CodingKeys.photoId) public static let videoId = Column(CodingKeys.videoId) public static let documentId = Column(CodingKeys.documentId) + public static let hasLink = Column(CodingKeys.hasLink) public static let entities = Column(CodingKeys.entities) } @@ -223,6 +231,7 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist documentId: Int64? = nil, transactionId: String? = nil, isSticker: Bool? = nil, + hasLink: Bool? = nil, entities: MessageEntities? = nil ) { self.messageId = messageId @@ -249,7 +258,9 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist self.documentId = documentId self.transactionId = transactionId self.isSticker = isSticker + self.hasLink = hasLink self.entities = entities + updateHasLinkIfNeeded() if peerUserId == nil, peerThreadId == nil { fatalError("One of peerUserId or peerThreadId must be set") @@ -274,7 +285,8 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist editDate: from.editDate.map { Date(timeIntervalSince1970: TimeInterval($0)) }, status: from.out == true ? MessageSendingStatus.sent : nil, repliedToMessageId: from.replyToMsgId, - isSticker: from.isSticker + isSticker: from.isSticker, + hasLink: from.hasLink ) } @@ -306,6 +318,7 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist videoId: from.media.video.hasVideo ? from.media.video.video.id : nil, documentId: from.media.document.hasDocument ? from.media.document.document.id : nil, isSticker: from.isSticker, + hasLink: from.hasHasLink_p ? from.hasLink_p : nil, entities: from.hasEntities ? from.entities : nil ) } @@ -319,6 +332,141 @@ public struct Message: FetchableRecord, Identifiable, Codable, Hashable, Persist peerThreadId: nil, chatId: 1 ) + + private mutating func updateHasLinkIfNeeded() { + guard hasLink != true else { return } + if Message.detectHasLink(text: text, entities: entities) { + hasLink = true + } + } + + private static func detectHasLink(text: String?, entities: MessageEntities?) -> Bool { + if let entities, + entities.entities.contains(where: { $0.type == .url || $0.type == .textURL }) + { + return true + } + + guard let text, !text.isEmpty else { return false } + return textContainsLink(text) + } + + private static func textContainsLink(_ text: String) -> Bool { + guard let detector = linkDetector else { return false } + let range = NSRange(text.startIndex..., in: text) + var hasLink = false + + detector.enumerateMatches(in: text, options: [], range: range) { match, _, stop in + guard let url = match?.url, + let scheme = url.scheme?.lowercased(), + Self.allowedLinkSchemes.contains(scheme) + else { + return + } + hasLink = true + stop.pointee = true + } + + return hasLink + } + + var detectedLinkPreview: UrlPreview? { + guard hasLink == true else { return nil } + guard let text, !text.isEmpty else { return nil } + guard let url = Self.detectedLinkURL(text: text, entities: entities) else { return nil } + + return UrlPreview( + id: Self.fallbackLinkPreviewId(messageId: messageId), + url: url.absoluteString, + siteName: url.host, + title: url.absoluteString, + description: nil, + photoId: nil, + duration: nil + ) + } + + private static func detectedLinkURL(text: String, entities: MessageEntities?) -> URL? { + if let entityURL = linkURL(from: text, entities: entities) { + return entityURL + } + return firstLinkURL(in: text) + } + + private static func linkURL(from text: String, entities: MessageEntities?) -> URL? { + guard let entities else { return nil } + let sorted = entities.entities.sorted { $0.offset < $1.offset } + + for entity in sorted { + switch entity.type { + case .url: + let range = NSRange(location: Int(entity.offset), length: Int(entity.length)) + guard range.location >= 0, range.location + range.length <= text.utf16.count else { continue } + let substring = (text as NSString).substring(with: range) + if let url = urlFromString(substring) { + return url + } + + case .textURL: + if case let .textURL(textURL) = entity.entity, + let url = urlFromString(textURL.url) + { + return url + } + + default: + continue + } + } + + return nil + } + + private static func firstLinkURL(in text: String) -> URL? { + guard let detector = linkDetector else { return nil } + let range = NSRange(text.startIndex..., in: text) + var firstURL: URL? = nil + + detector.enumerateMatches(in: text, options: [], range: range) { match, _, stop in + guard let url = match?.url, + let scheme = url.scheme?.lowercased(), + Self.allowedLinkSchemes.contains(scheme) + else { + return + } + + firstURL = url + stop.pointee = true + } + + return firstURL + } + + private static func urlFromString(_ value: String) -> URL? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + if let url = URL(string: trimmed), + let scheme = url.scheme?.lowercased(), + allowedLinkSchemes.contains(scheme) + { + return url + } + + if let url = URL(string: "https://\(trimmed)"), + let scheme = url.scheme?.lowercased(), + allowedLinkSchemes.contains(scheme) + { + return url + } + + return nil + } + + private static func fallbackLinkPreviewId(messageId: Int64) -> Int64 { + let baseId = messageId == 0 ? 1 : messageId + return baseId > 0 ? -baseId : baseId + } } // MARK: - UI helpers @@ -388,6 +536,7 @@ public extension Message { photoId = photoId ?? existing.photoId documentId = documentId ?? existing.documentId videoId = videoId ?? existing.videoId + hasLink = hasLink ?? existing.hasLink entities = entities ?? existing.entities transactionId = existing.transactionId isExisting = true @@ -396,6 +545,8 @@ public extension Message { isExisting = true } + updateHasLinkIfNeeded() + // Save the message let message = try saveAndFetch(db, onConflict: .ignore) @@ -467,6 +618,7 @@ public extension ApiMessage { message.fileId = existing.fileId message.text = existing.text message.transactionId = existing.transactionId + message.hasLink = existing.hasLink message.editDate = editDate.map { Date(timeIntervalSince1970: TimeInterval($0)) } // ... anything else? } else { @@ -525,6 +677,7 @@ public extension Message { message.documentId = message.documentId ?? existing.documentId message.transactionId = message.transactionId ?? existing.transactionId message.isSticker = message.isSticker ?? existing.isSticker + message.hasLink = message.hasLink ?? existing.hasLink message.editDate = message.editDate ?? existing.editDate message.repliedToMessageId = message.repliedToMessageId ?? existing.repliedToMessageId message.forwardFromPeerUserId = message.forwardFromPeerUserId ?? existing.forwardFromPeerUserId diff --git a/apple/InlineKit/Sources/InlineKit/ViewModels/ChatLinks.swift b/apple/InlineKit/Sources/InlineKit/ViewModels/ChatLinks.swift new file mode 100644 index 00000000..a8092ad8 --- /dev/null +++ b/apple/InlineKit/Sources/InlineKit/ViewModels/ChatLinks.swift @@ -0,0 +1,157 @@ +import Combine +import GRDB +import Logger +import SwiftUI + +public struct LinkMessage: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, Sendable, + Identifiable +{ + public var id: Int64 { + if let id = attachment.id { + return id + } + if let urlPreview { + return urlPreview.id + } + return message.messageId + } + + public var attachment: Attachment + public var message: Message + public var urlPreview: UrlPreview? + public var photoInfo: PhotoInfo? + + public enum CodingKeys: String, CodingKey { + case attachment + case message + case urlPreview + case photoInfo + } + + public init( + attachment: Attachment, + message: Message, + urlPreview: UrlPreview? = nil, + photoInfo: PhotoInfo? = nil + ) { + self.attachment = attachment + self.message = message + self.urlPreview = urlPreview + self.photoInfo = photoInfo + } + + public static func queryRequest(chatId: Int64) -> QueryInterfaceRequest { + Attachment + .filter(Column("urlPreviewId") != nil) + .including( + required: Attachment.message + .filter(Message.Columns.chatId == chatId) + .forKey(CodingKeys.message) + ) + .including( + optional: Attachment.urlPreview + .including( + optional: UrlPreview.photo + .forKey(CodingKeys.photoInfo) + .including(all: Photo.sizes.forKey(PhotoInfo.CodingKeys.sizes)) + ) + .forKey(CodingKeys.urlPreview) + ) + .asRequest(of: LinkMessage.self) + } +} + +@MainActor +public final class ChatLinksViewModel: ObservableObject, @unchecked Sendable { + private let chatId: Int64 + private let db: AppDatabase + + @Published public private(set) var linkMessages: [LinkMessage] = [] + + private var cancellable: AnyCancellable? + private var hasStarted = false + + public init(db: AppDatabase, chatId: Int64) { + self.db = db + self.chatId = chatId + fetchLinkMessages() + } + + private func fetchLinkMessages() { + cancellable = ValueObservation + .tracking { [chatId] db -> [LinkMessage] in + let previewMessages: [LinkMessage] = try LinkMessage.queryRequest(chatId: chatId) + .fetchAll(db) + let previewMessageIds = Set(previewMessages.map { $0.message.messageId }) + + let textLinkMessages: [Message] = try Message + .filter(Message.Columns.chatId == chatId) + .filter(Message.Columns.hasLink == true) + .fetchAll(db) + + let fallbackMessages: [LinkMessage] = textLinkMessages.compactMap { message in + guard !previewMessageIds.contains(message.messageId) else { return nil } + return Self.fallbackLinkMessage(from: message) + } + + return previewMessages + fallbackMessages + } + .publisher(in: db.dbWriter, scheduling: .immediate) + .sink( + receiveCompletion: { [chatId] completion in + if case let .failure(error) = completion { + Log.shared.error("Failed to load chat links for chat \(chatId)", error: error) + } + }, + receiveValue: { [weak self] (messages: [LinkMessage]) in + guard let self else { return } + self.linkMessages = messages.sorted { $0.message.date > $1.message.date } + } + ) + } + + private static func fallbackLinkMessage(from message: Message) -> LinkMessage { + var attachment = Attachment( + messageId: message.globalId, + externalTaskId: nil, + urlPreviewId: nil, + attachmentId: nil + ) + attachment.id = fallbackAttachmentId(messageId: message.messageId) + return LinkMessage( + attachment: attachment, + message: message, + urlPreview: message.detectedLinkPreview + ) + } + + private static func fallbackAttachmentId(messageId: Int64) -> Int64 { + let baseId = messageId == 0 ? 1 : messageId + return baseId > 0 ? -baseId : baseId + } + + public var groupedLinkMessages: [LinkMessageGroup] { + let calendar = Calendar.current + let grouped = Dictionary(grouping: linkMessages) { message in + calendar.startOfDay(for: message.message.date) + } + + return grouped.map { date, messages in + LinkMessageGroup(date: date, messages: messages.sorted { $0.message.date > $1.message.date }) + }.sorted { $0.date > $1.date } + } + + public func loadInitial() async { + guard !hasStarted else { return } + hasStarted = true + } + + public func loadMoreIfNeeded(currentMessageId: Int64) async { + _ = currentMessageId + } +} + +public struct LinkMessageGroup { + public let date: Date + public let messages: [LinkMessage] +} diff --git a/apple/InlineKit/Sources/InlineProtocol/client.pb.swift b/apple/InlineKit/Sources/InlineProtocol/client.pb.swift index 811f49ea..37f2b571 100644 --- a/apple/InlineKit/Sources/InlineProtocol/client.pb.swift +++ b/apple/InlineKit/Sources/InlineProtocol/client.pb.swift @@ -8,7 +8,6 @@ // For information on using the generated types, please see the documentation: // https://github.com/apple/swift-protobuf/ -import Foundation import SwiftProtobuf // If the compiler emits an error on this type, it is because this file diff --git a/apple/InlineKit/Sources/InlineProtocol/core.pb.swift b/apple/InlineKit/Sources/InlineProtocol/core.pb.swift index 1e1f1985..c656cd96 100644 --- a/apple/InlineKit/Sources/InlineProtocol/core.pb.swift +++ b/apple/InlineKit/Sources/InlineProtocol/core.pb.swift @@ -1150,6 +1150,15 @@ public struct Message: @unchecked Sendable { /// Clears the value of `isSticker`. Subsequent reads from it will return its default value. public mutating func clearIsSticker() {_uniqueStorage()._isSticker = nil} + public var hasLink_p: Bool { + get {return _storage._hasLink_p ?? false} + set {_uniqueStorage()._hasLink_p = newValue} + } + /// Returns true if `hasLink_p` has been explicitly set. + public var hasHasLink_p: Bool {return _storage._hasLink_p != nil} + /// Clears the value of `hasLink_p`. Subsequent reads from it will return its default value. + public mutating func clearHasLink_p() {_uniqueStorage()._hasLink_p = nil} + /// Rich text entities public var entities: MessageEntities { get {return _storage._entities ?? MessageEntities()} @@ -3908,6 +3917,15 @@ public struct SendMessageInput: Sendable { /// Clears the value of `isSticker`. Subsequent reads from it will return its default value. public mutating func clearIsSticker() {self._isSticker = nil} + public var hasLink_p: Bool { + get {return _hasLink_p ?? false} + set {_hasLink_p = newValue} + } + /// Returns true if `hasLink_p` has been explicitly set. + public var hasHasLink_p: Bool {return self._hasLink_p != nil} + /// Clears the value of `hasLink_p`. Subsequent reads from it will return its default value. + public mutating func clearHasLink_p() {self._hasLink_p = nil} + /// Entities in the message (bold, italic, mention, etc) public var entities: MessageEntities { get {return _entities ?? MessageEntities()} @@ -3949,6 +3967,7 @@ public struct SendMessageInput: Sendable { fileprivate var _media: InputMedia? = nil fileprivate var _temporarySendDate: Int64? = nil fileprivate var _isSticker: Bool? = nil + fileprivate var _hasLink_p: Bool? = nil fileprivate var _entities: MessageEntities? = nil fileprivate var _parseMarkdown: Bool? = nil fileprivate var _sendMode: MessageSendMode? = nil @@ -4094,7 +4113,8 @@ public struct SearchMessagesInput: Sendable { /// Clears the value of `peerID`. Subsequent reads from it will return its default value. public mutating func clearPeerID() {self._peerID = nil} - /// Queries to match in message text (space-separated terms ANDed within a query, ORed across queries) + /// Queries to match in message text (space-separated terms ANDed within a + /// query, ORed across queries) public var queries: [String] = [] /// Max number of results to return @@ -6903,6 +6923,7 @@ extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa 13: .same(proto: "attachments"), 14: .same(proto: "reactions"), 15: .standard(proto: "is_sticker"), + 6000: .standard(proto: "has_link"), 16: .same(proto: "entities"), 17: .standard(proto: "send_mode"), 18: .standard(proto: "fwd_from"), @@ -6924,6 +6945,7 @@ extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa var _attachments: MessageAttachments? = nil var _reactions: MessageReactions? = nil var _isSticker: Bool? = nil + var _hasLink_p: Bool? = nil var _entities: MessageEntities? = nil var _sendMode: MessageSendMode? = nil var _fwdFrom: MessageFwdHeader? = nil @@ -6956,6 +6978,7 @@ extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa _attachments = source._attachments _reactions = source._reactions _isSticker = source._isSticker + _hasLink_p = source._hasLink_p _entities = source._entities _sendMode = source._sendMode _fwdFrom = source._fwdFrom @@ -6995,6 +7018,7 @@ extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa case 16: try { try decoder.decodeSingularMessageField(value: &_storage._entities) }() case 17: try { try decoder.decodeSingularEnumField(value: &_storage._sendMode) }() case 18: try { try decoder.decodeSingularMessageField(value: &_storage._fwdFrom) }() + case 6000: try { try decoder.decodeSingularBoolField(value: &_storage._hasLink_p) }() default: break } } @@ -7061,6 +7085,9 @@ extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa try { if let v = _storage._fwdFrom { try visitor.visitSingularMessageField(value: v, fieldNumber: 18) } }() + try { if let v = _storage._hasLink_p { + try visitor.visitSingularBoolField(value: v, fieldNumber: 6000) + } }() } try unknownFields.traverse(visitor: &visitor) } @@ -7085,6 +7112,7 @@ extension Message: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBa if _storage._attachments != rhs_storage._attachments {return false} if _storage._reactions != rhs_storage._reactions {return false} if _storage._isSticker != rhs_storage._isSticker {return false} + if _storage._hasLink_p != rhs_storage._hasLink_p {return false} if _storage._entities != rhs_storage._entities {return false} if _storage._sendMode != rhs_storage._sendMode {return false} if _storage._fwdFrom != rhs_storage._fwdFrom {return false} @@ -11607,6 +11635,7 @@ extension SendMessageInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme 5: .same(proto: "media"), 1000: .standard(proto: "temporary_send_date"), 6: .standard(proto: "is_sticker"), + 6000: .standard(proto: "has_link"), 7: .same(proto: "entities"), 8: .standard(proto: "parse_markdown"), 9: .standard(proto: "send_mode"), @@ -11628,6 +11657,7 @@ extension SendMessageInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme case 8: try { try decoder.decodeSingularBoolField(value: &self._parseMarkdown) }() case 9: try { try decoder.decodeSingularEnumField(value: &self._sendMode) }() case 1000: try { try decoder.decodeSingularInt64Field(value: &self._temporarySendDate) }() + case 6000: try { try decoder.decodeSingularBoolField(value: &self._hasLink_p) }() default: break } } @@ -11668,6 +11698,9 @@ extension SendMessageInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme try { if let v = self._temporarySendDate { try visitor.visitSingularInt64Field(value: v, fieldNumber: 1000) } }() + try { if let v = self._hasLink_p { + try visitor.visitSingularBoolField(value: v, fieldNumber: 6000) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -11679,6 +11712,7 @@ extension SendMessageInput: SwiftProtobuf.Message, SwiftProtobuf._MessageImpleme if lhs._media != rhs._media {return false} if lhs._temporarySendDate != rhs._temporarySendDate {return false} if lhs._isSticker != rhs._isSticker {return false} + if lhs._hasLink_p != rhs._hasLink_p {return false} if lhs._entities != rhs._entities {return false} if lhs._parseMarkdown != rhs._parseMarkdown {return false} if lhs._sendMode != rhs._sendMode {return false} diff --git a/bun.lock b/bun.lock index 7f3ecb94..9ffb4d2b 100644 --- a/bun.lock +++ b/bun.lock @@ -1,5 +1,6 @@ { "lockfileVersion": 1, + "configVersion": 0, "workspaces": { "": { "name": "inline", @@ -15,6 +16,7 @@ "name": "inline-scripts", "devDependencies": { "@protobuf-ts/plugin": "^2.9.4", + "protoc-gen-ts": "^0.8.7", }, }, "server": { @@ -1789,6 +1791,8 @@ "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], + "protoc-gen-ts": ["protoc-gen-ts@0.8.7", "", { "bin": { "protoc-gen-ts": "protoc-gen-ts.js" } }, "sha512-jr4VJey2J9LVYCV7EVyVe53g1VMw28cCmYJhBe5e3YX5wiyiDwgxWxeDf9oTqAe4P1bN/YGAkW2jhlH8LohwiQ=="], + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 00000000..22c04153 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,2 @@ +[install] +linker = "isolated" \ No newline at end of file diff --git a/proto/core.proto b/proto/core.proto index 2baefe80..9d96aac7 100644 --- a/proto/core.proto +++ b/proto/core.proto @@ -213,6 +213,7 @@ message Message { // Whether the message is a sticker optional bool is_sticker = 15; + optional bool has_link = 6000; // Rich text entities optional MessageEntities entities = 16; @@ -976,6 +977,7 @@ message SendMessageInput { // Whether the message is a sticker optional bool is_sticker = 6; + optional bool has_link = 6000; // Entities in the message (bold, italic, mention, etc) optional MessageEntities entities = 7; @@ -1028,7 +1030,8 @@ enum SearchMessagesFilter { message SearchMessagesInput { InputPeer peer_id = 1; - // Queries to match in message text (space-separated terms ANDed within a query, ORed across queries) + // Queries to match in message text (space-separated terms ANDed within a + // query, ORed across queries) repeated string queries = 2; // Max number of results to return @@ -1388,9 +1391,7 @@ message UpdateChatVisibilityInput { repeated InputChatParticipant participants = 3; } -message UpdateChatVisibilityResult { - Chat chat = 1; -} +message UpdateChatVisibilityResult { Chat chat = 1; } // Apple only types message DraftMessage { diff --git a/scripts/package.json b/scripts/package.json index 99f73e68..3ff9c54e 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -13,6 +13,7 @@ }, "dependencies": {}, "devDependencies": { - "@protobuf-ts/plugin": "^2.9.4" + "@protobuf-ts/plugin": "^2.9.4", + "protoc-gen-ts": "^0.8.7" } } diff --git a/server/drizzle/0048_add-message-has-link.sql b/server/drizzle/0048_add-message-has-link.sql new file mode 100644 index 00000000..dc163360 --- /dev/null +++ b/server/drizzle/0048_add-message-has-link.sql @@ -0,0 +1 @@ +ALTER TABLE "messages" ADD COLUMN "has_link" boolean; \ No newline at end of file diff --git a/server/drizzle/meta/0048_snapshot.json b/server/drizzle/meta/0048_snapshot.json new file mode 100644 index 00000000..129c2bdd --- /dev/null +++ b/server/drizzle/meta/0048_snapshot.json @@ -0,0 +1,2879 @@ +{ + "id": "cc69d6ae-e7de-4599-ad9b-8c927e777217", + "prevId": "7956eb0d-75e1-4878-96ce-c080e7778d99", + "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()" + }, + "photo_file_id": { + "name": "photo_file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "pending_setup": { + "name": "pending_setup", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "time_zone": { + "name": "time_zone", + "type": "varchar(256)", + "primaryKey": false, + "notNull": false + }, + "bot": { + "name": "bot", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "bot_creator_id": { + "name": "bot_creator_id", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "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": { + "users_photo_file_id_files_id_fk": { + "name": "users_photo_file_id_files_id_fk", + "tableFrom": "users", + "tableTo": "files", + "columnsFrom": [ + "photo_file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_bot_creator_id_users_id_fk": { + "name": "users_bot_creator_id_users_id_fk", + "tableFrom": "users", + "tableTo": "users", + "columnsFrom": [ + "bot_creator_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "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 + }, + "device_id": { + "name": "device_id", + "type": "text", + "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": { + "device_id_user_unique": { + "name": "device_id_user_unique", + "nullsNotDistinct": false, + "columns": [ + "device_id", + "user_id" + ] + } + }, + "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 + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "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'" + }, + "can_access_public_chats": { + "name": "can_access_public_chats", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "invited_by": { + "name": "invited_by", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "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" + }, + "members_invited_by_users_id_fk": { + "name": "members_invited_by_users_id_fk", + "tableFrom": "members", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "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.chat_participants": { + "name": "chat_participants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "chat_participants_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "chat_participants_chat_id_chats_id_fk": { + "name": "chat_participants_chat_id_chats_id_fk", + "tableFrom": "chat_participants", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chat_participants_user_id_users_id_fk": { + "name": "chat_participants_user_id_users_id_fk", + "tableFrom": "chat_participants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_participant": { + "name": "unique_participant", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "user_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 + }, + "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()" + }, + "emoji": { + "name": "emoji", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "update_seq": { + "name": "update_seq", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_update_date": { + "name": "last_update_date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": false + } + }, + "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": "no action", + "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 + }, + "entities_encrypted": { + "name": "entities_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_iv": { + "name": "entities_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities_tag": { + "name": "entities_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()" + }, + "reply_to_msg_id": { + "name": "reply_to_msg_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_peer_user_id": { + "name": "fwd_from_peer_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_peer_chat_id": { + "name": "fwd_from_peer_chat_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_message_id": { + "name": "fwd_from_message_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fwd_from_sender_id": { + "name": "fwd_from_sender_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grouped_id": { + "name": "grouped_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "media_type": { + "name": "media_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "video_id": { + "name": "video_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "document_id": { + "name": "document_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_sticker": { + "name": "is_sticker", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "has_link": { + "name": "has_link", + "type": "boolean", + "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": {} + }, + "unread_count_index": { + "name": "unread_count_index", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_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" + }, + "messages_photo_id_photos_id_fk": { + "name": "messages_photo_id_photos_id_fk", + "tableFrom": "messages", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_video_id_videos_id_fk": { + "name": "messages_video_id_videos_id_fk", + "tableFrom": "messages", + "tableTo": "videos", + "columnsFrom": [ + "video_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_document_id_documents_id_fk": { + "name": "messages_document_id_documents_id_fk", + "tableFrom": "messages", + "tableTo": "documents", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "messages_file_id_files_id_fk": { + "name": "messages_file_id_files_id_fk", + "tableFrom": "messages", + "tableTo": "files", + "columnsFrom": [ + "file_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 + }, + "draft": { + "name": "draft", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "unread_mark": { + "name": "unread_mark", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": 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 + }, + "public.reactions": { + "name": "reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "reactions_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "message_id": { + "name": "message_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "emoji": { + "name": "emoji", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "reactions_chat_id_chats_id_fk": { + "name": "reactions_chat_id_chats_id_fk", + "tableFrom": "reactions", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reactions_user_id_users_id_fk": { + "name": "reactions_user_id_users_id_fk", + "tableFrom": "reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_id_fk": { + "name": "message_id_fk", + "tableFrom": "reactions", + "tableTo": "messages", + "columnsFrom": [ + "chat_id", + "message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_reaction_per_emoji": { + "name": "unique_reaction_per_emoji", + "nullsNotDistinct": false, + "columns": [ + "chat_id", + "message_id", + "user_id", + "emoji" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "file_unique_id": { + "name": "file_unique_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "path_encrypted": { + "name": "path_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "path_iv": { + "name": "path_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "path_tag": { + "name": "path_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cdn": { + "name": "cdn", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "video_duration": { + "name": "video_duration", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "thumb_size": { + "name": "thumb_size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "thumb_for": { + "name": "thumb_for", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "bytes_encrypted": { + "name": "bytes_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "bytes_iv": { + "name": "bytes_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "bytes_tag": { + "name": "bytes_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_encrypted": { + "name": "name_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_iv": { + "name": "name_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "name_tag": { + "name": "name_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "files_user_id_users_id_fk": { + "name": "files_user_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "files_thumb_for_files_id_fk": { + "name": "files_thumb_for_files_id_fk", + "tableFrom": "files", + "tableTo": "files", + "columnsFrom": [ + "thumb_for" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_file_unique_id_unique": { + "name": "files_file_unique_id_unique", + "nullsNotDistinct": false, + "columns": [ + "file_unique_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integrations": { + "name": "integrations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "space_id": { + "name": "space_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token_encrypted": { + "name": "access_token_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "access_token_iv": { + "name": "access_token_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "access_token_tag": { + "name": "access_token_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "notion_database_id": { + "name": "notion_database_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linear_team_id": { + "name": "linear_team_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "integrations_space_provider_unique": { + "name": "integrations_space_provider_unique", + "columns": [ + { + "expression": "space_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "integrations_user_id_users_id_fk": { + "name": "integrations_user_id_users_id_fk", + "tableFrom": "integrations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "integrations_space_id_spaces_id_fk": { + "name": "integrations_space_id_spaces_id_fk", + "tableFrom": "integrations", + "tableTo": "spaces", + "columnsFrom": [ + "space_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "documents_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "file_name": { + "name": "file_name", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_iv": { + "name": "file_name_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "file_name_tag": { + "name": "file_name_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "documents_file_id_files_id_fk": { + "name": "documents_file_id_files_id_fk", + "tableFrom": "documents", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "documents_photo_id_photos_id_fk": { + "name": "documents_photo_id_photos_id_fk", + "tableFrom": "documents", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photo_sizes": { + "name": "photo_sizes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "photo_sizes_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "size": { + "name": "size", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "photo_sizes_file_id_files_id_fk": { + "name": "photo_sizes_file_id_files_id_fk", + "tableFrom": "photo_sizes", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "photo_sizes_photo_id_photos_id_fk": { + "name": "photo_sizes_photo_id_photos_id_fk", + "tableFrom": "photo_sizes", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.photos": { + "name": "photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "photos_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "stripped": { + "name": "stripped", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "stripped_iv": { + "name": "stripped_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "stripped_tag": { + "name": "stripped_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.videos": { + "name": "videos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "videos_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "videos_file_id_files_id_fk": { + "name": "videos_file_id_files_id_fk", + "tableFrom": "videos", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "videos_photo_id_photos_id_fk": { + "name": "videos_photo_id_photos_id_fk", + "tableFrom": "videos", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.external_tasks": { + "name": "external_tasks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "external_tasks_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "application": { + "name": "application", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "task_id": { + "name": "task_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_user_id": { + "name": "assigned_user_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "external_tasks_assigned_user_id_users_id_fk": { + "name": "external_tasks_assigned_user_id_users_id_fk", + "tableFrom": "external_tasks", + "tableTo": "users", + "columnsFrom": [ + "assigned_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_attachments": { + "name": "message_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "message_attachments_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "external_task_id": { + "name": "external_task_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "url_preview_id": { + "name": "url_preview_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "message_attachments_message_id_messages_global_id_fk": { + "name": "message_attachments_message_id_messages_global_id_fk", + "tableFrom": "message_attachments", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "global_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_attachments_external_task_id_external_tasks_id_fk": { + "name": "message_attachments_external_task_id_external_tasks_id_fk", + "tableFrom": "message_attachments", + "tableTo": "external_tasks", + "columnsFrom": [ + "external_task_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "message_attachments_url_preview_id_url_preview_id_fk": { + "name": "message_attachments_url_preview_id_url_preview_id_fk", + "tableFrom": "message_attachments", + "tableTo": "url_preview", + "columnsFrom": [ + "url_preview_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.url_preview": { + "name": "url_preview", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "url_preview_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "url": { + "name": "url", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "url_iv": { + "name": "url_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "url_tag": { + "name": "url_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_iv": { + "name": "title_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "title_tag": { + "name": "title_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_iv": { + "name": "description_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "description_tag": { + "name": "description_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "photo_id": { + "name": "photo_id", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "url_preview_photo_id_photos_id_fk": { + "name": "url_preview_photo_id_photos_id_fk", + "tableFrom": "url_preview", + "tableTo": "photos", + "columnsFrom": [ + "photo_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_translations": { + "name": "message_translations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "message_translations_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "message_id": { + "name": "message_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "translation": { + "name": "translation", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "translation_iv": { + "name": "translation_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "translation_tag": { + "name": "translation_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "entities": { + "name": "entities", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "message_translations_chat_id_chats_id_fk": { + "name": "message_translations_chat_id_chats_id_fk", + "tableFrom": "message_translations", + "tableTo": "chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "chat_id_message_id_fk": { + "name": "chat_id_message_id_fk", + "tableFrom": "message_translations", + "tableTo": "messages", + "columnsFrom": [ + "chat_id", + "message_id" + ], + "columnsTo": [ + "chat_id", + "message_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_message_chat_language": { + "name": "unique_message_chat_language", + "nullsNotDistinct": false, + "columns": [ + "message_id", + "chat_id", + "language" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_settings": { + "name": "user_settings", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": true, + "notNull": true + }, + "general_encrypted": { + "name": "general_encrypted", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "general_iv": { + "name": "general_iv", + "type": "bytea", + "primaryKey": false, + "notNull": false + }, + "general_tag": { + "name": "general_tag", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_settings_user_id_users_id_fk": { + "name": "user_settings_user_id_users_id_fk", + "tableFrom": "user_settings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.updates": { + "name": "updates", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "updates_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "date": { + "name": "date", + "type": "timestamp (3)", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "bucket": { + "name": "bucket", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "bytea", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "updates_bucket_idx": { + "name": "updates_bucket_idx", + "columns": [ + { + "expression": "bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "updates_date_idx": { + "name": "updates_date_idx", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "updates_unique": { + "name": "updates_unique", + "nullsNotDistinct": false, + "columns": [ + "bucket", + "entity_id", + "seq" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.client_type": { + "name": "client_type", + "schema": "public", + "values": [ + "ios", + "macos", + "web", + "api", + "android", + "windows", + "linux", + "cli" + ] + }, + "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 439bc819..e2f87354 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -337,6 +337,13 @@ "when": 1767730371729, "tag": "0047_add_message_fwd_header", "breakpoints": true + }, + { + "idx": 48, + "version": "7", + "when": 1768646394304, + "tag": "0048_add-message-has-link", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/packages/protocol/src/core.ts b/server/packages/protocol/src/core.ts index a009e357..f3f1b2ad 100644 --- a/server/packages/protocol/src/core.ts +++ b/server/packages/protocol/src/core.ts @@ -568,6 +568,10 @@ export interface Message { * @generated from protobuf field: optional bool is_sticker = 15; */ isSticker?: boolean; + /** + * @generated from protobuf field: optional bool has_link = 6000; + */ + hasLink?: boolean; /** * Rich text entities * @@ -2563,6 +2567,10 @@ export interface SendMessageInput { * @generated from protobuf field: optional bool is_sticker = 6; */ isSticker?: boolean; + /** + * @generated from protobuf field: optional bool has_link = 6000; + */ + hasLink?: boolean; /** * Entities in the message (bold, italic, mention, etc) * @@ -2668,7 +2676,8 @@ export interface SearchMessagesInput { */ peerId?: InputPeer; /** - * Queries to match in message text (space-separated terms ANDed within a query, ORed across queries) + * Queries to match in message text (space-separated terms ANDed within a + * query, ORed across queries) * * @generated from protobuf field: repeated string queries = 2; */ @@ -5162,6 +5171,7 @@ class Message$Type extends MessageType { { no: 13, name: "attachments", kind: "message", T: () => MessageAttachments }, { no: 14, name: "reactions", kind: "message", T: () => MessageReactions }, { no: 15, name: "is_sticker", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6000, name: "has_link", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, { no: 16, name: "entities", kind: "message", T: () => MessageEntities }, { no: 17, name: "send_mode", kind: "enum", opt: true, T: () => ["MessageSendMode", MessageSendMode] }, { no: 18, name: "fwd_from", kind: "message", T: () => MessageFwdHeader } @@ -5228,6 +5238,9 @@ class Message$Type extends MessageType { case /* optional bool is_sticker */ 15: message.isSticker = reader.bool(); break; + case /* optional bool has_link */ 6000: + message.hasLink = reader.bool(); + break; case /* optional MessageEntities entities */ 16: message.entities = MessageEntities.internalBinaryRead(reader, reader.uint32(), options, message.entities); break; @@ -5294,6 +5307,9 @@ class Message$Type extends MessageType { /* optional bool is_sticker = 15; */ if (message.isSticker !== undefined) writer.tag(15, WireType.Varint).bool(message.isSticker); + /* optional bool has_link = 6000; */ + if (message.hasLink !== undefined) + writer.tag(6000, WireType.Varint).bool(message.hasLink); /* optional MessageEntities entities = 16; */ if (message.entities) MessageEntities.internalBinaryWrite(message.entities, writer.tag(16, WireType.LengthDelimited).fork(), options).join(); @@ -10026,6 +10042,7 @@ class SendMessageInput$Type extends MessageType { { no: 5, name: "media", kind: "message", T: () => InputMedia }, { no: 1000, name: "temporary_send_date", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, { no: 6, name: "is_sticker", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6000, name: "has_link", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, { no: 7, name: "entities", kind: "message", T: () => MessageEntities }, { no: 8, name: "parse_markdown", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, { no: 9, name: "send_mode", kind: "enum", opt: true, T: () => ["MessageSendMode", MessageSendMode] } @@ -10063,6 +10080,9 @@ class SendMessageInput$Type extends MessageType { case /* optional bool is_sticker */ 6: message.isSticker = reader.bool(); break; + case /* optional bool has_link */ 6000: + message.hasLink = reader.bool(); + break; case /* optional MessageEntities entities */ 7: message.entities = MessageEntities.internalBinaryRead(reader, reader.uint32(), options, message.entities); break; @@ -10105,6 +10125,9 @@ class SendMessageInput$Type extends MessageType { /* optional bool is_sticker = 6; */ if (message.isSticker !== undefined) writer.tag(6, WireType.Varint).bool(message.isSticker); + /* optional bool has_link = 6000; */ + if (message.hasLink !== undefined) + writer.tag(6000, WireType.Varint).bool(message.hasLink); /* optional MessageEntities entities = 7; */ if (message.entities) MessageEntities.internalBinaryWrite(message.entities, writer.tag(7, WireType.LengthDelimited).fork(), options).join(); diff --git a/server/src/db/models/messages.ts b/server/src/db/models/messages.ts index b178db81..107e091a 100644 --- a/server/src/db/models/messages.ts +++ b/server/src/db/models/messages.ts @@ -32,6 +32,7 @@ import { Encryption2 } from "@in/server/modules/encryption/encryption2" import { UpdateBucket, updates } from "@in/server/db/schema/updates" import { UpdatesModel, type UpdateSeqAndDate } from "@in/server/db/models/updates" import { encodeDateStrict } from "@in/server/realtime/encoders/helpers" +import { detectHasLink } from "@in/server/modules/message/linkDetection" const log = new Log("MessageModel", LogLevel.INFO) @@ -499,6 +500,7 @@ async function editMessage(input: EditMessageInput): Promise<{ const encryptedMessage = text ? encryptMessage(text) : undefined const binaryEntities = entities ? MessageEntities.toBinary(entities) : undefined const encryptedEntities = binaryEntities && binaryEntities?.length > 0 ? encryptBinary(binaryEntities) : undefined + const hasLink = detectHasLink({ entities }) let { message, update } = await db.transaction(async (tx) => { // First lock the specific chat row @@ -540,6 +542,7 @@ async function editMessage(input: EditMessageInput): Promise<{ entitiesEncrypted: encryptedEntities?.encrypted, entitiesIv: encryptedEntities?.iv, entitiesTag: encryptedEntities?.authTag, + hasLink: hasLink, }) .where(and(eq(messages.chatId, chatId), eq(messages.messageId, messageId))) .returning(), diff --git a/server/src/db/schema/messages.ts b/server/src/db/schema/messages.ts index aa983e40..89caa103 100644 --- a/server/src/db/schema/messages.ts +++ b/server/src/db/schema/messages.ts @@ -78,6 +78,8 @@ export const messages = pgTable( /** if this message is a sticker */ isSticker: boolean("is_sticker").default(false), + + hasLink: boolean("has_link"), }, (table) => ({ messageIdPerChatUnique: unique("msg_id_per_chat_unique").on(table.messageId, table.chatId), diff --git a/server/src/functions/messages.sendMessage.ts b/server/src/functions/messages.sendMessage.ts index 3217b0bc..d1555541 100644 --- a/server/src/functions/messages.sendMessage.ts +++ b/server/src/functions/messages.sendMessage.ts @@ -29,6 +29,7 @@ import { UserSettingsNotificationsMode } from "@in/server/db/models/userSettings import { encryptBinary } from "@in/server/modules/encryption/encryption" import { processMessageText } from "@in/server/modules/message/processText" import { isUserMentioned } from "@in/server/modules/message/helpers" +import { detectHasLink } from "@in/server/modules/message/linkDetection" import type { UpdateSeqAndDate } from "@in/server/db/models/updates" import { encodeDateStrict } from "@in/server/realtime/encoders/helpers" import { RealtimeRpcError } from "@in/server/realtime/errors" @@ -101,6 +102,10 @@ export const sendMessage = async (input: Input, context: FunctionContext): Promi entities = textContent?.entities } + const hasLink = + detectHasLink({ entities }) || + (input.messageAttachments?.some((attachment) => attachment.urlPreviewId != null) ?? false) + // Encrypt const encryptedMessage = text ? encryptMessage(text) : undefined @@ -172,6 +177,7 @@ export const sendMessage = async (input: Input, context: FunctionContext): Promi videoId: dbFullVideo?.id ?? null, documentId: dbFullDocument?.id ?? null, isSticker: input.isSticker ?? false, + hasLink: hasLink, entitiesEncrypted: encryptedEntities?.encrypted ?? null, entitiesIv: encryptedEntities?.iv ?? null, entitiesTag: encryptedEntities?.authTag ?? null, diff --git a/server/src/modules/message/linkDetection.test.ts b/server/src/modules/message/linkDetection.test.ts new file mode 100644 index 00000000..159a3192 --- /dev/null +++ b/server/src/modules/message/linkDetection.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "bun:test" +import { MessageEntity_Type } from "@in/protocol/core" +import { detectHasLink } from "./linkDetection" + +const entity = (type: MessageEntity_Type) => ({ + type, + offset: BigInt(0), + length: BigInt(1), + entity: { oneofKind: undefined }, +}) + +describe("detectHasLink", () => { + test("returns false without entities", () => { + expect(detectHasLink({ entities: undefined })).toBe(false) + expect(detectHasLink({ entities: { entities: [] } })).toBe(false) + }) + + test("returns true for url entity", () => { + expect( + detectHasLink({ + entities: { entities: [entity(MessageEntity_Type.URL)] }, + }), + ).toBe(true) + }) + + test("returns true for text_url entity", () => { + expect( + detectHasLink({ + entities: { + entities: [ + { + ...entity(MessageEntity_Type.TEXT_URL), + entity: { oneofKind: "textUrl", textUrl: { url: "https://example.com" } }, + }, + ], + }, + }), + ).toBe(true) + }) + + test("returns false for non-link entities", () => { + expect( + detectHasLink({ + entities: { + entities: [entity(MessageEntity_Type.MENTION), entity(MessageEntity_Type.BOLD)], + }, + }), + ).toBe(false) + }) +}) diff --git a/server/src/modules/message/linkDetection.ts b/server/src/modules/message/linkDetection.ts new file mode 100644 index 00000000..0253b686 --- /dev/null +++ b/server/src/modules/message/linkDetection.ts @@ -0,0 +1,13 @@ +import { MessageEntities, MessageEntity_Type } from "@in/protocol/core" + +type DetectHasLinkInput = { + entities?: MessageEntities | null +} + +export const detectHasLink = ({ entities }: DetectHasLinkInput): boolean => { + return ( + entities?.entities.some( + (entity) => entity.type === MessageEntity_Type.URL || entity.type === MessageEntity_Type.TEXT_URL, + ) ?? false + ) +} diff --git a/server/src/realtime/encoders/encodeMessage.ts b/server/src/realtime/encoders/encodeMessage.ts index 02349740..d82e0a3e 100644 --- a/server/src/realtime/encoders/encodeMessage.ts +++ b/server/src/realtime/encoders/encodeMessage.ts @@ -21,6 +21,7 @@ import type { DbFullMessage } from "@in/server/db/models/messages" import { encodeDateStrict } from "@in/server/realtime/encoders/helpers" import { encodeReaction } from "@in/server/realtime/encoders/encodeReaction" import { decryptBinary } from "@in/server/modules/encryption/encryption" +import { detectHasLink } from "@in/server/modules/message/linkDetection" export const encodeMessage = ({ message, @@ -62,6 +63,8 @@ export const encodeMessage = ({ entities = MessageEntities.fromBinary(decryptedEntities) } + const hasLink = message.hasLink ?? (detectHasLink({ entities }) ? true : undefined) + let peerId: Peer if ("legacyPeer" in encodingForPeer) { @@ -141,6 +144,7 @@ export const encodeMessage = ({ replyToMsgId: message.replyToMsgId ? BigInt(message.replyToMsgId) : undefined, media: media, isSticker: message.isSticker || undefined, + hasLink: hasLink, entities: entities, sendMode: sendMode ?? undefined, fwdFrom: fwdFrom, @@ -253,6 +257,8 @@ export const encodeFullMessage = ({ } } + const hasLinkFromAttachments = message.messageAttachments?.some((attachment) => attachment.linkEmbed) ?? false + const hasReactions = message.reactions.length > 0 let fwdFrom: MessageFwdHeader | undefined = undefined @@ -288,6 +294,9 @@ export const encodeFullMessage = ({ replyToMsgId: message.replyToMsgId ? BigInt(message.replyToMsgId) : undefined, media: media, isSticker: message.isSticker ?? false, + hasLink: + message.hasLink ?? + (detectHasLink({ entities: message.entities }) || hasLinkFromAttachments ? true : undefined), attachments: attachments, reactions: hasReactions ? { diff --git a/web/packages/protocol/src/core.ts b/web/packages/protocol/src/core.ts index a009e357..f3f1b2ad 100644 --- a/web/packages/protocol/src/core.ts +++ b/web/packages/protocol/src/core.ts @@ -568,6 +568,10 @@ export interface Message { * @generated from protobuf field: optional bool is_sticker = 15; */ isSticker?: boolean; + /** + * @generated from protobuf field: optional bool has_link = 6000; + */ + hasLink?: boolean; /** * Rich text entities * @@ -2563,6 +2567,10 @@ export interface SendMessageInput { * @generated from protobuf field: optional bool is_sticker = 6; */ isSticker?: boolean; + /** + * @generated from protobuf field: optional bool has_link = 6000; + */ + hasLink?: boolean; /** * Entities in the message (bold, italic, mention, etc) * @@ -2668,7 +2676,8 @@ export interface SearchMessagesInput { */ peerId?: InputPeer; /** - * Queries to match in message text (space-separated terms ANDed within a query, ORed across queries) + * Queries to match in message text (space-separated terms ANDed within a + * query, ORed across queries) * * @generated from protobuf field: repeated string queries = 2; */ @@ -5162,6 +5171,7 @@ class Message$Type extends MessageType { { no: 13, name: "attachments", kind: "message", T: () => MessageAttachments }, { no: 14, name: "reactions", kind: "message", T: () => MessageReactions }, { no: 15, name: "is_sticker", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6000, name: "has_link", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, { no: 16, name: "entities", kind: "message", T: () => MessageEntities }, { no: 17, name: "send_mode", kind: "enum", opt: true, T: () => ["MessageSendMode", MessageSendMode] }, { no: 18, name: "fwd_from", kind: "message", T: () => MessageFwdHeader } @@ -5228,6 +5238,9 @@ class Message$Type extends MessageType { case /* optional bool is_sticker */ 15: message.isSticker = reader.bool(); break; + case /* optional bool has_link */ 6000: + message.hasLink = reader.bool(); + break; case /* optional MessageEntities entities */ 16: message.entities = MessageEntities.internalBinaryRead(reader, reader.uint32(), options, message.entities); break; @@ -5294,6 +5307,9 @@ class Message$Type extends MessageType { /* optional bool is_sticker = 15; */ if (message.isSticker !== undefined) writer.tag(15, WireType.Varint).bool(message.isSticker); + /* optional bool has_link = 6000; */ + if (message.hasLink !== undefined) + writer.tag(6000, WireType.Varint).bool(message.hasLink); /* optional MessageEntities entities = 16; */ if (message.entities) MessageEntities.internalBinaryWrite(message.entities, writer.tag(16, WireType.LengthDelimited).fork(), options).join(); @@ -10026,6 +10042,7 @@ class SendMessageInput$Type extends MessageType { { no: 5, name: "media", kind: "message", T: () => InputMedia }, { no: 1000, name: "temporary_send_date", kind: "scalar", opt: true, T: 3 /*ScalarType.INT64*/, L: 0 /*LongType.BIGINT*/ }, { no: 6, name: "is_sticker", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, + { no: 6000, name: "has_link", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, { no: 7, name: "entities", kind: "message", T: () => MessageEntities }, { no: 8, name: "parse_markdown", kind: "scalar", opt: true, T: 8 /*ScalarType.BOOL*/ }, { no: 9, name: "send_mode", kind: "enum", opt: true, T: () => ["MessageSendMode", MessageSendMode] } @@ -10063,6 +10080,9 @@ class SendMessageInput$Type extends MessageType { case /* optional bool is_sticker */ 6: message.isSticker = reader.bool(); break; + case /* optional bool has_link */ 6000: + message.hasLink = reader.bool(); + break; case /* optional MessageEntities entities */ 7: message.entities = MessageEntities.internalBinaryRead(reader, reader.uint32(), options, message.entities); break; @@ -10105,6 +10125,9 @@ class SendMessageInput$Type extends MessageType { /* optional bool is_sticker = 6; */ if (message.isSticker !== undefined) writer.tag(6, WireType.Varint).bool(message.isSticker); + /* optional bool has_link = 6000; */ + if (message.hasLink !== undefined) + writer.tag(6000, WireType.Varint).bool(message.hasLink); /* optional MessageEntities entities = 7; */ if (message.entities) MessageEntities.internalBinaryWrite(message.entities, writer.tag(7, WireType.LengthDelimited).fork(), options).join();