From a2de9a384650a3b70e8810939326cbe3a61e46ef Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Thu, 11 May 2023 17:33:23 +0200 Subject: [PATCH 1/8] update typescript client (#2427) * update typescript client * update bot * fix bot --- .../typescript/examples/squaring-bot.js | 9 +- .../typescript/package.json | 6 +- .../typescript/src/client.ts | 32 +- .../typescript/src/command.ts | 346 ++++++++++++++++-- .../typescript/src/response.ts | 135 +++++-- 5 files changed, 465 insertions(+), 63 deletions(-) diff --git a/packages/simplex-chat-client/typescript/examples/squaring-bot.js b/packages/simplex-chat-client/typescript/examples/squaring-bot.js index 8f874b2f46..5a96dfb205 100644 --- a/packages/simplex-chat-client/typescript/examples/squaring-bot.js +++ b/packages/simplex-chat-client/typescript/examples/squaring-bot.js @@ -17,7 +17,7 @@ async function run() { const address = (await chat.apiGetUserAddress()) || (await chat.apiCreateUserAddress()) console.log(`Bot address: ${address}`) // enables automatic acceptance of contact connections - await chat.addressAutoAccept(true) + await chat.enableAddressAutoAccept() await processMessages(chat) async function processMessages(chat) { @@ -40,14 +40,11 @@ async function run() { const {chatInfo} = resp.chatItem if (chatInfo.type !== ChatInfoType.Direct) continue const msg = ciContentText(resp.chatItem.chatItem.content) - let reply if (msg) { const n = +msg - reply = typeof n === "number" ? `${n} * ${n} = ${n * n}` : `${n} is not a number` - } else { - reply = "no message text" + const reply = typeof n === "number" && !isNaN(n) ? `${n} * ${n} = ${n * n}` : `this is not a number` + await chat.apiSendTextMessage(ChatType.Direct, chatInfo.contact.contactId, reply) } - await chat.apiSendTextMessage(ChatType.Direct, chatInfo.contact.contactId, reply) } } } diff --git a/packages/simplex-chat-client/typescript/package.json b/packages/simplex-chat-client/typescript/package.json index e780d2f288..c8aa6b4f1e 100644 --- a/packages/simplex-chat-client/typescript/package.json +++ b/packages/simplex-chat-client/typescript/package.json @@ -1,6 +1,6 @@ { "name": "simplex-chat", - "version": "0.1.1", + "version": "0.2.0", "description": "SimpleX Chat client", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -36,7 +36,7 @@ }, "devDependencies": { "@types/jest": "^27.5.1", - "@types/node": "^17.0.24", + "@types/node": "^18.11.18", "@typescript-eslint/eslint-plugin": "^5.23.0", "@typescript-eslint/parser": "^5.23.0", "eslint": "^8.15.0", @@ -48,7 +48,7 @@ "rollup": "^2.72.1", "ts-jest": "^28.0.2", "ts-node": "^10.7.0", - "typescript": "^4.6.3" + "typescript": "^4.9.3" }, "husky": { "hooks": { diff --git a/packages/simplex-chat-client/typescript/src/client.ts b/packages/simplex-chat-client/typescript/src/client.ts index 3efbbd07d1..eb3e39f00a 100644 --- a/packages/simplex-chat-client/typescript/src/client.ts +++ b/packages/simplex-chat-client/typescript/src/client.ts @@ -105,8 +105,8 @@ export class ChatClient { } } - async apiCreateActiveUser(profile: CC.Profile): Promise { - const r = await this.sendChatCommand({type: "createActiveUser", profile}) + async apiCreateActiveUser(profile?: Profile, sameServers = true, pastTimestamp = false): Promise { + const r = await this.sendChatCommand({type: "createActiveUser", profile, sameServers, pastTimestamp}) if (r.type === "activeUser") return r.user throw new ChatCommandError("unexpected response", r) } @@ -129,15 +129,22 @@ export class ChatClient { return this.okChatCommand({type: "setIncognito", incognito}) } - async addressAutoAccept(autoAccept: boolean, autoReply: CC.MsgContent): Promise { - const r = await this.sendChatCommand({type: "addressAutoAccept", autoAccept, autoReply}) + async enableAddressAutoAccept(acceptIncognito = false, autoReply?: CC.MsgContent): Promise { + const r = await this.sendChatCommand({type: "addressAutoAccept", autoAccept: {acceptIncognito, autoReply}}) if (r.type !== "userContactLinkUpdated") { throw new ChatCommandError("error changing user contact address mode", r) } } - async apiGetChats(): Promise { - const r = await this.sendChatCommand({type: "apiGetChats"}) + async disableAddressAutoAccept(): Promise { + const r = await this.sendChatCommand({type: "addressAutoAccept"}) + if (r.type !== "userContactLinkUpdated") { + throw new ChatCommandError("error changing user contact address mode", r) + } + } + + async apiGetChats(userId: number): Promise { + const r = await this.sendChatCommand({type: "apiGetChats", userId}) if (r.type === "apiChats") return r.chats throw new ChatCommandError("error loading chats", r) } @@ -169,9 +176,14 @@ export class ChatClient { throw new ChatCommandError("error updating chat item", r) } - async apiDeleteChatItem(chatType: ChatType, chatId: number, chatItemId: number, deleteMode: CC.DeleteMode): Promise { + async apiDeleteChatItem( + chatType: ChatType, + chatId: number, + chatItemId: number, + deleteMode: CC.DeleteMode + ): Promise { const r = await this.sendChatCommand({type: "apiDeleteChatItem", chatType, chatId, chatItemId, deleteMode}) - if (r.type === "chatItemDeleted") return r.toChatItem.chatItem + if (r.type === "chatItemDeleted") return r.toChatItem?.chatItem throw new ChatCommandError("error deleting chat item", r) } @@ -215,8 +227,8 @@ export class ChatClient { throw new ChatCommandError("error clearing chat", r) } - async apiUpdateProfile(profile: CC.Profile): Promise { - const r = await this.sendChatCommand({type: "apiUpdateProfile", profile}) + async apiUpdateProfile(userId: number, profile: CC.Profile): Promise { + const r = await this.sendChatCommand({type: "apiUpdateProfile", userId, profile}) switch (r.type) { case "userProfileNoChange": return undefined diff --git a/packages/simplex-chat-client/typescript/src/command.ts b/packages/simplex-chat-client/typescript/src/command.ts index 2694e4d25d..b49a3605b6 100644 --- a/packages/simplex-chat-client/typescript/src/command.ts +++ b/packages/simplex-chat-client/typescript/src/command.ts @@ -1,9 +1,18 @@ export type ChatCommand = | ShowActiveUser | CreateActiveUser + | ListUsers + | APISetActiveUser + | APIHideUser + | APIUnhideUser + | APIMuteUser + | APIUnmuteUser + | APIDeleteUser | StartChat | APIStopChat + | SetTempFolder | SetFilesFolder + | APISetXFTPConfig | SetIncognito | APIExportArchive | APIImportArchive @@ -13,6 +22,7 @@ export type ChatCommand = | APISendMessage | APIUpdateChatItem | APIDeleteChatItem + | APIDeleteMemberChatItem | APIChatRead | APIDeleteChat | APIClearChat @@ -28,17 +38,31 @@ export type ChatCommand = | APILeaveGroup | APIListMembers | APIUpdateGroupProfile - | GetUserSMPServers - | SetUserSMPServers + | APICreateGroupLink + | APIGroupLinkMemberRole + | APIDeleteGroupLink + | APIGetGroupLink + | APIGetUserProtoServers + | APISetUserProtoServers | APIContactInfo | APIGroupMemberInfo + | APIGetContactCode + | APIGetGroupMemberCode + | APIVerifyContact + | APIVerifyGroupMember | AddContact | Connect | ConnectSimplex | CreateMyAddress | DeleteMyAddress | ShowMyAddress + | SetProfileAddress | AddressAutoAccept + | APICreateMyAddress + | APIDeleteMyAddress + | APIShowMyAddress + | APISetProfileAddress + | APIAddressAutoAccept | ReceiveFile | CancelFile | FileStatus @@ -64,6 +88,8 @@ export type ChatCommand = // APIMemberRole -- not implemented // ListContacts // ListGroups +// APISetChatItemTTL +// APIGetChatItemTTL // APISetNetworkConfig // APIGetNetworkConfig // APISetChatSettings @@ -74,9 +100,19 @@ export type ChatCommand = type ChatCommandTag = | "showActiveUser" | "createActiveUser" + | "listUsers" + | "apiSetActiveUser" + | "setActiveUser" + | "apiHideUser" + | "apiUnhideUser" + | "apiMuteUser" + | "apiUnmuteUser" + | "apiDeleteUser" | "startChat" | "apiStopChat" + | "setTempFolder" | "setFilesFolder" + | "apiSetXFTPConfig" | "setIncognito" | "apiExportArchive" | "apiImportArchive" @@ -86,6 +122,7 @@ type ChatCommandTag = | "apiSendMessage" | "apiUpdateChatItem" | "apiDeleteChatItem" + | "apiDeleteMemberChatItem" | "apiChatRead" | "apiDeleteChat" | "apiClearChat" @@ -101,17 +138,31 @@ type ChatCommandTag = | "apiLeaveGroup" | "apiListMembers" | "apiUpdateGroupProfile" - | "getUserSMPServers" - | "setUserSMPServers" + | "apiCreateGroupLink" + | "apiGroupLinkMemberRole" + | "apiDeleteGroupLink" + | "apiGetGroupLink" + | "apiGetUserProtoServers" + | "apiSetUserProtoServers" | "apiContactInfo" | "apiGroupMemberInfo" + | "apiGetContactCode" + | "apiGetGroupMemberCode" + | "apiVerifyContact" + | "apiVerifyGroupMember" | "addContact" | "connect" | "connectSimplex" | "createMyAddress" | "deleteMyAddress" | "showMyAddress" + | "setProfileAddress" | "addressAutoAccept" + | "apiCreateMyAddress" + | "apiDeleteMyAddress" + | "apiShowMyAddress" + | "apiSetProfileAddress" + | "apiAddressAutoAccept" | "receiveFile" | "cancelFile" | "fileStatus" @@ -126,24 +177,80 @@ export interface ShowActiveUser extends IChatCommand { export interface CreateActiveUser extends IChatCommand { type: "createActiveUser" - profile: Profile + profile?: Profile + sameServers: boolean + pastTimestamp: boolean +} + +export interface ListUsers extends IChatCommand { + type: "listUsers" +} + +export interface APISetActiveUser extends IChatCommand { + type: "apiSetActiveUser" + userId: number + viewPwd?: string +} + +export interface APIHideUser extends IChatCommand { + type: "apiHideUser" + userId: number + viewPwd: string +} + +export interface APIUnhideUser extends IChatCommand { + type: "apiUnhideUser" + userId: number + viewPwd: string +} + +export interface APIMuteUser extends IChatCommand { + type: "apiMuteUser" + userId: number +} + +export interface APIUnmuteUser extends IChatCommand { + type: "apiUnmuteUser" + userId: number +} + +export interface APIDeleteUser extends IChatCommand { + type: "apiDeleteUser" + userId: number + delSMPQueues: boolean + viewPwd?: string } export interface StartChat extends IChatCommand { type: "startChat" subscribeConnections?: boolean - expireChatItems?: boolean + enableExpireChatItems?: boolean + startXFTPWorkers?: boolean } export interface APIStopChat extends IChatCommand { type: "apiStopChat" } +export interface SetTempFolder extends IChatCommand { + type: "setTempFolder" + tempFolder: string +} + export interface SetFilesFolder extends IChatCommand { type: "setFilesFolder" filePath: string } +export interface APISetXFTPConfig extends IChatCommand { + type: "apiSetXFTPConfig" + config?: XFTPFileConfig +} + +export interface XFTPFileConfig { + minFileSize: number +} + export interface SetIncognito extends IChatCommand { type: "setIncognito" incognito: boolean @@ -165,6 +272,7 @@ export interface APIDeleteStorage extends IChatCommand { export interface APIGetChats extends IChatCommand { type: "apiGetChats" + userId: number pendingConnections?: boolean } @@ -205,6 +313,13 @@ export interface APIDeleteChatItem extends IChatCommand { deleteMode: DeleteMode } +export interface APIDeleteMemberChatItem extends IChatCommand { + type: "apiDeleteMemberChatItem" + groupId: number + groupMemberId: number + itemId: number +} + export interface APIChatRead extends IChatCommand { type: "apiChatRead" chatType: ChatType @@ -241,6 +356,7 @@ export interface APIRejectContact extends IChatCommand { export interface APIUpdateProfile extends IChatCommand { type: "apiUpdateProfile" + userId: number profile: Profile } @@ -294,13 +410,51 @@ export interface APIUpdateGroupProfile extends IChatCommand { groupProfile: GroupProfile } -export interface GetUserSMPServers extends IChatCommand { - type: "getUserSMPServers" +export interface APICreateGroupLink extends IChatCommand { + type: "apiCreateGroupLink" + groupId: number + memberRole: GroupMemberRole } -export interface SetUserSMPServers extends IChatCommand { - type: "setUserSMPServers" - servers: [string] +export interface APIGroupLinkMemberRole extends IChatCommand { + type: "apiGroupLinkMemberRole" + groupId: number + memberRole: GroupMemberRole +} + +export interface APIDeleteGroupLink extends IChatCommand { + type: "apiDeleteGroupLink" + groupId: number +} + +export interface APIGetGroupLink extends IChatCommand { + type: "apiGetGroupLink" + groupId: number +} + +export interface APIGetUserProtoServers extends IChatCommand { + type: "apiGetUserProtoServers" + userId: number + serverProtocol: ServerProtocol +} + +export interface APISetUserProtoServers extends IChatCommand { + type: "apiSetUserProtoServers" + userId: number + serverProtocol: ServerProtocol + servers: ServerCfg[] +} + +export interface ServerCfg { + server: string + preset: boolean + tested?: boolean + enabled: boolean +} + +export enum ServerProtocol { + SMP = "smp", + XFTP = "xftp", } export interface APIContactInfo extends IChatCommand { @@ -314,6 +468,30 @@ export interface APIGroupMemberInfo extends IChatCommand { memberId: number } +export interface APIGetContactCode extends IChatCommand { + type: "apiGetContactCode" + contactId: number +} + +export interface APIGetGroupMemberCode extends IChatCommand { + type: "apiGetGroupMemberCode" + groupId: number + groupMemberId: number +} + +export interface APIVerifyContact extends IChatCommand { + type: "apiVerifyContact" + contactId: number + connectionCode: string +} + +export interface APIVerifyGroupMember extends IChatCommand { + type: "apiVerifyGroupMember" + groupId: number + groupMemberId: number + connectionCode: string +} + export interface AddContact extends IChatCommand { type: "addContact" } @@ -339,9 +517,45 @@ export interface ShowMyAddress extends IChatCommand { type: "showMyAddress" } +export interface SetProfileAddress extends IChatCommand { + type: "setProfileAddress" + includeInProfile: boolean +} + export interface AddressAutoAccept extends IChatCommand { type: "addressAutoAccept" - autoAccept: boolean + autoAccept?: AutoAccept +} + +export interface APICreateMyAddress extends IChatCommand { + type: "apiCreateMyAddress" + userId: number +} + +export interface APIDeleteMyAddress extends IChatCommand { + type: "apiDeleteMyAddress" + userId: number +} + +export interface APIShowMyAddress extends IChatCommand { + type: "apiShowMyAddress" + userId: number +} + +export interface APISetProfileAddress extends IChatCommand { + type: "apiSetProfileAddress" + userId: number + includeInProfile: boolean +} + +export interface APIAddressAutoAccept extends IChatCommand { + type: "apiAddressAutoAccept" + userId: number + autoAccept?: AutoAccept +} + +export interface AutoAccept { + acceptIncognito: boolean autoReply?: MsgContent } @@ -361,10 +575,28 @@ export interface FileStatus extends IChatCommand { fileId: number } +interface NewUser { + profile?: Profile + sameServers: boolean + pastTimestamp: boolean +} + export interface Profile { displayName: string fullName: string // can be empty string image?: string + contactLink?: string + // preferences?: Preferences +} + +export interface LocalProfile { + profileId: number + displayName: string + fullName: string + image?: string + contactLink?: string + // preferences?: Preferences + localAlias: string } export enum ChatType { @@ -449,16 +681,36 @@ export function cmdString(cmd: ChatCommand): string { switch (cmd.type) { case "showActiveUser": return "/u" - case "createActiveUser": - return `/create user ${JSON.stringify(cmd.profile)}` + case "createActiveUser": { + const user: NewUser = {profile: cmd.profile, sameServers: cmd.sameServers, pastTimestamp: cmd.pastTimestamp} + return `/_create user ${JSON.stringify(user)}` + } + case "listUsers": + return `/users` + case "apiSetActiveUser": + return `/_user ${cmd.userId}${maybeJSON(cmd.viewPwd)}` + case "apiHideUser": + return `/_hide user ${cmd.userId} ${JSON.stringify(cmd.viewPwd)}` + case "apiUnhideUser": + return `/_unhide user ${cmd.userId} ${JSON.stringify(cmd.viewPwd)}` + case "apiMuteUser": + return `/_mute user ${cmd.userId}` + case "apiUnmuteUser": + return `/_unmute user ${cmd.userId}` + case "apiDeleteUser": + return `/_delete user ${cmd.userId} del_smp=${onOff(cmd.delSMPQueues)}${maybeJSON(cmd.viewPwd)}` case "startChat": - return `/_start subscribe=${cmd.subscribeConnections ? "on" : "off"} expire=${cmd.expireChatItems ? "on" : "off"}` + return `/_start subscribe=${cmd.subscribeConnections ? "on" : "off"} expire=${cmd.enableExpireChatItems ? "on" : "off"}` case "apiStopChat": return "/_stop" + case "setTempFolder": + return `/_temp_folder ${cmd.tempFolder}` case "setFilesFolder": return `/_files_folder ${cmd.filePath}` + case "apiSetXFTPConfig": + return `/_xftp ${onOff(cmd.config)}${maybeJSON(cmd.config)}` case "setIncognito": - return `/incognito ${cmd.incognito ? "on" : "off"}` + return `/incognito ${onOff(cmd.incognito)}` case "apiExportArchive": return `/_db export ${JSON.stringify(cmd.config)}` case "apiImportArchive": @@ -466,7 +718,7 @@ export function cmdString(cmd: ChatCommand): string { case "apiDeleteStorage": return "/_db delete" case "apiGetChats": - return `/_get chats pcc=${cmd.pendingConnections ? "on" : "off"}` + return `/_get chats pcc=${onOff(cmd.pendingConnections)}` case "apiGetChat": return `/_get chat ${cmd.chatType}${cmd.chatId}${paginationStr(cmd.pagination)}` case "apiSendMessage": @@ -475,6 +727,8 @@ export function cmdString(cmd: ChatCommand): string { return `/_update item ${cmd.chatType}${cmd.chatId} ${cmd.chatItemId} json ${JSON.stringify(cmd.msgContent)}` case "apiDeleteChatItem": return `/_delete item ${cmd.chatType}${cmd.chatId} ${cmd.chatItemId} ${cmd.deleteMode}` + case "apiDeleteMemberChatItem": + return `/_delete member item #${cmd.groupId} ${cmd.groupMemberId} ${cmd.itemId}` case "apiChatRead": { const itemRange = cmd.itemRange ? ` from=${cmd.itemRange.fromItem} to=${cmd.itemRange.toItem}` : "" return `/_read chat ${cmd.chatType}${cmd.chatId}${itemRange}` @@ -488,7 +742,7 @@ export function cmdString(cmd: ChatCommand): string { case "apiRejectContact": return `/_reject ${cmd.contactReqId}` case "apiUpdateProfile": - return `/_profile ${JSON.stringify(cmd.profile)}` + return `/_profile ${cmd.userId} ${JSON.stringify(cmd.profile)}` case "apiSetContactAlias": return `/_set alias @${cmd.contactId} ${cmd.localAlias.trim()}` case "apiParseMarkdown": @@ -507,14 +761,30 @@ export function cmdString(cmd: ChatCommand): string { return `/_members #${cmd.groupId}` case "apiUpdateGroupProfile": return `/_group_profile #${cmd.groupId} ${JSON.stringify(cmd.groupProfile)}` - case "getUserSMPServers": - return "/smp_servers" - case "setUserSMPServers": - return `/smp_servers ${cmd.servers.join(",") || "default"}` + case "apiCreateGroupLink": + return `/_create link #${cmd.groupId} ${cmd.memberRole}` + case "apiGroupLinkMemberRole": + return `/_set link role #${cmd.groupId} ${cmd.memberRole}` + case "apiDeleteGroupLink": + return `/_delete link #${cmd.groupId}` + case "apiGetGroupLink": + return `/_get link #${cmd.groupId}` + case "apiGetUserProtoServers": + return `/_servers ${cmd.userId} ${cmd.serverProtocol}` + case "apiSetUserProtoServers": + return `/_servers ${cmd.userId} ${cmd.serverProtocol} ${JSON.stringify({servers: cmd.servers})}` case "apiContactInfo": return `/_info @${cmd.contactId}` case "apiGroupMemberInfo": return `/_info #${cmd.groupId} ${cmd.memberId}` + case "apiGetContactCode": + return `/_get code @${cmd.contactId}` + case "apiGetGroupMemberCode": + return `/_get code #${cmd.groupId} ${cmd.groupMemberId}` + case "apiVerifyContact": + return `/_verify code @${cmd.contactId}${maybe(cmd.connectionCode)}` + case "apiVerifyGroupMember": + return `/_verify code #${cmd.groupId} ${cmd.groupMemberId}${maybe(cmd.connectionCode)}` case "addContact": return "/connect" case "connect": @@ -527,8 +797,20 @@ export function cmdString(cmd: ChatCommand): string { return "/delete_address" case "showMyAddress": return "/show_address" + case "setProfileAddress": + return `/profile_address ${onOff(cmd.includeInProfile)}` case "addressAutoAccept": - return `/auto_accept ${cmd.autoAccept ? "on" : "off"}${cmd.autoReply ? " " + JSON.stringify(cmd.autoReply) : ""}` + return `/auto_accept ${autoAcceptStr(cmd.autoAccept)}` + case "apiCreateMyAddress": + return `/_address ${cmd.userId}` + case "apiDeleteMyAddress": + return `/_delete_address ${cmd.userId}` + case "apiShowMyAddress": + return `/_show_address ${cmd.userId}` + case "apiSetProfileAddress": + return `/_profile_address ${cmd.userId} ${onOff(cmd.includeInProfile)}` + case "apiAddressAutoAccept": + return `/_auto_accept ${cmd.userId} ${autoAcceptStr(cmd.autoAccept)}` case "receiveFile": return `/freceive ${cmd.fileId}${cmd.filePath ? " " + cmd.filePath : ""}` case "cancelFile": @@ -542,3 +824,21 @@ function paginationStr(cp: ChatPagination): string { const base = "after" in cp ? ` after=${cp.after}` : "before" in cp ? ` before=${cp.before}` : "" return base + ` count=${cp.count}` } + +function maybe(value: T | undefined): string { + return value ? ` ${value}` : "" +} + +function maybeJSON(value: T | undefined): string { + return value ? ` json ${JSON.stringify(value)}` : "" +} + +function onOff(value: T | undefined): string { + return value ? "on" : "off" +} + +function autoAcceptStr(autoAccept: AutoAccept | undefined): string { + if (!autoAccept) return "off" + const msg = autoAccept.autoReply + return "on" + (autoAccept.acceptIncognito ? " incognito=on" : "") + (msg ? " json " + JSON.stringify(msg) : "") +} diff --git a/packages/simplex-chat-client/typescript/src/response.ts b/packages/simplex-chat-client/typescript/src/response.ts index 3de336c054..0e1b2799b1 100644 --- a/packages/simplex-chat-client/typescript/src/response.ts +++ b/packages/simplex-chat-client/typescript/src/response.ts @@ -1,14 +1,15 @@ -import {ChatItemId, MsgContent, DeleteMode, Profile, GroupMemberRole} from "./command" +import {ChatItemId, MsgContent, DeleteMode, Profile, GroupMemberRole, LocalProfile, ServerProtocol, ServerCfg} from "./command" export type ChatResponse = | CRActiveUser + | CRUsersList | CRChatStarted | CRChatRunning | CRChatStopped | CRApiChats | CRApiChat | CRApiParsedMarkdown - | CRUserSMPServers + | CRUserProtoServers | CRContactInfo | CRGroupMemberInfo | CRNewChatItem @@ -40,8 +41,6 @@ export type ChatResponse = | CRContactConnecting | CRContactConnected | CRContactAnotherClient - | CRContactDisconnected - | CRContactSubscribed | CRContactSubError | CRContactSubSummary | CRContactsDisconnected @@ -101,13 +100,14 @@ export type ChatResponse = type ChatResponseTag = | "activeUser" + | "usersList" | "chatStarted" | "chatRunning" | "chatStopped" | "apiChats" | "apiChat" | "apiParsedMarkdown" - | "userSMPServers" + | "userProtoServers" | "contactInfo" | "groupMemberInfo" | "newChatItem" @@ -139,8 +139,6 @@ type ChatResponseTag = | "contactConnecting" | "contactConnected" | "contactAnotherClient" - | "contactDisconnected" - | "contactSubscribed" | "contactSubError" | "contactSubSummary" | "contactsDisconnected" @@ -202,6 +200,11 @@ export interface CRActiveUser extends CR { user: User } +export interface CRUsersList extends CR { + type: "usersList" + users: UserInfo[] +} + export interface CRChatStarted extends CR { type: "chatStarted" } @@ -216,11 +219,13 @@ export interface CRChatStopped extends CR { export interface CRApiChats extends CR { type: "apiChats" + user: User chats: Chat[] } export interface CRApiChat extends CR { type: "apiChat" + user: User chat: Chat } @@ -229,13 +234,15 @@ export interface CRApiParsedMarkdown extends CR { formattedText?: FormattedText[] } -export interface CRUserSMPServers extends CR { - type: "userSMPServers" - smpServers: string[] +export interface CRUserProtoServers extends CR { + type: "userProtoServers" + user: User + servers: UserProtoServers } export interface CRContactInfo extends CR { type: "contactInfo" + user: User contact: Contact connectionStats: ConnectionStats customUserProfile?: Profile @@ -243,6 +250,7 @@ export interface CRContactInfo extends CR { export interface CRGroupMemberInfo extends CR { type: "groupMemberInfo" + user: User groupInfo: GroupInfo member: GroupMember connectionStats_?: ConnectionStats @@ -250,21 +258,25 @@ export interface CRGroupMemberInfo extends CR { export interface CRNewChatItem extends CR { type: "newChatItem" + user: User chatItem: AChatItem } export interface CRChatItemStatusUpdated extends CR { type: "chatItemStatusUpdated" + user: User chatItem: AChatItem } export interface CRChatItemUpdated extends CR { type: "chatItemUpdated" + user: User chatItem: AChatItem } export interface CRChatItemDeleted extends CR { type: "chatItemDeleted" + user: User deletedChatItem: AChatItem toChatItem?: AChatItem byUser: boolean @@ -272,20 +284,24 @@ export interface CRChatItemDeleted extends CR { export interface CRMsgIntegrityError extends CR { type: "msgIntegrityError" + user: User msgError: MsgErrorType } export interface CRCmdOk extends CR { type: "cmdOk" + user_?: User } export interface CRUserContactLink extends CR { type: "userContactLink" + user: User contactLink: UserContactLink } export interface CRUserContactLinkUpdated extends CR { type: "userContactLinkUpdated" + user: User connReqContact: string autoAccept: boolean autoReply?: MsgContent @@ -293,138 +309,153 @@ export interface CRUserContactLinkUpdated extends CR { export interface CRContactRequestRejected extends CR { type: "contactRequestRejected" + user: User contactRequest: UserContactRequest } export interface CRUserProfile extends CR { type: "userProfile" + user: User profile: Profile } export interface CRUserProfileNoChange extends CR { type: "userProfileNoChange" + user: User } export interface CRUserProfileUpdated extends CR { type: "userProfileUpdated" + user: User fromProfile: Profile toProfile: Profile } export interface CRContactAliasUpdated extends CR { type: "contactAliasUpdated" + user: User toContact: Contact } export interface CRInvitation extends CR { type: "invitation" + user: User connReqInvitation: string } export interface CRSentConfirmation extends CR { type: "sentConfirmation" + user: User } export interface CRSentInvitation extends CR { type: "sentInvitation" + user: User } export interface CRContactUpdated extends CR { type: "contactUpdated" + user: User fromContact: Contact toContact: Contact } export interface CRContactsMerged extends CR { type: "contactsMerged" + user: User intoContact: Contact mergedContact: Contact } export interface CRContactDeleted extends CR { type: "contactDeleted" + user: User contact: Contact } export interface CRChatCleared extends CR { type: "chatCleared" + user: User chatInfo: ChatInfo } export interface CRUserContactLinkCreated extends CR { type: "userContactLinkCreated" + user: User connReqContact: string } export interface CRUserContactLinkDeleted extends CR { type: "userContactLinkDeleted" + user: User } export interface CRReceivedContactRequest extends CR { type: "receivedContactRequest" + user: User contactRequest: UserContactRequest } export interface CRAcceptingContactRequest extends CR { type: "acceptingContactRequest" + user: User contact: Contact } export interface CRContactAlreadyExists extends CR { type: "contactAlreadyExists" + user: User contact: Contact } export interface CRContactRequestAlreadyAccepted extends CR { type: "contactRequestAlreadyAccepted" + user: User contact: Contact } export interface CRContactConnecting extends CR { type: "contactConnecting" + user: User contact: Contact } export interface CRContactConnected extends CR { type: "contactConnected" contact: Contact + user: User userCustomProfile?: Profile } export interface CRContactAnotherClient extends CR { type: "contactAnotherClient" - contact: Contact -} - -export interface CRContactDisconnected extends CR { - type: "contactDisconnected" - contact: Contact -} - -export interface CRContactSubscribed extends CR { - type: "contactSubscribed" + user: User contact: Contact } export interface CRContactSubError extends CR { type: "contactSubError" + user: User contact: Contact chatError: ChatError } export interface CRContactSubSummary extends CR { type: "contactSubSummary" + user: User contactSubscriptions: ContactSubStatus[] } export interface CRContactsDisconnected extends CR { type: "contactsDisconnected" + user: User server: string contactRefs: ContactRef[] } export interface CRContactsSubscribed extends CR { type: "contactsSubscribed" + user: User server: string contactRefs: ContactRef[] } @@ -443,11 +474,13 @@ export interface CRHostDisconnected extends CR { export interface CRGroupEmpty extends CR { type: "groupEmpty" + user: User groupInfo: GroupInfo } export interface CRMemberSubError extends CR { type: "memberSubError" + user: User groupInfo: GroupInfo member: GroupMember chatError: ChatError @@ -455,70 +488,83 @@ export interface CRMemberSubError extends CR { export interface CRMemberSubSummary extends CR { type: "memberSubSummary" + user: User memberSubscriptions: MemberSubStatus[] } export interface CRGroupSubscribed extends CR { type: "groupSubscribed" + user: User groupInfo: GroupInfo } export interface CRRcvFileAccepted extends CR { type: "rcvFileAccepted" + user: User chatItem: AChatItem } export interface CRRcvFileAcceptedSndCancelled extends CR { type: "rcvFileAcceptedSndCancelled" + user: User rcvFileTransfer: RcvFileTransfer } export interface CRRcvFileStart extends CR { type: "rcvFileStart" + user: User chatItem: AChatItem } export interface CRRcvFileComplete extends CR { type: "rcvFileComplete" + user: User chatItem: AChatItem } export interface CRRcvFileCancelled extends CR { type: "rcvFileCancelled" + user: User rcvFileTransfer: RcvFileTransfer } export interface CRRcvFileSndCancelled extends CR { type: "rcvFileSndCancelled" + user: User rcvFileTransfer: RcvFileTransfer } export interface CRSndFileStart extends CR { type: "sndFileStart" + user: User chatItem: AChatItem sndFileTransfer: SndFileTransfer } export interface CRSndFileComplete extends CR { type: "sndFileComplete" + user: User chatItem: AChatItem sndFileTransfer: SndFileTransfer } export interface CRSndFileCancelled extends CR { type: "sndFileCancelled" + user: User chatItem: AChatItem sndFileTransfer: SndFileTransfer } export interface CRSndFileRcvCancelled extends CR { type: "sndFileRcvCancelled" + user: User chatItem: AChatItem sndFileTransfer: SndFileTransfer } export interface CRSndGroupFileCancelled extends CR { type: "sndGroupFileCancelled" + user: User chatItem: AChatItem fileTransferMeta: FileTransferMeta sndFileTransfers: SndFileTransfer[] @@ -526,45 +572,53 @@ export interface CRSndGroupFileCancelled extends CR { export interface CRSndFileSubError extends CR { type: "sndFileSubError" + user: User sndFileTransfer: SndFileTransfer chatError: ChatError } export interface CRRcvFileSubError extends CR { type: "rcvFileSubError" + user: User rcvFileTransfer: RcvFileTransfer chatError: ChatError } export interface CRPendingSubSummary extends CR { type: "pendingSubSummary" + user: User pendingSubStatus: PendingSubStatus[] } export interface CRGroupCreated extends CR { type: "groupCreated" + user: User groupInfo: GroupInfo } export interface CRGroupMembers extends CR { type: "groupMembers" + user: User group: Group } export interface CRUserAcceptedGroupSent extends CR { type: "userAcceptedGroupSent" + user: User groupInfo: GroupInfo hostContact?: Contact // included when joining group via group link } export interface CRUserDeletedMember extends CR { type: "userDeletedMember" + user: User groupInfo: GroupInfo member: GroupMember } export interface CRSentGroupInvitation extends CR { type: "sentGroupInvitation" + user: User groupInfo: GroupInfo contact: Contact member: GroupMember @@ -572,21 +626,25 @@ export interface CRSentGroupInvitation extends CR { export interface CRLeftMemberUser extends CR { type: "leftMemberUser" + user: User groupInfo: GroupInfo } export interface CRGroupDeletedUser extends CR { type: "groupDeletedUser" + user: User groupInfo: GroupInfo } export interface CRGroupInvitation extends CR { type: "groupInvitation" + user: User groupInfo: GroupInfo } export interface CRReceivedGroupInvitation extends CR { type: "receivedGroupInvitation" + user: User groupInfo: GroupInfo contact: Contact memberRole: GroupMemberRole @@ -594,18 +652,21 @@ export interface CRReceivedGroupInvitation extends CR { export interface CRUserJoinedGroup extends CR { type: "userJoinedGroup" + user: User groupInfo: GroupInfo hostMember: GroupMember } export interface CRJoinedGroupMember extends CR { type: "joinedGroupMember" + user: User groupInfo: GroupInfo member: GroupMember } export interface CRJoinedGroupMemberConnecting extends CR { type: "joinedGroupMemberConnecting" + user: User groupInfo: GroupInfo hostMember: GroupMember member: GroupMember @@ -613,12 +674,14 @@ export interface CRJoinedGroupMemberConnecting extends CR { export interface CRConnectedToGroupMember extends CR { type: "connectedToGroupMember" + user: User groupInfo: GroupInfo member: GroupMember } export interface CRDeletedMember extends CR { type: "deletedMember" + user: User groupInfo: GroupInfo byMember: GroupMember deletedMember: GroupMember @@ -626,29 +689,34 @@ export interface CRDeletedMember extends CR { export interface CRDeletedMemberUser extends CR { type: "deletedMemberUser" + user: User groupInfo: GroupInfo member: GroupMember } export interface CRLeftMember extends CR { type: "leftMember" + user: User groupInfo: GroupInfo member: GroupMember } export interface CRGroupRemoved extends CR { type: "groupRemoved" + user: User groupInfo: GroupInfo } export interface CRGroupDeleted extends CR { type: "groupDeleted" + user: User groupInfo: GroupInfo member: GroupMember } export interface CRGroupUpdated extends CR { type: "groupUpdated" + user: User fromGroup: GroupInfo toGroup: GroupInfo member_?: GroupMember @@ -665,36 +733,51 @@ export interface CRUserContactLinkSubError extends CR { export interface CRNewContactConnection extends CR { type: "newContactConnection" + user: User connection: PendingContactConnection } export interface CRContactConnectionDeleted extends CR { type: "contactConnectionDeleted" + user: User connection: PendingContactConnection } export interface CRMessageError extends CR { type: "messageError" + user: User severity: string errorMessage: string } export interface CRChatCmdError extends CR { type: "chatCmdError" + user_?: User chatError: ChatError } export interface CRChatError extends CR { type: "chatError" + user_?: User chatError: ChatError } export interface User { userId: number + agentUserId: string userContactId: number localDisplayName: string - profile: Profile + profile: LocalProfile + // fullPreferences :: FullPreferences activeUser: boolean + viewPwdHash: string + showNtfs: boolean +} + +export interface UserProtoServers { + serverProtocol: ServerProtocol + protoServers: ServerCfg[] + presetServers: string } export interface Chat { @@ -730,6 +813,16 @@ interface CInfoContactRequest extends IChatInfo { contactRequest: UserContactRequest } +export interface UserPwdHash { + hash: string + salt: string +} + +interface UserInfo { + user: User + unreadCount: number +} + export interface Contact { contactId: number localDisplayName: string From 0cfc9fd1faf135b2ec5ba3a01faa30d047899f87 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Sat, 13 May 2023 11:22:31 +0200 Subject: [PATCH 2/8] mobile: enable calls preference (#2436) * ios: enable calls preference * android: enable calls preference --- .../java/chat/simplex/app/views/chat/ContactPreferences.kt | 3 +-- .../java/chat/simplex/app/views/usersettings/Preferences.kt | 3 +-- apps/ios/Shared/Views/Chat/ContactPreferencesView.swift | 4 ++-- apps/ios/Shared/Views/UserSettings/PreferencesView.swift | 4 ++-- 4 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt index c5fa9daa57..006eb77151 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/ContactPreferences.kt @@ -139,7 +139,6 @@ private fun FeatureSection( ContactFeatureAllowed.values(userDefault).map { it to it.text }, allowFeature, icon = null, - enabled = remember { mutableStateOf(feature != ChatFeature.Calls) }, onSelected = onSelected ) InfoRow( @@ -147,7 +146,7 @@ private fun FeatureSection( pref.contactPreference.allow.text ) } - SectionTextFooter(feature.enabledDescription(enabled) + (if (feature == ChatFeature.Calls) generalGetString(R.string.available_in_v51) else "")) + SectionTextFooter(feature.enabledDescription(enabled)) } @Composable diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt index ad0537331b..702156a948 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/usersettings/Preferences.kt @@ -99,11 +99,10 @@ private fun FeatureSection(feature: ChatFeature, allowFeature: State some View { - (Text(feature.enabledDescription(enabled)) + (feature == .calls ? Text("\nAvailable in v5.1").bold() : Text(""))) + Text(feature.enabledDescription(enabled)) .frame(height: 36, alignment: .topLeading) } diff --git a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift index 4869c3bde3..d78fe421d6 100644 --- a/apps/ios/Shared/Views/UserSettings/PreferencesView.swift +++ b/apps/ios/Shared/Views/UserSettings/PreferencesView.swift @@ -21,7 +21,7 @@ struct PreferencesView: View { timedMessagesFeatureSection($preferences.timedMessages.allow) featureSection(.fullDelete, $preferences.fullDelete.allow) featureSection(.voice, $preferences.voice.allow) - featureSection(.calls, $preferences.calls.allow).disabled(true) + featureSection(.calls, $preferences.calls.allow) Section { Button("Reset") { preferences = currentPreferences } @@ -61,7 +61,7 @@ struct PreferencesView: View { } private func featureFooter(_ feature: ChatFeature, _ allowFeature: Binding) -> some View { - (Text(feature.allowDescription(allowFeature.wrappedValue)) + (feature == .calls ? Text("\nAvailable in v5.1").bold() : Text(""))) + Text(feature.allowDescription(allowFeature.wrappedValue)) .frame(height: 36, alignment: .topLeading) } From 0ec2468dce0b79cc87ce2c5ca33569972693c25d Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Sun, 14 May 2023 20:07:34 +0300 Subject: [PATCH 3/8] ios: slider for voice messages (#2432) * ios: slider for voice messages * better layout hiding and showing * properly stop playback when other media started * better layout * change padding * code style * refactor --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/AudioRecPlay.swift | 16 +++- .../Views/Chat/ChatItem/CIVoiceView.swift | 77 ++++++++++++++----- .../Chat/ChatItem/FramedCIVoiceView.swift | 36 ++++++++- .../Views/Chat/ChatItem/FramedItemView.swift | 66 ++++++++-------- .../Shared/Views/Chat/ChatItemInfoView.swift | 2 +- apps/ios/Shared/Views/Chat/ChatItemView.swift | 20 ++++- apps/ios/Shared/Views/Chat/ChatView.swift | 36 +++++++-- .../ComposeMessage/ComposeVoiceView.swift | 27 +++++-- .../Shared/Views/Helpers/ContextMenu.swift | 11 +-- 9 files changed, 214 insertions(+), 77 deletions(-) diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift index 778b30772f..5d4eb41d20 100644 --- a/apps/ios/Shared/Model/AudioRecPlay.swift +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -102,11 +102,14 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { self.onFinishPlayback = onFinishPlayback } - func start(fileName: String) { + func start(fileName: String, at: TimeInterval?) { let url = getAppFilePath(fileName) audioPlayer = try? AVAudioPlayer(contentsOf: url) audioPlayer?.delegate = self audioPlayer?.prepareToPlay() + if let at = at { + audioPlayer?.currentTime = at + } audioPlayer?.play() playbackTimer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in @@ -125,6 +128,17 @@ class AudioPlayer: NSObject, AVAudioPlayerDelegate { audioPlayer?.play() } + func seek(_ to: TimeInterval) { + if audioPlayer?.isPlaying == true { + audioPlayer?.pause() + audioPlayer?.currentTime = to + audioPlayer?.play() + } else { + audioPlayer?.currentTime = to + } + self.onTimer?(to) + } + func stop() { if let player = audioPlayer { player.stop() diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 374d69a57c..11f8781c53 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -13,14 +13,20 @@ struct CIVoiceView: View { var chatItem: ChatItem let recordingFile: CIFile? let duration: Int - @State var playbackState: VoiceMessagePlaybackState = .noPlayback - @State var playbackTime: TimeInterval? + @Binding var audioPlayer: AudioPlayer? + @Binding var playbackState: VoiceMessagePlaybackState + @Binding var playbackTime: TimeInterval? + @Binding var allowMenu: Bool + @State private var seek: (TimeInterval) -> Void = { _ in } var body: some View { Group { if chatItem.chatDir.sent { VStack (alignment: .trailing, spacing: 6) { HStack { + if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { + playbackSlider() + } playerTime() player() } @@ -32,13 +38,16 @@ struct CIVoiceView: View { HStack { player() playerTime() + if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { + playbackSlider() + } } .frame(alignment: .leading) metaView().padding(.leading, -6) } } } - .padding([.top, .horizontal], 4) + .padding(.top, 4) .padding(.bottom, 8) } @@ -48,8 +57,11 @@ struct CIVoiceView: View { recordingFile: recordingFile, recordingTime: TimeInterval(duration), showBackground: true, + seek: $seek, + audioPlayer: $audioPlayer, playbackState: $playbackState, - playbackTime: $playbackTime + playbackTime: $playbackTime, + allowMenu: $allowMenu ) } @@ -61,6 +73,22 @@ struct CIVoiceView: View { ) .foregroundColor(.secondary) } + + private func playbackSlider() -> some View { + ComposeVoiceView.SliderBar( + length: TimeInterval(duration), + progress: $playbackTime, + seek: { + let time = max(0.0001, $0) + seek(time) + playbackTime = time + }) + .onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in + if !show { + allowMenu = true + } + } + } private func metaView() -> some View { CIMetaView(chatItem: chatItem) @@ -95,10 +123,11 @@ struct VoiceMessagePlayer: View { var recordingTime: TimeInterval var showBackground: Bool - @State private var audioPlayer: AudioPlayer? + @Binding var seek: (TimeInterval) -> Void + @Binding var audioPlayer: AudioPlayer? @Binding var playbackState: VoiceMessagePlaybackState @Binding var playbackTime: TimeInterval? - @State private var startingPlayback: Bool = false + @Binding var allowMenu: Bool var body: some View { ZStack { @@ -120,18 +149,24 @@ struct VoiceMessagePlayer: View { playPauseIcon("play.fill", Color(uiColor: .tertiaryLabel)) } } - .onDisappear { - audioPlayer?.stop() + .onAppear { + seek = { to in audioPlayer?.seek(to) } + audioPlayer?.onTimer = { playbackTime = $0 } + audioPlayer?.onFinishPlayback = { + playbackState = .noPlayback + playbackTime = TimeInterval(0) + } } - .onChange(of: chatModel.stopPreviousRecPlay) { _ in - if !startingPlayback { + .onChange(of: chatModel.stopPreviousRecPlay) { it in + if let recordingFileName = getLoadedFileName(recordingFile), chatModel.stopPreviousRecPlay != getAppFilePath(recordingFileName) { audioPlayer?.stop() playbackState = .noPlayback playbackTime = TimeInterval(0) - } else { - startingPlayback = false } } + .onChange(of: playbackState) { state in + allowMenu = state == .paused || state == .noPlayback + } } @ViewBuilder private func playbackButton() -> some View { @@ -204,7 +239,6 @@ struct VoiceMessagePlayer: View { } private func startPlayback(_ recordingFileName: String) { - startingPlayback = true chatModel.stopPreviousRecPlay = getAppFilePath(recordingFileName) audioPlayer = AudioPlayer( onTimer: { playbackTime = $0 }, @@ -213,8 +247,7 @@ struct VoiceMessagePlayer: View { playbackTime = TimeInterval(0) } ) - audioPlayer?.start(fileName: recordingFileName) - playbackTime = TimeInterval(0) + audioPlayer?.start(fileName: recordingFileName, at: playbackTime) playbackState = .playing } } @@ -240,13 +273,15 @@ struct CIVoiceView_Previews: PreviewProvider { chatItem: ChatItem.getVoiceMsgContentSample(), recordingFile: CIFile.getSample(fileName: "voice.m4a", fileSize: 65536, fileStatus: .rcvComplete), duration: 30, - playbackState: .playing, - playbackTime: TimeInterval(20) + audioPlayer: .constant(nil), + playbackState: .constant(.playing), + playbackTime: .constant(TimeInterval(20)), + allowMenu: Binding.constant(true) ) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false)) - ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false)) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: sentVoiceMessage, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getVoiceMsgContentSample(fileStatus: .rcvTransfer(rcvProgress: 7, rcvTotal: 10)), revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + ChatItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: voiceMessageWtFile, revealed: Binding.constant(false), allowMenu: .constant(true), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 360)) .environmentObject(Chat.sampleData) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift index 34c3ecb4a8..4446131a75 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedCIVoiceView.swift @@ -15,9 +15,15 @@ struct FramedCIVoiceView: View { var chatItem: ChatItem let recordingFile: CIFile? let duration: Int - @State var playbackState: VoiceMessagePlaybackState = .noPlayback - @State var playbackTime: TimeInterval? - + + @Binding var allowMenu: Bool + + @Binding var audioPlayer: AudioPlayer? + @Binding var playbackState: VoiceMessagePlaybackState + @Binding var playbackTime: TimeInterval? + + @State private var seek: (TimeInterval) -> Void = { _ in } + var body: some View { HStack { VoiceMessagePlayer( @@ -25,8 +31,11 @@ struct FramedCIVoiceView: View { recordingFile: recordingFile, recordingTime: TimeInterval(duration), showBackground: false, + seek: $seek, + audioPlayer: $audioPlayer, playbackState: $playbackState, - playbackTime: $playbackTime + playbackTime: $playbackTime, + allowMenu: $allowMenu ) VoiceMessagePlayerTime( recordingTime: TimeInterval(duration), @@ -35,12 +44,31 @@ struct FramedCIVoiceView: View { ) .foregroundColor(.secondary) .frame(width: 50, alignment: .leading) + if .playing == playbackState || (playbackTime ?? 0) > 0 || !allowMenu { + playbackSlider() + } } .padding(.top, 6) .padding(.leading, 6) .padding(.trailing, 12) .padding(.bottom, chatItem.content.text.isEmpty ? 10 : 0) } + + private func playbackSlider() -> some View { + ComposeVoiceView.SliderBar( + length: TimeInterval(duration), + progress: $playbackTime, + seek: { + let time = max(0.0001, $0) + seek(time) + playbackTime = time + }) + .onChange(of: .playing == playbackState || (playbackTime ?? 0) > 0) { show in + if !show { + allowMenu = true + } + } + } } struct FramedCIVoiceView_Previews: PreviewProvider { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index ac9b7236cf..2841df808a 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -26,7 +26,12 @@ struct FramedItemView: View { @State var videoWidth: CGFloat? = nil @State var metaColor = Color.secondary @State var showFullScreenImage = false + @Binding var allowMenu: Bool + @Binding var audioPlayer: AudioPlayer? + @Binding var playbackState: VoiceMessagePlaybackState + @Binding var playbackTime: TimeInterval? + var body: some View { let v = ZStack(alignment: .bottomTrailing) { VStack(alignment: .leading, spacing: 0) { @@ -118,7 +123,7 @@ struct FramedItemView: View { ciMsgContentView (chatItem, showMember) } case let .voice(text, duration): - FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration) + FramedCIVoiceView(chatItem: chatItem, recordingFile: chatItem.file, duration: duration, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) .overlay(DetermineWidth()) if text != "" { ciMsgContentView (chatItem, showMember) @@ -214,7 +219,8 @@ struct FramedItemView: View { ciQuotedMsgView(qi) } } - .overlay(DetermineWidth()) + // if enable this always, size of the framed voice message item will be incorrect after end of playback + .overlay { if case .voice = chatItem.content.msgContent {} else { DetermineWidth() } } .frame(minWidth: msgWidth, alignment: .leading) .background(chatItemFrameContextColor(chatItem, colorScheme)) @@ -345,14 +351,14 @@ func chatItemFrameContextColor(_ ci: ChatItem, _ colorScheme: ColorScheme) -> Co struct FramedItemView_Previews: PreviewProvider { static var previews: some View { Group{ - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello")) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd))) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv))) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv))) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -")) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ")) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat")) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat")) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv)), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line "), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat"), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -361,16 +367,16 @@ struct FramedItemView_Previews: PreviewProvider { struct FramedItemView_Edited_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemEdited: true), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } @@ -379,16 +385,16 @@ struct FramedItemView_Edited_Previews: PreviewProvider { struct FramedItemView_Deleted_Previews: PreviewProvider { static var previews: some View { Group { - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted)) - FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent, itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directSnd), itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "https://simplex.chat", .sndSent, quotedItem: CIQuote.getSample(1, .now, "hi", chatDir: .directRcv), itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directSnd, .now, "👍", .sndSent, quotedItem: CIQuote.getSample(1, .now, "Hello too", chatDir: .directRcv), itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this covers -", .rcvRead, itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "hello there too!!! this text has the time on the same line ", .rcvRead, itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "https://simplex.chat", .rcvRead, itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(2, .directRcv, .now, "chaT@simplex.chat", .rcvRead, itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello", quotedItem: CIQuote.getSample(1, .now, "hi there hello hello hello ther hello hello", chatDir: .directSnd, image: "data:image/jpg;base64,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"), itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) + FramedItemView(chatInfo: ChatInfo.sampleData.direct, chatItem: ChatItem.getSample(1, .groupRcv(groupMember: GroupMember.sampleData), .now, "hello there this is a long text", quotedItem: CIQuote.getSample(1, .now, "hi there", chatDir: .directSnd, image: "data:image/jpg;base64,/9j/4AAQSkZJRgABAQAASABIAAD/4QBYRXhpZgAATU0AKgAAAAgAAgESAAMAAAABAAEAAIdpAAQAAAABAAAAJgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAuKADAAQAAAABAAAAYAAAAAD/7QA4UGhvdG9zaG9wIDMuMAA4QklNBAQAAAAAAAA4QklNBCUAAAAAABDUHYzZjwCyBOmACZjs+EJ+/8AAEQgAYAC4AwEiAAIRAQMRAf/EAB8AAAEFAQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEAAAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUhMQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uLj5OXm5+jp6vLz9PX29/j5+v/bAEMAAQEBAQEBAgEBAgMCAgIDBAMDAwMEBgQEBAQEBgcGBgYGBgYHBwcHBwcHBwgICAgICAkJCQkJCwsLCwsLCwsLC//bAEMBAgICAwMDBQMDBQsIBggLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLCwsLC//dAAQADP/aAAwDAQACEQMRAD8A/v4ooooAKKKKACiiigAooooAKK+CP2vP+ChXwZ/ZPibw7dMfEHi2VAYdGs3G9N33TO/IiU9hgu3ZSOa/NzXNL/4KJ/td6JJ49+NXiq2+Cvw7kG/ZNKbDMLcjKblmfI/57SRqewrwMdxBRo1HQoRdWqt1HaP+KT0j838j7XKOCMXiqEcbjKkcPh5bSne8/wDr3BXlN+is+5+43jb45/Bf4bs0fj/xZpGjSL1jvL2KF/8AvlmDfpXjH/DfH7GQuPsv/CydD35x/wAfIx+fT9a/AO58D/8ABJj4UzvF4v8AFfif4l6mp/evpkfkWzP3w2Isg+omb61X/wCF0/8ABJr/AI9f+FQeJPL6ed9vbzPrj7ZivnavFuIT+KhHyc5Sf3wjY+7w/hlgZQv7PF1P70aUKa+SqTUvwP6afBXx2+CnxIZYvAHi3R9ZkfpHZ3sUz/8AfKsW/SvVq/lItvBf/BJX4rTLF4V8UeJ/hpqTH91JqUfn2yv2y2JcD3MqfUV9OaFon/BRH9krQ4vH3wI8XW3xq+HkY3+XDKb/ABCvJxHuaZMDr5Ergd1ruwvFNVrmq0VOK3lSkp29Y6SS+R5GY+HGGi1DD4qVKo9oYmm6XN5RqK9Nvsro/obor4A/ZC/4KH/Bv9qxV8MLnw54vjU+bo9443SFPvG3k4EoHdcB17rjmvv+vqcHjaGKpKth5qUX1X9aPyZ+b5rlOMy3ESwmOpOFRdH+aezT6NXTCiiiuo84KKKKACiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/Q/v4ooooAKKKKACiiigAr8tf+ChP7cWs/BEWfwD+A8R1P4k+JQkUCQr5rWUc52o+zndNIf9Up4H324wD9x/tDfGjw/wDs9fBnX/i/4jAeHRrZpI4c4M87YWKIe7yFV9gc9q/n6+B3iOb4GfCLxL/wU1+Oypq3jzxndT2nhK2uBwZptyvcBeoQBSq4xthjwPvivluIs0lSthKM+WUk5Sl/JBbtebekfM/R+BOHaeIcszxVL2kISUKdP/n7WlrGL/uxXvT8u6uizc6b8I/+CbmmRePPi9HD8Q/j7rifbktLmTz7bSGm582ZzktITyX++5+5tX5z5L8LPgv+0X/wVH12+8ZfEbxneW/2SRxB9o02eTSosdY4XRlgjYZGV++e5Jr8xvF3i7xN4+8UX/jXxney6jquqTNcXVzMcvJI5ySfQdgBwBgDgV+sP/BPX9jj9oL9oXw9H4tuvG2s+DfAVlM8VsthcyJLdSBsyCBNwREDZ3SEHLcBTgkfmuX4j+0MXHB06LdBXagna/8AenK6u+7el9Ej9+zvA/2Jls81r4uMcY7J1px5lHf93ShaVo9FFJNq8pMyPil/wRs/aj8D6dLq3gq70vxdHECxgtZGtrogf3UmAQn2EmT2r8rPEPh3xB4R1u58M+KrGfTdRsnMdxa3MbRTROOzKwBBr+674VfCnTfhNoI0DTtX1jWFAGZtYvpL2U4934X/AICAK8V/aW/Yf/Z9/areHUvibpkkerWsRhg1KxkMFyqHkBiMrIAeQJFYDJxjJr6bNPD+nOkqmAfLP+WTuvk7XX4/I/PeHvG6tSxDo5zH2lLpUhHll6uN7NelmvPY/iir2T4KftA/GD9njxMvir4Q65caTPkGWFTutrgD+GaE/I4+oyOxB5r2n9tb9jTxj+x18RYvD+pTtqmgaqrS6VqezZ5qpjfHIBwsseRuA4IIYdcD4yr80q0sRgcQ4SvCpB+jT8mvzP6Bw2JwOcYGNany1aFRdVdNdmn22aauno9T9tLO0+D/APwUr02Txd8NI4Ph38ftGT7b5NtIYLXWGh58yJwQVkBGd/8ArEP3i6fMP0R/4J7ftw6/8YZ7z9nb9oGJtN+JPhoPFIJ18p75IPlclegnj/5aKOGHzrxnH8rPhXxT4j8D+JbHxj4QvZdO1TTJkuLW5hba8UqHIIP8x0I4PFfsZ8bPEdx+0N8FvDv/AAUl+CgXSfiJ4EuYLXxZBbDALw4CXO0clMEZznMLlSf3Zr7PJM+nzyxUF+9ir1IrRVILeVtlOO+lrr5n5RxfwbRdKGXVXfDzfLRm9ZUKr+GDlq3RqP3UnfllZfy2/ptorw/9m/43aF+0X8FNA+L+gARpq1uGnhByYLlCUmiP+44IHqMHvXuFfsNGtCrTjVpu8ZJNPyZ/LWKwtXDVp4evG04Nxa7NOzX3hRRRWhzhRRRQBBdf8e0n+6f5Vx1djdf8e0n+6f5Vx1AH/9H+/iiiigAooooAKKKKAPw9/wCCvXiPWviH4q+F/wCyN4XlKT+K9TS6uQvoXFvAT7AvI3/AQe1fnF/wVO+IOnXfxx034AeDj5Xhv4ZaXb6TawKfkE7Ro0rY6bgvlofdT61+h3xNj/4Tv/gtd4Q0W/8Anh8P6THLGp6Ax21xOD/324Nfg3+0T4kufGH7QHjjxRdtukvte1GXJ9PPcKPwAAr8a4pxUpLEz6zq8n/btOK0+cpX9Uf1d4c5bCDy+lbSlh3W/wC38RNq/qoQcV5M8fjiaeRYEOGchR9TxX9svw9+GHijSvgB4I+Gnwr1ceGbGztYY728gijluhbohLLAJVeJZJJCN0jo+0Zwu4gj+JgO8REsf3l+YfUV/bf8DNVm+Mv7KtkNF1CTTZ9Z0d4Ir2D/AFls9zF8sidPmj3hhz1Fel4YyhGtiHpzWjur6e9f9Dw/H9VXQwFvgvUv62hb8Oa3zPoDwfp6aPoiaONXuNaa1Zo3ubp43nLDqrmJEXI/3QfWukmjMsTRBihYEbl6jPcZ7ivxk/4JMf8ABOv9ob9hBvFdr8ZvGOma9Yak22wttLiYGV2kMkl1dzSIkkkzcKisX8tSwDYNfs/X7Bj6NOlXlCjUU4/zJWv8j+ZsNUnOmpThyvtufj/+1Z8Hf2bPi58PviF8Avh/4wl1j4iaBZjXG0m71qfU7i3u4FMqt5VxLL5LzR70Kx7AVfJXAXH8sysGUMOh5r+vzwl+wD+y78KP2wPEX7bGn6xqFv4g8QmWa70+fUFGlrdTRmGS4EGATIY2dRvdlXe+0DPH83Nh+x58bPFev3kljpSaVYPcymGS+kEX7oudp2DL/dx/DX4Z4xZxkmCxGHxdTGRTlG0ueUU7q3S93a7S69Oh/SngTnNSjgcZhMc1CnCSlC70966dr/4U7Lq79T5Kr9MP+CWfxHsNH+P138EPF2JvDfxL0640a9gc/I0vls0Rx6kb4x/v1x3iz9hmHwV4KuPFHiLxlaWkltGzt5sBSAsBkIHL7iT0GFJJ7V8qfAnxLc+D/jd4N8V2bFJdP1vT5wR/szoT+YyK/NeD+Lcvx+Ijisuq88ackpPlklruveSvdX2ufsmavC5zlWKw9CV7xaTs1aSV4tXS1Ukmrdj9/P8Agkfrus/DD4ifFP8AY/8AEkrPJ4Z1F7y1DeiSG3mI9m2wv/wI1+5Ffhd4Ki/4Qf8A4Lb+INM0/wCSHxDpDySqOhL2cMx/8fizX7o1/RnC7ccLPDP/AJdTnBeid1+DP5M8RkqmZUselZ4ijSqv1lG0vvcWwooor6Q+BCiiigCC6/49pP8AdP8AKuOrsbr/AI9pP90/yrjqAP/S/v4ooooAKKKKACiiigD8LfiNIfBP/BbLwpq9/wDJDr2kJHGTwCZLS4gH/j0eK/Bj9oPw7c+Evj3428M3ilZLHXtRiIPoJ3x+Ywa/fL/grnoWsfDPx98K/wBrzw5EzyeGNSS0uSvokguYQfZtsy/8CFfnB/wVP+HNho/7QFp8bvCeJvDnxK0231mznQfI0vlqsoz6kbJD/v1+M8U4WUViYW1hV5/+3akVr/4FG3qz+r/DnMYTeX1b6VcP7L/t/Dzenq4Tcl5I/M2v6yP+CR3j4eLP2XbLRZZN0uku9sRnp5bMB/45sr+Tev3u/wCCJXj7yNW8T/DyZ+C6XUak9pUw36xD865uAcV7LNFTf24tfd736Hd405d9Y4cddLWlOMvk7wf/AKUvuP6Kq/P/APaa+InjJfF8vge3lez06KONgIyVM+8ZJYjkgHIx045r9AK/Gr/gsB8UPHXwg8N+AvFfgV4oWmv7u3uTJEsiyL5SsiNkZxkMeCDmvU8bsgzPN+Fa+FyrEujUUot6tKcdnBtapO6fny2ejZ/OnAOFWJzqjheVOU+ZK+yaTlfr2t8z85td/b18H6D4n1DQLrw5fSLY3Elv5okRWcxsVJKMAVyR0yTivEPHf7f3jjVFe18BaXb6PGeBPcH7RN9QMBAfqGrFP7UPwj8c3f2/4y/DuzvbxgA93ZNtd8dyGwT+Lmuvh/aP/ZT8IxC58EfD0y3Y5UzwxKAf99mlP5Cv49wvCeBwUoc3D9Sday3qRlTb73c7Wf8Aej8j+rKWVUKLV8vlKf8AiTj/AOlW+9Hw74w8ceNvHl8NX8bajc6jK2SjTsSo/wBxeFUf7orovgf4dufF3xp8H+F7NS0uoa3p8Cgf7c6A/pW98avjx4q+NmoW0mswW9jY2G/7LaWy4WPfjJLHlicD0HoBX13/AMEtPhrZeI/2jH+L3inEPh34cWE+t31w/wBxJFRliBPqPmkH/XOv3fhXCVa/1ahUoRoybV4RacYq/dKK0jq7Ky1s3uezm+PeByeviqkFBxhK0U767RirJattLTqz9H/CMg8af8Futd1DT/ni8P6OySsOxSyiiP8A49Niv3Qr8NP+CS+j6t8V/iv8V/2wdfiZD4i1B7K0LDtLJ9olUf7imFfwr9y6/oLhe88LUxPSrUnNejdl+CP5G8RWqeY0cAnd4ejSpP8AxRjd/c5NBRRRX0h8CFFFFAEF1/x7Sf7p/lXHV2N1/wAe0n+6f5Vx1AH/0/7+KKKKACiiigAooooA8M/aT+B+iftGfBLxB8INcIjGrWxFvORnyLmMh4ZB/uSAE46jI71+AfwU8N3H7SXwL8Qf8E5fjFt0r4kfD65nuvCstycbmhz5ltuPVcE4x1idWHEdf031+UX/AAUL/Yj8T/FG/sv2mP2c5H074keGtkoFufLe+jg5Taennx9Ezw6/Ie2PleI8slUtjKUOZpOM4/zwe6X96L1j5/cfpPAXEMKF8rxNX2cZSU6VR7Uq0dE3/cmvcn5dldn8r/iXw3r/AIN8Q3vhPxXZy6fqemzPb3VtMNskUsZwysPY/n1HFfe3/BL3x/8A8IP+1bptvK+2HVbeSBvdoyso/RWH419SX8fwg/4Kc6QmleIpLfwB8f8ASI/ssiXCGC11kwfLtZSNwkGMbceZH0w6Dj88tM+HvxW/ZK/aO8OQ/FvR7nQ7uw1OElpV/czQs+x2ilGUkUqTypPvivy3DYWWX46hjaT56HOrSXa+ql/LK26fy0P6LzDMYZ3lGMynEx9ni/ZyvTfV2bjKD+3BtJqS9HZn9gnxB/aM+Cvwp8XWXgj4ja/Bo+o6hB9ogW5DrG0ZYoCZNvlr8wI+Zh0r48/4KkfDey+NP7GOqeIPDUsV7L4elh1u0khYOskcOVl2MCQcwu5GDyRXwx/wVBnbVPH3gjxGeVvPDwUt2LxzOW/9Cr87tO8PfFXVdPisbDS9avNImbzLNILa4mtXfo5j2KULZwDjmvqs+4srKvi8rqYfnjays2nqlq9JX3v0P4FwfiDisjzqNanQU3RnGUbNq9rOz0ej207nxZovhrV9enMNhHwpwztwq/U+vt1qrrWlT6JqUumXBDNHj5l6EEZr7U+IHhHxF8JvEUHhL4j2Umiald2sV/Hb3Q8t2hnztbB75BDKfmVgQQCK8e0f4N/E349/FRvBvwh0a41y+YRq/kD91ECPvSyHCRqPVmFfl8aNZ1vYcj59rWd79rbn9T+HPjFnnEPE1WhmmEWEwKw8qkVJNbSppTdSSimmpO1ko2a3aueH+H/D+ueLNds/DHhi0lv9R1CZLe2toV3SSyyHCqoHUk1+yfxl8N3X7Ln7P+h/8E9/hOF1X4nfEm4gufFDWp3FBMR5dqGHRTgLzx5au5wJKtaZZ/B7/gmFpBhsJLbx78fdVi+zwQWyma00UzjbgAfMZDnGMCSToAiElvv/AP4J7fsS+LPh5q15+1H+0q76h8R/Em+ZUuSHksI5/vFj0E8g4YDiNPkH8VfeZJkVTnlhYfxpK02tqUHur7c8trdFfzt9dxdxjQ9lDMKi/wBlpvmpRejxFVfDK26o03713bmla2yv90/sw/ArRv2bvgboHwh0crK2mQZup1GPPu5Tvmk9fmcnGei4HavfKKK/YaFGFGnGlTVoxSSXkj+WMXi6uKr1MTXlec25N923dsKKKK1OcKKKKAILr/j2k/3T/KuOrsbr/j2k/wB0/wAq46gD/9T+/iiiigAooooAKKKKACiiigD87P2wf+Ccnwm/ahmbxvosh8K+NY8NHq1onyzOn3ftEYK7yMcSKVkX1IAFfnT4m8f/ALdv7L+gyfDn9rjwFb/GLwFD8q3ssf2srGOjfaAjspA6GeMMOzV/RTRXz+N4eo1akq+Hm6VR7uNrS/xRekvzPuMo45xOGoQweOpRxFCPwqd1KH/XuorSh8m0uiPwz0L/AIKEf8E3vi6miH4saHd6Xc6B5gs4tWs3vYIPNILAGFpA65UcSLxjgCvtS1/4KT/sLWVlHFZePrCGCJAqRJa3K7VHQBRFxj0xXv8A48/Zc/Zx+J0z3Xj3wPoupzyHLTS2cfnE+8iqH/WvGP8Ah23+w953n/8ACu9PznOPMn2/98+bj9K5oYTOqMpSpyoyb3k4yjJ2015Xqac/BNSbrPD4mlKW6hKlJf8AgUkpP5n5zfta/tof8Ex/jPq+k+IPHelan491HQlljtI7KGWyikWUqSkryNCzJlcgc4JPHNcZ4V+Iv7c37TGgJ8N/2Ovh7bfB7wHN8pvoo/shMZ4LfaSiMxx1MERf/ar9sPAn7LH7N3wxmS68B+BtF02eM5WaOzjMwI9JGBf9a98AAGBWSyDF16kquKrqPN8Xso8rfrN3lY9SXG+WYPDww2W4SdRQ+B4io5xjre6pRtTvfW+up+cv7H//AATg+FX7MdynjzxHMfFnjeTLvqt2vyQO/wB77OjFtpOeZGLSH1AOK/Rqiivo8FgaGEpKjh4KMV/V33fmz4LNs5xuZ4h4rHVXOb6vouyWyS6JJIKKKK6zzAooooAKKKKAILr/AI9pP90/yrjq7G6/49pP90/yrjqAP//Z"), itemDeleted: .deleted), allowMenu: Binding.constant(true), audioPlayer: .constant(nil), playbackState: .constant(.noPlayback), playbackTime: .constant(nil)) } .previewLayout(.fixed(width: 360, height: 200)) } diff --git a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift index 748b6a83c0..0096c4a0e0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemInfoView.swift @@ -82,7 +82,7 @@ struct ChatItemInfoView: View { .padding(.vertical, 6) .background(ciDirFrameColor(chatItemSent: chatItemSent, colorScheme: colorScheme)) .cornerRadius(18) - .uiKitContextMenu(menu: uiMenu) + .uiKitContextMenu(menu: uiMenu, allowMenu: Binding.constant(true)) Text( localTimestamp(itemVersion.itemVersionTs) + (current diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index 82895e3f8a..530f969ddb 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -16,6 +16,22 @@ struct ChatItemView: View { var maxWidth: CGFloat = .infinity @State var scrollProxy: ScrollViewProxy? = nil @Binding var revealed: Bool + @Binding var allowMenu: Bool + @Binding var audioPlayer: AudioPlayer? + @Binding var playbackState: VoiceMessagePlaybackState + @Binding var playbackTime: TimeInterval? + init(chatInfo: ChatInfo, chatItem: ChatItem, showMember: Bool = false, maxWidth: CGFloat = .infinity, scrollProxy: ScrollViewProxy? = nil, revealed: Binding, allowMenu: Binding = .constant(false), audioPlayer: Binding = .constant(nil), playbackState: Binding = .constant(.noPlayback), playbackTime: Binding = .constant(nil)) { + self.chatInfo = chatInfo + self.chatItem = chatItem + self.showMember = showMember + self.maxWidth = maxWidth + _scrollProxy = .init(initialValue: scrollProxy) + _revealed = revealed + _allowMenu = allowMenu + _audioPlayer = audioPlayer + _playbackState = playbackState + _playbackTime = playbackTime + } var body: some View { let ci = chatItem @@ -25,7 +41,7 @@ struct ChatItemView: View { if let mc = ci.content.msgContent, mc.isText && isShortEmoji(ci.content.text) { EmojiItemView(chatItem: ci) } else if ci.content.text.isEmpty, case let .voice(_, duration) = ci.content.msgContent { - CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration) + CIVoiceView(chatItem: ci, recordingFile: ci.file, duration: duration, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime, allowMenu: $allowMenu) } else if ci.content.msgContent == nil { ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: { Text(ci.text) }) // msgContent is unreachable branch in this case } else { @@ -37,7 +53,7 @@ struct ChatItemView: View { } private func framedItemView() -> some View { - FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy) + FramedItemView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) } } diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 55f8fa5c63..322262418d 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -219,17 +219,25 @@ struct ChatView: View { .padding(.vertical, 8) } + private func voiceWithoutFrame(_ ci: ChatItem) -> Bool { + ci.content.msgContent?.isVoice == true && ci.content.text.count == 0 && ci.quotedItem == nil + } + private func chatItemsList() -> some View { let cInfo = chat.chatInfo return GeometryReader { g in ScrollViewReader { proxy in ScrollView { - let maxWidth = - cInfo.chatType == .group - ? (g.size.width - 28) * 0.84 - 42 - : (g.size.width - 32) * 0.84 LazyVStack(spacing: 5) { ForEach(chatModel.reversedChatItems, id: \.viewId) { ci in + let voiceNoFrame = voiceWithoutFrame(ci) + let maxWidth = cInfo.chatType == .group + ? voiceNoFrame + ? (g.size.width - 28) - 42 + : (g.size.width - 28) * 0.84 - 42 + : voiceNoFrame + ? (g.size.width - 32) + : (g.size.width - 32) * 0.84 chatItemView(ci, maxWidth) .scaleEffect(x: 1, y: -1, anchor: .center) .onAppear { @@ -448,15 +456,21 @@ struct ChatView: View { @State private var showChatItemInfoSheet: Bool = false @State private var chatItemInfo: ChatItemInfo? + @State private var allowMenu: Bool = true + + @State private var audioPlayer: AudioPlayer? + @State private var playbackState: VoiceMessagePlaybackState = .noPlayback + @State private var playbackTime: TimeInterval? + var body: some View { let alignment: Alignment = ci.chatDir.sent ? .trailing : .leading let uiMenu: Binding = Binding( get: { UIMenu(title: "", children: menu(live: composeState.liveMessage != nil)) }, set: { _ in } ) - - ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed) - .uiKitContextMenu(menu: uiMenu) + + ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) + .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal) @@ -469,6 +483,14 @@ struct ChatView: View { } .frame(maxWidth: maxWidth, maxHeight: .infinity, alignment: alignment) .frame(minWidth: 0, maxWidth: .infinity, alignment: alignment) + .onDisappear { + if ci.content.msgContent?.isVoice == true { + allowMenu = true + audioPlayer?.stop() + playbackState = .noPlayback + playbackTime = TimeInterval(0) + } + } .sheet(isPresented: $showChatItemInfoSheet, onDismiss: { chatItemInfo = nil }) { diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift index 98dc94e124..2bd23f8ae7 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeVoiceView.swift @@ -38,7 +38,7 @@ struct ComposeVoiceView: View { @State private var playbackTime: TimeInterval? @State private var startingPlayback: Bool = false - private static let previewHeight: CGFloat = 50 + private static let previewHeight: CGFloat = 55 var body: some View { ZStack { @@ -66,6 +66,7 @@ struct ComposeVoiceView: View { } } .padding(.trailing, 12) + .padding(.top, 4) ProgressBar(length: MAX_VOICE_MESSAGE_LENGTH, progress: $recordingTime) } @@ -105,9 +106,12 @@ struct ComposeVoiceView: View { } } .padding(.trailing, 12) + .padding(.top, 4) if let recordingLength = recordingTime { - ProgressBar(length: recordingLength, progress: $playbackTime) + GeometryReader { _ in + SliderBar(length: recordingLength, progress: $playbackTime, seek: { audioPlayer?.seek($0) }) + } } } .onChange(of: stopPlayback) { _ in @@ -145,6 +149,18 @@ struct ComposeVoiceView: View { } } + struct SliderBar: View { + var length: TimeInterval + @Binding var progress: TimeInterval? + var seek: (TimeInterval) -> Void + + var body: some View { + Slider(value: Binding(get: { progress ?? TimeInterval(0) }, set: { seek($0) }), in: 0 ... length) + .frame(maxWidth: .infinity) + .frame(height: 4) + } + } + private struct ProgressBar: View { var length: TimeInterval @Binding var progress: TimeInterval? @@ -154,10 +170,10 @@ struct ComposeVoiceView: View { ZStack { Rectangle() .fill(Color.accentColor) - .frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 3) + .frame(width: min(CGFloat((progress ?? TimeInterval(0)) / length) * geometry.size.width, geometry.size.width), height: 4) .animation(.linear, value: progress) } - .frame(height: ComposeVoiceView.previewHeight - 1, alignment: .bottom) // minus 1 is for the bottom padding + .frame(height: 4) } } } @@ -172,8 +188,7 @@ struct ComposeVoiceView: View { playbackTime = recordingTime // animate progress bar to the end } ) - audioPlayer?.start(fileName: recordingFileName) - playbackTime = TimeInterval(0) + audioPlayer?.start(fileName: recordingFileName, at: playbackTime) playbackState = .playing } } diff --git a/apps/ios/Shared/Views/Helpers/ContextMenu.swift b/apps/ios/Shared/Views/Helpers/ContextMenu.swift index 4efc114032..287aba5262 100644 --- a/apps/ios/Shared/Views/Helpers/ContextMenu.swift +++ b/apps/ios/Shared/Views/Helpers/ContextMenu.swift @@ -11,11 +11,12 @@ import UIKit import SwiftUI extension View { - func uiKitContextMenu(menu: Binding) -> some View { - self.overlay(Color(uiColor: .systemBackground)) - .overlay( - InteractionView(content: self, menu: menu) - ) + func uiKitContextMenu(menu: Binding, allowMenu: Binding) -> some View { + self.overlay { + if allowMenu.wrappedValue { + self.overlay(Color(uiColor: .systemBackground)).overlay(InteractionView(content: self, menu: menu)) + } + } } } From baf3a12009e90a01aba03ae0cad624b33e5e5fc9 Mon Sep 17 00:00:00 2001 From: Stanislav Dmitrenko <7953703+avently@users.noreply.github.com> Date: Mon, 15 May 2023 11:17:32 +0300 Subject: [PATCH 4/8] mobile: increased size of voice messages (#2150) * android: increased size of voice messages * ios: increased size of voice messages * size changes * increase audio quality to 16kHz/32Kbps, refactor auto-accept logic --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- .../java/chat/simplex/app/model/SimpleXAPI.kt | 12 +++++------ .../simplex/app/views/chat/SendMsgView.kt | 2 +- .../app/views/chat/item/CIVoiceView.kt | 21 +++++++++++-------- .../app/views/chat/item/ChatItemView.kt | 2 +- .../app/views/chat/item/FramedItemView.kt | 2 +- .../simplex/app/views/helpers/RecAndPlay.kt | 5 ++--- .../chat/simplex/app/views/helpers/Util.kt | 10 ++++----- apps/ios/Shared/Model/AudioRecPlay.swift | 5 +++-- apps/ios/Shared/Model/SimpleXAPI.swift | 12 +++-------- .../Views/Chat/ChatItem/CIVideoView.swift | 1 - .../Views/Chat/ChatItem/CIVoiceView.swift | 14 ++++++++++++- .../ios/SimpleX NSE/NotificationService.swift | 21 ++----------------- apps/ios/SimpleXChat/ChatTypes.swift | 14 ++++++++++++- apps/ios/SimpleXChat/FileUtils.swift | 14 ++++++------- 14 files changed, 69 insertions(+), 66 deletions(-) diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt index 9f0af623e2..fd2157898e 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/SimpleXAPI.kt @@ -1355,12 +1355,12 @@ open class ChatController(var ctrl: ChatCtrl?, val ntfManager: NtfManager, val a } val file = cItem.file val mc = cItem.content.msgContent - if (file != null && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) { - val acceptImages = appPrefs.privacyAcceptImages.get() - if ((mc is MsgContent.MCImage && acceptImages) - || (mc is MsgContent.MCVoice && ((file.fileSize > MAX_VOICE_SIZE_FOR_SENDING && acceptImages) || cInfo is ChatInfo.Group))) { - withApi { receiveFile(r.user, file.fileId) } // TODO check inlineFileMode != IFMSent - } + if (file != null && + appPrefs.privacyAcceptImages.get() && + ((mc is MsgContent.MCImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) + || (mc is MsgContent.MCVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus !is CIFileStatus.RcvAccepted))) { + withApi { receiveFile(r.user, file.fileId) } } if (cItem.showNotification && (!SimplexApp.context.isAppOnForeground || chatModel.chatId.value != cInfo.id)) { ntfManager.notifyMessageReceived(r.user, cInfo, cItem) diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt index 366f79c1f3..831badf7fa 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/SendMsgView.kt @@ -307,7 +307,7 @@ private fun BoxScope.DeleteTextButton(composeState: MutableState) @Composable private fun RecordVoiceView(recState: MutableState, stopRecOnNextClick: MutableState) { - val rec: Recorder = remember { RecorderNative(MAX_VOICE_SIZE_FOR_SENDING) } + val rec: Recorder = remember { RecorderNative() } DisposableEffect(Unit) { onDispose { rec.stop() } } val stopRecordingAndAddAudio: () -> Unit = { recState.value.filePathNullable?.let { diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt index f5ca6d7596..fa94e00c74 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIVoiceView.kt @@ -36,6 +36,7 @@ fun CIVoiceView( ci: ChatItem, timedMessagesTTL: Int?, longClick: () -> Unit, + receiveFile: (Long) -> Unit, ) { Row( Modifier.padding(top = if (hasText) 14.dp else 4.dp, bottom = if (hasText) 14.dp else 6.dp, start = if (hasText) 6.dp else 0.dp, end = if (hasText) 6.dp else 0.dp), @@ -64,11 +65,11 @@ fun CIVoiceView( durationText(time / 1000) } } - VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick) { + VoiceLayout(file, ci, text, audioPlaying, progress, duration, brokenAudio, sent, hasText, timedMessagesTTL, play, pause, longClick, receiveFile) { AudioPlayer.seekTo(it, progress, filePath) } } else { - VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick) + VoiceMsgIndicator(null, false, sent, hasText, null, null, false, {}, {}, longClick, receiveFile) val metaReserve = if (edited) " " else @@ -93,8 +94,8 @@ private fun VoiceLayout( play: () -> Unit, pause: () -> Unit, longClick: () -> Unit, + receiveFile: (Long) -> Unit, onProgressChanged: (Int) -> Unit, - ) { @Composable fun RowScope.Slider(backgroundColor: Color, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING_HALF)) { @@ -142,7 +143,7 @@ private fun VoiceLayout( val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage Spacer(Modifier.width(6.dp)) - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) Row(verticalAlignment = Alignment.CenterVertically) { DurationText(text, PaddingValues(start = 12.dp)) Slider(if (ci.chatDir.sent) sentColor else receivedColor) @@ -156,7 +157,7 @@ private fun VoiceLayout( DurationText(text, PaddingValues(end = 12.dp)) } Column { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { CIMetaView(ci, timedMessagesTTL) } @@ -166,7 +167,7 @@ private fun VoiceLayout( else -> { Row { Column { - VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick) + VoiceMsgIndicator(file, audioPlaying.value, sent, hasText, progress, duration, brokenAudio, play, pause, longClick, receiveFile) Box(Modifier.align(Alignment.CenterHorizontally).padding(top = 6.dp)) { CIMetaView(ci, timedMessagesTTL) } @@ -245,7 +246,8 @@ private fun VoiceMsgIndicator( error: Boolean, play: () -> Unit, pause: () -> Unit, - longClick: () -> Unit + longClick: () -> Unit, + receiveFile: (Long) -> Unit, ) { val strokeWidth = with(LocalDensity.current) { 3.dp.toPx() } val strokeColor = MaterialTheme.colors.primary @@ -264,8 +266,9 @@ private fun VoiceMsgIndicator( PlayPauseButton(audioPlaying, sent, angle, strokeWidth, strokeColor, true, error, play, pause, longClick = longClick) } } else { - if (file?.fileStatus is CIFileStatus.RcvInvitation - || file?.fileStatus is CIFileStatus.RcvTransfer + if (file?.fileStatus is CIFileStatus.RcvInvitation) { + PlayPauseButton(audioPlaying, sent, 0f, strokeWidth, strokeColor, true, error, { receiveFile(file.fileId) }, {}, longClick = longClick) + } else if (file?.fileStatus is CIFileStatus.RcvTransfer || file?.fileStatus is CIFileStatus.RcvAccepted ) { Box( diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt index f5376355f4..4d68c41c76 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/ChatItemView.kt @@ -203,7 +203,7 @@ fun ChatItemView( EmojiItemView(cItem, cInfo.timedMessagesTTL) MsgContentItemDropdownMenu() } else if (mc is MsgContent.MCVoice && cItem.content.text.isEmpty()) { - CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }) + CIVoiceView(mc.duration, cItem.file, cItem.meta.itemEdited, cItem.chatDir.sent, hasText = false, cItem, cInfo.timedMessagesTTL, longClick = { onLinkLongClick("") }, receiveFile) MsgContentItemDropdownMenu() } else { framedItemView() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt index 99d6ab7eae..61d8080e06 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/FramedItemView.kt @@ -221,7 +221,7 @@ fun FramedItemView( } } is MsgContent.MCVoice -> { - CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }) + CIVoiceView(mc.duration, ci.file, ci.meta.itemEdited, ci.chatDir.sent, hasText = true, ci, timedMessagesTTL = chatTTL, longClick = { onLinkLongClick("") }, receiveFile) if (mc.text != "") { CIMarkdownText(ci, chatTTL, showMember, linkMode, uriHandler) } diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt index f3aa27aa24..63da5f5c24 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/RecAndPlay.kt @@ -21,7 +21,7 @@ interface Recorder { fun stop(): Int } -class RecorderNative(private val recordedBytesLimit: Long): Recorder { +class RecorderNative(): Recorder { companion object { // Allows to stop the recorder from outside without having the recorder in a variable var stopRecording: (() -> Unit)? = null @@ -48,9 +48,8 @@ class RecorderNative(private val recordedBytesLimit: Long): Recorder { rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) rec.setAudioChannels(1) rec.setAudioSamplingRate(16000) - rec.setAudioEncodingBitRate(16000) + rec.setAudioEncodingBitRate(32000) rec.setMaxDuration(MAX_VOICE_MILLIS_FOR_SENDING) - rec.setMaxFileSize(recordedBytesLimit) val tmpDir = SimplexApp.context.getDir("temp", Application.MODE_PRIVATE) val fileToSave = File.createTempFile(generateNewFileName(SimplexApp.context, "voice", "${extension}_"), ".tmp", tmpDir) fileToSave.deleteOnExit() diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt index f001719b1f..1f6d3e7f0b 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/views/helpers/Util.kt @@ -236,16 +236,16 @@ private fun spannableStringToAnnotatedString( } // maximum image file size to be auto-accepted -const val MAX_IMAGE_SIZE: Long = 236700 +const val MAX_IMAGE_SIZE: Long = 261_120 // 255KB const val MAX_IMAGE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 -const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE +const val MAX_VOICE_SIZE_AUTO_RCV: Long = MAX_IMAGE_SIZE * 2 +const val MAX_VIDEO_SIZE_AUTO_RCV: Long = 1_047_552 // 1023KB -const val MAX_VOICE_SIZE_FOR_SENDING: Long = 94680 // 6 chunks * 15780 bytes per chunk -const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 43_000 +const val MAX_VOICE_MILLIS_FOR_SENDING: Int = 300_000 const val MAX_FILE_SIZE_SMP: Long = 8000000 -const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 +const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB fun getFilesDirectory(context: Context): String { return context.filesDir.toString() diff --git a/apps/ios/Shared/Model/AudioRecPlay.swift b/apps/ios/Shared/Model/AudioRecPlay.swift index 5d4eb41d20..698799457e 100644 --- a/apps/ios/Shared/Model/AudioRecPlay.swift +++ b/apps/ios/Shared/Model/AudioRecPlay.swift @@ -36,8 +36,9 @@ class AudioRecorder { try av.setActive(true) let settings: [String : Any] = [ AVFormatIDKey: kAudioFormatMPEG4AAC, - AVSampleRateKey: 12000, - AVEncoderBitRateKey: 12000, + AVSampleRateKey: 16000, + AVEncoderBitRateKey: 32000, + AVEncoderBitRateStrategyKey: AVAudioBitRateStrategy_VariableConstrained, AVNumberOfChannelsKey: 1 ] let url = getAppFilePath(fileName) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 97b073cb48..68df35d973 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1240,15 +1240,9 @@ func processReceivedMsg(_ res: ChatResponse) async { } else if cItem.isRcvNew && cInfo.ntfsEnabled { m.increaseUnreadCounter(user: user) } - if let file = cItem.file, - let mc = cItem.content.msgContent, - file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV { - let acceptImages = UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES) - if (mc.isImage && acceptImages) - || (mc.isVoice && ((file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND && acceptImages) || cInfo.chatType == .group)) { - Task { - await receiveFile(user: user, fileId: file.fileId) // TODO check inlineFileMode != IFMSent - } + if let file = cItem.autoReceiveFile() { + Task { + await receiveFile(user: user, fileId: file.fileId) } } if cItem.showNotification { diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift index 6c7d023cbb..0232be700b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVideoView.swift @@ -251,7 +251,6 @@ struct CIVideoView: View { if let user = ChatModel.shared.currentUser { await receiveFile(user, file.fileId) } - // TODO image accepted alert? } } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift index 11f8781c53..e6ce74953b 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIVoiceView.swift @@ -138,7 +138,7 @@ struct VoiceMessagePlayer: View { case .sndComplete: playbackButton() case .sndCancelled: playbackButton() case .sndError: playbackButton() - case .rcvInvitation: loadingIcon() + case .rcvInvitation: downloadButton(recordingFile) case .rcvAccepted: loadingIcon() case .rcvTransfer: loadingIcon() case .rcvComplete: playbackButton() @@ -214,6 +214,18 @@ struct VoiceMessagePlayer: View { } } + private func downloadButton(_ recordingFile: CIFile) -> some View { + Button { + Task { + if let user = ChatModel.shared.currentUser { + await receiveFile(user: user, fileId: recordingFile.fileId) + } + } + } label: { + playPauseIcon("play.fill") + } + } + private struct ProgressCircle: View { var length: TimeInterval @Binding var progress: TimeInterval? diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index b4a6bbd974..24f5bc5144 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -271,25 +271,8 @@ func receivedMsgNtf(_ res: ChatResponse) async -> (String, NSENotification)? { if !cInfo.ntfsEnabled { ntfBadgeCountGroupDefault.set(max(0, ntfBadgeCountGroupDefault.get() - 1)) } - if case .image = cItem.content.msgContent { - if let file = cItem.file, - file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV, - privacyAcceptImagesGroupDefault.get() { - cItem = autoReceiveFile(file) ?? cItem - } - } else if case .video = cItem.content.msgContent { - if let file = cItem.file, - file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV, - privacyAcceptImagesGroupDefault.get() { - cItem = autoReceiveFile(file) ?? cItem - } - } else if case .voice = cItem.content.msgContent { // TODO check inlineFileMode != IFMSent - if let file = cItem.file, - file.fileSize <= MAX_IMAGE_SIZE, - file.fileSize > MAX_VOICE_MESSAGE_SIZE_INLINE_SEND, - privacyAcceptImagesGroupDefault.get() { - cItem = autoReceiveFile(file) ?? cItem - } + if let file = cItem.autoReceiveFile() { + cItem = autoReceiveFile(file) ?? cItem } let ntf: NSENotification = cInfo.ntfsEnabled ? .nse(notification: createMessageReceivedNtf(user, cInfo, cItem)) : .empty return cItem.showMutableNotification ? (aChatItem.chatId, ntf) : nil diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 492758fa04..23a1d95565 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1851,6 +1851,18 @@ public struct ChatItem: Identifiable, Decodable { } } + public func autoReceiveFile() -> CIFile? { + if let file = file, + let mc = content.msgContent, + privacyAcceptImagesGroupDefault.get(), + (mc.isImage && file.fileSize <= MAX_IMAGE_SIZE_AUTO_RCV) + || (mc.isVideo && file.fileSize <= MAX_VIDEO_SIZE_AUTO_RCV) + || (mc.isVoice && file.fileSize <= MAX_VOICE_SIZE_AUTO_RCV && file.fileStatus != .rcvAccepted) { + return file + } + return nil + } + public var showMutableNotification: Bool { switch content { case .rcvCall: return false @@ -2383,7 +2395,7 @@ public enum FileProtocol: String, Decodable { case xftp = "xftp" } -public enum CIFileStatus: Decodable { +public enum CIFileStatus: Decodable, Equatable { case sndStored case sndTransfer(sndProgress: Int64, sndTotal: Int64) case sndComplete diff --git a/apps/ios/SimpleXChat/FileUtils.swift b/apps/ios/SimpleXChat/FileUtils.swift index dd6cdbf178..148ab12e29 100644 --- a/apps/ios/SimpleXChat/FileUtils.swift +++ b/apps/ios/SimpleXChat/FileUtils.swift @@ -11,20 +11,20 @@ import OSLog let logger = Logger() -// maximum image file size to be auto-accepted -public let MAX_IMAGE_SIZE: Int64 = 236700 +// image file size for complession +public let MAX_IMAGE_SIZE: Int64 = 261_120 // 255KB public let MAX_IMAGE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 -public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 8000000 +public let MAX_VOICE_SIZE_AUTO_RCV: Int64 = MAX_IMAGE_SIZE * 2 -public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 +public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB + +public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB public let MAX_FILE_SIZE_SMP: Int64 = 8000000 -public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(30) - -public let MAX_VOICE_MESSAGE_SIZE_INLINE_SEND: Int64 = 94680 +public let MAX_VOICE_MESSAGE_LENGTH = TimeInterval(300) private let CHAT_DB: String = "_chat.db" From c06a97098782c42ef14f41a8b03d5ca9f61d0b0c Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 15 May 2023 12:28:53 +0200 Subject: [PATCH 5/8] core: message reactions (#2429) * core: message reactions * remove comments * core: commands to set reactions * fix tests * process reaction messages * store functions * include reactions on item updates * remove print * view, tests * load reactions for new items * test removing reaction * remove spaces * limit the number of different reactions on one item * remove unique constraints * fix permissions * indexes * check chat item content before adding reaction * fix group reactions * simpler index --------- Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> --- .../Views/Chat/ChatItem/FramedItemView.swift | 10 +- apps/ios/Shared/Views/Chat/ChatView.swift | 34 ++- apps/ios/SimpleXChat/ChatTypes.swift | 32 +++ apps/simplex-broadcast-bot/Options.hs | 1 + simplex-chat.cabal | 1 + src/Simplex/Chat.hs | 194 +++++++++++++---- src/Simplex/Chat/Controller.hs | 4 + src/Simplex/Chat/Messages.hs | 40 ++++ .../Chat/Migrations/M20230511_reactions.hs | 47 ++++ src/Simplex/Chat/Migrations/chat_schema.sql | 32 +++ src/Simplex/Chat/Mobile.hs | 1 + src/Simplex/Chat/Options.hs | 7 + src/Simplex/Chat/Protocol.hs | 38 ++++ src/Simplex/Chat/Store.hs | 202 ++++++++++++++---- src/Simplex/Chat/Terminal/Input.hs | 2 +- src/Simplex/Chat/Terminal/Output.hs | 4 +- src/Simplex/Chat/Types.hs | 72 ++++++- src/Simplex/Chat/View.hs | 64 +++++- tests/ChatClient.hs | 1 + tests/ChatTests/Direct.hs | 60 +++++- tests/ChatTests/Groups.hs | 72 +++++++ tests/ChatTests/Profiles.hs | 2 +- tests/ChatTests/Utils.hs | 16 +- tests/MobileTests.hs | 4 +- tests/ProtocolTests.hs | 28 +-- 25 files changed, 843 insertions(+), 125 deletions(-) create mode 100644 src/Simplex/Chat/Migrations/M20230511_reactions.hs diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 2841df808a..6c6c605536 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -44,7 +44,7 @@ struct FramedItemView: View { } else if chatItem.meta.isLive { framedItemHeader(caption: Text("LIVE")) } - + if let qi = chatItem.quotedItem { ciQuoteView(qi) .onTapGesture { @@ -56,13 +56,13 @@ struct FramedItemView: View { } } } - + ChatItemContentView(chatInfo: chatInfo, chatItem: chatItem, showMember: showMember, msgContentView: framedMsgContentView) .padding(chatItem.content.msgContent != nil ? 0 : 4) .overlay(DetermineWidth()) } .onPreferenceChange(MetaColorPreferenceKey.self) { metaColor = $0 } - + if chatItem.content.msgContent != nil { CIMetaView(chatItem: chatItem, metaColor: metaColor) .padding(.horizontal, 12) @@ -73,7 +73,7 @@ struct FramedItemView: View { .background(chatItemFrameColorMaybeImageOrVideo(chatItem, colorScheme)) .cornerRadius(18) .onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 } - + switch chatItem.meta.itemStatus { case .sndErrorAuth: v.onTapGesture { msgDeliveryError("Most likely this contact has deleted the connection with you.") } @@ -82,7 +82,7 @@ struct FramedItemView: View { default: v } } - + @ViewBuilder private func framedMsgContentView() -> some View { if chatItem.formattedText == nil && chatItem.file == nil && !chatItem.meta.isLive && isShortEmoji(chatItem.content.text) { VStack { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 322262418d..b879327683 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -443,6 +443,7 @@ struct ChatView: View { private struct ChatItemWithMenu: View { @EnvironmentObject var chat: Chat + @Environment(\.colorScheme) var colorScheme var ci: ChatItem var showMember: Bool = false var maxWidth: CGFloat @@ -469,8 +470,14 @@ struct ChatView: View { set: { _ in } ) - ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) - .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) + VStack(alignment: .trailing, spacing: 4) { + ChatItemView(chatInfo: chat.chatInfo, chatItem: ci, showMember: showMember, maxWidth: maxWidth, scrollProxy: scrollProxy, revealed: $revealed, allowMenu: $allowMenu, audioPlayer: $audioPlayer, playbackState: $playbackState, playbackTime: $playbackTime) + .uiKitContextMenu(menu: uiMenu, allowMenu: $allowMenu) + if ci.reactions.count > 0 { + chatItemReactions(ci.reactions) + .padding(.bottom, 4) + } + } .confirmationDialog("Delete message?", isPresented: $showDeleteMessage, titleVisibility: .visible) { Button("Delete for me", role: .destructive) { deleteMessage(.cidmInternal) @@ -497,7 +504,26 @@ struct ChatView: View { ChatItemInfoView(chatItemSent: ci.chatDir.sent, chatItemInfo: $chatItemInfo) } } - + + private func chatItemReactions(_ reactions: [CIReaction]) -> some View { + HStack(spacing: 4) { + ForEach(reactions, id: \.reaction) { r in + HStack(spacing: 4) { + switch r.reaction { + case let .emoji(emoji): Text(emoji).font(.caption) + } + if r.totalReacted > 1 { + Text("\(r.totalReacted)").font(.caption).foregroundColor(.secondary) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(!r.userReacted ? Color.clear : colorScheme == .dark ? sentColorDark : sentColorLight) + .cornerRadius(16) + } + } + } + private func menu(live: Bool) -> [UIAction] { var menu: [UIAction] = [] if let mc = ci.content.msgContent, ci.meta.itemDeleted == nil || revealed { @@ -718,7 +744,7 @@ struct ChatView: View { chat.chatInfo.featureEnabled(.fullDelete) ? "Delete for everyone" : "Mark deleted for everyone" } } - + private func showMemberImage(_ member: GroupMember, _ prevItem: ChatItem?) -> Bool { switch (prevItem?.chatDir) { case .groupSnd: return true diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 23a1d95565..9369fccccc 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1762,6 +1762,17 @@ public struct ChatItem: Identifiable, Decodable { self.content = content self.formattedText = formattedText self.quotedItem = quotedItem + self.reactions = [] // [ +// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1), +// CIReaction(reaction: .emoji(emoji: "❤️"), userReacted: false, totalReacted: 1), +// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3), +// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2), +// ] self.file = file } @@ -1770,6 +1781,17 @@ public struct ChatItem: Identifiable, Decodable { public var content: CIContent public var formattedText: [FormattedText]? public var quotedItem: CIQuote? + public var reactions: [CIReaction] = [] // [ +// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: false, totalReacted: 1), +// CIReaction(reaction: .emoji(emoji: "❤️"), userReacted: false, totalReacted: 1), +// CIReaction(reaction: .emoji(emoji: "🚀"), userReacted: false, totalReacted: 3), +// CIReaction(reaction: .emoji(emoji: "👍"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "👎"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "👀"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "🎉"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "😀"), userReacted: true, totalReacted: 2), +// CIReaction(reaction: .emoji(emoji: "😕"), userReacted: true, totalReacted: 2), +// ] public var file: CIFile? public var viewTimestamp = Date.now @@ -2298,6 +2320,16 @@ public struct CIQuote: Decodable, ItemContent { } } +public struct CIReaction: Decodable { + public var reaction: MsgReaction + public var userReacted: Bool + public var totalReacted: Int +} + +public enum MsgReaction: Decodable, Hashable { + case emoji(emoji: String) +} + public struct CIFile: Decodable { public var fileId: Int64 public var fileName: String diff --git a/apps/simplex-broadcast-bot/Options.hs b/apps/simplex-broadcast-bot/Options.hs index 0c69845f0f..f15035fa16 100644 --- a/apps/simplex-broadcast-bot/Options.hs +++ b/apps/simplex-broadcast-bot/Options.hs @@ -104,6 +104,7 @@ mkChatOpts BroadcastBotOpts {coreOptions} = chatCmdDelay = 3, chatServerPort = Nothing, optFilesFolder = Nothing, + showReactions = False, allowInstantFiles = True, muteNotifications = True, maintenance = False diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 591d677ca8..d9dd632181 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -95,6 +95,7 @@ library Simplex.Chat.Migrations.M20230422_profile_contact_links Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages Simplex.Chat.Migrations.M20230505_chat_item_versions + Simplex.Chat.Migrations.M20230511_reactions Simplex.Chat.Mobile Simplex.Chat.Mobile.WebRTC Simplex.Chat.Options diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 91a2b12db0..b327880736 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -114,6 +114,7 @@ defaultChatConfig = inlineFiles = defaultInlineFilesConfig, xftpFileConfig = Just defaultXFTPFileConfig, tempDir = Nothing, + showReactions = False, logLevel = CLLImportant, subscriptionEvents = False, hostEvents = False, @@ -135,6 +136,9 @@ _defaultNtfServers = ["ntf://FB-Uop7RTaZZEG0ZLD2CIaTjsPh-Fw0zFAnb7QyA8Ks=@ntf2.s maxImageSize :: Integer maxImageSize = 236700 +maxMsgReactions :: Int +maxMsgReactions = 3 + fixedImagePreview :: ImageData fixedImagePreview = ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAKVJREFUeF7t1kENACEUQ0FQhnVQ9lfGO+xggITQdvbMzArPey+8fa3tAfwAEdABZQspQStgBssEcgAIkSAJkiAJljtEgiRIgmUCSZAESZAESZAEyx0iQRIkwTKBJEiCv5fgvTd1wDmn7QAP4AeIgA4oW0gJWgEzWCZwbQ7gAA7ggLKFOIADOKBMIAeAEAmSIAmSYLlDJEiCJFgmkARJkARJ8N8S/ADTZUewBvnTOQAAAABJRU5ErkJggg==" @@ -148,9 +152,9 @@ createChatDatabase filePrefix key confirmMigrations = runExceptT $ do pure ChatDatabase {chatStore, agentStore} newChatController :: ChatDatabase -> Maybe User -> ChatConfig -> ChatOpts -> Maybe (Notification -> IO ()) -> IO ChatController -newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, allowInstantFiles} sendToast = do +newChatController ChatDatabase {chatStore, agentStore} user cfg@ChatConfig {agentConfig = aCfg, defaultServers, inlineFiles, tempDir} ChatOpts {coreOptions = CoreChatOpts {smpServers, xftpServers, networkConfig, logLevel, logConnections, logServerHosts, logFile, tbqSize}, optFilesFolder, showReactions, allowInstantFiles} sendToast = do let inlineFiles' = if allowInstantFiles then inlineFiles else inlineFiles {sendChunks = 0, receiveInstant = False} - config = cfg {logLevel, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles'} + config = cfg {logLevel, showReactions, tbqSize, subscriptionEvents = logConnections, hostEvents = logServerHosts, defaultServers = configServers, inlineFiles = inlineFiles'} sendNotification = fromMaybe (const $ pure ()) sendToast firstTime = dbNew chatStore activeTo <- newTVarIO ActiveNone @@ -728,6 +732,55 @@ processChatCommand = \case SndMessage {msgId} <- sendGroupMessage user gInfo ms $ XMsgDel itemSharedMId $ Just memberId delGroupChatItem user gInfo ci msgId (Just membership) (_, _) -> throwChatError CEInvalidChatItemDelete + APIChatItemReaction (ChatRef cType chatId) itemId reaction add -> withUser $ \user -> withChatLock "chatItemReaction" $ case cType of + CTDirect -> + withStore (\db -> (,) <$> getContact db user chatId <*> getDirectChatItem db user chatId itemId) >>= \case + (ct, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do + unless (featureAllowed SCFReactions forUser ct) $ + throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) + unless (ciReactionAllowed ci) $ + throwChatError $ CECommandError "reaction not allowed - chat item has no content" + rs <- withStore' $ \db -> getDirectReactions db ct itemSharedMId True + checkReactionAllowed rs + (SndMessage {msgId}, _) <- sendDirectContactMessage ct $ XMsgReact itemSharedMId Nothing reaction add + createdAt <- liftIO getCurrentTime + reactions <- withStore' $ \db -> do + setDirectReaction db ct itemSharedMId True reaction add msgId createdAt + liftIO $ getDirectCIReactions db ct itemSharedMId + let ci' = CChatItem md ci {reactions} + r = ACIReaction SCTDirect SMDSnd (DirectChat ct) $ CIReaction CIDirectSnd ci' createdAt reaction + pure $ CRChatItemReaction user r add + _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" + CTGroup -> + withStore (\db -> (,) <$> getGroup db user chatId <*> getGroupChatItem db user chatId itemId) >>= \case + (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}, chatDir}) -> do + unless (groupFeatureAllowed SGFReactions g) $ + throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) + unless (ciReactionAllowed ci) $ + throwChatError $ CECommandError "reaction not allowed - chat item has no content" + let GroupMember {memberId} = membership + itemMemberId = case chatDir of + CIGroupSnd -> memberId + CIGroupRcv GroupMember {memberId = mId} -> mId + rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True + checkReactionAllowed rs + SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) + createdAt <- liftIO getCurrentTime + reactions <- withStore' $ \db -> do + setGroupReaction db g membership itemMemberId itemSharedMId True reaction add msgId createdAt + liftIO $ getGroupCIReactions db g itemMemberId itemSharedMId + let ci' = CChatItem md ci {reactions} + r = ACIReaction SCTGroup SMDSnd (GroupChat g) $ CIReaction CIGroupSnd ci' createdAt reaction + pure $ CRChatItemReaction user r add + _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" + CTContactRequest -> pure $ chatCmdError (Just user) "not supported" + CTContactConnection -> pure $ chatCmdError (Just user) "not supported" + where + checkReactionAllowed rs = do + when ((reaction `elem` rs) == add) $ + throwChatError $ CECommandError $ "reaction already " <> if add then "added" else "removed" + when (add && length rs >= maxMsgReactions) $ + throwChatError $ CECommandError "too many reactions" APIChatRead (ChatRef cType chatId) fromToIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withStore $ \db -> getUserByContactId db chatId @@ -1229,6 +1282,10 @@ processChatCommand = \case chatRef <- getChatRef user chatName let mc = MCText msg processChatCommand $ APIUpdateChatItem chatRef chatItemId live mc + ReactToMessage chatName msg reaction add -> withUser $ \user -> do + chatRef <- getChatRef user chatName + chatItemId <- getChatItemIdByText user chatRef msg + processChatCommand $ APIChatItemReaction chatRef chatItemId reaction add APINewGroup userId gProfile -> withUserId userId $ \user -> do gVar <- asks idsDrg groupInfo <- withStore $ \db -> createNewGroup db gVar user gProfile @@ -2650,6 +2707,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XMsgFileCancel sharedMsgId -> cancelMessageFile ct sharedMsgId msgMeta XMsgUpdate sharedMsgId mContent ttl live -> messageUpdate ct sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId _ -> messageDelete ct sharedMsgId msg msgMeta + XMsgReact sharedMsgId _ reaction add -> directMsgReaction ct sharedMsgId reaction add msg msgMeta -- TODO discontinue XFile XFile fInv -> processFileInvitation' ct fInv msg msgMeta XFileCancel sharedMsgId -> xFileCancel ct sharedMsgId msgMeta @@ -2880,6 +2938,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do XMsgFileCancel sharedMsgId -> cancelGroupMessageFile gInfo m sharedMsgId msgMeta XMsgUpdate sharedMsgId mContent ttl live -> canSend $ groupMessageUpdate gInfo m sharedMsgId mContent msg msgMeta ttl live XMsgDel sharedMsgId memberId -> groupMessageDelete gInfo m sharedMsgId memberId msg + XMsgReact sharedMsgId (Just memberId) reaction add -> groupMsgReaction gInfo m sharedMsgId memberId reaction add msg msgMeta -- TODO discontinue XFile XFile fInv -> processGroupFileInvitation' gInfo m fInv msg msgMeta XFileCancel sharedMsgId -> xFileCancelGroup gInfo m sharedMsgId msgMeta @@ -3260,7 +3319,8 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do where newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live - toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci) + reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ + toView $ CRNewChatItem user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}) pure ci messageFileDescription :: Contact -> SharedMsgId -> FileDescr -> MsgMeta -> m () @@ -3316,20 +3376,17 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageUpdate :: Contact -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () messageUpdate ct@Contact {contactId, localDisplayName = c} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl live_ = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - updateRcvChatItem `catchError` \e -> - case e of - (ChatErrorStore (SEChatItemSharedMsgIdNotFound _)) -> do - -- This patches initial sharedMsgId into chat item when locally deleted chat item - -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). - -- Chat item and update message which created it will have different sharedMsgId in this case... - let timed_ = rcvContactCITimed ct ttl - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live - ci' <- withStore' $ \db -> do - createChatItemVersion db (chatItemId' ci) brokerTs mc - updateDirectChatItem' db user contactId ci content live Nothing - toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') - setActive $ ActiveC c - _ -> throwError e + updateRcvChatItem `catchCINotFound` \_ -> do + -- This patches initial sharedMsgId into chat item when locally deleted chat item + -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). + -- Chat item and update message which created it will have different sharedMsgId in this case... + let timed_ = rcvContactCITimed ct ttl + ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) msgMeta content Nothing timed_ live + ci' <- withStore' $ \db -> do + createChatItemVersion db (chatItemId' ci) brokerTs mc + updateDirectChatItem' db user contactId ci content live Nothing + toView $ CRChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + setActive $ ActiveC c where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -3353,10 +3410,7 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do messageDelete :: Contact -> SharedMsgId -> RcvMessage -> MsgMeta -> m () messageDelete ct@Contact {contactId} sharedMsgId RcvMessage {msgId} msgMeta = do checkIntegrityCreateItem (CDDirectRcv ct) msgMeta - deleteRcvChatItem `catchError` \e -> - case e of - (ChatErrorStore (SEChatItemSharedMsgIdNotFound sMsgId)) -> toView $ CRChatItemDeletedNotFound user ct sMsgId - _ -> throwError e + deleteRcvChatItem `catchCINotFound` (toView . CRChatItemDeletedNotFound user ct) where deleteRcvChatItem = do ci@(CChatItem msgDir _) <- withStore $ \db -> getDirectChatItemBySharedMsgId db user contactId sharedMsgId @@ -3367,8 +3421,60 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do else markDirectCIDeleted user ct ci msgId False >>= toView SMDSnd -> messageError "x.msg.del: contact attempted invalid message delete" + directMsgReaction :: Contact -> SharedMsgId -> MsgReaction -> Bool -> RcvMessage -> MsgMeta -> m () + directMsgReaction ct sharedMsgId reaction add RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do + when (featureAllowed SCFReactions forContact ct) $ do + rs <- withStore' $ \db -> getDirectReactions db ct sharedMsgId False + when (reactionAllowed add reaction rs) $ do + updateChatItemReaction `catchCINotFound` \_ -> + withStore' $ \db -> setDirectReaction db ct sharedMsgId False reaction add msgId brokerTs + where + updateChatItemReaction = do + cr_ <- withStore $ \db -> do + CChatItem md ci <- getDirectChatItemBySharedMsgId db user (contactId' ct) sharedMsgId + if ciReactionAllowed ci + then liftIO $ do + setDirectReaction db ct sharedMsgId False reaction add msgId brokerTs + reactions <- getDirectCIReactions db ct sharedMsgId + let ci' = CChatItem md ci {reactions} + r = ACIReaction SCTDirect SMDRcv (DirectChat ct) $ CIReaction CIDirectRcv ci' brokerTs reaction + pure $ Just $ CRChatItemReaction user r add + else pure Nothing + mapM_ toView cr_ + + groupMsgReaction :: GroupInfo -> GroupMember -> SharedMsgId -> MemberId -> MsgReaction -> Bool -> RcvMessage -> MsgMeta -> m () + groupMsgReaction g@GroupInfo {groupId} m sharedMsgId itemMemberId reaction add RcvMessage {msgId} MsgMeta {broker = (_, brokerTs)} = do + when (groupFeatureAllowed SGFReactions g) $ do + rs <- withStore' $ \db -> getGroupReactions db g m itemMemberId sharedMsgId False + when (reactionAllowed add reaction rs) $ do + updateChatItemReaction `catchCINotFound` \_ -> + withStore' $ \db -> setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs + where + updateChatItemReaction = do + cr_ <- withStore $ \db -> do + CChatItem md ci <- getGroupMemberCIBySharedMsgId db user groupId itemMemberId sharedMsgId + if ciReactionAllowed ci + then liftIO $ do + setGroupReaction db g m itemMemberId sharedMsgId False reaction add msgId brokerTs + reactions <- getGroupCIReactions db g itemMemberId sharedMsgId + let ci' = CChatItem md ci {reactions} + r = ACIReaction SCTGroup SMDRcv (GroupChat g) $ CIReaction (CIGroupRcv m) ci' brokerTs reaction + pure $ Just $ CRChatItemReaction user r add + else pure Nothing + mapM_ toView cr_ + + reactionAllowed :: Bool -> MsgReaction -> [MsgReaction] -> Bool + reactionAllowed add reaction rs = (reaction `elem` rs) /= add && not (add && length rs >= maxMsgReactions) + + catchCINotFound :: m a -> (SharedMsgId -> m a) -> m a + catchCINotFound f handle = + f `catchError` \case + ChatErrorStore (SEChatItemSharedMsgIdNotFound sharedMsgId) -> handle sharedMsgId + e -> throwError e + newGroupContentMessage :: GroupInfo -> GroupMember -> MsgContainer -> RcvMessage -> MsgMeta -> m () - newGroupContentMessage gInfo m@GroupMember {localDisplayName = c} mc msg@RcvMessage {sharedMsgId_} msgMeta = do + newGroupContentMessage gInfo m@GroupMember {localDisplayName = c, memberId} mc msg@RcvMessage {sharedMsgId_} msgMeta = do + -- TODO integrity message check let (ExtMsgContent content fInv_ _ _) = mcExtMsgContent mc if isVoice content && not (groupFeatureAllowed SGFVoice gInfo) then void $ newChatItem (CIRcvGroupFeatureRejected GFVoice) Nothing Nothing False @@ -3385,25 +3491,23 @@ processAgentMessageConn user@User {userId} corrId agentConnId agentMessage = do where newChatItem ciContent ciFile_ timed_ live = do ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg sharedMsgId_ msgMeta ciContent ciFile_ timed_ live - groupMsgToView gInfo m ci msgMeta + reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo memberId sharedMsgId) sharedMsgId_ + groupMsgToView gInfo m ci {reactions} msgMeta pure ci groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> RcvMessage -> MsgMeta -> Maybe Int -> Maybe Bool -> m () groupMessageUpdate gInfo@GroupInfo {groupId, localDisplayName = g} m@GroupMember {groupMemberId, memberId} sharedMsgId mc msg@RcvMessage {msgId} msgMeta ttl_ live_ = - updateRcvChatItem `catchError` \e -> - case e of - (ChatErrorStore (SEChatItemSharedMsgIdNotFound _)) -> do - -- This patches initial sharedMsgId into chat item when locally deleted chat item - -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). - -- Chat item and update message which created it will have different sharedMsgId in this case... - let timed_ = rcvGroupCITimed gInfo ttl_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live - ci' <- withStore' $ \db -> do - createChatItemVersion db (chatItemId' ci) brokerTs mc - updateGroupChatItem db user groupId ci content live Nothing - toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') - setActive $ ActiveG g - _ -> throwError e + updateRcvChatItem `catchCINotFound` \_ -> do + -- This patches initial sharedMsgId into chat item when locally deleted chat item + -- received an update from the sender, so that it can be referenced later (e.g. by broadcast delete). + -- Chat item and update message which created it will have different sharedMsgId in this case... + let timed_ = rcvGroupCITimed gInfo ttl_ + ci <- saveRcvChatItem' user (CDGroupRcv gInfo m) msg (Just sharedMsgId) msgMeta content Nothing timed_ live + ci' <- withStore' $ \db -> do + createChatItemVersion db (chatItemId' ci) brokerTs mc + updateGroupChatItem db user groupId ci content live Nothing + toView $ CRChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo) ci') + setActive $ ActiveG g where MsgMeta {broker = (_, brokerTs)} = msgMeta content = CIRcvMsgContent mc @@ -4306,7 +4410,7 @@ mkChatItem cd ciId content file quotedItem sharedMsgId itemTimed live itemTs cur let itemText = ciContentToText content itemStatus = ciCreateStatus content meta = mkCIMeta ciId content itemText itemStatus sharedMsgId Nothing False itemTimed (justTrue live) tz currentTs itemTs currentTs currentTs - pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, file} + pure ChatItem {chatDir = toCIDirection cd, meta, content, formattedText = parseMaybeMarkdownList itemText, quotedItem, reactions = [], file} deleteDirectCI :: ChatMonad m => User -> Contact -> CChatItem 'CTDirect -> Bool -> Bool -> m ChatResponse deleteDirectCI user ct ci@(CChatItem msgDir deletedItem@ChatItem {file}) byUser timed = do @@ -4656,6 +4760,7 @@ chatCommandP = "/_update item " *> (APIUpdateChatItem <$> chatRefP <* A.space <*> A.decimal <*> liveMessageP <* A.space <*> msgContentP), "/_delete item " *> (APIDeleteChatItem <$> chatRefP <* A.space <*> A.decimal <* A.space <*> ciDeleteMode), "/_delete member item #" *> (APIDeleteMemberChatItem <$> A.decimal <* A.space <*> A.decimal <* A.space <*> A.decimal), + "/_reaction " *> (APIChatItemReaction <$> chatRefP <* A.space <*> A.decimal <* A.space <*> reactionP <* A.space <*> onOffP), "/_read chat " *> (APIChatRead <$> chatRefP <*> optional (A.space *> ((,) <$> ("from=" *> A.decimal) <* A.space <*> ("to=" *> A.decimal)))), "/_unread chat " *> (APIChatUnread <$> chatRefP <* A.space <*> onOffP), "/_delete " *> (APIDeleteChat <$> chatRefP), @@ -4774,6 +4879,7 @@ chatCommandP = ("\\ " <|> "\\") *> (DeleteMessage <$> chatNameP <* A.space <*> textP), ("\\\\ #" <|> "\\\\#") *> (DeleteMemberMessage <$> displayName <* A.space <* char_ '@' <*> displayName <* A.space <*> textP), ("! " <|> "!") *> (EditMessage <$> chatNameP <* A.space <*> (quotedMsg <|> pure "") <*> msgTextP), + (("+" $> True) <|> ("-" $> False)) >>= \add -> reactionP <* A.space >>= \reaction -> ReactToMessage <$> chatNameP' <* A.space <*> textP <*> pure reaction <*> pure add, "/feed " *> (SendMessageBroadcast <$> msgTextP), ("/chats" <|> "/cs") *> (LastChats <$> (" all" $> Nothing <|> Just <$> (A.space *> A.decimal <|> pure 20))), ("/tail" <|> "/t") *> (LastMessages <$> optional (A.space *> chatNameP) <*> msgCountP <*> pure Nothing), @@ -4844,6 +4950,18 @@ chatCommandP = displayName = safeDecodeUtf8 <$> (B.cons <$> A.satisfy refChar <*> A.takeTill (== ' ')) sendMsgQuote msgDir = SendMessageQuote <$> displayName <* A.space <*> pure msgDir <*> quotedMsg <*> msgTextP quotedMsg = safeDecodeUtf8 <$> (A.char '(' *> A.takeTill (== ')') <* A.char ')') <* optional A.space + reactionP = MREmoji <$> (mrEmojiChar <$?> (toEmoji <$> A.anyChar)) + toEmoji = \case + '1' -> '👍' + '+' -> '👍' + '-' -> '👎' + ')' -> '😀' + '!' -> '🎉' + '?' -> '😕' + '*' -> head "❤️" + '^' -> '🚀' + '%' -> '👀' + c -> c refChar c = c > ' ' && c /= '#' && c /= '@' liveMessageP = " live=" *> onOffP <|> pure False sendMessageTTLP = " ttl=" *> ((Just <$> A.decimal) <|> ("default" $> Nothing)) <|> pure Nothing diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 90cc8583ca..05154440ee 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -104,6 +104,7 @@ data ChatConfig = ChatConfig inlineFiles :: InlineFilesConfig, xftpFileConfig :: Maybe XFTPFileConfig, -- Nothing - XFTP is disabled tempDir :: Maybe FilePath, + showReactions :: Bool, subscriptionEvents :: Bool, hostEvents :: Bool, logLevel :: ChatLogLevel, @@ -218,6 +219,7 @@ data ChatCommand | APIUpdateChatItem {chatRef :: ChatRef, chatItemId :: ChatItemId, liveMessage :: Bool, msgContent :: MsgContent} | APIDeleteChatItem ChatRef ChatItemId CIDeleteMode | APIDeleteMemberChatItem GroupId GroupMemberId ChatItemId + | APIChatItemReaction {chatRef :: ChatRef, chatItemId :: ChatItemId, reaction :: MsgReaction, add :: Bool} | APIChatRead ChatRef (Maybe (ChatItemId, ChatItemId)) | APIChatUnread ChatRef Bool | APIDeleteChat ChatRef @@ -319,6 +321,7 @@ data ChatCommand | DeleteMemberMessage GroupName ContactName Text | EditMessage {chatName :: ChatName, editedMsg :: Text, message :: Text} | UpdateLiveMessage {chatName :: ChatName, chatItemId :: ChatItemId, liveMessage :: Bool, message :: Text} + | ReactToMessage {chatName :: ChatName, reactToMessage :: Text, reaction :: MsgReaction, add :: Bool} | APINewGroup UserId GroupProfile | NewGroup GroupProfile | AddMember GroupName ContactName GroupMemberRole @@ -398,6 +401,7 @@ data ChatResponse | CRChatItemStatusUpdated {user :: User, chatItem :: AChatItem} | CRChatItemUpdated {user :: User, chatItem :: AChatItem} | CRChatItemNotChanged {user :: User, chatItem :: AChatItem} + | CRChatItemReaction {user :: User, reaction :: ACIReaction, added :: Bool} | CRChatItemDeleted {user :: User, deletedChatItem :: AChatItem, toChatItem :: Maybe AChatItem, byUser :: Bool, timed :: Bool} | CRChatItemDeletedNotFound {user :: User, contact :: Contact, sharedMsgId :: SharedMsgId} | CRBroadcastSent User MsgContent Int ZonedTime diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index b699cbbc06..7e6d1a0a10 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -137,6 +137,7 @@ data ChatItem (c :: ChatType) (d :: MsgDirection) = ChatItem content :: CIContent d, formattedText :: Maybe MarkdownList, quotedItem :: Maybe (CIQuote c), + reactions :: [CIReactionCount], file :: Maybe (CIFile d) } deriving (Show, Generic) @@ -175,6 +176,11 @@ jsonCIDirection = \case CIGroupSnd -> JCIGroupSnd CIGroupRcv m -> JCIGroupRcv m +data CIReactionCount = CIReactionCount {reaction :: MsgReaction, userReacted :: Bool, totalReacted :: Int} + deriving (Show, Generic) + +instance ToJSON CIReactionCount where toEncoding = J.genericToEncoding J.defaultOptions + data CChatItem c = forall d. MsgDirectionI d => CChatItem (SMsgDirection d) (ChatItem c d) deriving instance Show (CChatItem c) @@ -388,6 +394,33 @@ instance ToJSON (CIQuote c) where toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} +data CIReaction (c :: ChatType) (d :: MsgDirection) = CIReaction + { chatDir :: CIDirection c d, + chatItem :: CChatItem c, + sentAt :: UTCTime, + reaction :: MsgReaction + } + deriving (Show, Generic) + +instance ToJSON (CIReaction c d) where + toJSON = J.genericToJSON J.defaultOptions {J.omitNothingFields = True} + toEncoding = J.genericToEncoding J.defaultOptions {J.omitNothingFields = True} + +data ACIReaction = forall c d. ACIReaction (SChatType c) (SMsgDirection d) (ChatInfo c) (CIReaction c d) + +deriving instance Show ACIReaction + +instance ToJSON ACIReaction where + toJSON (ACIReaction _ _ chat reaction) = J.toJSON $ JSONCIReaction chat reaction + toEncoding (ACIReaction _ _ chat reaction) = J.toEncoding $ JSONCIReaction chat reaction + +data JSONCIReaction c d = JSONCIReaction {chatInfo :: ChatInfo c, chatReaction :: CIReaction c d} + deriving (Generic) + +instance ToJSON (JSONCIReaction c d) where + toJSON = J.genericToJSON J.defaultOptions + toEncoding = J.genericToEncoding J.defaultOptions + data CIQDirection (c :: ChatType) where CIQDirectSnd :: CIQDirection 'CTDirect CIQDirectRcv :: CIQDirection 'CTDirect @@ -766,6 +799,13 @@ instance ToJSON MsgDecryptError where instance FromJSON MsgDecryptError where parseJSON = J.genericParseJSON . enumJSON $ dropPrefix "MDE" +ciReactionAllowed :: ChatItem c d -> Bool +ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False +ciReactionAllowed ChatItem {content} = case content of + CISndMsgContent _ -> True + CIRcvMsgContent _ -> True + _ -> False + ciRequiresAttention :: forall d. MsgDirectionI d => CIContent d -> Bool ciRequiresAttention content = case msgDirection @d of SMDSnd -> True diff --git a/src/Simplex/Chat/Migrations/M20230511_reactions.hs b/src/Simplex/Chat/Migrations/M20230511_reactions.hs new file mode 100644 index 0000000000..f01954a373 --- /dev/null +++ b/src/Simplex/Chat/Migrations/M20230511_reactions.hs @@ -0,0 +1,47 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Migrations.M20230511_reactions where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +m20230511_reactions :: Query +m20230511_reactions = + [sql| +CREATE TABLE chat_item_reactions ( + chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT, + item_member_id BLOB, -- member that created item, NULL for items in direct chats + shared_msg_id BLOB NOT NULL, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- member that sent reaction, NULL for items in direct chats + created_by_msg_id INTEGER REFERENCES messages(message_id) ON DELETE SET NULL, + reaction TEXT NOT NULL, -- JSON of MsgReaction + reaction_sent INTEGER NOT NULL, -- 0 for received, 1 for sent + reaction_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); + +CREATE INDEX idx_chat_item_reactions_shared_msg_id ON chat_item_reactions(shared_msg_id); +CREATE INDEX idx_chat_item_reactions_contact_id ON chat_item_reactions(contact_id); +CREATE INDEX idx_chat_item_reactions_group_id ON chat_item_reactions(group_id); +CREATE INDEX idx_chat_item_reactions_group_member_id ON chat_item_reactions(group_member_id); + +CREATE INDEX idx_chat_item_reactions_contact ON chat_item_reactions(contact_id, shared_msg_id); +CREATE INDEX idx_chat_item_reactions_group ON chat_item_reactions(group_id, shared_msg_id); +|] + +down_m20230511_reactions :: Query +down_m20230511_reactions = + [sql| +DROP INDEX idx_chat_item_reactions_group; +DROP INDEX idx_chat_item_reactions_contact; + +DROP INDEX idx_chat_item_reactions_group_member_id; +DROP INDEX idx_chat_item_reactions_group_id; +DROP INDEX idx_chat_item_reactions_contact_id; +DROP INDEX idx_chat_item_reactions_shared_msg_id; + +DROP TABLE chat_item_reactions; +|] diff --git a/src/Simplex/Chat/Migrations/chat_schema.sql b/src/Simplex/Chat/Migrations/chat_schema.sql index 83a8ec6520..311a701885 100644 --- a/src/Simplex/Chat/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Migrations/chat_schema.sql @@ -463,6 +463,20 @@ CREATE TABLE chat_item_versions( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ); +CREATE TABLE chat_item_reactions( + chat_item_reaction_id INTEGER PRIMARY KEY AUTOINCREMENT, + item_member_id BLOB, -- member that created item, NULL for items in direct chats + shared_msg_id BLOB NOT NULL, + contact_id INTEGER REFERENCES contacts ON DELETE CASCADE, + group_id INTEGER REFERENCES groups ON DELETE CASCADE, + group_member_id INTEGER REFERENCES group_members ON DELETE SET NULL, -- member that sent reaction, NULL for items in direct chats + created_by_msg_id INTEGER REFERENCES messages(message_id) ON DELETE SET NULL, + reaction TEXT NOT NULL, -- JSON of MsgReaction + reaction_sent INTEGER NOT NULL, -- 0 for received, 1 for sent + reaction_ts TEXT NOT NULL, -- broker_ts of creating message for received, created_at for sent + created_at TEXT NOT NULL DEFAULT(datetime('now')), + updated_at TEXT NOT NULL DEFAULT(datetime('now')) +); CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -607,3 +621,21 @@ CREATE INDEX idx_xftp_file_descriptions_user_id ON xftp_file_descriptions( CREATE INDEX idx_chat_item_versions_chat_item_id ON chat_item_versions( chat_item_id ); +CREATE INDEX idx_chat_item_reactions_shared_msg_id ON chat_item_reactions( + shared_msg_id +); +CREATE INDEX idx_chat_item_reactions_contact_id ON chat_item_reactions( + contact_id +); +CREATE INDEX idx_chat_item_reactions_group_id ON chat_item_reactions(group_id); +CREATE INDEX idx_chat_item_reactions_group_member_id ON chat_item_reactions( + group_member_id +); +CREATE INDEX idx_chat_item_reactions_contact ON chat_item_reactions( + contact_id, + shared_msg_id +); +CREATE INDEX idx_chat_item_reactions_group ON chat_item_reactions( + group_id, + shared_msg_id +); diff --git a/src/Simplex/Chat/Mobile.hs b/src/Simplex/Chat/Mobile.hs index 03f7f60e2e..10badd0e2c 100644 --- a/src/Simplex/Chat/Mobile.hs +++ b/src/Simplex/Chat/Mobile.hs @@ -128,6 +128,7 @@ mobileChatOpts dbFilePrefix dbKey = chatCmdDelay = 3, chatServerPort = Nothing, optFilesFolder = Nothing, + showReactions = False, allowInstantFiles = True, muteNotifications = True, maintenance = True diff --git a/src/Simplex/Chat/Options.hs b/src/Simplex/Chat/Options.hs index af4009b787..57b653ca98 100644 --- a/src/Simplex/Chat/Options.hs +++ b/src/Simplex/Chat/Options.hs @@ -35,6 +35,7 @@ data ChatOpts = ChatOpts chatCmdDelay :: Int, chatServerPort :: Maybe String, optFilesFolder :: Maybe FilePath, + showReactions :: Bool, allowInstantFiles :: Bool, muteNotifications :: Bool, maintenance :: Bool @@ -216,6 +217,11 @@ chatOptsP appDir defaultDbFileName = do <> metavar "FOLDER" <> help "Folder to use for sent and received files" ) + showReactions <- + switch + ( long "reactions" + <> help "Show message reactions" + ) allowInstantFiles <- switch ( long "allow-instant-files" @@ -240,6 +246,7 @@ chatOptsP appDir defaultDbFileName = do chatCmdDelay, chatServerPort, optFilesFolder, + showReactions, allowInstantFiles, muteNotifications, maintenance diff --git a/src/Simplex/Chat/Protocol.hs b/src/Simplex/Chat/Protocol.hs index f14efbc101..36ff3927f7 100644 --- a/src/Simplex/Chat/Protocol.hs +++ b/src/Simplex/Chat/Protocol.hs @@ -184,6 +184,7 @@ data ChatMsgEvent (e :: MsgEncoding) where XMsgUpdate :: {msgId :: SharedMsgId, content :: MsgContent, ttl :: Maybe Int, live :: Maybe Bool} -> ChatMsgEvent 'Json XMsgDel :: SharedMsgId -> Maybe MemberId -> ChatMsgEvent 'Json XMsgDeleted :: ChatMsgEvent 'Json + XMsgReact :: {msgId :: SharedMsgId, memberId :: Maybe MemberId, reaction :: MsgReaction, add :: Bool} -> ChatMsgEvent 'Json XFile :: FileInvitation -> ChatMsgEvent 'Json -- TODO discontinue XFileAcpt :: String -> ChatMsgEvent 'Json -- direct file protocol XFileAcptInv :: SharedMsgId -> Maybe ConnReqInvitation -> String -> ChatMsgEvent 'Json @@ -224,6 +225,37 @@ data AChatMsgEvent = forall e. MsgEncodingI e => ACME (SMsgEncoding e) (ChatMsgE deriving instance Show AChatMsgEvent +data MsgReaction = MREmoji {emoji :: MREmojiChar} + deriving (Eq, Show, Generic) + +instance ToJSON MsgReaction where + toEncoding = J.genericToEncoding . taggedObjectJSON $ dropPrefix "MR" + toJSON = J.genericToJSON . taggedObjectJSON $ dropPrefix "MR" + +instance FromJSON MsgReaction where + parseJSON = J.genericParseJSON . taggedObjectJSON $ dropPrefix "MR" + +instance ToField MsgReaction where + toField = toField . encodeJSON + +instance FromField MsgReaction where + fromField = fromTextField_ decodeJSON + +newtype MREmojiChar = MREmojiChar Char + deriving (Eq, Show) + +instance ToJSON MREmojiChar where + toEncoding (MREmojiChar c) = J.toEncoding c + toJSON (MREmojiChar c) = J.toJSON c + +instance FromJSON MREmojiChar where + parseJSON v = mrEmojiChar <$?> J.parseJSON v + +mrEmojiChar :: Char -> Either String MREmojiChar +mrEmojiChar c + | c `elem` ("👍👎😀🎉😕❤️🚀👀" :: String) = Right $ MREmojiChar c + | otherwise = Left "bad emoji" + data FileChunk = FileChunk {chunkNo :: Integer, chunkBytes :: ByteString} | FileChunkCancel deriving (Eq, Show) @@ -473,6 +505,7 @@ data CMEventTag (e :: MsgEncoding) where XMsgUpdate_ :: CMEventTag 'Json XMsgDel_ :: CMEventTag 'Json XMsgDeleted_ :: CMEventTag 'Json + XMsgReact_ :: CMEventTag 'Json XFile_ :: CMEventTag 'Json XFileAcpt_ :: CMEventTag 'Json XFileAcptInv_ :: CMEventTag 'Json @@ -517,6 +550,7 @@ instance MsgEncodingI e => StrEncoding (CMEventTag e) where XMsgUpdate_ -> "x.msg.update" XMsgDel_ -> "x.msg.del" XMsgDeleted_ -> "x.msg.deleted" + XMsgReact_ -> "x.msg.react" XFile_ -> "x.file" XFileAcpt_ -> "x.file.acpt" XFileAcptInv_ -> "x.file.acpt.inv" @@ -562,6 +596,7 @@ instance StrEncoding ACMEventTag where "x.msg.update" -> XMsgUpdate_ "x.msg.del" -> XMsgDel_ "x.msg.deleted" -> XMsgDeleted_ + "x.msg.react" -> XMsgReact_ "x.file" -> XFile_ "x.file.acpt" -> XFileAcpt_ "x.file.acpt.inv" -> XFileAcptInv_ @@ -603,6 +638,7 @@ toCMEventTag msg = case msg of XMsgUpdate {} -> XMsgUpdate_ XMsgDel {} -> XMsgDel_ XMsgDeleted -> XMsgDeleted_ + XMsgReact {} -> XMsgReact_ XFile _ -> XFile_ XFileAcpt _ -> XFileAcpt_ XFileAcptInv {} -> XFileAcptInv_ @@ -690,6 +726,7 @@ appJsonToCM AppMessageJson {msgId, event, params} = do XMsgUpdate_ -> XMsgUpdate <$> p "msgId" <*> p "content" <*> opt "ttl" <*> opt "live" XMsgDel_ -> XMsgDel <$> p "msgId" <*> opt "memberId" XMsgDeleted_ -> pure XMsgDeleted + XMsgReact_ -> XMsgReact <$> p "msgId" <*> opt "memberId" <*> p "reaction" <*> p "add" XFile_ -> XFile <$> p "file" XFileAcpt_ -> XFileAcpt <$> p "fileName" XFileAcptInv_ -> XFileAcptInv <$> p "msgId" <*> opt "fileConnReq" <*> p "fileName" @@ -745,6 +782,7 @@ chatToAppMessage ChatMessage {msgId, chatMsgEvent} = case encoding @e of XMsgUpdate msgId' content ttl live -> o $ ("ttl" .=? ttl) $ ("live" .=? live) ["msgId" .= msgId', "content" .= content] XMsgDel msgId' memberId -> o $ ("memberId" .=? memberId) ["msgId" .= msgId'] XMsgDeleted -> JM.empty + XMsgReact msgId' memberId reaction add -> o $ ("memberId" .=? memberId) ["msgId" .= msgId', "reaction" .= reaction, "add" .= add] XFile fileInv -> o ["file" .= fileInv] XFileAcpt fileName -> o ["fileName" .= fileName] XFileAcptInv sharedMsgId fileConnReq fileName -> o $ ("fileConnReq" .=? fileConnReq) ["msgId" .= sharedMsgId, "fileName" .= fileName] diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index d670c52dc2..54fe584c81 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -228,6 +228,12 @@ module Simplex.Chat.Store getAllChatItems, getAChatItem, getChatItemVersions, + getDirectCIReactions, + getDirectReactions, + setDirectReaction, + getGroupCIReactions, + getGroupReactions, + setGroupReaction, getChatItemIdByAgentMsgId, getDirectChatItem, getDirectChatItemBySharedMsgId, @@ -384,6 +390,7 @@ import Simplex.Chat.Migrations.M20230420_rcv_files_to_receive import Simplex.Chat.Migrations.M20230422_profile_contact_links import Simplex.Chat.Migrations.M20230504_recreate_msg_delivery_events_cleanup_messages import Simplex.Chat.Migrations.M20230505_chat_item_versions +import Simplex.Chat.Migrations.M20230511_reactions import Simplex.Chat.Protocol import Simplex.Chat.Types import Simplex.Chat.Util (week) @@ -461,7 +468,8 @@ schemaMigrations = ("20230420_rcv_files_to_receive", m20230420_rcv_files_to_receive, Just down_m20230420_rcv_files_to_receive), ("20230422_profile_contact_links", m20230422_profile_contact_links, Just down_m20230422_profile_contact_links), ("20230504_recreate_msg_delivery_events_cleanup_messages", m20230504_recreate_msg_delivery_events_cleanup_messages, Just down_m20230504_recreate_msg_delivery_events_cleanup_messages), - ("20230505_chat_item_versions", m20230505_chat_item_versions, Just down_m20230505_chat_item_versions) + ("20230505_chat_item_versions", m20230505_chat_item_versions, Just down_m20230505_chat_item_versions), + ("20230511_reactions", m20230511_reactions, Just down_m20230511_reactions) ] -- | The list of migrations in ascending order by date @@ -3993,17 +4001,17 @@ toPendingContactConnection (pccConnId, acId, pccConnStatus, connReqHash, viaUser getDirectChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTDirect) getDirectChat db user contactId pagination search_ = do let search = fromMaybe "" search_ - case pagination of - CPLast count -> getDirectChatLast_ db user contactId count search - CPAfter afterId count -> getDirectChatAfter_ db user contactId afterId count search - CPBefore beforeId count -> getDirectChatBefore_ db user contactId beforeId count search + ct <- getContact db user contactId + liftIO . getDirectChatReactions_ db ct =<< case pagination of + CPLast count -> getDirectChatLast_ db user ct count search + CPAfter afterId count -> getDirectChatAfter_ db user ct afterId count search + CPBefore beforeId count -> getDirectChatBefore_ db user ct beforeId count search -getDirectChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatLast_ db user contactId count search = do - contact <- getContact db user contactId +getDirectChatLast_ :: DB.Connection -> User -> Contact -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatLast_ db user ct@Contact {contactId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- getDirectChatItemsLast db user contactId count search - pure $ Chat (DirectChat contact) (reverse chatItems) stats + pure $ Chat (DirectChat ct) (reverse chatItems) stats -- the last items in reverse order (the last item in the conversation is the first in the returned list) getDirectChatItemsLast :: DB.Connection -> User -> ContactId -> Int -> String -> ExceptT StoreError IO [CChatItem 'CTDirect] @@ -4030,12 +4038,11 @@ getDirectChatItemsLast db User {userId} contactId count search = ExceptT $ do |] (userId, contactId, search, count) -getDirectChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search = do - contact <- getContact db user contactId +getDirectChatAfter_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatAfter_ db User {userId} ct@Contact {contactId} afterChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsAfter_ - pure $ Chat (DirectChat contact) chatItems stats + pure $ Chat (DirectChat ct) chatItems stats where getDirectChatItemsAfter_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsAfter_ = do @@ -4062,12 +4069,11 @@ getDirectChatAfter_ db user@User {userId} contactId afterChatItemId count search |] (userId, contactId, search, afterChatItemId, count) -getDirectChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) -getDirectChatBefore_ db user@User {userId} contactId beforeChatItemId count search = do - contact <- getContact db user contactId +getDirectChatBefore_ :: DB.Connection -> User -> Contact -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTDirect) +getDirectChatBefore_ db User {userId} ct@Contact {contactId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItems <- ExceptT getDirectChatItemsBefore_ - pure $ Chat (DirectChat contact) (reverse chatItems) stats + pure $ Chat (DirectChat ct) (reverse chatItems) stats where getDirectChatItemsBefore_ :: IO (Either StoreError [CChatItem 'CTDirect]) getDirectChatItemsBefore_ = do @@ -4133,18 +4139,18 @@ getContact db user@User {userId} contactId = getGroupChat :: DB.Connection -> User -> Int64 -> ChatPagination -> Maybe String -> ExceptT StoreError IO (Chat 'CTGroup) getGroupChat db user groupId pagination search_ = do let search = fromMaybe "" search_ - case pagination of - CPLast count -> getGroupChatLast_ db user groupId count search - CPAfter afterId count -> getGroupChatAfter_ db user groupId afterId count search - CPBefore beforeId count -> getGroupChatBefore_ db user groupId beforeId count search + g <- getGroupInfo db user groupId + liftIO . getGroupChatReactions_ db g =<< case pagination of + CPLast count -> getGroupChatLast_ db user g count search + CPAfter afterId count -> getGroupChatAfter_ db user g afterId count search + CPBefore beforeId count -> getGroupChatBefore_ db user g beforeId count search -getGroupChatLast_ :: DB.Connection -> User -> Int64 -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatLast_ db user@User {userId} groupId count search = do - groupInfo <- getGroupInfo db user groupId +getGroupChatLast_ :: DB.Connection -> User -> GroupInfo -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatLast_ db user@User {userId} g@GroupInfo {groupId} count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} chatItemIds <- liftIO getGroupChatItemIdsLast_ chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds - pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats + pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsLast_ :: IO [ChatItemId] getGroupChatItemIdsLast_ = @@ -4176,14 +4182,13 @@ getGroupMemberChatItemLast db user@User {userId} groupId groupMemberId = do (userId, groupId, groupMemberId) getGroupChatItem db user groupId chatItemId -getGroupChatAfter_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatAfter_ db user@User {userId} groupId afterChatItemId count search = do - groupInfo <- getGroupInfo db user groupId +getGroupChatAfter_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatAfter_ db user@User {userId} g@GroupInfo {groupId} afterChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} afterChatItem <- getGroupChatItem db user groupId afterChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsAfter_ (chatItemTs afterChatItem) chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds - pure $ Chat (GroupChat groupInfo) chatItems stats + pure $ Chat (GroupChat g) chatItems stats where getGroupChatItemIdsAfter_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsAfter_ afterChatItemTs = @@ -4200,14 +4205,13 @@ getGroupChatAfter_ db user@User {userId} groupId afterChatItemId count search = |] (userId, groupId, search, afterChatItemTs, afterChatItemTs, afterChatItemId, count) -getGroupChatBefore_ :: DB.Connection -> User -> Int64 -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) -getGroupChatBefore_ db user@User {userId} groupId beforeChatItemId count search = do - groupInfo <- getGroupInfo db user groupId +getGroupChatBefore_ :: DB.Connection -> User -> GroupInfo -> ChatItemId -> Int -> String -> ExceptT StoreError IO (Chat 'CTGroup) +getGroupChatBefore_ db user@User {userId} g@GroupInfo {groupId} beforeChatItemId count search = do let stats = ChatStats {unreadCount = 0, minUnreadItemId = 0, unreadChat = False} beforeChatItem <- getGroupChatItem db user groupId beforeChatItemId chatItemIds <- liftIO $ getGroupChatItemIdsBefore_ (chatItemTs beforeChatItem) chatItems <- mapM (getGroupChatItem db user groupId) chatItemIds - pure $ Chat (GroupChat groupInfo) (reverse chatItems) stats + pure $ Chat (GroupChat g) (reverse chatItems) stats where getGroupChatItemIdsBefore_ :: UTCTime -> IO [ChatItemId] getGroupChatItemIdsBefore_ beforeChatItemTs = @@ -4286,7 +4290,7 @@ getAllChatItems db user@User {userId} pagination search_ = do CPLast count -> liftIO $ getAllChatItemsLast_ count CPAfter afterId count -> liftIO . getAllChatItemsAfter_ afterId count . aChatItemTs =<< getAChatItem db user afterId CPBefore beforeId count -> liftIO . getAllChatItemsBefore_ beforeId count . aChatItemTs =<< getAChatItem db user beforeId - mapM (uncurry $ getAChatItem_ db user) itemRefs + mapM (uncurry (getAChatItem_ db user) >=> liftIO . getACIReactions db) itemRefs where search = fromMaybe "" search_ getAllChatItemsLast_ count = @@ -4833,6 +4837,132 @@ getChatItemVersions db itemId = do toChatItemVersion :: (Int64, MsgContent, UTCTime, UTCTime) -> ChatItemVersion toChatItemVersion (chatItemVersionId, msgContent, itemVersionTs, createdAt) = ChatItemVersion {chatItemVersionId, msgContent, itemVersionTs, createdAt} +getDirectChatReactions_ :: DB.Connection -> Contact -> Chat 'CTDirect -> IO (Chat 'CTDirect) +getDirectChatReactions_ db ct c@Chat {chatItems} = do + chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do + reactions <- maybe (pure []) (getDirectCIReactions db ct) itemSharedMsgId + pure $ CChatItem md ci {reactions} + pure c {chatItems = chatItems'} + +getGroupChatReactions_ :: DB.Connection -> GroupInfo -> Chat 'CTGroup -> IO (Chat 'CTGroup) +getGroupChatReactions_ db g@GroupInfo {membership} c@Chat {chatItems} = do + chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) -> do + let GroupMember {memberId} = membership + itemMemberId = case chatDir of + CIGroupSnd -> memberId + CIGroupRcv GroupMember {memberId = mId} -> mId + reactions <- maybe (pure []) (getGroupCIReactions db g itemMemberId) itemSharedMsgId + pure $ CChatItem md ci {reactions} + pure c {chatItems = chatItems'} + +getDirectCIReactions :: DB.Connection -> Contact -> SharedMsgId -> IO [CIReactionCount] +getDirectCIReactions db Contact {contactId} itemSharedMsgId = + map toCIReaction <$> + DB.query + db + [sql| + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? + GROUP BY reaction + |] + (contactId, itemSharedMsgId) + +getGroupCIReactions :: DB.Connection -> GroupInfo -> MemberId -> SharedMsgId -> IO [CIReactionCount] +getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = + map toCIReaction <$> + DB.query + db + [sql| + SELECT reaction, MAX(reaction_sent), COUNT(chat_item_reaction_id) + FROM chat_item_reactions + WHERE group_id = ? AND item_member_id = ? AND shared_msg_id = ? + GROUP BY reaction + |] + (groupId, itemMemberId, itemSharedMsgId) + +getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem +getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of + Just itemSharedMId -> case chat of + DirectChat ct -> do + reactions <- getDirectCIReactions db ct itemSharedMId + pure $ AChatItem SCTDirect md chat ci {reactions} + GroupChat g@GroupInfo {membership = GroupMember {memberId}} -> do + let itemMemberId = case chatDir of + CIGroupSnd -> memberId + CIGroupRcv GroupMember {memberId = mId} -> mId + reactions <- getGroupCIReactions db g itemMemberId itemSharedMId + pure $ AChatItem SCTGroup md chat ci {reactions} + _ -> pure aci + _ -> pure aci + +toCIReaction :: (MsgReaction, Bool, Int) -> CIReactionCount +toCIReaction (reaction, userReacted, totalReacted) = CIReactionCount {reaction, userReacted, totalReacted} + +getDirectReactions :: DB.Connection -> Contact -> SharedMsgId -> Bool -> IO [MsgReaction] +getDirectReactions db ct itemSharedMId sent = + map fromOnly <$> + DB.query + db + [sql| + SELECT reaction + FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? + |] + (contactId' ct, itemSharedMId, sent) + +setDirectReaction :: DB.Connection -> Contact -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () +setDirectReaction db ct itemSharedMId sent reaction add msgId reactionTs + | add = + DB.execute + db + [sql| + INSERT INTO chat_item_reactions + (contact_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?) + |] + (contactId' ct, itemSharedMId, sent, reaction, msgId, reactionTs) + | otherwise = + DB.execute + db + [sql| + DELETE FROM chat_item_reactions + WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? + |] + (contactId' ct, itemSharedMId, sent, reaction) + +getGroupReactions :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> IO [MsgReaction] +getGroupReactions db GroupInfo {groupId} m itemMemberId itemSharedMId sent = + map fromOnly <$> + DB.query + db + [sql| + SELECT reaction + FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND item_member_id = ? AND shared_msg_id = ? AND reaction_sent = ? + |] + (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent) + +setGroupReaction :: DB.Connection -> GroupInfo -> GroupMember -> MemberId -> SharedMsgId -> Bool -> MsgReaction -> Bool -> MessageId -> UTCTime -> IO () +setGroupReaction db GroupInfo {groupId} m itemMemberId itemSharedMId sent reaction add msgId reactionTs + | add = + DB.execute + db + [sql| + INSERT INTO chat_item_reactions + (group_id, group_member_id, item_member_id, shared_msg_id, reaction_sent, reaction, created_by_msg_id, reaction_ts) + VALUES (?,?,?,?,?,?,?,?) + |] + (groupId, groupMemberId' m, itemMemberId, itemSharedMId, sent, reaction, msgId, reactionTs) + | otherwise = + DB.execute + db + [sql| + DELETE FROM chat_item_reactions + WHERE group_id = ? AND group_member_id = ? AND shared_msg_id = ? AND item_member_id = ? AND reaction_sent = ? AND reaction = ? + |] + (groupId, groupMemberId' m, itemSharedMId, itemMemberId, sent, reaction) + updateDirectCIFileStatus :: forall d. MsgDirectionI d => DB.Connection -> User -> Int64 -> CIFileStatus d -> ExceptT StoreError IO AChatItem updateDirectCIFileStatus db user fileId fileStatus = do aci@(AChatItem cType d cInfo ci) <- getChatItemByFileId db user fileId @@ -5001,7 +5131,7 @@ toDirectChatItem tz currentTs (((itemId, itemTs, AMsgDirection msgDir, itemConte _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTDirect d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTDirect cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toDirectQuote quoteRow, reactions = [], file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTDirect d ciMeta content status = @@ -5054,7 +5184,7 @@ toGroupChatItem tz currentTs userContactId (((itemId, itemTs, AMsgDirection msgD _ -> Nothing cItem :: MsgDirectionI d => SMsgDirection d -> CIDirection 'CTGroup d -> CIStatus d -> CIContent d -> Maybe (CIFile d) -> CChatItem 'CTGroup cItem d chatDir ciStatus content file = - CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, file} + CChatItem d ChatItem {chatDir, meta = ciMeta content ciStatus, content, formattedText = parseMaybeMarkdownList itemText, quotedItem = toGroupQuote quoteRow quotedMember_, reactions = [], file} badItem = Left $ SEBadChatItem itemId ciMeta :: CIContent d -> CIStatus d -> CIMeta 'CTGroup d ciMeta content status = diff --git a/src/Simplex/Chat/Terminal/Input.hs b/src/Simplex/Chat/Terminal/Input.hs index a422a8db7e..719b77b73c 100644 --- a/src/Simplex/Chat/Terminal/Input.hs +++ b/src/Simplex/Chat/Terminal/Input.hs @@ -322,7 +322,7 @@ updateTermState user_ st ac live tw (key, ms) ts@TerminalState {inputString = s, go _ _ = "" charsWithContact cs | live = cs - | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" = + | null s && cs /= "@" && cs /= "#" && cs /= "/" && cs /= ">" && cs /= "\\" && cs /= "!" && cs /= "+" && cs /= "-" = contactPrefix <> cs | (s == ">" || s == "\\" || s == "!") && cs == " " = cs <> contactPrefix diff --git a/src/Simplex/Chat/Terminal/Output.hs b/src/Simplex/Chat/Terminal/Output.hs index 94d76b8a32..ce68d715fe 100644 --- a/src/Simplex/Chat/Terminal/Output.hs +++ b/src/Simplex/Chat/Terminal/Output.hs @@ -122,8 +122,8 @@ runTerminalOutput ct cc@ChatController {outputQ, showLiveItems, logFilePath} = d liveItems <- readTVarIO showLiveItems responseString cc liveItems r >>= printResp where - markChatItemRead (AChatItem _ _ chat item@ChatItem {meta = CIMeta {itemStatus}}) = - case (muted chat item, itemStatus) of + markChatItemRead (AChatItem _ _ chat item@ChatItem {chatDir, meta = CIMeta {itemStatus}}) = + case (muted chat chatDir, itemStatus) of (False, CISRcvNew) -> do let itemId = chatItemId' item chatRef = chatInfoToRef chat diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 0999998861..fbfe7830bc 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -349,13 +349,15 @@ data ChatFeature = CFTimedMessages | CFFullDelete | -- | CFReceipts - CFVoice + CFReactions + | CFVoice | CFCalls deriving (Show, Generic) data SChatFeature (f :: ChatFeature) where SCFTimedMessages :: SChatFeature 'CFTimedMessages SCFFullDelete :: SChatFeature 'CFFullDelete + SCFReactions :: SChatFeature 'CFReactions SCFVoice :: SChatFeature 'CFVoice SCFCalls :: SChatFeature 'CFCalls @@ -369,6 +371,7 @@ chatFeatureNameText :: ChatFeature -> Text chatFeatureNameText = \case CFTimedMessages -> "Disappearing messages" CFFullDelete -> "Full deletion" + CFReactions -> "Message reactions" CFVoice -> "Voice messages" CFCalls -> "Audio/video calls" @@ -391,7 +394,8 @@ allChatFeatures :: [AChatFeature] allChatFeatures = [ ACF SCFTimedMessages, ACF SCFFullDelete, - -- CFReceipts, + -- ACF SCFReceipts, + ACF SCFReactions, ACF SCFVoice, ACF SCFCalls ] @@ -400,7 +404,8 @@ chatPrefSel :: SChatFeature f -> Preferences -> Maybe (FeaturePreference f) chatPrefSel = \case SCFTimedMessages -> timedMessages SCFFullDelete -> fullDelete - -- CFReceipts -> receipts + -- SCFReceipts -> receipts + SCFReactions -> reactions SCFVoice -> voice SCFCalls -> calls @@ -408,6 +413,7 @@ chatFeature :: SChatFeature f -> ChatFeature chatFeature = \case SCFTimedMessages -> CFTimedMessages SCFFullDelete -> CFFullDelete + SCFReactions -> CFReactions SCFVoice -> CFVoice SCFCalls -> CFCalls @@ -425,6 +431,7 @@ instance PreferenceI FullPreferences where SCFTimedMessages -> timedMessages SCFFullDelete -> fullDelete -- CFReceipts -> receipts + SCFReactions -> reactions SCFVoice -> voice SCFCalls -> calls {-# INLINE getPreference #-} @@ -445,6 +452,7 @@ setPreference_ f pref_ prefs = case f of SCFTimedMessages -> prefs {timedMessages = pref_} SCFFullDelete -> prefs {fullDelete = pref_} + SCFReactions -> prefs {reactions = pref_} SCFVoice -> prefs {voice = pref_} SCFCalls -> prefs {calls = pref_} @@ -453,6 +461,7 @@ data Preferences = Preferences { timedMessages :: Maybe TimedMessagesPreference, fullDelete :: Maybe FullDeletePreference, -- receipts :: Maybe SimplePreference, + reactions :: Maybe ReactionsPreference, voice :: Maybe VoicePreference, calls :: Maybe CallsPreference } @@ -473,14 +482,16 @@ data GroupFeature | GFDirectMessages | GFFullDelete | -- | GFReceipts - GFVoice + GFReactions + | GFVoice deriving (Show, Generic) data SGroupFeature (f :: GroupFeature) where SGFTimedMessages :: SGroupFeature 'GFTimedMessages SGFDirectMessages :: SGroupFeature 'GFDirectMessages SGFFullDelete :: SGroupFeature 'GFFullDelete - -- SGFReceipts + -- SGFReceipts :: SGroupFeature 'GFReceipts + SGFReactions :: SGroupFeature 'GFReactions SGFVoice :: SGroupFeature 'GFVoice deriving instance Show (SGroupFeature f) @@ -494,6 +505,7 @@ groupFeatureNameText = \case GFTimedMessages -> "Disappearing messages" GFDirectMessages -> "Direct messages" GFFullDelete -> "Full deletion" + GFReactions -> "Message reactions" GFVoice -> "Voice messages" groupFeatureNameText' :: SGroupFeature f -> Text @@ -519,6 +531,7 @@ allGroupFeatures = AGF SGFDirectMessages, AGF SGFFullDelete, -- GFReceipts, + AGF SGFReactions, AGF SGFVoice ] @@ -528,6 +541,7 @@ groupPrefSel = \case SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete -- GFReceipts -> receipts + SGFReactions -> reactions SGFVoice -> voice toGroupFeature :: SGroupFeature f -> GroupFeature @@ -535,6 +549,7 @@ toGroupFeature = \case SGFTimedMessages -> GFTimedMessages SGFDirectMessages -> GFDirectMessages SGFFullDelete -> GFFullDelete + SGFReactions -> GFReactions SGFVoice -> GFVoice class GroupPreferenceI p where @@ -552,6 +567,7 @@ instance GroupPreferenceI FullGroupPreferences where SGFDirectMessages -> directMessages SGFFullDelete -> fullDelete -- GFReceipts -> receipts + SGFReactions -> reactions SGFVoice -> voice {-# INLINE getGroupPreference #-} @@ -561,6 +577,7 @@ data GroupPreferences = GroupPreferences directMessages :: Maybe DirectMessagesGroupPreference, fullDelete :: Maybe FullDeleteGroupPreference, -- receipts :: Maybe GroupPreference, + reactions :: Maybe ReactionsGroupPreference, voice :: Maybe VoiceGroupPreference } deriving (Eq, Show, Generic, FromJSON) @@ -592,6 +609,7 @@ setGroupPreference_ f pref prefs = toGroupPreferences $ case f of SGFTimedMessages -> prefs {timedMessages = pref} SGFDirectMessages -> prefs {directMessages = pref} + SGFReactions -> prefs {reactions = pref} SGFVoice -> prefs {voice = pref} SGFFullDelete -> prefs {fullDelete = pref} @@ -607,6 +625,7 @@ data FullPreferences = FullPreferences { timedMessages :: TimedMessagesPreference, fullDelete :: FullDeletePreference, -- receipts :: SimplePreference, + reactions :: ReactionsPreference, voice :: VoicePreference, calls :: CallsPreference } @@ -621,6 +640,7 @@ data FullGroupPreferences = FullGroupPreferences directMessages :: DirectMessagesGroupPreference, fullDelete :: FullDeleteGroupPreference, -- receipts :: GroupPreference, + reactions :: ReactionsGroupPreference, voice :: VoiceGroupPreference } deriving (Eq, Show, Generic, FromJSON) @@ -632,6 +652,7 @@ data ContactUserPreferences = ContactUserPreferences { timedMessages :: ContactUserPreference TimedMessagesPreference, fullDelete :: ContactUserPreference FullDeletePreference, -- receipts :: ContactUserPreference, + reactions :: ContactUserPreference ReactionsPreference, voice :: ContactUserPreference VoicePreference, calls :: ContactUserPreference CallsPreference } @@ -656,11 +677,12 @@ instance ToJSON p => ToJSON (ContactUserPref p) where toEncoding = J.genericToEncoding . sumTypeJSON $ dropPrefix "CUP" toChatPrefs :: FullPreferences -> Preferences -toChatPrefs FullPreferences {fullDelete, voice, timedMessages, calls} = +toChatPrefs FullPreferences {timedMessages, fullDelete, reactions, voice, calls} = Preferences { timedMessages = Just timedMessages, fullDelete = Just fullDelete, -- receipts = Just receipts, + reactions = Just reactions, voice = Just voice, calls = Just calls } @@ -671,12 +693,13 @@ defaultChatPrefs = { timedMessages = TimedMessagesPreference {allow = FAYes, ttl = Nothing}, fullDelete = FullDeletePreference {allow = FANo}, -- receipts = SimplePreference {allow = FANo}, + reactions = ReactionsPreference {allow = FAYes}, voice = VoicePreference {allow = FAYes}, calls = CallsPreference {allow = FAYes} } emptyChatPrefs :: Preferences -emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing +emptyChatPrefs = Preferences Nothing Nothing Nothing Nothing Nothing defaultGroupPrefs :: FullGroupPreferences defaultGroupPrefs = @@ -685,11 +708,12 @@ defaultGroupPrefs = directMessages = DirectMessagesGroupPreference {enable = FEOff}, fullDelete = FullDeleteGroupPreference {enable = FEOff}, -- receipts = GroupPreference {enable = FEOff}, + reactions = ReactionsGroupPreference {enable = FEOn}, voice = VoiceGroupPreference {enable = FEOn} } emptyGroupPrefs :: GroupPreferences -emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing +emptyGroupPrefs = GroupPreferences Nothing Nothing Nothing Nothing Nothing data TimedMessagesPreference = TimedMessagesPreference { allow :: FeatureAllowed, @@ -706,6 +730,11 @@ data FullDeletePreference = FullDeletePreference {allow :: FeatureAllowed} instance ToJSON FullDeletePreference where toEncoding = J.genericToEncoding J.defaultOptions +data ReactionsPreference = ReactionsPreference {allow :: FeatureAllowed} + deriving (Eq, Show, Generic, FromJSON) + +instance ToJSON ReactionsPreference where toEncoding = J.genericToEncoding J.defaultOptions + data VoicePreference = VoicePreference {allow :: FeatureAllowed} deriving (Eq, Show, Generic, FromJSON) @@ -727,6 +756,9 @@ instance HasField "allow" TimedMessagesPreference FeatureAllowed where instance HasField "allow" FullDeletePreference FeatureAllowed where hasField p = (\allow -> p {allow}, allow (p :: FullDeletePreference)) +instance HasField "allow" ReactionsPreference FeatureAllowed where + hasField p = (\allow -> p {allow}, allow (p :: ReactionsPreference)) + instance HasField "allow" VoicePreference FeatureAllowed where hasField p = (\allow -> p {allow}, allow (p :: VoicePreference)) @@ -743,6 +775,11 @@ instance FeatureI 'CFFullDelete where sFeature = SCFFullDelete prefParam _ = Nothing +instance FeatureI 'CFReactions where + type FeaturePreference 'CFReactions = ReactionsPreference + sFeature = SCFReactions + prefParam _ = Nothing + instance FeatureI 'CFVoice where type FeaturePreference 'CFVoice = VoicePreference sFeature = SCFVoice @@ -771,6 +808,10 @@ data FullDeleteGroupPreference = FullDeleteGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show, Generic, FromJSON) +data ReactionsGroupPreference = ReactionsGroupPreference + {enable :: GroupFeatureEnabled} + deriving (Eq, Show, Generic, FromJSON) + data VoiceGroupPreference = VoiceGroupPreference {enable :: GroupFeatureEnabled} deriving (Eq, Show, Generic, FromJSON) @@ -781,6 +822,8 @@ instance ToJSON TimedMessagesGroupPreference where toEncoding = J.genericToEncod instance ToJSON DirectMessagesGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions +instance ToJSON ReactionsGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions + instance ToJSON FullDeleteGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions instance ToJSON VoiceGroupPreference where toEncoding = J.genericToEncoding J.defaultOptions @@ -799,6 +842,9 @@ instance HasField "enable" TimedMessagesGroupPreference GroupFeatureEnabled wher instance HasField "enable" DirectMessagesGroupPreference GroupFeatureEnabled where hasField p = (\enable -> p {enable}, enable (p :: DirectMessagesGroupPreference)) +instance HasField "enable" ReactionsGroupPreference GroupFeatureEnabled where + hasField p = (\enable -> p {enable}, enable (p :: ReactionsGroupPreference)) + instance HasField "enable" FullDeleteGroupPreference GroupFeatureEnabled where hasField p = (\enable -> p {enable}, enable (p :: FullDeleteGroupPreference)) @@ -820,6 +866,11 @@ instance GroupFeatureI 'GFFullDelete where sGroupFeature = SGFFullDelete groupPrefParam _ = Nothing +instance GroupFeatureI 'GFReactions where + type GroupFeaturePreference 'GFReactions = ReactionsGroupPreference + sGroupFeature = SGFReactions + groupPrefParam _ = Nothing + instance GroupFeatureI 'GFVoice where type GroupFeaturePreference 'GFVoice = VoiceGroupPreference sGroupFeature = SGFVoice @@ -930,6 +981,7 @@ mergePreferences contactPrefs userPreferences = { timedMessages = pref SCFTimedMessages, fullDelete = pref SCFFullDelete, -- receipts = pref CFReceipts, + reactions = pref SCFReactions, voice = pref SCFVoice, calls = pref SCFCalls } @@ -954,6 +1006,7 @@ mergeGroupPreferences groupPreferences = directMessages = pref SGFDirectMessages, fullDelete = pref SGFFullDelete, -- receipts = pref GFReceipts, + reactions = pref SGFReactions, voice = pref SGFVoice } where @@ -967,6 +1020,7 @@ toGroupPreferences groupPreferences = directMessages = pref SGFDirectMessages, fullDelete = pref SGFFullDelete, -- receipts = pref GFReceipts, + reactions = pref SGFReactions, voice = pref SGFVoice } where @@ -1044,6 +1098,7 @@ contactUserPreferences user userPreferences contactPreferences connectedIncognit { timedMessages = pref SCFTimedMessages, fullDelete = pref SCFFullDelete, -- receipts = pref CFReceipts, + reactions = pref SCFReactions, voice = pref SCFVoice, calls = pref SCFCalls } @@ -1071,6 +1126,7 @@ getContactUserPreference = \case SCFTimedMessages -> timedMessages SCFFullDelete -> fullDelete -- CFReceipts -> receipts + SCFReactions -> reactions SCFVoice -> voice SCFCalls -> calls diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e64d70aa38..8989481a09 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -61,7 +61,7 @@ serializeChatResponse :: Maybe User -> CurrentTime -> TimeZone -> ChatResponse - serializeChatResponse user_ ts tz = unlines . map unStyle . responseToView user_ defaultChatConfig False ts tz responseToView :: Maybe User -> ChatConfig -> Bool -> CurrentTime -> TimeZone -> ChatResponse -> [StyledString] -responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case +responseToView user_ ChatConfig {logLevel, showReactions, testView} liveItems ts tz = \case CRActiveUser User {profile} -> viewUserProfile $ fromLocalProfile profile CRUsersList users -> viewUsersList users CRChatStarted -> ["chat started"] @@ -83,14 +83,17 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case CRConnectionVerified u verified code -> ttyUser u [plain $ if verified then "connection verified" else "connection not verified, current code is " <> code] CRContactCode u ct code -> ttyUser u $ viewContactCode ct code testView CRGroupMemberCode u g m code -> ttyUser u $ viewGroupMemberCode g m code testView - CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts - CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts) chatItems + CRNewChatItem u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewChatItem chat item False ts <> viewItemReactions item + CRChatItems u chatItems -> ttyUser u $ concatMap (\(AChatItem _ _ chat item) -> viewChatItem chat item True ts <> viewItemReactions item) chatItems CRChatItemInfo u ci ciInfo -> ttyUser u $ viewChatItemInfo ci ciInfo tz CRChatItemId u itemId -> ttyUser u [plain $ maybe "no item" show itemId] CRChatItemStatusUpdated u _ -> ttyUser u [] CRChatItemUpdated u (AChatItem _ _ chat item) -> ttyUser u $ unmuted chat item $ viewItemUpdate chat item liveItems ts CRChatItemNotChanged u ci -> ttyUser u $ viewItemNotChanged ci CRChatItemDeleted u (AChatItem _ _ chat deletedItem) toItem byUser timed -> ttyUser u $ unmuted chat deletedItem $ viewItemDelete chat deletedItem toItem byUser timed ts testView + CRChatItemReaction u (ACIReaction _ _ chat reaction) added + | showReactions -> ttyUser u $ unmutedReaction chat reaction $ viewItemReaction chat reaction added ts tz + | otherwise -> [] CRChatItemDeletedNotFound u Contact {localDisplayName = c} _ -> ttyUser u [ttyFrom $ c <> "> [deleted - original message not found]"] CRBroadcastSent u mc n t -> ttyUser u $ viewSentBroadcast mc n ts t CRMsgIntegrityError u mErr -> ttyUser u $ viewMsgIntegrityError mErr @@ -141,7 +144,7 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case CRGroupDeletedUser u g -> ttyUser u [ttyGroup' g <> ": you deleted the group"] CRRcvFileDescrReady _ _ -> [] CRRcvFileDescrNotReady _ _ -> [] - CRRcvFileProgressXFTP _ _ _ _ -> [] + CRRcvFileProgressXFTP {} -> [] CRRcvFileAccepted u ci -> ttyUser u $ savingFile' ci CRRcvFileAcceptedSndCancelled u ft -> ttyUser u $ viewRcvFileSndCancelled ft CRSndFileCancelled u _ ftm fts -> ttyUser u $ viewSndFileCancelled ftm fts @@ -299,8 +302,12 @@ responseToView user_ ChatConfig {logLevel, testView} liveItems ts tz = \case contactList :: [ContactRef] -> String contactList cs = T.unpack . T.intercalate ", " $ map (\ContactRef {localDisplayName = n} -> "@" <> n) cs unmuted :: ChatInfo c -> ChatItem c d -> [StyledString] -> [StyledString] - unmuted chat chatItem s - | muted chat chatItem = [] + unmuted chat ChatItem {chatDir} = unmuted' chat chatDir + unmutedReaction :: ChatInfo c -> CIReaction c d -> [StyledString] -> [StyledString] + unmutedReaction chat CIReaction {chatDir} = unmuted' chat chatDir + unmuted' :: ChatInfo c -> CIDirection c d -> [StyledString] -> [StyledString] + unmuted' chat chatDir s + | muted chat chatDir = [] | otherwise = s chatItemDeletedText :: ChatItem c d -> Maybe GroupMember -> Maybe Text @@ -330,8 +337,8 @@ viewUsersList = mapMaybe userInfo . sortOn ldn <> ["muted" | not showNtfs] <> [plain ("unread: " <> show count) | count /= 0] -muted :: ChatInfo c -> ChatItem c d -> Bool -muted chat ChatItem {chatDir} = case (chat, chatDir) of +muted :: ChatInfo c -> CIDirection c d -> Bool +muted chat chatDir = case (chat, chatDir) of (DirectChat Contact {chatSettings = DisableNtfs}, CIDirectRcv) -> True (GroupChat GroupInfo {chatSettings = DisableNtfs}, CIGroupRcv _) -> True _ -> False @@ -504,6 +511,38 @@ viewItemDelete chat ChatItem {chatDir, meta, content = deletedContent} toItem by Just (AChatItem _ _ _ ci) -> chatItemDeletedText ci $ chatInfoMembership chat prohibited = [styled (colored Red) ("[unexpected message deletion, please report to developers]" :: String)] +viewItemReaction :: forall c d. ChatInfo c -> CIReaction c d -> Bool -> CurrentTime -> TimeZone -> [StyledString] +viewItemReaction chat CIReaction {chatDir, chatItem = CChatItem md ChatItem {chatDir = itemDir, content}, sentAt, reaction} added ts tz = + case (chat, chatDir) of + (DirectChat c, CIDirectRcv) -> case content of + CIRcvMsgContent mc -> view from $ reactionMsg mc + CISndMsgContent mc -> view from $ reactionMsg mc + _ -> [] + where + from = ttyFromContact c + reactionMsg mc = quoteText mc $ if toMsgDirection md == MDSnd then ">>" else ">" + (GroupChat g, CIGroupRcv m) -> case content of + CIRcvMsgContent mc -> view from $ reactionMsg mc + CISndMsgContent mc -> view from $ reactionMsg mc + _ -> [] + where + from = ttyFromGroup g m + reactionMsg mc = quoteText mc . ttyQuotedMember . Just $ sentByMember' g itemDir + (_, CIDirectSnd) -> [sentText] + (_, CIGroupSnd) -> [sentText] + where + view from msg = viewReceivedReaction from msg reactionText ts $ utcToZonedTime tz sentAt + reactionText = plain $ (if added then "+ " else "- ") <> [emoji] + MREmoji (MREmojiChar emoji) = reaction + sentText = plain $ (if added then "added " else "removed ") <> [emoji] + +viewItemReactions :: ChatItem c d -> [StyledString] +viewItemReactions ChatItem {reactions} = [" " <> viewReactions reactions | not (null reactions)] + where + viewReactions = mconcat . intersperse " " . map viewReaction + viewReaction CIReactionCount {reaction = MREmoji (MREmojiChar emoji), userReacted, totalReacted} = + plain [emoji, ' '] <> (if userReacted then styled Italic else plain) (show totalReacted) + directQuote :: forall d'. MsgDirectionI d' => CIDirection 'CTDirect d' -> CIQuote 'CTDirect -> [StyledString] directQuote _ CIQuote {content = qmc, chatDir = quoteDir} = quoteText qmc $ if toMsgDirection (msgDirection @d') == quoteMsgDirection quoteDir then ">>" else ">" @@ -516,6 +555,11 @@ sentByMember GroupInfo {membership} = \case CIQGroupSnd -> Just membership CIQGroupRcv m -> m +sentByMember' :: GroupInfo -> CIDirection 'CTGroup d -> GroupMember +sentByMember' GroupInfo {membership} = \case + CIGroupSnd -> membership + CIGroupRcv m -> m + quoteText :: MsgContent -> StyledString -> [StyledString] quoteText qmc sentBy = prependFirst (sentBy <> " ") $ msgPreview qmc @@ -1037,6 +1081,10 @@ viewReceivedUpdatedMessage = viewReceivedMessage_ True viewReceivedMessage_ :: Bool -> StyledString -> [StyledString] -> MsgContent -> CurrentTime -> CIMeta c d -> [StyledString] viewReceivedMessage_ updated from quote mc ts meta = receivedWithTime_ ts from quote meta (ttyMsgContent mc) updated +viewReceivedReaction :: StyledString -> [StyledString] -> StyledString -> CurrentTime -> ZonedTime -> [StyledString] +viewReceivedReaction from styledMsg reactionText ts reactionTs = + prependFirst (ttyMsgTime ts reactionTs <> " " <> from) (styledMsg <> [" " <> reactionText]) + receivedWithTime_ :: CurrentTime -> StyledString -> [StyledString] -> CIMeta c d -> [StyledString] -> Bool -> [StyledString] receivedWithTime_ ts from quote CIMeta {localItemTs, itemId, itemEdited, itemDeleted, itemLive} styledMsg updated = do prependFirst (ttyMsgTime ts localItemTs <> " " <> from) (quote <> prependFirst (indent <> live) styledMsg) diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 097f6aed80..8359e72e33 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -72,6 +72,7 @@ testOpts = chatCmdDelay = 3, chatServerPort = Nothing, optFilesFolder = Nothing, + showReactions = True, allowInstantFiles = True, muteNotifications = True, maintenance = False diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 96400585c3..67f6171a1d 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -85,6 +85,8 @@ chatDirectTests = do it "mark group member verified" testMarkGroupMemberVerified describe "message errors" $ do xit "show message decryption error and update count" testMsgDecryptError + describe "message reactions" $ do + it "set message reactions" testSetMessageReactions testAddContact :: HasCallStack => SpecWith FilePath testAddContact = versionTestMatrix2 runTestAddContact @@ -421,13 +423,13 @@ testDirectLiveMessage = connectUsers alice bob -- non-empty live message is sent instantly alice `send` "/live @bob hello" - bob <# "alice> [LIVE started] use /show [on/off/5] hello" + bob <# "alice> [LIVE started] use /show [on/off/6] hello" alice ##> ("/_update item @2 " <> itemId 1 <> " text hello there") alice <# "@bob [LIVE] hello there" bob <# "alice> [LIVE ended] hello there" -- empty live message is also sent instantly alice `send` "/live @bob" - bob <# "alice> [LIVE started] use /show [on/off/6]" + bob <# "alice> [LIVE started] use /show [on/off/7]" alice ##> ("/_update item @2 " <> itemId 2 <> " text hello 2") alice <# "@bob [LIVE] hello 2" bob <# "alice> [LIVE ended] hello 2" @@ -1691,14 +1693,15 @@ testUserPrivacy = alice <##? chatHistory alice ##> "/_get items count=10" alice <##? chatHistory - alice ##> "/_get items before=9 count=10" + alice ##> "/_get items before=11 count=10" alice <##? [ "bob> Disappearing messages: allowed", "bob> Full deletion: off", + "bob> Message reactions: enabled", "bob> Voice messages: enabled", "bob> Audio/video calls: enabled" ] - alice ##> "/_get items after=8 count=10" + alice ##> "/_get items after=10 count=10" alice <##? [ "@bob hello", "bob> hey", @@ -1756,6 +1759,7 @@ testUserPrivacy = chatHistory = [ "bob> Disappearing messages: allowed", "bob> Full deletion: off", + "bob> Message reactions: enabled", "bob> Voice messages: enabled", "bob> Audio/video calls: enabled", "@bob hello", @@ -1938,3 +1942,51 @@ testMsgDecryptError tmp = copyDb from to = do copyFile (chatStoreFile $ tmp from) (chatStoreFile $ tmp to) copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + +testSetMessageReactions :: HasCallStack => FilePath -> IO () +testSetMessageReactions = + testChat2 aliceProfile bobProfile $ + \alice bob -> do + connectUsers alice bob + alice #> "@bob hi" + bob <# "alice> hi" + bob ##> "+1 alice hi" + bob <## "added 👍" + alice <# "bob> >> hi" + alice <## " + 👍" + bob ##> "+1 alice hi" + bob <## "bad chat command: reaction already added" + bob ##> "+^ alice hi" + bob <## "added 🚀" + alice <# "bob> >> hi" + alice <## " + 🚀" + alice ##> "/tail @bob 1" + alice <# "@bob hi" + alice <## " 👍 1 🚀 1" + bob ##> "/tail @alice 1" + bob <# "alice> hi" + bob <## " 👍 1 🚀 1" + alice ##> "+1 bob hi" + alice <## "added 👍" + bob <# "alice> > hi" + bob <## " + 👍" + alice ##> "/tail @bob 1" + alice <# "@bob hi" + alice <## " 👍 2 🚀 1" + bob ##> "/tail @alice 1" + bob <# "alice> hi" + bob <## " 👍 2 🚀 1" + bob ##> "-1 alice hi" + bob <## "removed 👍" + alice <# "bob> >> hi" + alice <## " - 👍" + bob ##> "-^ alice hi" + bob <## "removed 🚀" + alice <# "bob> >> hi" + alice <## " - 🚀" + alice ##> "/tail @bob 1" + alice <# "@bob hi" + alice <## " 👍 1" + bob ##> "/tail @alice 1" + bob <# "alice> hi" + bob <## " 👍 1" diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 79af235cd6..3319ef8486 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -54,6 +54,8 @@ chatGroupTests = do it "leaving and deleting the group joined via link should NOT delete previously existing direct contacts" testGroupLinkLeaveDelete describe "group message errors" $ do xit "show message decryption error and update count" testGroupMsgDecryptError + describe "message reactions" $ do + it "set group message reactions" testSetGroupMessageReactions testGroup :: HasCallStack => SpecWith FilePath testGroup = versionTestMatrix3 runTestGroup @@ -1289,6 +1291,7 @@ testGroupDescription = testChat4 aliceProfile bobProfile cathProfile danProfile alice <## "Disappearing messages: off" alice <## "Direct messages: on" alice <## "Full deletion: off" + alice <## "Message reactions: on" alice <## "Voice messages: on" bobAddedDan :: HasCallStack => TestCC -> IO () bobAddedDan cc = do @@ -2155,3 +2158,72 @@ testGroupMsgDecryptError tmp = copyDb from to = do copyFile (chatStoreFile $ tmp from) (chatStoreFile $ tmp to) copyFile (agentStoreFile $ tmp from) (agentStoreFile $ tmp to) + +testSetGroupMessageReactions :: HasCallStack => FilePath -> IO () +testSetGroupMessageReactions = + testChat3 aliceProfile bobProfile cathProfile $ + \alice bob cath -> do + createGroup3 "team" alice bob cath + threadDelay 1000000 + alice #> "#team hi" + bob <# "#team alice> hi" + cath <# "#team alice> hi" + bob ##> "+1 #team hi" + bob <## "added 👍" + alice <# "#team bob> > alice hi" + alice <## " + 👍" + cath <# "#team bob> > alice hi" + cath <## " + 👍" + bob ##> "+1 #team hi" + bob <## "bad chat command: reaction already added" + bob ##> "+^ #team hi" + bob <## "added 🚀" + alice <# "#team bob> > alice hi" + alice <## " + 🚀" + cath <# "#team bob> > alice hi" + cath <## " + 🚀" + alice ##> "/tail #team 1" + alice <# "#team hi" + alice <## " 👍 1 🚀 1" + bob ##> "/tail #team 1" + bob <# "#team alice> hi" + bob <## " 👍 1 🚀 1" + bob ##> "/tail #team 1" + bob <# "#team alice> hi" + bob <## " 👍 1 🚀 1" + alice ##> "+1 #team hi" + alice <## "added 👍" + bob <# "#team alice> > alice hi" + bob <## " + 👍" + cath <# "#team alice> > alice hi" + cath <## " + 👍" + alice ##> "/tail #team 1" + alice <# "#team hi" + alice <## " 👍 2 🚀 1" + bob ##> "/tail #team 1" + bob <# "#team alice> hi" + bob <## " 👍 2 🚀 1" + cath ##> "/tail #team 1" + cath <# "#team alice> hi" + cath <## " 👍 2 🚀 1" + bob ##> "-1 #team hi" + bob <## "removed 👍" + alice <# "#team bob> > alice hi" + alice <## " - 👍" + cath <# "#team bob> > alice hi" + cath <## " - 👍" + bob ##> "-^ #team hi" + bob <## "removed 🚀" + alice <# "#team bob> > alice hi" + alice <## " - 🚀" + cath <# "#team bob> > alice hi" + cath <## " - 🚀" + alice ##> "/tail #team 1" + alice <# "#team hi" + alice <## " 👍 1" + bob ##> "/tail #team 1" + bob <# "#team alice> hi" + bob <## " 👍 1" + cath ##> "/tail #team 1" + cath <# "#team alice> hi" + cath <## " 👍 1" diff --git a/tests/ChatTests/Profiles.hs b/tests/ChatTests/Profiles.hs index 6bbd4b1447..ccb638fb18 100644 --- a/tests/ChatTests/Profiles.hs +++ b/tests/ChatTests/Profiles.hs @@ -1054,7 +1054,7 @@ testSetContactPrefs = testChat2 aliceProfile bobProfile $ alice ##> "/_set prefs @2 {}" alice <## "your preferences for bob did not change" (bob ("/_get chat @2 count=100", chat, startFeatures) bob #$> ("/_get chat @2 count=100", chat, startFeatures) let sendVoice = "/_send @2 json {\"filePath\": \"test.txt\", \"msgContent\": {\"type\": \"voice\", \"text\": \"\", \"duration\": 10}}" diff --git a/tests/ChatTests/Utils.hs b/tests/ChatTests/Utils.hs index 5abfbb0136..5b9a993486 100644 --- a/tests/ChatTests/Utils.hs +++ b/tests/ChatTests/Utils.hs @@ -183,7 +183,13 @@ chatFeaturesF :: [((Int, String), Maybe String)] chatFeaturesF = map (\(a, _, c) -> (a, c)) chatFeatures'' chatFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] -chatFeatures'' = [((0, "Disappearing messages: allowed"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Voice messages: enabled"), Nothing, Nothing), ((0, "Audio/video calls: enabled"), Nothing, Nothing)] +chatFeatures'' = + [ ((0, "Disappearing messages: allowed"), Nothing, Nothing), + ((0, "Full deletion: off"), Nothing, Nothing), + ((0, "Message reactions: enabled"), Nothing, Nothing), + ((0, "Voice messages: enabled"), Nothing, Nothing), + ((0, "Audio/video calls: enabled"), Nothing, Nothing) + ] lastChatFeature :: String lastChatFeature = snd $ last chatFeatures @@ -192,7 +198,13 @@ groupFeatures :: [(Int, String)] groupFeatures = map (\(a, _, _) -> a) groupFeatures'' groupFeatures'' :: [((Int, String), Maybe (Int, String), Maybe String)] -groupFeatures'' = [((0, "Disappearing messages: off"), Nothing, Nothing), ((0, "Direct messages: on"), Nothing, Nothing), ((0, "Full deletion: off"), Nothing, Nothing), ((0, "Voice messages: on"), Nothing, Nothing)] +groupFeatures'' = + [ ((0, "Disappearing messages: off"), Nothing, Nothing), + ((0, "Direct messages: on"), Nothing, Nothing), + ((0, "Full deletion: off"), Nothing, Nothing), + ((0, "Message reactions: on"), Nothing, Nothing), + ((0, "Voice messages: on"), Nothing, Nothing) + ] itemId :: Int -> String itemId i = show $ length chatFeatures + i diff --git a/tests/MobileTests.hs b/tests/MobileTests.hs index 92119aa8fb..339dee6bb2 100644 --- a/tests/MobileTests.hs +++ b/tests/MobileTests.hs @@ -33,9 +33,9 @@ activeUserExists = "{\"resp\":{\"type\":\"chatCmdError\",\"user_\":{\"userId\":1 activeUser :: String #if defined(darwin_HOST_OS) && defined(swiftJSON) -activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}}" +activeUser = "{\"resp\":{\"activeUser\":{\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}}" #else -activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}" +activeUser = "{\"resp\":{\"type\":\"activeUser\",\"user\":{\"userId\":1,\"agentUserId\":\"1\",\"userContactId\":1,\"localDisplayName\":\"alice\",\"profile\":{\"profileId\":1,\"displayName\":\"alice\",\"fullName\":\"Alice\",\"localAlias\":\"\"},\"fullPreferences\":{\"timedMessages\":{\"allow\":\"yes\"},\"fullDelete\":{\"allow\":\"no\"},\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"},\"calls\":{\"allow\":\"yes\"}},\"activeUser\":true,\"showNtfs\":true}}}" #endif chatStarted :: String diff --git a/tests/ProtocolTests.hs b/tests/ProtocolTests.hs index dcc7e88c9f..601f136615 100644 --- a/tests/ProtocolTests.hs +++ b/tests/ProtocolTests.hs @@ -86,10 +86,10 @@ s #==# msg = do s ==# msg testChatPreferences :: Maybe Preferences -testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing} +testChatPreferences = Just Preferences {voice = Just VoicePreference {allow = FAYes}, fullDelete = Nothing, timedMessages = Nothing, calls = Nothing, reactions = Just ReactionsPreference {allow = FAYes}} testGroupPreferences :: Maybe GroupPreferences -testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, voice = Just VoiceGroupPreference {enable = FEOn}, fullDelete = Nothing} +testGroupPreferences = Just GroupPreferences {timedMessages = Nothing, directMessages = Nothing, reactions = Just ReactionsGroupPreference {enable = FEOn}, voice = Just VoiceGroupPreference {enable = FEOn}, fullDelete = Nothing} testProfile :: Profile testProfile = Profile {displayName = "alice", fullName = "Alice", image = Just (ImageData "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII="), contactLink = Nothing, preferences = testChatPreferences} @@ -194,46 +194,46 @@ decodeChatMessageTest = describe "Chat message encoding/decoding" $ do "{\"event\":\"x.file.cancel\",\"params\":{\"msgId\":\"AQIDBA==\"}}" #==# XFileCancel (SharedMsgId "\1\2\3\4") it "x.info" $ - "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo testProfile it "x.info with empty full name" $ - "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"event\":\"x.info\",\"params\":{\"profile\":{\"fullName\":\"\",\"displayName\":\"alice\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XInfo Profile {displayName = "alice", fullName = "", image = Nothing, contactLink = Nothing, preferences = testChatPreferences} it "x.contact with xContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"contactReqId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile (Just $ XContactId "\1\2\3\4") it "x.contact without XContactId" $ - "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XContact testProfile Nothing it "x.contact with content null" $ - "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"content\":null,\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.contact with content (ignored)" $ - "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"event\":\"x.contact\",\"params\":{\"content\":{\"text\":\"hello\",\"type\":\"text\"},\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" ==# XContact testProfile Nothing it "x.grp.inv" $ - "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" + "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Nothing} it "x.grp.inv with group link id" $ - "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" + "{\"event\":\"x.grp.inv\",\"params\":{\"groupInvitation\":{\"connRequest\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"invitedMember\":{\"memberRole\":\"member\",\"memberId\":\"BQYHCA==\"},\"groupProfile\":{\"fullName\":\"Team\",\"displayName\":\"team\",\"groupPreferences\":{\"reactions\":{\"enable\":\"on\"},\"voice\":{\"enable\":\"on\"}}},\"fromMember\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\"}, \"groupLinkId\":\"AQIDBA==\"}}}" #==# XGrpInv GroupInvitation {fromMember = MemberIdRole (MemberId "\1\2\3\4") GRAdmin, invitedMember = MemberIdRole (MemberId "\5\6\7\8") GRMember, connRequest = testConnReq, groupProfile = testGroupProfile, groupLinkId = Just $ GroupLinkId "\1\2\3\4"} it "x.grp.acpt without incognito profile" $ "{\"event\":\"x.grp.acpt\",\"params\":{\"memberId\":\"AQIDBA==\"}}" #==# XGrpAcpt (MemberId "\1\2\3\4") it "x.grp.mem.new" $ - "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"event\":\"x.grp.mem.new\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemNew MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} it "x.grp.mem.intro" $ - "{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"event\":\"x.grp.mem.intro\",\"params\":{\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemIntro MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} it "x.grp.mem.inv" $ "{\"event\":\"x.grp.mem.inv\",\"params\":{\"memberId\":\"AQIDBA==\",\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"}}}" #==# XGrpMemInv (MemberId "\1\2\3\4") IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} it "x.grp.mem.fwd" $ - "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}}" + "{\"event\":\"x.grp.mem.fwd\",\"params\":{\"memberIntro\":{\"directConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\",\"groupConnReq\":\"https://simplex.chat/invitation#/?v=1&smp=smp%3A%2F%2F1234-w%3D%3D%40smp.simplex.im%3A5223%2F3456-w%3D%3D%23%2F%3Fv%3D1-2%26dh%3DMCowBQYDK2VuAyEAjiswwI3O_NlS8Fk3HJUW870EY2bAwmttMBsvRB9eV3o%253D&e2e=v%3D1-2%26x3dh%3DMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D%2CMEIwBQYDK2VvAzkAmKuSYeQ_m0SixPDS8Wq8VBaTS1cW-Lp0n0h4Diu-kUpR-qXx4SDJ32YGEFoGFGSbGPry5Ychr6U%3D\"},\"memberInfo\":{\"memberRole\":\"admin\",\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}}" #==# XGrpMemFwd MemberInfo {memberId = MemberId "\1\2\3\4", memberRole = GRAdmin, profile = testProfile} IntroInvitation {groupConnReq = testConnReq, directConnReq = testConnReq} it "x.grp.mem.info" $ - "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"voice\":{\"allow\":\"yes\"}}}}}" + "{\"event\":\"x.grp.mem.info\",\"params\":{\"memberId\":\"AQIDBA==\",\"profile\":{\"fullName\":\"Alice\",\"displayName\":\"alice\",\"image\":\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAIAQMAAAD+wSzIAAAABlBMVEX///+/v7+jQ3Y5AAAADklEQVQI12P4AIX8EAgALgAD/aNpbtEAAAAASUVORK5CYII=\",\"preferences\":{\"reactions\":{\"allow\":\"yes\"},\"voice\":{\"allow\":\"yes\"}}}}}" #==# XGrpMemInfo (MemberId "\1\2\3\4") testProfile it "x.grp.mem.con" $ "{\"event\":\"x.grp.mem.con\",\"params\":{\"memberId\":\"AQIDBA==\"}}" From 817c0a5672af0acef955439f9887bbf34a3f00a6 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 15 May 2023 13:43:22 +0200 Subject: [PATCH 6/8] core: delete message reaction (#2438) * core: delete message reaction * remove unused name * refactor * remove unused names * refactor 2 --- src/Simplex/Chat.hs | 7 ++---- src/Simplex/Chat/Messages.hs | 5 +++++ src/Simplex/Chat/Store.hs | 42 ++++++++++++++++++++++++------------ 3 files changed, 35 insertions(+), 19 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index b327880736..99f4492028 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -753,15 +753,12 @@ processChatCommand = \case _ -> throwChatError $ CECommandError "reaction not possible - no shared item ID" CTGroup -> withStore (\db -> (,) <$> getGroup db user chatId <*> getGroupChatItem db user chatId itemId) >>= \case - (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}, chatDir}) -> do + (Group g@GroupInfo {membership} ms, CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId = Just itemSharedMId}}) -> do unless (groupFeatureAllowed SGFReactions g) $ throwChatError $ CECommandError $ "feature not allowed " <> T.unpack (chatFeatureNameText CFReactions) unless (ciReactionAllowed ci) $ throwChatError $ CECommandError "reaction not allowed - chat item has no content" - let GroupMember {memberId} = membership - itemMemberId = case chatDir of - CIGroupSnd -> memberId - CIGroupRcv GroupMember {memberId = mId} -> mId + let GroupMember {memberId = itemMemberId} = chatItemMember g ci rs <- withStore' $ \db -> getGroupReactions db g membership itemMemberId itemSharedMId True checkReactionAllowed rs SndMessage {msgId} <- sendGroupMessage user g ms (XMsgReact itemSharedMId (Just itemMemberId) reaction add) diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 7e6d1a0a10..a3841412eb 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -204,6 +204,11 @@ chatItemTs' ChatItem {meta = CIMeta {itemTs}} = itemTs chatItemTimed :: ChatItem c d -> Maybe CITimed chatItemTimed ChatItem {meta = CIMeta {itemTimed}} = itemTimed +chatItemMember :: GroupInfo -> ChatItem 'CTGroup d -> GroupMember +chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of + CIGroupSnd -> membership + CIGroupRcv m -> m + data CIDeletedState = CIDeletedState { markedDeleted :: Bool, deletedByMember :: Maybe GroupMember diff --git a/src/Simplex/Chat/Store.hs b/src/Simplex/Chat/Store.hs index 54fe584c81..47cac9d038 100644 --- a/src/Simplex/Chat/Store.hs +++ b/src/Simplex/Chat/Store.hs @@ -3443,6 +3443,7 @@ deleteContactCIs db user@User {userId} ct@Contact {contactId} = do connIds <- getContactConnIds_ db user ct forM_ connIds $ \connId -> DB.execute db "DELETE FROM messages WHERE connection_id = ?" (Only connId) + DB.execute db "DELETE FROM chat_item_reactions WHERE contact_id = ?" (Only contactId) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ?" (userId, contactId) getContactConnIds_ :: DB.Connection -> User -> Contact -> IO [Int64] @@ -3458,6 +3459,7 @@ getGroupFileInfo db User {userId} GroupInfo {groupId} = deleteGroupCIs :: DB.Connection -> User -> GroupInfo -> IO () deleteGroupCIs db User {userId} GroupInfo {groupId} = do DB.execute db "DELETE FROM messages WHERE group_id = ?" (Only groupId) + DB.execute db "DELETE FROM chat_item_reactions WHERE group_id = ?" (Only groupId) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ?" (userId, groupId) createNewSndMessage :: MsgEncodingI e => DB.Connection -> TVar ChaChaDRG -> ConnOrGroupId -> (SharedMsgId -> NewMessage e) -> ExceptT StoreError IO SndMessage @@ -4442,6 +4444,7 @@ deleteDirectChatItem db User {userId} Contact {contactId} (CChatItem _ ci) = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId + deleteDirectCIReactions_ db contactId ci DB.execute db [sql| @@ -4582,10 +4585,11 @@ updateGroupChatItem_ db User {userId} groupId ChatItem {content, meta} msgId_ = forM_ msgId_ $ \msgId -> insertChatItemMessage_ db itemId msgId updatedAt deleteGroupChatItem :: DB.Connection -> User -> GroupInfo -> CChatItem 'CTGroup -> IO () -deleteGroupChatItem db User {userId} GroupInfo {groupId} (CChatItem _ ci) = do +deleteGroupChatItem db User {userId} g@GroupInfo {groupId} (CChatItem _ ci) = do let itemId = chatItemId' ci deleteChatItemMessages_ db itemId deleteChatItemVersions_ db itemId + deleteGroupCIReactions_ db g ci DB.execute db [sql| @@ -4845,13 +4849,10 @@ getDirectChatReactions_ db ct c@Chat {chatItems} = do pure c {chatItems = chatItems'} getGroupChatReactions_ :: DB.Connection -> GroupInfo -> Chat 'CTGroup -> IO (Chat 'CTGroup) -getGroupChatReactions_ db g@GroupInfo {membership} c@Chat {chatItems} = do - chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) -> do - let GroupMember {memberId} = membership - itemMemberId = case chatDir of - CIGroupSnd -> memberId - CIGroupRcv GroupMember {memberId = mId} -> mId - reactions <- maybe (pure []) (getGroupCIReactions db g itemMemberId) itemSharedMsgId +getGroupChatReactions_ db g c@Chat {chatItems} = do + chatItems' <- forM chatItems $ \(CChatItem md ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- maybe (pure []) (getGroupCIReactions db g memberId) itemSharedMsgId pure $ CChatItem md ci {reactions} pure c {chatItems = chatItems'} @@ -4882,20 +4883,31 @@ getGroupCIReactions db GroupInfo {groupId} itemMemberId itemSharedMsgId = (groupId, itemMemberId, itemSharedMsgId) getACIReactions :: DB.Connection -> AChatItem -> IO AChatItem -getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {chatDir, meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of +getACIReactions db aci@(AChatItem _ md chat ci@ChatItem {meta = CIMeta {itemSharedMsgId}}) = case itemSharedMsgId of Just itemSharedMId -> case chat of DirectChat ct -> do reactions <- getDirectCIReactions db ct itemSharedMId pure $ AChatItem SCTDirect md chat ci {reactions} - GroupChat g@GroupInfo {membership = GroupMember {memberId}} -> do - let itemMemberId = case chatDir of - CIGroupSnd -> memberId - CIGroupRcv GroupMember {memberId = mId} -> mId - reactions <- getGroupCIReactions db g itemMemberId itemSharedMId + GroupChat g -> do + let GroupMember {memberId} = chatItemMember g ci + reactions <- getGroupCIReactions db g memberId itemSharedMId pure $ AChatItem SCTGroup md chat ci {reactions} _ -> pure aci _ -> pure aci +deleteDirectCIReactions_ :: DB.Connection -> ContactId -> ChatItem 'CTDirect d -> IO () +deleteDirectCIReactions_ db contactId ChatItem {meta = CIMeta {itemSharedMsgId}} = + forM_ itemSharedMsgId $ \itemSharedMId -> + DB.execute db "DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ?" (contactId, itemSharedMId) + +deleteGroupCIReactions_ :: DB.Connection -> GroupInfo -> ChatItem 'CTGroup d -> IO () +deleteGroupCIReactions_ db g@GroupInfo {groupId} ci@ChatItem {meta = CIMeta {itemSharedMsgId}} = + forM_ itemSharedMsgId $ \itemSharedMId -> do + let GroupMember {memberId} = chatItemMember g ci + DB.execute db + "DELETE FROM chat_item_reactions WHERE group_id = ? AND shared_msg_id = ? AND item_member_id = ?" + (groupId, itemSharedMId, memberId) + toCIReaction :: (MsgReaction, Bool, Int) -> CIReactionCount toCIReaction (reaction, userReacted, totalReacted) = CIReactionCount {reaction, userReacted, totalReacted} @@ -5449,6 +5461,7 @@ deleteContactExpiredCIs db user@User {userId} ct@Contact {contactId} expirationD connIds <- getContactConnIds_ db user ct forM_ connIds $ \connId -> DB.execute db "DELETE FROM messages WHERE connection_id = ? AND created_at <= ?" (connId, expirationDate) + DB.execute db "DELETE FROM chat_item_reactions WHERE contact_id = ? AND created_at <= ?" (contactId, expirationDate) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND contact_id = ? AND created_at <= ?" (userId, contactId, expirationDate) getGroupExpiredFileInfo :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO [CIFileInfo] @@ -5462,6 +5475,7 @@ getGroupExpiredFileInfo db User {userId} GroupInfo {groupId} expirationDate crea deleteGroupExpiredCIs :: DB.Connection -> User -> GroupInfo -> UTCTime -> UTCTime -> IO () deleteGroupExpiredCIs db User {userId} GroupInfo {groupId} expirationDate createdAtCutoff = do DB.execute db "DELETE FROM messages WHERE group_id = ? AND created_at <= ?" (groupId, min expirationDate createdAtCutoff) + DB.execute db "DELETE FROM chat_item_reactions WHERE group_id = ? AND reaction_ts <= ? AND created_at <= ?" (groupId, expirationDate, createdAtCutoff) DB.execute db "DELETE FROM chat_items WHERE user_id = ? AND group_id = ? AND item_ts <= ? AND created_at <= ?" (userId, groupId, expirationDate, createdAtCutoff) -- | Saves unique local display name based on passed displayName, suffixed with _N if required. From d62761b3a831ba6e38c31213207dd088c36f8d87 Mon Sep 17 00:00:00 2001 From: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> Date: Mon, 15 May 2023 13:47:16 +0200 Subject: [PATCH 7/8] terminal: show message that failed to parse (#2439) --- src/Simplex/Chat.hs | 2 +- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/View.hs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 99f4492028..c07228e7c4 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -4151,7 +4151,7 @@ parseAChatMessage = parseChatMessage_ {-# INLINE parseAChatMessage #-} parseChatMessage_ :: (ChatMonad m, StrEncoding s) => ByteString -> m s -parseChatMessage_ = liftEither . first (ChatError . CEInvalidChatMessage) . strDecode +parseChatMessage_ s = liftEither . first (ChatError . CEInvalidChatMessage (safeDecodeUtf8 s)) $ strDecode s sendFileChunk :: ChatMonad m => User -> SndFileTransfer -> m () sendFileChunk user ft@SndFileTransfer {fileId, fileStatus, agentConnId = AgentConnId acId} = diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 05154440ee..2dc683d4dc 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -775,7 +775,7 @@ data ChatErrorType | CEChatNotStopped | CEChatStoreChanged | CEInvalidConnReq - | CEInvalidChatMessage {message :: String} + | CEInvalidChatMessage {messageData :: Text, message :: String} | CEContactNotReady {contact :: Contact} | CEContactDisabled {contact :: Contact} | CEConnectionDisabled {connection :: Connection} diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8989481a09..d270f2cd7e 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1377,7 +1377,7 @@ viewChatError logLevel = \case CEChatNotStopped -> ["error: chat not stopped"] CEChatStoreChanged -> ["error: chat store changed, please restart chat"] CEInvalidConnReq -> viewInvalidConnReq - CEInvalidChatMessage e -> ["chat message error: " <> sShow e] + CEInvalidChatMessage msg e -> [plain $ "chat message error: " <> e <> " (" <> T.unpack (T.take 120 msg) <> ")"] CEContactNotReady c -> [ttyContact' c <> ": not ready"] CEContactDisabled Contact {localDisplayName = c} -> [ttyContact c <> ": disabled, to enable: " <> highlight ("/enable " <> c) <> ", to delete: " <> highlight ("/d " <> c)] CEConnectionDisabled Connection {connId, connType} -> [plain $ "connection " <> textEncode connType <> " (" <> tshow connId <> ") is disabled" | logLevel <= CLLWarning] From 25156bb56c66d6fa86bc69c90c24f27abf4a2ec4 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Mon, 15 May 2023 16:07:55 +0400 Subject: [PATCH 8/8] ios: allow to set disappearance interval when sending message; allow to configure custom interval (#2428) * ios: allow to set disappearance interval when sending message; allow to configure custom interval * custom time picker wip * improve interaction with time picker - touch area, cancel, keyboard, preference * dropdown picker, refactor, text * button condition * weeks limit * refactor * update texts * simplify * fix null selection * texts, set current, switch columns --------- Co-authored-by: Evgeny Poberezkin <2769109+epoberezkin@users.noreply.github.com> --- apps/ios/Shared/Model/SimpleXAPI.swift | 4 +- .../Views/Chat/ChatItem/CIMetaView.swift | 2 +- .../Chat/ComposeMessage/ComposeView.swift | 50 ++-- .../Chat/ComposeMessage/SendMessageView.swift | 83 +++++-- .../Views/Chat/ContactPreferencesView.swift | 23 +- .../Chat/Group/GroupPreferencesView.swift | 11 +- .../Views/Helpers/CustomTimePicker.swift | 223 ++++++++++++++++++ apps/ios/Shared/Views/TerminalView.swift | 4 +- .../Views/UserSettings/SettingsView.swift | 4 + apps/ios/SimpleX.xcodeproj/project.pbxproj | 4 + apps/ios/SimpleXChat/APITypes.swift | 7 +- apps/ios/SimpleXChat/ChatTypes.swift | 139 +++++++---- 12 files changed, 442 insertions(+), 112 deletions(-) create mode 100644 apps/ios/Shared/Views/Helpers/CustomTimePicker.swift diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 68df35d973..d36b3ade8f 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -301,9 +301,9 @@ func apiGetChatItemInfo(itemId: Int64) async throws -> ChatItemInfo { throw r } -func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false) async -> ChatItem? { +func apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool = false, ttl: Int? = nil) async -> ChatItem? { let chatModel = ChatModel.shared - let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live) + let cmd: ChatCommand = .apiSendMessage(type: type, id: id, file: file, quotedItemId: quotedItemId, msg: msg, live: live, ttl: ttl) let r: ChatResponse if type == .direct { var cItem: ChatItem! diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift index f93e73411c..dbcee3fbb4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIMetaView.swift @@ -32,7 +32,7 @@ func ciMetaText(_ meta: CIMeta, chatTTL: Int?, color: Color = .clear, transparen r = r + statusIconText("timer", color).font(.caption2) let ttl = meta.itemTimed?.ttl if ttl != chatTTL { - r = r + Text(TimedMessagesPreference.shortTtlText(ttl)).foregroundColor(color) + r = r + Text(shortTimeText(ttl)).foregroundColor(color) } r = r + Text(" ") } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift index 6a1f221905..f853103b4d 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ComposeView.swift @@ -277,8 +277,8 @@ struct ComposeView: View { ZStack(alignment: .leading) { SendMessageView( composeState: $composeState, - sendMessage: { - sendMessage() + sendMessage: { ttl in + sendMessage(ttl: ttl) resetLinkPreview() }, sendLiveMessage: sendLiveMessage, @@ -296,6 +296,9 @@ struct ComposeView: View { }, finishVoiceMessageRecording: finishVoiceMessageRecording, allowVoiceMessagesToContact: allowVoiceMessagesToContact, + // TODO in 5.2 - allow if ttl is not configured + // timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages), + timedMessageAllowed: chat.chatInfo.featureEnabled(.timedMessages) && chat.chatInfo.timedMessagesTTL != nil, onMediaAdded: { media in if !media.isEmpty { chosenMedia = media }}, keyboardVisible: $keyboardVisible ) @@ -425,7 +428,7 @@ struct ComposeView: View { && (!composeState.message.isEmpty || composeState.liveMessage?.sentMsg != nil) { cancelCurrentVoiceRecording() clearCurrentDraft() - sendMessage() + sendMessage(ttl: nil) resetLinkPreview() } else if (composeState.inProgress) { clearCurrentDraft() @@ -470,7 +473,7 @@ struct ComposeView: View { let lm = composeState.liveMessage if (composeState.sendEnabled || composeState.quoting) && (lm == nil || lm?.sentMsg == nil), - let ci = await sendMessageAsync(typedMsg, live: true) { + let ci = await sendMessageAsync(typedMsg, live: true, ttl: nil) { await MainActor.run { composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: typedMsg)) } @@ -486,7 +489,7 @@ struct ComposeView: View { let typedMsg = composeState.message if let liveMessage = composeState.liveMessage { if let sentMsg = liveMessageToSend(liveMessage, typedMsg), - let ci = await sendMessageAsync(sentMsg, live: true) { + let ci = await sendMessageAsync(sentMsg, live: true, ttl: nil) { await MainActor.run { composeState = composeState.copy(liveMessage: LiveMessage(chatItem: ci, typedMsg: typedMsg, sentMsg: sentMsg)) } @@ -578,15 +581,15 @@ struct ComposeView: View { } } - private func sendMessage() { + private func sendMessage(ttl: Int?) { logger.debug("ChatView sendMessage") Task { logger.debug("ChatView sendMessage: in Task") - _ = await sendMessageAsync(nil, live: false) + _ = await sendMessageAsync(nil, live: false, ttl: ttl) } } - private func sendMessageAsync(_ text: String?, live: Bool) async -> ChatItem? { + private func sendMessageAsync(_ text: String?, live: Bool, ttl: Int?) async -> ChatItem? { var sent: ChatItem? let msgText = text ?? composeState.message let liveMessage = composeState.liveMessage @@ -606,36 +609,36 @@ struct ComposeView: View { switch (composeState.preview) { case .noPreview: - sent = await send(.text(msgText), quoted: quoted, live: live) + sent = await send(.text(msgText), quoted: quoted, live: live, ttl: ttl) case .linkPreview: - sent = await send(checkLinkPreview(), quoted: quoted, live: live) + sent = await send(checkLinkPreview(), quoted: quoted, live: live, ttl: ttl) case let .mediaPreviews(mediaPreviews: media): let last = media.count - 1 if last >= 0 { for i in 0.. ChatItem? { + func sendImage(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { let (image, data) = imageData if let data = data, let savedFile = saveAnyImage(data) { - return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live) + return await send(.image(text: text, image: image), quoted: quoted, file: savedFile, live: live, ttl: ttl) } return nil } - func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false) async -> ChatItem? { + func sendVideo(_ imageData: (String, UploadContent?), text: String = "", quoted: Int64? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { let (image, data) = imageData if case let .video(_, url, duration) = data, let savedFile = saveFileFromURLWithoutLoad(url) { - return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live) + return await send(.video(text: text, image: image, duration: duration), quoted: quoted, file: savedFile, live: live, ttl: ttl) } return nil } - func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false) async -> ChatItem? { + func send(_ mc: MsgContent, quoted: Int64?, file: String? = nil, live: Bool = false, ttl: Int?) async -> ChatItem? { if let chatItem = await apiSendMessage( type: chat.chatInfo.chatType, id: chat.chatInfo.apiId, file: file, quotedItemId: quoted, msg: mc, - live: live + live: live, + ttl: ttl ) { await MainActor.run { chatModel.removeLiveDummy(animated: false) diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift index 490d770b62..d610dec105 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/SendMessageView.swift @@ -13,7 +13,7 @@ private let liveMsgInterval: UInt64 = 3000_000000 struct SendMessageView: View { @Binding var composeState: ComposeState - var sendMessage: () -> Void + var sendMessage: (Int?) -> Void var sendLiveMessage: (() async -> Void)? = nil var updateLiveMessage: (() async -> Void)? = nil var cancelLiveMessage: (() -> Void)? = nil @@ -23,6 +23,7 @@ struct SendMessageView: View { var startVoiceMessageRecording: (() -> Void)? = nil var finishVoiceMessageRecording: (() -> Void)? = nil var allowVoiceMessagesToContact: (() -> Void)? = nil + var timedMessageAllowed: Bool = false var onMediaAdded: ([UploadContent]) -> Void @State private var holdingVMR = false @Namespace var namespace @@ -32,6 +33,9 @@ struct SendMessageView: View { @State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body) @State private var sendButtonSize: CGFloat = 29 @State private var sendButtonOpacity: CGFloat = 1 + @State private var showCustomDisappearingMessageDialogue = false + @State private var showCustomTimePicker = false + @State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get() var maxHeight: CGFloat = 360 var minHeight: CGFloat = 37 @AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false @@ -147,15 +151,17 @@ struct SendMessageView: View { .padding([.top, .trailing], 4) } - @ViewBuilder private func sendMessageButton() -> some View { - let v = Button(action: sendMessage) { + private func sendMessageButton() -> some View { + Button { + sendMessage(nil) + } label: { Image(systemName: composeState.editing || composeState.liveMessage != nil - ? "checkmark.circle.fill" - : "arrow.up.circle.fill") - .resizable() - .foregroundColor(.accentColor) - .frame(width: sendButtonSize, height: sendButtonSize) - .opacity(sendButtonOpacity) + ? "checkmark.circle.fill" + : "arrow.up.circle.fill") + .resizable() + .foregroundColor(.accentColor) + .frame(width: sendButtonSize, height: sendButtonSize) + .opacity(sendButtonOpacity) } .disabled( !composeState.sendEnabled || @@ -164,22 +170,61 @@ struct SendMessageView: View { composeState.endLiveDisabled ) .frame(width: 29, height: 29) + .contextMenu{ + sendButtonContextMenuItems() + } + .padding([.bottom, .trailing], 4) + .confirmationDialog("Send disappearing message", isPresented: $showCustomDisappearingMessageDialogue, titleVisibility: .visible) { + Button("30 seconds") { sendMessage(30) } + Button("1 minute") { sendMessage(60) } + Button("5 minutes") { sendMessage(300) } + Button("Custom time") { showCustomTimePicker = true } + } + .sheet(isPresented: $showCustomTimePicker, onDismiss: { selectedDisappearingMessageTime = customDisappearingMessageTimeDefault.get() }) { + if #available(iOS 16.0, *) { + disappearingMessageCustomTimePicker() + .presentationDetents([.fraction(0.6)]) + } else { + disappearingMessageCustomTimePicker() + } + } + } + private func disappearingMessageCustomTimePicker() -> some View { + CustomTimePickerView( + selection: $selectedDisappearingMessageTime, + confirmButtonText: "Send", + confirmButtonAction: { + if let time = selectedDisappearingMessageTime { + sendMessage(time) + customDisappearingMessageTimeDefault.set(time) + } + }, + description: "Delete after" + ) + } + + @ViewBuilder private func sendButtonContextMenuItems() -> some View { if composeState.liveMessage == nil, - case .noContextItem = composeState.contextItem, - !composeState.voicePreview && !composeState.editing, - let send = sendLiveMessage, - let update = updateLiveMessage { - v.contextMenu{ + !composeState.editing { + if case .noContextItem = composeState.contextItem, + !composeState.voicePreview, + let send = sendLiveMessage, + let update = updateLiveMessage { Button { startLiveMessage(send: send, update: update) } label: { Label("Send live message", systemImage: "bolt.fill") } } - .padding([.bottom, .trailing], 4) - } else { - v.padding([.bottom, .trailing], 4) + if timedMessageAllowed { + Button { + hideKeyboard() + showCustomDisappearingMessageDialogue = true + } label: { + Label("Disappearing message", systemImage: "stopwatch") + } + } } } @@ -365,7 +410,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateNew, - sendMessage: {}, + sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: $keyboardVisible ) @@ -375,7 +420,7 @@ struct SendMessageView_Previews: PreviewProvider { Spacer(minLength: 0) SendMessageView( composeState: $composeStateEditing, - sendMessage: {}, + sendMessage: { _ in }, onMediaAdded: { _ in }, keyboardVisible: $keyboardVisible ) diff --git a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift index bcdbeaa1c4..c669d95853 100644 --- a/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/ContactPreferencesView.swift @@ -89,9 +89,16 @@ struct ContactPreferencesView: View { } infoRow("Contact allows", pref.contactPreference.allow.text) if featuresAllowed.timedMessagesAllowed { - timedMessagesTTLPicker($featuresAllowed.timedMessagesTTL) + DropdownCustomTimePicker( + selection: $featuresAllowed.timedMessagesTTL, + label: "Delete after", + dropdownValues: TimedMessagesPreference.ttlValues, + customPickerConfirmButtonText: "Select", + customPickerDescription: "Delete after" + ) + .frame(height: 36) } else if pref.contactPreference.allow == .yes || pref.contactPreference.allow == .always { - infoRow("Delete after", TimedMessagesPreference.ttlText(pref.contactPreference.ttl)) + infoRow("Delete after", timeText(pref.contactPreference.ttl)) } } header: { featureHeader(.timedMessages, enabled) } @@ -129,18 +136,6 @@ struct ContactPreferencesView: View { } } -func timedMessagesTTLPicker(_ selection: Binding) -> some View { - Picker("Delete after", selection: selection) { - let selectedTTL = selection.wrappedValue - let ttlValues = TimedMessagesPreference.ttlValues - let values = ttlValues + (ttlValues.contains(selectedTTL) ? [] : [selectedTTL]) - ForEach(values, id: \.self) { ttl in - Text(TimedMessagesPreference.ttlText(ttl)) - } - } - .frame(height: 36) -} - struct ContactPreferencesView_Previews: PreviewProvider { static var previews: some View { ContactPreferencesView( diff --git a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift index 42f06618ec..652e649ec0 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupPreferencesView.swift @@ -75,14 +75,21 @@ struct GroupPreferencesView: View { Toggle(feature.text, isOn: enable) } if timedOn { - timedMessagesTTLPicker($preferences.timedMessages.ttl) + DropdownCustomTimePicker( + selection: $preferences.timedMessages.ttl, + label: "Delete after", + dropdownValues: TimedMessagesPreference.ttlValues, + customPickerConfirmButtonText: "Select", + customPickerDescription: "Delete after" + ) + .frame(height: 36) } } else { settingsRow(icon, color: color) { infoRow(Text(feature.text), enableFeature.wrappedValue.text) } if timedOn { - infoRow("Delete after", TimedMessagesPreference.ttlText(preferences.timedMessages.ttl)) + infoRow("Delete after", timeText(preferences.timedMessages.ttl)) } } } footer: { diff --git a/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift new file mode 100644 index 0000000000..e9c13676d6 --- /dev/null +++ b/apps/ios/Shared/Views/Helpers/CustomTimePicker.swift @@ -0,0 +1,223 @@ +// +// CustomTimePicker.swift +// SimpleX (iOS) +// +// Created by spaced4ndy on 11.05.2023. +// Copyright © 2023 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct CustomTimePicker: View { + @Binding var selection: Int? + @State var timeUnitsLimits = TimeUnitLimits.defaultUnitsLimits + @State private var selectedUnit: CustomTimeUnit = .second + @State private var selectedDuration: Int = 1 + + struct TimeUnitLimits { + var timeUnit: CustomTimeUnit + var minValue: Int = 1 + var maxValue: Int + + public static func defaultUnitLimits(_ unit: CustomTimeUnit) -> TimeUnitLimits { + switch unit { + case .second: return TimeUnitLimits.init(timeUnit: .second, maxValue: 120) + case .minute: return TimeUnitLimits.init(timeUnit: .minute, maxValue: 120) + case .hour: return TimeUnitLimits.init(timeUnit: .hour, maxValue: 72) + case .day: return TimeUnitLimits.init(timeUnit: .day, maxValue: 30) + case .week: return TimeUnitLimits.init(timeUnit: .week, maxValue: 12) + case .month: return TimeUnitLimits.init(timeUnit: .month, maxValue: 3) + } + } + + public static var defaultUnitsLimits: [TimeUnitLimits] {[ + defaultUnitLimits(.second), + defaultUnitLimits(.minute), + defaultUnitLimits(.hour), + defaultUnitLimits(.day), + defaultUnitLimits(.week), + defaultUnitLimits(.month), + ]} + } + + var body: some View { + HStack(spacing: 0) { + Group { + Picker("Duration", selection: $selectedDuration) { + let selectedUnitLimits = timeUnitsLimits.first(where: { $0.timeUnit == selectedUnit }) ?? TimeUnitLimits.defaultUnitLimits(selectedUnit) + let selectedUnitValues = Array(selectedUnitLimits.minValue...selectedUnitLimits.maxValue) + let values = selectedUnitValues + (selectedUnitValues.contains(selectedDuration) ? [] : [selectedDuration]) + ForEach(values, id: \.self) { value in + Text("\(value)") + } + } + Picker("Unit", selection: $selectedUnit) { + ForEach(timeUnitsLimits.map { $0.timeUnit }, id: \.self) { timeUnit in + Text(timeUnit.text) + } + } + } + .pickerStyle(.wheel) + .frame(minWidth: 0) + .compositingGroup() + .clipped() + } + .onAppear { + if let selection = selection, + selection > 0 { + (selectedUnit, selectedDuration) = CustomTimeUnit.toTimeUnit(seconds: selection) + } else { + selection = selectedUnit.toSeconds * selectedDuration + } + } + .onChange(of: selectedUnit) { unit in + if let maxValue = timeUnitsLimits.first(where: { $0.timeUnit == unit })?.maxValue, + selectedDuration > maxValue { + selectedDuration = maxValue + } else { + selection = unit.toSeconds * selectedDuration + } + } + .onChange(of: selectedDuration) { duration in + selection = selectedUnit.toSeconds * duration + } + } +} + +extension UIPickerView { + open override var intrinsicContentSize: CGSize { + return CGSize(width: UIView.noIntrinsicMetric, height: super.intrinsicContentSize.height) + } +} + +struct CustomTimePickerView: View { + @Environment(\.dismiss) var dismiss + @Binding var selection: Int? + var confirmButtonText: LocalizedStringKey + var confirmButtonAction: () -> Void + var description: LocalizedStringKey? = nil + var timeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits + + var body: some View { + NavigationView { + customTimePickerView() + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Cancel") { + dismiss() + } + } + ToolbarItem(placement: .navigationBarTrailing) { + Button { + confirmButtonAction() + dismiss() + } label: { + Text(confirmButtonText) + .fontWeight(.medium) + } + .disabled(selection == nil) + } + } + } + } + + private func customTimePickerView() -> some View { + VStack(alignment: .leading) { + List { + Group { + Section(description ?? "") { + CustomTimePicker(selection: $selection) + } + } + .listRowInsets(.init(top: 0, leading: 16, bottom: 0, trailing: 16)) + } + .listStyle(.insetGrouped) + } + } +} + +struct DropdownCustomTimePicker: View { + @Binding var selection: Int? + var label: LocalizedStringKey + var dropdownValues: [Int?] + var customPickerConfirmButtonText: LocalizedStringKey + var customPickerDescription: LocalizedStringKey? = nil + var customPickerTimeUnitsLimits = CustomTimePicker.TimeUnitLimits.defaultUnitsLimits + @State private var dropdownSelection: DropdownSelection = .dropdownValue(value: nil) + @State private var showCustomTimePicker = false + @State private var selectedCustomTime: Int? = nil + + enum DropdownSelection: Hashable { + case dropdownValue(value: Int?) + case custom + } + + var body: some View { + Picker(label, selection: $dropdownSelection) { + let values: [DropdownSelection] = + dropdownValues.map { .dropdownValue(value: $0) } + + (dropdownValues.contains(selection) ? [] : [.dropdownValue(value: selection)]) + + [.custom] + ForEach(values, id: \.self) { v in + switch v { + case let .dropdownValue(value): Text(timeText(value)) + case .custom: Text(NSLocalizedString("custom", comment: "dropdown time picker choice")) + } + } + } + .onAppear { + dropdownSelection = .dropdownValue(value: selection) + } + .onChange(of: selection) { v in + logger.debug("*** .onChange(of: selection)") + dropdownSelection = .dropdownValue(value: v) + } + .onChange(of: dropdownSelection) { v in + logger.debug("*** .onChange(of: dropdownSelection)") + switch v { + case let .dropdownValue(value): selection = value + case .custom: showCustomTimePicker = true + } + } + .sheet( + isPresented: $showCustomTimePicker, + onDismiss: { + dropdownSelection = .dropdownValue(value: selection) + selectedCustomTime = nil + } + ) { + if #available(iOS 16.0, *) { + customTimePicker() + .presentationDetents([.fraction(0.6)]) + } else { + customTimePicker() + } + } + } + + private func customTimePicker() -> some View { + CustomTimePickerView( + selection: $selectedCustomTime, + confirmButtonText: customPickerConfirmButtonText, + confirmButtonAction: { + if let time = selectedCustomTime { + selection = time + } + }, + description: customPickerDescription, + timeUnitsLimits: customPickerTimeUnitsLimits + ) + .onAppear { + selectedCustomTime = selection + } + } +} + +struct CustomTimePicker_Previews: PreviewProvider { + static var previews: some View { + CustomTimePicker( + selection: Binding.constant(300) + ) + } +} diff --git a/apps/ios/Shared/Views/TerminalView.swift b/apps/ios/Shared/Views/TerminalView.swift index 52132fb6e4..a7161e08e0 100644 --- a/apps/ios/Shared/Views/TerminalView.swift +++ b/apps/ios/Shared/Views/TerminalView.swift @@ -72,7 +72,7 @@ struct TerminalView: View { SendMessageView( composeState: $composeState, - sendMessage: sendMessage, + sendMessage: { _ in consoleSendMessage() }, showVoiceMessageButton: false, onMediaAdded: { _ in }, keyboardVisible: $keyboardVisible @@ -108,7 +108,7 @@ struct TerminalView: View { .onDisappear { terminalItem = nil } } - func sendMessage() { + func consoleSendMessage() { let cmd = ChatCommand.string(composeState.message) if composeState.message.starts(with: "/sql") && (!prefPerformLA || !developerTools) { let resp = ChatResponse.chatCmdError(user_: nil, chatError: ChatError.error(errorType: ChatErrorType.commandError(message: "Failed reading: empty"))) diff --git a/apps/ios/Shared/Views/UserSettings/SettingsView.swift b/apps/ios/Shared/Views/UserSettings/SettingsView.swift index 714de5b2b2..8533060c84 100644 --- a/apps/ios/Shared/Views/UserSettings/SettingsView.swift +++ b/apps/ios/Shared/Views/UserSettings/SettingsView.swift @@ -48,6 +48,7 @@ let DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE = "showHiddenProfilesNotice" let DEFAULT_SHOW_MUTE_PROFILE_ALERT = "showMuteProfileAlert" let DEFAULT_WHATS_NEW_VERSION = "defaultWhatsNewVersion" let DEFAULT_ONBOARDING_STAGE = "onboardingStage" +let DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME = "customDisappearingMessageTime" let appDefaults: [String: Any] = [ DEFAULT_SHOW_LA_NOTICE: false, @@ -76,6 +77,7 @@ let appDefaults: [String: Any] = [ DEFAULT_SHOW_HIDDEN_PROFILES_NOTICE: true, DEFAULT_SHOW_MUTE_PROFILE_ALERT: true, DEFAULT_ONBOARDING_STAGE: OnboardingStage.onboardingComplete.rawValue, + DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME: 300, ] enum SimpleXLinkMode: String, Identifiable { @@ -112,6 +114,8 @@ let privacyLocalAuthModeDefault = EnumDefault(defaults: UserDefaults.sta let onboardingStageDefault = EnumDefault(defaults: UserDefaults.standard, forKey: DEFAULT_ONBOARDING_STAGE, withDefault: .onboardingComplete) +let customDisappearingMessageTimeDefault = IntDefault(defaults: UserDefaults.standard, forKey: DEFAULT_CUSTOM_DISAPPEARING_MESSAGE_TIME) + func setGroupDefaults() { privacyAcceptImagesGroupDefault.set(UserDefaults.standard.bool(forKey: DEFAULT_PRIVACY_ACCEPT_IMAGES)) } diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index de0fde24a1..f970fc394a 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -173,6 +173,7 @@ 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 */; }; + 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.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 */; }; @@ -444,6 +445,7 @@ 64AA1C6827EE10C800AC7277 /* ContextItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextItemView.swift; sourceTree = ""; }; 64AA1C6B27F3537400AC7277 /* DeletedItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedItemView.swift; sourceTree = ""; }; 64C06EB42A0A4A7C00792D4D /* ChatItemInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemInfoView.swift; sourceTree = ""; }; + 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = ""; }; 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = ""; }; 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = ""; }; 64D0C2C529FAC1EC00B38D5F /* AddContactLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactLearnMore.swift; sourceTree = ""; }; @@ -612,6 +614,7 @@ 5CCB939B297EFCB100399E78 /* NavStackCompat.swift */, 18415DAAAD1ADBEDB0EDA852 /* VideoPlayerView.swift */, 64466DCB29FFE3E800E3D48D /* MailView.swift */, + 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */, ); path = Helpers; sourceTree = ""; @@ -1190,6 +1193,7 @@ 1841538E296606C74533367C /* UserPicker.swift in Sources */, 18415B0585EB5A9A0A7CA8CD /* PressedButtonStyle.swift in Sources */, 1841560FD1CD447955474C1D /* UserProfilesView.swift in Sources */, + 64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */, 18415C6C56DBCEC2CBBD2F11 /* WebRTCClient.swift in Sources */, 184152CEF68D2336FC2EBCB0 /* CallViewRenderers.swift in Sources */, 5CB634AD29E46CF70066AD6B /* LocalAuthView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/APITypes.swift b/apps/ios/SimpleXChat/APITypes.swift index eb21b347d8..ebee5973a5 100644 --- a/apps/ios/SimpleXChat/APITypes.swift +++ b/apps/ios/SimpleXChat/APITypes.swift @@ -37,7 +37,7 @@ public enum ChatCommand { 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 apiSendMessage(type: ChatType, id: Int64, file: String?, quotedItemId: Int64?, msg: MsgContent, live: Bool, ttl: Int?) case apiUpdateChatItem(type: ChatType, id: Int64, itemId: Int64, msg: MsgContent, live: Bool) case apiDeleteChatItem(type: ChatType, id: Int64, itemId: Int64, mode: CIDeleteMode) case apiDeleteMemberChatItem(groupId: Int64, groupMemberId: Int64, itemId: Int64) @@ -141,9 +141,10 @@ public enum ChatCommand { 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): + case let .apiSendMessage(type, id, file, quotedItemId, mc, live, ttl): let msg = encodeJSON(ComposedMessage(filePath: file, quotedItemId: quotedItemId, msgContent: mc)) - return "/_send \(ref(type, id)) live=\(onOff(live)) json \(msg)" + let ttlStr = ttl != nil ? "\(ttl!)" : "default" + return "/_send \(ref(type, id)) live=\(onOff(live)) ttl=\(ttlStr) json \(msg)" case let .apiUpdateChatItem(type, id, itemId, mc, live): return "/_update item \(ref(type, id)) \(itemId) live=\(onOff(live)) \(mc.cmdString)" case let .apiDeleteChatItem(type, id, itemId, mode): return "/_delete item \(ref(type, id)) \(itemId) \(mode.rawValue)" case let .apiDeleteMemberChatItem(groupId, groupMemberId, itemId): return "/_delete member item #\(groupId) \(groupMemberId) \(itemId)" diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 9369fccccc..668b901113 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -280,60 +280,107 @@ public struct TimedMessagesPreference: Preference { } public static var ttlValues: [Int?] { - [30, 300, 3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, nil] + [3600, 8 * 3600, 86400, 7 * 86400, 30 * 86400, nil] + } +} + +public enum CustomTimeUnit { + case second + case minute + case hour + case day + case week + case month + + public var toSeconds: Int { + switch self { + case .second: return 1 + case .minute: return 60 + case .hour: return 3600 + case .day: return 86400 + case .week: return 7 * 86400 + case .month: return 30 * 86400 + } } - public static func ttlText(_ ttl: Int?) -> String { - guard let ttl = ttl else { return "off" } - if ttl == 0 { return "0 sec" } - let (m_, s) = divMod(ttl, by: 60) - let (h_, m) = divMod(m_, by: 60) - let (d_, h) = divMod(h_, by: 24) - let (mm, d) = divMod(d_, by: 30) - return maybe(mm, - mm == 1 - ? NSLocalizedString("1 month", comment: "message ttl") - : String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "message ttl"), mm) - ) - + maybe(d, - d == 1 - ? NSLocalizedString("1 day", comment: "message ttl") - : d == 7 - ? NSLocalizedString("1 week", comment: "message ttl") - : d == 14 - ? NSLocalizedString("2 weeks", comment: "message ttl") - : String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "message ttl"), d) - ) - + maybe(h, - h == 1 - ? NSLocalizedString("1 hour", comment: "message ttl") - : String.localizedStringWithFormat(NSLocalizedString("%d hours", comment: "message ttl"), h) - ) - + maybe(m, String.localizedStringWithFormat(NSLocalizedString("%d min", comment: "message ttl"), m)) - + maybe(s, String.localizedStringWithFormat(NSLocalizedString("%d sec", comment: "message ttl"), s)) + public var text: String { + switch self { + case .second: return NSLocalizedString("seconds", comment: "time unit") + case .minute: return NSLocalizedString("minutes", comment: "time unit") + case .hour: return NSLocalizedString("hours", comment: "time unit") + case .day: return NSLocalizedString("days", comment: "time unit") + case .week: return NSLocalizedString("weeks", comment: "time unit") + case .month: return NSLocalizedString("months", comment: "time unit") + } } - public static func shortTtlText(_ ttl: Int?) -> LocalizedStringKey { - guard let ttl = ttl else { return "off" } - let m = ttl / 60 - if m == 0 { return "\(ttl)s" } - let h = m / 60 - if h == 0 { return "\(m)m" } - let d = h / 24 - if d == 0 { return "\(h)h" } - let mm = d / 30 - if mm > 0 { return "\(mm)mth" } - let w = d / 7 - return w == 0 || d % 7 != 0 ? "\(d)d" : "\(w)w" + public static func toTimeUnit(seconds: Int) -> (CustomTimeUnit, Int) { + let tryUnits = [month, week, day, hour, minute] + var selectedUnit: (CustomTimeUnit, Int)? = nil + for unit in tryUnits { + let (v, r) = divMod(seconds, by: unit.toSeconds) + if r == 0 { + selectedUnit = (unit, v) + break + } + } + return selectedUnit ?? (CustomTimeUnit.second, seconds) } - static func divMod(_ n: Int, by d: Int) -> (Int, Int) { + private static func divMod(_ n: Int, by d: Int) -> (Int, Int) { (n / d, n % d) } - static func maybe(_ n: Int, _ s: String) -> String { - n == 0 ? "" : s + public static func toText(seconds: Int) -> String { + let (unit, value) = toTimeUnit(seconds: seconds) + switch unit { + case .second: + return String.localizedStringWithFormat(NSLocalizedString("%d sec", comment: "time interval"), value) + case .minute: + return String.localizedStringWithFormat(NSLocalizedString("%d min", comment: "time interval"), value) + case .hour: + return value == 1 + ? NSLocalizedString("1 hour", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d hours", comment: "time interval"), value) + case .day: + return value == 1 + ? NSLocalizedString("1 day", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d days", comment: "time interval"), value) + case .week: + return value == 1 + ? NSLocalizedString("1 week", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d weeks", comment: "time interval"), value) + case .month: + return value == 1 + ? NSLocalizedString("1 month", comment: "time interval") + : String.localizedStringWithFormat(NSLocalizedString("%d months", comment: "time interval"), value) + } } + + public static func toShortText(seconds: Int) -> LocalizedStringKey { + let (unit, value) = toTimeUnit(seconds: seconds) + switch unit { + case .second: return "\(value)s" + case .minute: return "\(value)m" + case .hour: return "\(value)h" + case .day: return "\(value)d" + case .week: return "\(value)w" + case .month: return "\(value)mth" + } + } +} + + +public func timeText(_ seconds: Int?) -> String { + guard let seconds = seconds else { return "off" } + if seconds == 0 { return "0 sec" } + return CustomTimeUnit.toText(seconds: seconds) +} + +public func shortTimeText(_ seconds: Int?) -> LocalizedStringKey { + guard let seconds = seconds else { return "off" } + if seconds == 0 { return "0s" } + return CustomTimeUnit.toShortText(seconds: seconds) } public struct ContactUserPreferences: Decodable { @@ -2249,13 +2296,13 @@ public enum CIContent: Decodable, ItemContent { static func featureText(_ feature: Feature, _ enabled: String, _ param: Int?) -> String { feature.hasParam - ? "\(feature.text): \(TimedMessagesPreference.ttlText(param))" + ? "\(feature.text): \(timeText(param))" : "\(feature.text): \(enabled)" } public static func preferenceText(_ feature: Feature, _ allowed: FeatureAllowed, _ param: Int?) -> String { allowed != .no && feature.hasParam && param != nil - ? String.localizedStringWithFormat(NSLocalizedString("offered %@: %@", comment: "feature offered item"), feature.text, TimedMessagesPreference.ttlText(param)) + ? String.localizedStringWithFormat(NSLocalizedString("offered %@: %@", comment: "feature offered item"), feature.text, timeText(param)) : allowed != .no ? String.localizedStringWithFormat(NSLocalizedString("offered %@", comment: "feature offered item"), feature.text) : String.localizedStringWithFormat(NSLocalizedString("cancelled %@", comment: "feature offered item"), feature.text)