mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-03-31 03:16:05 +00:00
ios: display reactions in groups by member (#5265)
* ios: display reactions in groups by member * fetch data * load on open * wip * fix text * less api calls * matching image sizes * updates * progress dump * mostly works * add member to list needed * open member faster --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
This commit is contained in:
@@ -446,6 +446,13 @@ func apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, re
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiGetReactionMembers(groupId: Int64, itemId: Int64, reaction: MsgReaction) async throws -> [MemberReaction] {
|
||||
let userId = try currentUserId("apiGetReactionMemebers")
|
||||
let r = await chatSendCmd(.apiGetReactionMembers(userId: userId, groupId: groupId, itemId: itemId, reaction: reaction ))
|
||||
if case let .reactionMembers(_, memberReactions) = r { return memberReactions }
|
||||
throw r
|
||||
}
|
||||
|
||||
func apiDeleteChatItems(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode) async throws -> [ChatItemDeletion] {
|
||||
let r = await chatSendCmd(.apiDeleteChatItem(type: type, id: id, itemIds: itemIds, mode: mode), bgDelay: msgDelay)
|
||||
if case let .chatItemsDeleted(_, items, _) = r { return items }
|
||||
|
||||
@@ -901,7 +901,7 @@ struct ChatView: View {
|
||||
@State private var showChatItemInfoSheet: Bool = false
|
||||
@State private var chatItemInfo: ChatItemInfo?
|
||||
@State private var msgWidth: CGFloat = 0
|
||||
|
||||
|
||||
@Binding var selectedChatItems: Set<Int64>?
|
||||
@Binding var forwardedChatItems: [ChatItem]
|
||||
|
||||
@@ -1117,14 +1117,12 @@ struct ChatView: View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
MemberProfileImage(member, size: memberImageSize, backgroundColor: theme.colors.background)
|
||||
.onTapGesture {
|
||||
if let member = m.getGroupMember(member.groupMemberId) {
|
||||
selectedMember = member
|
||||
if let mem = m.getGroupMember(member.groupMemberId) {
|
||||
selectedMember = mem
|
||||
} else {
|
||||
Task {
|
||||
await m.loadGroupMembers(groupInfo) {
|
||||
selectedMember = m.getGroupMember(member.groupMemberId)
|
||||
}
|
||||
}
|
||||
let mem = GMember.init(member)
|
||||
m.groupMembers.append(mem)
|
||||
selectedMember = mem
|
||||
}
|
||||
}
|
||||
chatItemWithMenu(ci, range, maxWidth, itemSeparation)
|
||||
@@ -1244,11 +1242,20 @@ struct ChatView: View {
|
||||
}
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 4)
|
||||
|
||||
if chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted) {
|
||||
.if(chat.chatInfo.featureEnabled(.reactions) && (ci.allowAddReaction || r.userReacted)) { v in
|
||||
v.onTapGesture {
|
||||
setReaction(ci, add: !r.userReacted, reaction: r.reaction)
|
||||
}
|
||||
}
|
||||
if case let .group(groupInfo) = chat.chatInfo {
|
||||
v.contextMenu {
|
||||
ReactionContextMenu(
|
||||
groupInfo: groupInfo,
|
||||
itemId: ci.id,
|
||||
reactionCount: r,
|
||||
selectedMember: $selectedMember
|
||||
)
|
||||
}
|
||||
} else {
|
||||
v
|
||||
}
|
||||
@@ -1838,6 +1845,108 @@ private func buildTheme() -> AppTheme {
|
||||
}
|
||||
}
|
||||
|
||||
struct ReactionContextMenu: View {
|
||||
@EnvironmentObject var m: ChatModel
|
||||
let groupInfo: GroupInfo
|
||||
var itemId: Int64
|
||||
var reactionCount: CIReactionCount
|
||||
@Binding var selectedMember: GMember?
|
||||
@State private var memberReactions: [MemberReaction] = []
|
||||
@AppStorage(DEFAULT_PROFILE_IMAGE_CORNER_RADIUS) private var radius = defaultProfileImageCorner
|
||||
|
||||
var body: some View {
|
||||
groupMemberReactionList()
|
||||
.task {
|
||||
logger.debug("ReactionContextMenu task \(radius)")
|
||||
await loadChatItemReaction()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder private func groupMemberReactionList() -> some View {
|
||||
if memberReactions.isEmpty {
|
||||
ForEach(Array(repeating: 0, count: reactionCount.totalReacted), id: \.self) { _ in
|
||||
Text(verbatim: " ")
|
||||
}
|
||||
} else {
|
||||
ForEach(memberReactions, id: \.groupMember.groupMemberId) { mr in
|
||||
let mem = mr.groupMember
|
||||
let userMember = mem.groupMemberId == groupInfo.membership.groupMemberId
|
||||
Button {
|
||||
if let member = m.getGroupMember(mem.groupMemberId) {
|
||||
selectedMember = member
|
||||
} else {
|
||||
let member = GMember.init(mem)
|
||||
m.groupMembers.append(member)
|
||||
selectedMember = member
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
Text(mem.displayName)
|
||||
if let img = cropImage(mem.image) {
|
||||
Image(uiImage: img)
|
||||
} else {
|
||||
Image(systemName: "person.crop.circle")
|
||||
}
|
||||
}
|
||||
}
|
||||
.disabled(userMember)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cropImage(_ img: String?) -> UIImage? {
|
||||
return if let originalImage = imageFromBase64(img) {
|
||||
maskToCustomShape(originalImage, size: 30, radius: radius)
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
}
|
||||
|
||||
private func loadChatItemReaction() async {
|
||||
do {
|
||||
let memberReactions = try await apiGetReactionMembers(
|
||||
groupId: groupInfo.groupId,
|
||||
itemId: itemId,
|
||||
reaction: reactionCount.reaction
|
||||
)
|
||||
await MainActor.run {
|
||||
self.memberReactions = memberReactions
|
||||
}
|
||||
} catch let error {
|
||||
logger.error("apiGetReactionMembers error: \(responseError(error))")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func maskToCustomShape(_ image: UIImage, size: CGFloat, radius: CGFloat) -> UIImage {
|
||||
let path = Path { path in
|
||||
if radius >= 50 {
|
||||
path.addEllipse(in: CGRect(x: 0, y: 0, width: size, height: size))
|
||||
} else if radius <= 0 {
|
||||
path.addRect(CGRect(x: 0, y: 0, width: size, height: size))
|
||||
} else {
|
||||
let cornerRadius = size * CGFloat(radius) / 100
|
||||
path.addRoundedRect(
|
||||
in: CGRect(x: 0, y: 0, width: size, height: size),
|
||||
cornerSize: CGSize(width: cornerRadius, height: cornerRadius),
|
||||
style: .continuous
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return UIGraphicsImageRenderer(size: CGSize(width: size, height: size)).image { context in
|
||||
context.cgContext.addPath(path.cgPath)
|
||||
context.cgContext.clip()
|
||||
let scale = size / max(image.size.width, image.size.height)
|
||||
let imageSize = CGSize(width: image.size.width * scale, height: image.size.height * scale)
|
||||
let imageOrigin = CGPoint(
|
||||
x: (size - imageSize.width) / 2,
|
||||
y: (size - imageSize.height) / 2
|
||||
)
|
||||
image.draw(in: CGRect(origin: imageOrigin, size: imageSize))
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleNtfsButton: View {
|
||||
@ObservedObject var chat: Chat
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ public enum ChatCommand {
|
||||
case apiDeleteChatItem(type: ChatType, id: Int64, itemIds: [Int64], mode: CIDeleteMode)
|
||||
case apiDeleteMemberChatItem(groupId: Int64, itemIds: [Int64])
|
||||
case apiChatItemReaction(type: ChatType, id: Int64, itemId: Int64, add: Bool, reaction: MsgReaction)
|
||||
case apiGetReactionMembers(userId: Int64, groupId: Int64, itemId: Int64, reaction: MsgReaction)
|
||||
case apiPlanForwardChatItems(toChatType: ChatType, toChatId: Int64, itemIds: [Int64])
|
||||
case apiForwardChatItems(toChatType: ChatType, toChatId: Int64, fromChatType: ChatType, fromChatId: Int64, itemIds: [Int64], ttl: Int?)
|
||||
case apiGetNtfToken
|
||||
@@ -212,6 +213,7 @@ public enum ChatCommand {
|
||||
case let .apiDeleteChatItem(type, id, itemIds, mode): return "/_delete item \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ",")) \(mode.rawValue)"
|
||||
case let .apiDeleteMemberChatItem(groupId, itemIds): return "/_delete member item #\(groupId) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
|
||||
case let .apiChatItemReaction(type, id, itemId, add, reaction): return "/_reaction \(ref(type, id)) \(itemId) \(onOff(add)) \(encodeJSON(reaction))"
|
||||
case let .apiGetReactionMembers(userId, groupId, itemId, reaction): return "/_reaction members \(userId) #\(groupId) \(itemId) \(encodeJSON(reaction))"
|
||||
case let .apiPlanForwardChatItems(type, id, itemIds): return "/_forward plan \(ref(type, id)) \(itemIds.map({ "\($0)" }).joined(separator: ","))"
|
||||
case let .apiForwardChatItems(toChatType, toChatId, fromChatType, fromChatId, itemIds, ttl):
|
||||
let ttlStr = ttl != nil ? "\(ttl!)" : "default"
|
||||
@@ -375,6 +377,7 @@ public enum ChatCommand {
|
||||
case .apiConnectContactViaAddress: return "apiConnectContactViaAddress"
|
||||
case .apiDeleteMemberChatItem: return "apiDeleteMemberChatItem"
|
||||
case .apiChatItemReaction: return "apiChatItemReaction"
|
||||
case .apiGetReactionMembers: return "apiGetReactionMembers"
|
||||
case .apiPlanForwardChatItems: return "apiPlanForwardChatItems"
|
||||
case .apiForwardChatItems: return "apiForwardChatItems"
|
||||
case .apiGetNtfToken: return "apiGetNtfToken"
|
||||
@@ -629,6 +632,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case chatItemUpdated(user: UserRef, chatItem: AChatItem)
|
||||
case chatItemNotChanged(user: UserRef, chatItem: AChatItem)
|
||||
case chatItemReaction(user: UserRef, added: Bool, reaction: ACIReaction)
|
||||
case reactionMembers(user: UserRef, memberReactions: [MemberReaction])
|
||||
case chatItemsDeleted(user: UserRef, chatItemDeletions: [ChatItemDeletion], byUser: Bool)
|
||||
case contactsList(user: UserRef, contacts: [Contact])
|
||||
// group events
|
||||
@@ -805,6 +809,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case .chatItemUpdated: return "chatItemUpdated"
|
||||
case .chatItemNotChanged: return "chatItemNotChanged"
|
||||
case .chatItemReaction: return "chatItemReaction"
|
||||
case .reactionMembers: return "reactionMembers"
|
||||
case .chatItemsDeleted: return "chatItemsDeleted"
|
||||
case .contactsList: return "contactsList"
|
||||
case .groupCreated: return "groupCreated"
|
||||
@@ -983,6 +988,7 @@ public enum ChatResponse: Decodable, Error {
|
||||
case let .chatItemUpdated(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case let .chatItemNotChanged(u, chatItem): return withUser(u, String(describing: chatItem))
|
||||
case let .chatItemReaction(u, added, reaction): return withUser(u, "added: \(added)\n\(String(describing: reaction))")
|
||||
case let .reactionMembers(u, reaction): return withUser(u, "memberReactions: \(String(describing: reaction))")
|
||||
case let .chatItemsDeleted(u, items, byUser):
|
||||
let itemsString = items.map { item in
|
||||
"deletedChatItem:\n\(String(describing: item.deletedChatItem))\ntoChatItem:\n\(String(describing: item.toChatItem))" }.joined(separator: "\n")
|
||||
|
||||
@@ -2311,6 +2311,11 @@ public struct ACIReaction: Decodable, Hashable {
|
||||
public var chatReaction: CIReaction
|
||||
}
|
||||
|
||||
public struct MemberReaction: Decodable, Hashable {
|
||||
public var groupMember: GroupMember
|
||||
public var reactionTs: Date
|
||||
}
|
||||
|
||||
public struct CIReaction: Decodable, Hashable {
|
||||
public var chatDir: CIDirection
|
||||
public var chatItem: ChatItem
|
||||
|
||||
@@ -138,7 +138,7 @@ private func reduceSize(_ image: UIImage, ratio: CGFloat, hasAlpha: Bool) -> UII
|
||||
return resizeImage(image, newBounds: bounds, drawIn: bounds, hasAlpha: hasAlpha)
|
||||
}
|
||||
|
||||
private func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
|
||||
public func resizeImage(_ image: UIImage, newBounds: CGRect, drawIn: CGRect, hasAlpha: Bool) -> UIImage {
|
||||
let format = UIGraphicsImageRendererFormat()
|
||||
format.scale = 1.0
|
||||
format.opaque = !hasAlpha
|
||||
|
||||
Reference in New Issue
Block a user