diff --git a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt index 8013beab0c..bbc4e021b5 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/model/ChatModel.kt @@ -479,6 +479,11 @@ class Profile( override val fullName: String, override val image: String? = null ): NamedChat { + val displayNameWithOptionalFullName: String + get() { + return if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" + } + companion object { val sampleData = Profile( displayName = "alice", @@ -857,6 +862,15 @@ data class ChatItem ( quotedItem = null, file = null ) + + fun getGroupEventSample() = + ChatItem( + chatDir = CIDirection.DirectRcv(), + meta = CIMeta.getSample(1, Clock.System.now(), "group event text", CIStatus.RcvRead(), itemDeleted = false, itemEdited = false, editable = false), + content = CIContent.RcvGroupEventContent(rcvGroupEvent = RcvGroupEvent.MemberAdded(groupMemberId = 1, profile = Profile.sampleData)), + quotedItem = null, + file = null + ) } } @@ -949,6 +963,8 @@ sealed class CIContent: ItemContent { @Serializable @SerialName("rcvIntegrityError") class RcvIntegrityError(val msgError: MsgErrorType): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("rcvGroupInvitation") class RcvGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } @Serializable @SerialName("sndGroupInvitation") class SndGroupInvitation(val groupInvitation: CIGroupInvitation, val memberRole: GroupMemberRole): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("rcvGroupEvent") class RcvGroupEventContent(val rcvGroupEvent: RcvGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } + @Serializable @SerialName("sndGroupEvent") class SndGroupEventContent(val sndGroupEvent: SndGroupEvent): CIContent() { override val msgContent: MsgContent? get() = null } override val text: String get() = when(this) { is SndMsgContent -> msgContent.text @@ -958,8 +974,10 @@ sealed class CIContent: ItemContent { is SndCall -> status.text(duration) is RcvCall -> status.text(duration) is RcvIntegrityError -> msgError.text - is RcvGroupInvitation -> groupInvitation.text() - is SndGroupInvitation -> groupInvitation.text() + is RcvGroupInvitation -> groupInvitation.text + is SndGroupInvitation -> groupInvitation.text + is RcvGroupEventContent -> rcvGroupEvent.text + is SndGroupEventContent -> sndGroupEvent.text } } @@ -1061,7 +1079,7 @@ class CIGroupInvitation ( val groupProfile: GroupProfile, val status: CIGroupInvitationStatus ) { - fun text(): String = String.format(generalGetString(R.string.group_invitation_item_description), groupProfile.displayName) + val text: String get() = String.format(generalGetString(R.string.group_invitation_item_description), groupProfile.displayName) companion object { fun getSample( @@ -1268,3 +1286,33 @@ sealed class MsgErrorType() { is MsgDuplicate -> generalGetString(R.string.integrity_msg_duplicate) // not used now } } + +@Serializable +sealed class RcvGroupEvent() { + @Serializable @SerialName("memberAdded") class MemberAdded(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() + @Serializable @SerialName("memberConnected") class MemberConnected(): RcvGroupEvent() + @Serializable @SerialName("memberLeft") class MemberLeft(): RcvGroupEvent() + @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): RcvGroupEvent() + @Serializable @SerialName("userDeleted") class UserDeleted(): RcvGroupEvent() + @Serializable @SerialName("groupDeleted") class GroupDeleted(): RcvGroupEvent() + + val text: String get() = when (this) { + is MemberAdded -> String.format(generalGetString(R.string.rcv_group_event_member_added), profile.displayNameWithOptionalFullName) + is MemberConnected -> generalGetString(R.string.rcv_group_event_member_connected) + is MemberLeft -> generalGetString(R.string.rcv_group_event_member_left) + is MemberDeleted -> String.format(generalGetString(R.string.rcv_group_event_member_deleted), profile.displayNameWithOptionalFullName) + is UserDeleted -> generalGetString(R.string.rcv_group_event_user_deleted) + is GroupDeleted -> generalGetString(R.string.rcv_group_event_group_deleted) + } +} + +@Serializable +sealed class SndGroupEvent() { + @Serializable @SerialName("memberDeleted") class MemberDeleted(val groupMemberId: Long, val profile: Profile): SndGroupEvent() + @Serializable @SerialName("userLeft") class UserLeft(): SndGroupEvent() + + val text: String get() = when (this) { + is MemberDeleted -> String.format(generalGetString(R.string.snd_group_event_member_deleted), profile.displayNameWithOptionalFullName) + is UserLeft -> generalGetString(R.string.snd_group_event_user_left) + } +} diff --git a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt index fda4d6949b..7297eeec45 100644 --- a/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt +++ b/apps/android/app/src/main/java/chat/simplex/app/ui/theme/Type.kt @@ -13,6 +13,7 @@ val Inter = FontFamily( Font(R.font.inter_bold, weight = FontWeight.Bold), Font(R.font.inter_semi_bold, weight = FontWeight.SemiBold), Font(R.font.inter_medium, weight = FontWeight.Medium), + Font(R.font.inter_light, weight = FontWeight.Light), ) // Set of Material typography styles to start with diff --git a/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupEventView.kt b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupEventView.kt new file mode 100644 index 0000000000..1915c97746 --- /dev/null +++ b/apps/android/app/src/main/java/chat/simplex/app/views/chat/item/CIGroupEventView.kt @@ -0,0 +1,59 @@ +package chat.simplex.app.views.chat.item + +import android.content.res.Configuration +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.material.* +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.* +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import chat.simplex.app.model.ChatItem +import chat.simplex.app.ui.theme.HighOrLowlight +import chat.simplex.app.ui.theme.SimpleXTheme + +@Composable +fun CIGroupEventView(ci: ChatItem) { + fun withGroupEventStyle(builder: AnnotatedString.Builder, text: String) { + return builder.withStyle(SpanStyle(fontSize = 12.sp, fontWeight = FontWeight.Light, color = HighOrLowlight)) { append(text) } + } + + Surface { + Row( + Modifier.padding(horizontal = 6.dp, vertical = 6.dp), + verticalAlignment = Alignment.Bottom + ) { + Text( + buildAnnotatedString { + val memberDisplayName = ci.memberDisplayName + if (memberDisplayName != null) { + withGroupEventStyle(this, memberDisplayName) + append(" ") + } + withGroupEventStyle(this, ci.content.text) + append(" ") + withGroupEventStyle(this, ci.timestampText) + }, + style = MaterialTheme.typography.body1.copy(lineHeight = 22.sp) + ) + } + } +} + +@Preview(showBackground = true) +@Preview( + uiMode = Configuration.UI_MODE_NIGHT_YES, + name = "Dark Mode" +) +@Composable +fun CIGroupEventViewPreview() { + SimpleXTheme { + CIGroupEventView( + ChatItem.getGroupEventSample() + ) + } +} 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 b98560a839..a343bb6cf8 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 @@ -149,6 +149,8 @@ fun ChatItemView( is CIContent.RcvIntegrityError -> IntegrityErrorItemView(cItem, showMember = showMember) is CIContent.RcvGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) is CIContent.SndGroupInvitation -> CIGroupInvitationView(cItem, c.groupInvitation, c.memberRole, joinGroup = joinGroup) + is CIContent.RcvGroupEventContent -> CIGroupEventView(cItem) + is CIContent.SndGroupEventContent -> CIGroupEventView(cItem) } } } diff --git a/apps/android/app/src/main/res/font/inter_light.ttf b/apps/android/app/src/main/res/font/inter_light.ttf new file mode 100644 index 0000000000..ebaa005740 Binary files /dev/null and b/apps/android/app/src/main/res/font/inter_light.ttf differ diff --git a/apps/android/app/src/main/res/values-ru/strings.xml b/apps/android/app/src/main/res/values-ru/strings.xml index 24b17d38ad..bfcd7606f5 100644 --- a/apps/android/app/src/main/res/values-ru/strings.xml +++ b/apps/android/app/src/main/res/values-ru/strings.xml @@ -420,7 +420,7 @@ Принять звонок - %1$d пропущенных сообщений" + %1$d пропущенных сообщений ошибка хэш сообщения ошибка ID сообщения повторное сообщение @@ -505,4 +505,14 @@ Вы вступили в эту группу Вы отклонили приглашение в группу Приглашение в группу истекло + + + пригласил(а) %1$s + соединен(а) + покинул(а) группу + удалил(а) %1$s + удалил(а) вас из группы + удалил(а) группу + вы удалили %1$s + вы покинули группу diff --git a/apps/android/app/src/main/res/values/strings.xml b/apps/android/app/src/main/res/values/strings.xml index 5d6f51a6a1..ef6d0abcac 100644 --- a/apps/android/app/src/main/res/values/strings.xml +++ b/apps/android/app/src/main/res/values/strings.xml @@ -422,7 +422,7 @@ Answer call - %1$d skipped message(s)" + %1$d skipped message(s) bad message hash bad message ID duplicate message @@ -507,4 +507,14 @@ You joined this group You rejected group invitation Group invitation expired + + + invited %1$s + connected + left + removed %1$s + removed you + deleted group + you removed %1$s + you left diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIGroupEventView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupEventView.swift new file mode 100644 index 0000000000..4c5579653c --- /dev/null +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIGroupEventView.swift @@ -0,0 +1,50 @@ +// +// CIGroupEventView.swift +// SimpleX (iOS) +// +// Created by JRoberts on 20.07.2022. +// Copyright © 2022 SimpleX Chat. All rights reserved. +// + +import SwiftUI +import SimpleXChat + +struct CIGroupEventView: View { + var chatItem: ChatItem + + var body: some View { + HStack(alignment: .bottom, spacing: 0) { + if let member = chatItem.memberDisplayName { + Text(member) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + + Text(" ") + + eventText() + } else { + eventText() + } + } + .padding(.leading, 6) + .padding(.bottom, 6) + .textSelection(.disabled) + } + + func eventText() -> Text { + Text(chatItem.content.text) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.light) + + Text(" ") + + chatItem.timestampText + .font(.caption) + .foregroundColor(Color.secondary) + .fontWeight(.light) + } +} + +struct CIGroupEventView_Previews: PreviewProvider { + static var previews: some View { + CIGroupEventView(chatItem: ChatItem.getGroupEventSample()) + } +} diff --git a/apps/ios/Shared/Views/Chat/ChatItemView.swift b/apps/ios/Shared/Views/Chat/ChatItemView.swift index ce5f68dc2b..c43fc9daf0 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemView.swift @@ -26,6 +26,8 @@ struct ChatItemView: View { case .rcvIntegrityError: IntegrityErrorItemView(chatItem: chatItem, showMember: showMember) case let .rcvGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) case let .sndGroupInvitation(groupInvitation, memberRole): groupInvitationItemView(groupInvitation, memberRole) + case .rcvGroupEvent: groupEventItemView() + case .sndGroupEvent: groupEventItemView() } } @@ -48,6 +50,10 @@ struct ChatItemView: View { private func groupInvitationItemView(_ groupInvitation: CIGroupInvitation, _ memberRole: GroupMemberRole) -> some View { CIGroupInvitationView(chatItem: chatItem, groupInvitation: groupInvitation, memberRole: memberRole) } + + private func groupEventItemView() -> some View { + CIGroupEventView(chatItem: chatItem) + } } struct ChatItemView_Previews: PreviewProvider { diff --git a/apps/ios/SimpleX.xcodeproj/project.pbxproj b/apps/ios/SimpleX.xcodeproj/project.pbxproj index 2dae8a4b2c..c865b201ce 100644 --- a/apps/ios/SimpleX.xcodeproj/project.pbxproj +++ b/apps/ios/SimpleX.xcodeproj/project.pbxproj @@ -111,6 +111,7 @@ 5CFE0921282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 5CFE0922282EEAF60002594B /* ZoomableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */; }; 640F50E327CF991C001E05C2 /* SMPServers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 640F50E227CF991C001E05C2 /* SMPServers.swift */; }; + 6440CA00288857A10062C672 /* CIGroupEventView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6440C9FF288857A10062C672 /* CIGroupEventView.swift */; }; 6442E0BA287F169300CEC0F9 /* AddGroupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */; }; 6442E0BE2880182D00CEC0F9 /* GroupChatInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */; }; 6454036F2822A9750090DDFF /* ComposeFileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6454036E2822A9750090DDFF /* ComposeFileView.swift */; }; @@ -286,6 +287,7 @@ 5CFA59CF286477B400863A68 /* ChatArchiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatArchiveView.swift; sourceTree = ""; }; 5CFE0920282EEAF60002594B /* ZoomableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ZoomableScrollView.swift; path = Shared/Views/ZoomableScrollView.swift; sourceTree = SOURCE_ROOT; }; 640F50E227CF991C001E05C2 /* SMPServers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMPServers.swift; sourceTree = ""; }; + 6440C9FF288857A10062C672 /* CIGroupEventView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIGroupEventView.swift; sourceTree = ""; }; 6442E0B9287F169300CEC0F9 /* AddGroupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupView.swift; sourceTree = ""; }; 6442E0BD2880182D00CEC0F9 /* GroupChatInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupChatInfoView.swift; sourceTree = ""; }; 6454036E2822A9750090DDFF /* ComposeFileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeFileView.swift; sourceTree = ""; }; @@ -592,6 +594,7 @@ 5C029EA72837DBB3004A9677 /* CICallItemView.swift */, 5C3F1D552842B68D00EC8A82 /* IntegrityErrorItemView.swift */, 64E972062881BB22008DBC02 /* CIGroupInvitationView.swift */, + 6440C9FF288857A10062C672 /* CIGroupEventView.swift */, ); path = ChatItem; sourceTree = ""; @@ -880,6 +883,7 @@ 5C2E260727A2941F00F70299 /* SimpleXAPI.swift in Sources */, 5CB924D427A853F100ACCCDD /* SettingsButton.swift in Sources */, 3C714777281C081000CB4D4B /* WebRTCView.swift in Sources */, + 6440CA00288857A10062C672 /* CIGroupEventView.swift in Sources */, 5CB0BA92282713FD00B3292C /* CreateProfile.swift in Sources */, 5C5F2B7027EBC704006A9D5F /* ProfileImage.swift in Sources */, 64AA1C6C27F3537400AC7277 /* DeletedItemView.swift in Sources */, diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 59abdad2b6..bfdc2b6a62 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -44,6 +44,10 @@ public struct Profile: Codable, NamedChat { public var fullName: String public var image: String? + var displayNameWithOptionalFullName: String { + (fullName == "" || displayName == fullName) ? displayName : "\(displayName) (\(fullName))" + } + static let sampleData = Profile( displayName: "alice", fullName: "Alice" @@ -680,6 +684,16 @@ public struct ChatItem: Identifiable, Decodable { file: nil ) } + + public static func getGroupEventSample () -> ChatItem { + ChatItem( + chatDir: .directRcv, + meta: CIMeta.getSample(1, .now, "group event text", .rcvRead, false, false, false), + content: .rcvGroupEvent(rcvGroupEvent: .memberAdded(groupMemberId: 1, profile: Profile.sampleData)), + quotedItem: nil, + file: nil + ) + } } public enum CIDirection: Decodable { @@ -764,6 +778,8 @@ public enum CIContent: Decodable, ItemContent { case rcvIntegrityError(msgError: MsgErrorType) case rcvGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) case sndGroupInvitation(groupInvitation: CIGroupInvitation, memberRole: GroupMemberRole) + case rcvGroupEvent(rcvGroupEvent: RcvGroupEvent) + case sndGroupEvent(sndGroupEvent: SndGroupEvent) public var text: String { get { @@ -775,8 +791,10 @@ public enum CIContent: Decodable, ItemContent { case let .sndCall(status, duration): return status.text(duration) case let .rcvCall(status, duration): return status.text(duration) case let .rcvIntegrityError(msgError): return msgError.text - case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text() - case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text() + case let .rcvGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .sndGroupInvitation(groupInvitation, _): return groupInvitation.text + case let .rcvGroupEvent(rcvGroupEvent): return rcvGroupEvent.text + case let .sndGroupEvent(sndGroupEvent): return sndGroupEvent.text } } } @@ -1095,7 +1113,7 @@ public struct CIGroupInvitation: Decodable { public var groupProfile: GroupProfile public var status: CIGroupInvitationStatus - func text() -> String { + var text: String { String.localizedStringWithFormat(NSLocalizedString("invitation to group %@", comment: "group name"), groupProfile.displayName) } @@ -1110,3 +1128,38 @@ public enum CIGroupInvitationStatus: String, Decodable { case rejected case expired } + +public enum RcvGroupEvent: Decodable { + case memberAdded(groupMemberId: Int64, profile: Profile) + case memberConnected + case memberLeft + case memberDeleted(groupMemberId: Int64, profile: Profile) + case userDeleted + case groupDeleted + + var text: String { + switch self { + case let .memberAdded(_, profile): + return String.localizedStringWithFormat(NSLocalizedString("invited %@", comment: "rcv group event chat item"), profile.displayNameWithOptionalFullName) + case .memberConnected: return NSLocalizedString("connected", comment: "rcv group event chat item") + case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item") + case let .memberDeleted(_, profile): + return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.displayNameWithOptionalFullName) + case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item") + case .groupDeleted: return NSLocalizedString("deleted group", comment: "rcv group event chat item") + } + } +} + +public enum SndGroupEvent: Decodable { + case memberDeleted(groupMemberId: Int64, profile: Profile) + case userLeft + + var text: String { + switch self { + case let .memberDeleted(_, profile): + return String.localizedStringWithFormat(NSLocalizedString("you removed %@", comment: "snd group event chat item"), profile.displayNameWithOptionalFullName) + case .userLeft: return NSLocalizedString("you left", comment: "snd group event chat item") + } + } +}