Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .swiftformat
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
--disable redundantType
--disable extensionAccessControl
--disable andOperator
--disable hoistPatternLet

# Rules inferred from Swift Standard Library:
--disable anyObjectProtocol, wrapMultilineStatementBraces
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

## StreamChat
### 🔄 Changed
- Replace message update request to partial update on message pinning [#3166](https://github.com/GetStream/stream-chat-swift/pull/3166)

# [4.53.0](https://github.com/GetStream/stream-chat-swift/releases/tag/4.53.0)
_April 30, 2024_
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,13 @@ final class DemoChatMessageActionsVC: ChatMessageActionsVC {
override var messageActions: [ChatMessageActionItem] {
var actions = super.messageActions
if message?.isSentByCurrentUser == true {
if message?.isBounced == false {
actions.append(pinMessageActionItem())
}

if AppConfig.shared.demoAppConfig.isHardDeleteEnabled {
actions.append(hardDeleteActionItem())
}
}

if message?.isBounced == false {
actions.append(pinMessageActionItem())
actions.append(translateActionItem())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ final class DemoChatMessageContentView: ChatMessageContentView {
if content?.isPinned == true, let pinInfoLabel = pinInfoLabel {
pinInfoLabel.text = "📌 Pinned"
if let pinDetails = content?.pinDetails {
let pinnedByName = content?.isSentByCurrentUser == true
? (content?.author.id == pinDetails.pinnedBy.id ? "You" : pinDetails.pinnedBy.name ?? pinDetails.pinnedBy.id)
let pinnedByName = pinDetails.pinnedBy.id == UserDefaults.shared.currentUserId
? "You"
: pinDetails.pinnedBy.name ?? pinDetails.pinnedBy.id
pinInfoLabel.text?.append(" by \(pinnedByName)")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Foundation
extension EndpointPath {
var shouldBeQueuedOffline: Bool {
switch self {
case .sendMessage, .editMessage, .deleteMessage, .addReaction, .deleteReaction:
case .sendMessage, .editMessage, .deleteMessage, .pinMessage, .unpinMessage, .addReaction, .deleteReaction:
return true
case .createChannel, .connect, .sync, .users, .guest, .members, .search, .devices, .channels, .updateChannel,
.deleteChannel, .channelUpdate, .muteChannel, .showChannel, .truncateChannel, .markChannelRead, .markChannelUnread,
Expand Down
4 changes: 4 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/EndpointPath.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ enum EndpointPath: Codable {
case message(MessageId)
case editMessage(MessageId)
case deleteMessage(MessageId)
case pinMessage(MessageId)
case unpinMessage(MessageId)
case replies(MessageId)
case reactions(MessageId)
case addReaction(MessageId)
Expand Down Expand Up @@ -100,6 +102,8 @@ enum EndpointPath: Codable {
case let .message(messageId): return "messages/\(messageId)"
case let .editMessage(messageId): return "messages/\(messageId)"
case let .deleteMessage(messageId): return "messages/\(messageId)"
case let .pinMessage(messageId): return "messages/\(messageId)"
case let .unpinMessage(messageId): return "messages/\(messageId)"
case let .replies(messageId): return "messages/\(messageId)/replies"
case let .reactions(messageId): return "messages/\(messageId)/reactions"
case let .addReaction(messageId): return "messages/\(messageId)/reaction"
Expand Down
35 changes: 35 additions & 0 deletions Sources/StreamChat/APIClient/Endpoints/MessageEndpoints.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,17 @@ extension Endpoint {
]
)
}

static func pinMessage(messageId: MessageId, request: MessagePartialUpdateRequest)
-> Endpoint<EmptyResponse> {
.init(
path: .pinMessage(messageId),
method: .put,
queryItems: nil,
requiresConnectionId: false,
body: request
)
}

static func loadReplies(messageId: MessageId, pagination: MessagesPagination)
-> Endpoint<MessageRepliesPayload> {
Expand Down Expand Up @@ -82,3 +93,27 @@ extension Endpoint {
)
}
}

// MARK: - Helper data structures

struct MessagePartialUpdateRequest: Encodable {
var set: SetProperties?
var unset: [String]? = []
var skipEnrichUrl: Bool?
var userId: String?
var user: UserRequestBody?

/// The available message properties that can be updated.
struct SetProperties: Encodable {
var pinned: Bool?
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: MessagePayloadsCodingKeys.self)
try container.encodeIfPresent(skipEnrichUrl, forKey: .skipEnrichUrl)
try container.encodeIfPresent(userId, forKey: .userId)
try container.encodeIfPresent(user, forKey: .user)
try container.encodeIfPresent(set, forKey: .set)
try container.encodeIfPresent(unset, forKey: .unset)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable {
case cid
case type
case user
case userId = "user_id"
case createdAt = "created_at"
case updatedAt = "updated_at"
case deletedAt = "deleted_at"
Expand Down Expand Up @@ -44,6 +45,9 @@ enum MessagePayloadsCodingKeys: String, CodingKey, CaseIterable {
case messageTextUpdatedAt = "message_text_updated_at"
case poll
case pollId = "poll_id"
case set
case unset
case skipEnrichUrl = "skip_enrich_url"
}

extension MessagePayload {
Expand Down
111 changes: 75 additions & 36 deletions Sources/StreamChat/Workers/MessageUpdater.swift
Original file line number Diff line number Diff line change
Expand Up @@ -460,55 +460,94 @@ class MessageUpdater: Worker {
/// - pinning: The pinning expiration information. It supports setting an infinite expiration, setting a date, or the amount of time a message is pinned.
/// - completion: The completion. Will be called with an error if smth goes wrong, otherwise - will be called with `nil`.
func pinMessage(messageId: MessageId, pinning: MessagePinning, completion: ((Error?) -> Void)? = nil) {
database.write({ session in
guard let messageDTO = session.message(id: messageId) else {
throw ClientError.MessageDoesNotExist(messageId: messageId)
pinLocalMessage(on: messageId, pinning: pinning) { [weak self] error in
if let error {
completion?(error)
return
}

switch messageDTO.localMessageState {
case nil, .pendingSync, .syncingFailed, .deletingFailed:
try session.pin(message: messageDTO, pinning: pinning)
messageDTO.localMessageState = .pendingSync
case .pendingSend, .sendingFailed:
try session.pin(message: messageDTO, pinning: pinning)
messageDTO.localMessageState = .pendingSend
case .sending, .syncing, .deleting:
throw ClientError.MessageEditing(
messageId: messageId,
reason: "message is in `\(messageDTO.localMessageState!)` state"
)

let endpoint: Endpoint<EmptyResponse> = .pinMessage(
messageId: messageId,
request: .init(set: .init(pinned: true))
)

self?.apiClient.request(endpoint: endpoint) { result in
switch result {
case .success:
completion?(nil)
case .failure(let apiError):
self?.unpinLocalMessage(on: messageId) { _, _ in
completion?(apiError)
}
}
}
}, completion: {
completion?($0)
})
}
}

/// Unpin the message with the provided message id.
/// - Parameters:
/// - messageId: The message identifier.
/// - completion: The completion. Will be called with an error if smth goes wrong, otherwise - will be called with `nil`.
func unpinMessage(messageId: MessageId, completion: ((Error?) -> Void)? = nil) {
database.write({ session in
unpinLocalMessage(on: messageId) { [weak self] error, pinning in
if let error {
completion?(error)
return
}

let endpoint: Endpoint<EmptyResponse> = .pinMessage(
messageId: messageId,
request: .init(set: .init(pinned: false))
)

self?.apiClient.request(endpoint: endpoint) { result in
switch result {
case .success:
completion?(nil)
case .failure(let apiError):
self?.pinLocalMessage(on: messageId, pinning: pinning) { _ in
completion?(apiError)
}
}
}
}
}

private func pinLocalMessage(
on messageId: MessageId,
pinning: MessagePinning,
completion: ((Error?) -> Void)? = nil
) {
database.write { session in
guard let messageDTO = session.message(id: messageId) else {
throw ClientError.MessageDoesNotExist(messageId: messageId)
}

switch messageDTO.localMessageState {
case nil, .pendingSync, .syncingFailed, .deletingFailed:
session.unpin(message: messageDTO)
messageDTO.localMessageState = .pendingSync
case .pendingSend, .sendingFailed:
session.unpin(message: messageDTO)
messageDTO.localMessageState = .pendingSend
case .sending, .syncing, .deleting:
throw ClientError.MessageEditing(
messageId: messageId,
reason: "message is in `\(messageDTO.localMessageState!)` state"
)
try session.pin(message: messageDTO, pinning: pinning)
} completion: { error in
if let error = error {
log.error("Error pinning the message with id \(messageId): \(error)")
}
}, completion: {
completion?($0)
})
completion?(error)
}
}

private func unpinLocalMessage(
on messageId: MessageId,
completion: ((Error?, MessagePinning) -> Void)? = nil
) {
var pinning: MessagePinning = .noExpiration
database.write { session in
guard let messageDTO = session.message(id: messageId) else {
throw ClientError.MessageDoesNotExist(messageId: messageId)
}
pinning = .init(expirationDate: messageDTO.pinExpires?.bridgeDate)
session.unpin(message: messageDTO)
} completion: { error in
if let error = error {
log.error("Error unpinning the message with id \(messageId): \(error)")
}
completion?(error, pinning)
}
}

/// Updates local state of attachment with provided `id` to be enqueued by attachment uploader.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ final class EndpointPathTests: XCTestCase {
func test_deleteMessage_shouldBeQueuedOffline() {
XCTAssertTrue(EndpointPath.deleteMessage("").shouldBeQueuedOffline)
}

func test_pinMessage_shouldBeQueuedOffline() {
XCTAssertTrue(EndpointPath.pinMessage("").shouldBeQueuedOffline)
}

func test_addReaction_shouldBeQueuedOffline() {
XCTAssertTrue(EndpointPath.addReaction("").shouldBeQueuedOffline)
Expand Down Expand Up @@ -95,6 +99,7 @@ final class EndpointPathTests: XCTestCase {
assertResultEncodingAndDecoding(.message("message_idm"))
assertResultEncodingAndDecoding(.editMessage("message_ide"))
assertResultEncodingAndDecoding(.deleteMessage("message_idd"))
assertResultEncodingAndDecoding(.pinMessage("message_idp"))
assertResultEncodingAndDecoding(.replies("message_idr"))
assertResultEncodingAndDecoding(.reactions("message_idre"))
assertResultEncodingAndDecoding(.addReaction("message_ida"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,26 @@ final class MessageEndpoints_Tests: XCTestCase {
XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint))
XCTAssertEqual("messages/\(payload.id)", endpoint.path.value)
}

func test_pinMessage_buildsCorrectly() {
let messageId: MessageId = .unique
let payload: MessagePartialUpdateRequest = .init(set: .init(pinned: true))

let expectedEndpoint = Endpoint<EmptyResponse>(
path: .pinMessage(messageId),
method: .put,
queryItems: nil,
requiresConnectionId: false,
body: payload
)

// Build endpoint
let endpoint: Endpoint<EmptyResponse> = .pinMessage(messageId: messageId, request: payload)

// Assert endpoint is built correctly
XCTAssertEqual(AnyEndpoint(expectedEndpoint), AnyEndpoint(endpoint))
XCTAssertEqual("messages/\(messageId)", endpoint.path.value)
}

func test_loadReplies_buildsCorrectly() {
let messageId: MessageId = .unique
Expand Down
Loading