ios: view edit history; core: prohibit item updates w/t changes (#2413)

* ios: view edit history; core: prohibit item updates w/t changes

* read more less wip

* Revert "read more less wip"

This reverts commit 8e0663377b.

* comment for translations
This commit is contained in:
spaced4ndy 2023-05-09 20:43:21 +04:00 committed by GitHub
parent 0b8d9d11e2
commit 63f344bde6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 254 additions and 35 deletions

View File

@ -295,6 +295,12 @@ func loadChat(chat: Chat, search: String = "") {
}
}
func apiGetChatItemInfo(itemId: Int64) async throws -> ChatItemInfo {
let r = await chatSendCmd(.apiGetChatItemInfo(itemId: itemId))
if case let .chatItemInfo(_, _, chatItemInfo) = r { return chatItemInfo }
throw r
}
func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? {
let chatModel = ChatModel.shared
let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live)

View File

@ -327,7 +327,11 @@ func chatItemFrameColorMaybeImageOrVideo(_ ci: ChatItem, _ colorScheme: ColorSch
}
func chatItemFrameColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Color {
ci.chatDir.sent
ciDirFrameColor(chatItemSent: ci.chatDir.sent, colorScheme: colorScheme)
}
func ciDirFrameColor(chatItemSent: Bool, colorScheme: ColorScheme) -> Color {
chatItemSent
? (colorScheme == .light ? sentColorLight : sentColorDark)
: Color(uiColor: .tertiarySystemGroupedBackground)
}

View File

@ -0,0 +1,147 @@
//
// ChatItemInfoView.swift
// SimpleX (iOS)
//
// Created by spaced4ndy on 09.05.2023.
// Copyright © 2023 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChatItemInfoView: View {
@Environment(\.colorScheme) var colorScheme
var chatItemSent: Bool
@Binding var chatItemInfo: ChatItemInfo?
@AppStorage(DEFAULT_DEVELOPER_TOOLS) private var developerTools = false
var body: some View {
if let chatItemInfo = chatItemInfo {
NavigationView {
itemInfoView(chatItemInfo)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button { showShareSheet(items: [itemInfoShareText(chatItemInfo)]) } label: {
Image(systemName: "square.and.arrow.up")
}
}
}
}
} else {
Text("No message details")
}
}
@ViewBuilder private func itemInfoView(_ chatItemInfo: ChatItemInfo) -> some View {
GeometryReader { g in
ScrollView {
VStack(alignment: .leading, spacing: 12) {
Text("Message details")
.font(.largeTitle)
.bold()
.padding(.bottom)
let maxWidth = (g.size.width - 32) * 0.84
if developerTools {
infoRow("Database ID", "\(chatItemInfo.chatItemId)")
}
infoRow("Sent at", localTimestamp(chatItemInfo.itemTs))
if !chatItemSent {
infoRow("Received at", localTimestamp(chatItemInfo.createdAt))
}
if !chatItemInfo.itemVersions.isEmpty {
Divider()
.padding(.top)
Text("Edit history")
.font(.title)
.padding(.bottom, 4)
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(Array(chatItemInfo.itemVersions.enumerated()), id: \.element.chatItemVersionId) { index, itemVersion in
itemVersionView(itemVersion, maxWidth, current: index == 0)
}
}
}
}
}
.padding()
.frame(maxHeight: .infinity, alignment: .top)
}
}
@ViewBuilder private func itemVersionView(_ itemVersion: ChatItemVersion, _ maxWidth: CGFloat, current: Bool) -> some View {
let uiMenu: Binding<UIMenu> = Binding(
get: { UIMenu(title: "", children: itemVersionMenu(itemVersion)) },
set: { _ in }
)
VStack(alignment: .leading, spacing: 4) {
messageText(itemVersion.msgContent.text, parseSimpleXMarkdown(itemVersion.msgContent.text), nil)
.allowsHitTesting(false)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(ciDirFrameColor(chatItemSent: chatItemSent, colorScheme: colorScheme))
.cornerRadius(18)
.uiKitContextMenu(menu: uiMenu)
Text(
localTimestamp(itemVersion.itemVersionTs)
+ (current
? (" (" + NSLocalizedString("Current", comment: "designation of the current version of the message") + ")")
: "")
)
.foregroundStyle(.secondary)
.font(.caption)
.padding(.horizontal, 12)
}
.frame(maxWidth: maxWidth, alignment: .leading)
}
func itemVersionMenu(_ itemVersion: ChatItemVersion) -> [UIAction] {[
UIAction(
title: NSLocalizedString("Share", comment: "chat item action"),
image: UIImage(systemName: "square.and.arrow.up")
) { _ in
showShareSheet(items: [itemVersion.msgContent.text])
},
UIAction(
title: NSLocalizedString("Copy", comment: "chat item action"),
image: UIImage(systemName: "doc.on.doc")
) { _ in
UIPasteboard.general.string = itemVersion.msgContent.text
}
]}
func itemInfoShareText(_ chatItemInfo: ChatItemInfo) -> String {
var shareText = ""
let nl = "\n"
shareText += "Message details" + nl + nl
if developerTools {
shareText += "Database ID: \(chatItemInfo.chatItemId)" + nl
}
shareText += "Sent at: \(localTimestamp(chatItemInfo.itemTs))" + nl
if !chatItemSent {
shareText += "Received at: \(localTimestamp(chatItemInfo.createdAt))" + nl
}
if !chatItemInfo.itemVersions.isEmpty {
shareText += nl + "Edit history" + nl + nl
for (index, itemVersion) in chatItemInfo.itemVersions.enumerated() {
shareText += localTimestamp(itemVersion.itemVersionTs) + (index == 0 ? " (Current)" : "") + ":" + nl
shareText += itemVersion.msgContent.text + nl + nl
}
}
return shareText.trimmingCharacters(in: .newlines)
}
}
func localTimestamp(_ date: Date) -> String {
let localDateFormatter = DateFormatter()
localDateFormatter.dateStyle = .medium
localDateFormatter.timeStyle = .medium
return localDateFormatter.string(from: date)
}
struct ChatItemInfoView_Previews: PreviewProvider {
static var previews: some View {
ChatItemInfoView(chatItemSent: true, chatItemInfo: Binding.constant(nil))
}
}

View File

@ -445,6 +445,8 @@ struct ChatView: View {
@Binding var showDeleteMessage: Bool
@State private var revealed = false
@State private var showChatItemInfoSheet: Bool = false
@State private var chatItemInfo: ChatItemInfo?
var body: some View {
let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading
@ -467,6 +469,11 @@ struct ChatView: View {
}
.frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment)
.frame(minWidth: 0, maxWidth: .infinity, alignment: alignment)
.sheet(isPresented: $showChatItemInfoSheet, onDismiss: {
chatItemInfo = nil
}) {
ChatItemInfoView(chatItemSent: ci.chatDir.sent, chatItemInfo: $chatItemInfo)
}
}
private func menu(live: Bool) -> [UIAction] {
@ -491,6 +498,7 @@ struct ChatView: View {
if ci.meta.editable && !mc.isVoice && !live {
menu.append(editAction())
}
menu.append(viewInfoUIAction())
if revealed {
menu.append(hideUIAction())
}
@ -589,6 +597,25 @@ struct ChatView: View {
}
}
private func viewInfoUIAction() -> UIAction {
UIAction(
title: NSLocalizedString("View details", comment: "chat item action"),
image: UIImage(systemName: "info")
) { _ in
Task {
do {
let ciInfo = try await apiGetChatItemInfo(itemId: ci.id)
await MainActor.run {
chatItemInfo = ciInfo
}
} catch let error {
logger.error("apiGetChatItemInfo error: \(responseError(error))")
}
await MainActor.run { showChatItemInfoSheet = true }
}
}
}
private func cancelFileUIAction(_ fileId: Int64, _ cancelAction: CancelAction) -> UIAction {
return UIAction(
title: cancelAction.uiAction,

View File

@ -172,6 +172,7 @@
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
64AA1C6927EE10C800AC7277 /* ContextItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */; };
64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */; };
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */; };
@ -442,6 +443,7 @@
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = "<group>"; };
64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = "<group>"; };
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = "<group>"; };
@ -548,6 +550,7 @@
5CADE79B292131E900072E13 /* ContactPreferencesView.swift */,
5CBE6C11294487F7002D9531 /* VerifyCodeView.swift */,
5CBE6C132944CC12002D9531 /* ScanCodeView.swift */,
64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */,
);
path = Chat;
sourceTree = "<group>";
@ -1058,6 +1061,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
64C06EB52A0A4A7C00792D4D /* ChatItemInfoView.swift in Sources */,
6440CA03288AECA70062C672 /* AddGroupMembersView.swift in Sources */,
5C6AD81327A834E300348BD7 /* NewChatButton.swift in Sources */,
5C3F1D58284363C400EC8A82 /* PrivacySettings.swift in Sources */,

View File

@ -36,6 +36,7 @@ public enum ChatCommand {
case apiStorageEncryption(config: DBEncryptionConfig)
case apiGetChats(userId: Int64)
case apiGetChat(type: ChatType, id: Int64, pagination: ChatPagination, search: String)
case apiGetChatItemInfo(itemId: Int64)
case apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool)
case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool)
case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode)
@ -141,6 +142,7 @@ public enum ChatCommand {
case let .apiGetChats(userId): return "/_get chats \(userId) pcc=on"
case let .apiGetChat(type, id, pagination, search): return "/_get chat \(ref(type, id)) \(pagination.cmdString)" +
(search == "" ? "" : " search=\(search)")
case let .apiGetChatItemInfo(itemId): return "/_get item info \(itemId)"
case let .apiSendMessage(type, id, file, quotedItemId, mc, live):
let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc))
return "/_send \(ref(type, id)) live=\(onOff(live)) json \(msg)"
@ -247,6 +249,7 @@ public enum ChatCommand {
case .apiStorageEncryption: return "apiStorageEncryption"
case .apiGetChats: return "apiGetChats"
case .apiGetChat: return "apiGetChat"
case .apiGetChatItemInfo: return "apiGetChatItemInfo"
case .apiSendMessage: return "apiSendMessage"
case .apiUpdateChatItem: return "apiUpdateChatItem"
case .apiDeleteChatItem: return "apiDeleteChatItem"
@ -385,6 +388,7 @@ public enum ChatResponse: Decodable, Error {
case chatSuspended
case apiChats(user: User, chats: [ChatData])
case apiChat(user: User, chat: ChatData)
case chatItemInfo(user: User, chatItem: AChatItem, chatItemInfo: ChatItemInfo)
case userProtoServers(user: User, servers: UserProtoServers)
case serverTestResult(user: User, testServer: String, testFailure: ProtocolTestFailure?)
case chatItemTTL(user: User, chatItemTTL: Int64?)
@ -501,6 +505,7 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return "chatSuspended"
case .apiChats: return "apiChats"
case .apiChat: return "apiChat"
case .chatItemInfo: return "chatItemInfo"
case .userProtoServers: return "userProtoServers"
case .serverTestResult: return "serverTestResult"
case .chatItemTTL: return "chatItemTTL"
@ -616,6 +621,7 @@ public enum ChatResponse: Decodable, Error {
case .chatSuspended: return noDetails
case let .apiChats(u, chats): return withUser(u, String(describing: chats))
case let .apiChat(u, chat): return withUser(u, String(describing: chat))
case let .chatItemInfo(u, chatItem, chatItemInfo): return withUser(u, "chatItem: \(String(describing: chatItem))\nchatItemInfo: \(String(describing: chatItemInfo))")
case let .userProtoServers(u, servers): return withUser(u, "servers: \(String(describing: servers))")
case let .serverTestResult(u, server, testFailure): return withUser(u, "server: \(server)\nresult: \(String(describing: testFailure))")
case let .chatItemTTL(u, chatItemTTL): return withUser(u, String(describing: chatItemTTL))

View File

@ -2857,3 +2857,18 @@ public enum ChatItemTTL: Hashable, Identifiable, Comparable {
return lhs.comparisonValue < rhs.comparisonValue
}
}
public struct ChatItemInfo: Decodable {
public var chatItemId: Int64
public var itemTs: Date
public var createdAt: Date
public var updatedAt: Date
public var itemVersions: [ChatItemVersion]
}
public struct ChatItemVersion: Decodable {
public var chatItemVersionId: Int64
public var msgContent: MsgContent
public var itemVersionTs: Date
public var createdAt: Date
}

View File

@ -650,15 +650,18 @@ processChatCommand = \case
case cci of
CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive}, content = ciContent} -> do
case (ciContent, itemSharedMsgId) of
(CISndMsgContent oldMC, Just itemSharedMId) -> do
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
ci' <- withStore' $ \db -> do
currentTs <- liftIO getCurrentTime
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
setActive $ ActiveC c
pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci')
(CISndMsgContent oldMC, Just itemSharedMId) ->
if mc /= oldMC
then do
(SndMessage {msgId}, _) <- sendDirectContactMessage ct (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
ci' <- withStore' $ \db -> do
currentTs <- liftIO getCurrentTime
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
updateDirectChatItem' db user contactId ci (CISndMsgContent mc) live $ Just msgId
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
setActive $ ActiveC c
pure $ CRChatItemUpdated user (AChatItem SCTDirect SMDSnd (DirectChat ct) ci')
else throwChatError CEInvalidChatItemUpdate
_ -> throwChatError CEInvalidChatItemUpdate
CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
CTGroup -> do
@ -668,15 +671,18 @@ processChatCommand = \case
case cci of
CChatItem SMDSnd ci@ChatItem {meta = CIMeta {itemSharedMsgId, itemTimed, itemLive}, content = ciContent} -> do
case (ciContent, itemSharedMsgId) of
(CISndMsgContent oldMC, Just itemSharedMId) -> do
SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
ci' <- withStore' $ \db -> do
currentTs <- liftIO getCurrentTime
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId
startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
setActive $ ActiveG gName
pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci')
(CISndMsgContent oldMC, Just itemSharedMId) ->
if mc /= oldMC
then do
SndMessage {msgId} <- sendGroupMessage user gInfo ms (XMsgUpdate itemSharedMId mc (ttl' <$> itemTimed) (justTrue . (live &&) =<< itemLive))
ci' <- withStore' $ \db -> do
currentTs <- liftIO getCurrentTime
addInitialAndNewCIVersions db itemId (chatItemTs' ci, oldMC) (currentTs, mc)
updateGroupChatItem db user groupId ci (CISndMsgContent mc) live $ Just msgId
startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
setActive $ ActiveG gName
pure $ CRChatItemUpdated user (AChatItem SCTGroup SMDSnd (GroupChat gInfo) ci')
else throwChatError CEInvalidChatItemUpdate
_ -> throwChatError CEInvalidChatItemUpdate
CChatItem SMDRcv _ -> throwChatError CEInvalidChatItemUpdate
CTContactRequest -> pure $ chatCmdError (Just user) "not supported"
@ -3324,12 +3330,15 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
updateRcvChatItem = do
cci <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId
case cci of
CChatItem SMDRcv ci@ChatItem {content = CIRcvMsgContent oldMC} -> do
ci' <- withStore' $ \db -> do
addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc)
updateDirectChatItem' db user contactId ci content live $ Just msgId
toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci')
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
CChatItem SMDRcv ci@ChatItem {content = CIRcvMsgContent oldMC} ->
if mc /= oldMC
then do
ci' <- withStore' $ \db -> do
addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc)
updateDirectChatItem' db user contactId ci content live $ Just msgId
toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci')
startUpdatedTimedItemThread user (ChatRef CTDirect contactId) ci ci'
else messageError "x.msg.update: contact attempted invalid message update"
_ -> messageError "x.msg.update: contact attempted invalid message update"
messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m ()
@ -3393,8 +3402,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
updateRcvChatItem = do
cci <- withStore $ \db -> getGroupChatItemBySharedMsgId db user groupId groupMemberId sharedMsgId
case cci of
CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', content = CIRcvMsgContent oldMC} -> do
if sameMemberId memberId m'
CChatItem SMDRcv ci@ChatItem {chatDir = CIGroupRcv m', content = CIRcvMsgContent oldMC} ->
if sameMemberId memberId m' && mc /= oldMC
then do
ci' <- withStore' $ \db -> do
addInitialAndNewCIVersions db (chatItemId' ci) (chatItemTs' ci, oldMC) (brokerTs, mc)
@ -3402,7 +3411,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do
toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci')
setActive $ ActiveG g
startUpdatedTimedItemThread user (ChatRef CTGroup groupId) ci ci'
else messageError "x.msg.update: group member attempted to update a message of another member" -- shouldn't happen now that query includes group member id
else messageError "x.msg.update: group member attempted invalid message update"
_ -> messageError "x.msg.update: group member attempted invalid message update"
groupMessageDelete :: GroupInfo -> GroupMember -> SharedMsgId -> Maybe MemberId -> RcvMessage -> m ()

View File

@ -1044,6 +1044,7 @@ testGroupLiveMessage =
bob <# "#team alice> [LIVE ended] hello there"
cath <# "#team alice> [LIVE ended] hello there"
-- empty live message is also sent instantly
threadDelay 1000000
alice `send` "/live #team"
msgItemId2 <- lastItemId alice
bob <#. "#team alice> [LIVE started]"
@ -1058,13 +1059,13 @@ testGroupLiveMessage =
alice <## "message history:"
alice .<## ": hello 2"
alice .<## ":"
-- bobItemId <- lastItemId bob
-- bob ##> ("/_get item info " <> bobItemId)
-- bob <##. "sent at: "
-- bob <##. "received at: "
-- bob <## "message history:"
-- bob .<## ": hello 2"
-- bob .<## ":"
bobItemId <- lastItemId bob
bob ##> ("/_get item info " <> bobItemId)
bob <##. "sent at: "
bob <##. "received at: "
bob <## "message history:"
bob .<## ": hello 2"
bob .<## ":"
testUpdateGroupProfile :: HasCallStack => FilePath -> IO ()
testUpdateGroupProfile =