Compare commits

..

22 Commits

Author SHA1 Message Date
Diogo Cunha 387faa0c27 android: implemented one hand ui for chat list screen 2024-07-12 23:02:14 +01:00
Diogo Cunha aa990da17c android, desktop: added setting for one hand ui 2024-07-11 21:25:03 +01:00
Diogo Cunha a48c82f4a1 android, desktop: added action buttons and delete to contact card, added toolbar 2024-07-10 22:23:43 +01:00
spaced4ndy f41c04735b ios: lookup group members via map (#4431) 2024-07-10 16:15:14 +04:00
spaced4ndy a8da9b9cd9 core: fix invitee sending redundant pending messages whey they should be forwarded (#4430) 2024-07-10 13:52:04 +04:00
Evgeny Poberezkin 64a0f509f7 6.0-beta.0: ios 227, android 225, desktop 56 2024-07-09 22:38:08 +01:00
spaced4ndy c4f8a50f0d ios: show inactive and forwarded group message status; show inactive members (#4423)
* ios: differentiate inactive and forwarded group snd statuses; show member connection disabled/inactive

* lazy

* simplify

* Update apps/ios/SimpleXChat/ChatTypes.swift

* export localizations, update strings

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 21:52:33 +01:00
spaced4ndy 93a4c0854e desktop, android: show inactive and forwarded group message status; show inactive members (#4426)
* multiplatform: differentiate inactive and forwarded group snd statuses; show member connection disabled/inactive

* forward icon

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 21:28:01 +01:00
Evgeny Poberezkin 49c29c74df core: 6.0.0.0 2024-07-09 19:41:42 +01:00
Evgeny Poberezkin e6ee5df158 core: update simplexmq 6.0.0.0 2024-07-09 19:07:44 +01:00
Stanislav Dmitrenko f4be0278b6 desktop: zoom and font size (#4421)
* desktop: font scale

* new line

* moved to slider

* default value highlighting

* clickable

* more places with adapted scale

* attachment and edit icons

* verified

* icons in chat view

* zoom

* new chat button size

* preview icons

* android support

* preview

* text scale in chat view's text field

* paddings

---------

Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 18:41:23 +01:00
spaced4ndy a9d2535292 core: differentiate inactive and forwarded group snd statuses (#4420)
* core: GroupSndStatus

* rfc

* encoding, db apis

* pending, forwarded statuses

* encoding
2024-07-09 18:29:36 +01:00
spaced4ndy 3e623684bc android, desktop: servers summary (#4398)
* multiplatform: subscription icon (#4397)

* multiplatform: added network setting to control display of subscription percentage

* multiplatform: moved filter to search bar and scan to button

* multiplatform: added types and calls for new apis

* Update apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt

Co-authored-by: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com>

* chore: minor changes in API connections and usage

* fix: removed show sub setting from net cfg

* multiplatform: added subscription status to chats screen

* multiplatform: added reconnect all servers api

* multiplatform: added basic view for single user messages

* multiplatform: added basic xftp server info view

* multiplatform: added detailed stats view for SMP server

* multiplatform: added detailed stats view for XFTP server

* multiplatform: added individual server view for XFTP servers

* multiplatform: added individual server view for SMP servers

* multiplatform: added custom coloring for connections

* multiplatform: added all translation strings

* multiplatform: added support for multi user in serve summary

* multiplatform: added missing translations

* multiplatform: added share button to servers info

* better type safety for server summaries

* multiplatform: fixed action arrow paddings in server summary views

* multiplatform: serverSummaryView padding and icon fixes

* multiplatform: reused shared section divider

* move and rename

* remove tab icons, text

* colors

* filter button

* paddings

* fix translation keys

* text

* fix buttons clickable area, alerts

* stats view

* remove chevrons

* colors

* remove id, fix open server button

* don't log terminal items

* desktop left modal

* single timer, pass state variable

* remove unused

* fix no summary view

* net cfg

* ability to hide servers screen by clicking outside servers screen

* addressed review feedback

* move user/all users selector under tabs

* disable horizontal scroll on desktop

---------

Co-authored-by: Diogo Cunha <diogofncunha@gmail.com>
Co-authored-by: Avently <7953703+avently@users.noreply.github.com>
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-09 12:45:09 +01:00
Evgeny Poberezkin fd90b47194 Merge branch 'stable' 2024-07-09 11:23:54 +01:00
Stanislav Dmitrenko acd3467d10 desktop: change libffi dependency (#4416) 2024-07-08 21:23:25 +01:00
Evgeny Poberezkin 71ce598355 ios: make message corners rounded in theme preview (#4410)
Co-authored-by: Levitating Pineapple <noreply@levitatingpineapple.com>
2024-07-05 14:43:36 +01:00
Arturs Krumins 63393eaf0b ios: make theme wallpaper visible on iOS15, further improve scrolling (#4408) 2024-07-05 13:34:03 +01:00
Evgeny Poberezkin f90de83215 ios: corrections, localizations (#4406) 2024-07-05 08:23:29 +01:00
Evgeny Poberezkin 1c10209a31 blog: future of privacy (#4396)
* blog: future of privacy

* update
2024-07-04 21:13:48 +01:00
Arturs Krumins 5d7abf31ce ios: make message corners rounded when selecting context menus (#4401)
Co-authored-by: Evgeny Poberezkin <evgeny@poberezkin.com>
2024-07-04 18:37:03 +01:00
spaced4ndy 44c0861fe4 ios: apply theming to servers summary view (#4404) 2024-07-04 21:11:20 +04:00
sh 1e6dc8002c flatpak: update metainfo (#4400) 2024-07-04 17:39:16 +04:00
112 changed files with 12234 additions and 823 deletions
+19 -5
View File
@@ -73,6 +73,7 @@ final class ChatModel: ObservableObject {
var chatItemStatuses: Dictionary<Int64, CIStatus> = [:]
@Published var chatToTop: String?
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
// items in the terminal view
@Published var showingTerminal = false
@Published var terminalItems: [TerminalItem] = []
@@ -180,8 +181,18 @@ final class ChatModel: ObservableObject {
}
}
func populateGroupMembersIndexes() {
groupMembersIndexes.removeAll()
for (i, member) in groupMembers.enumerated() {
groupMembersIndexes[member.groupMemberId] = i
}
}
func getGroupMember(_ groupMemberId: Int64) -> GMember? {
groupMembers.first { $0.groupMemberId == groupMemberId }
if let i = groupMembersIndexes[groupMemberId] {
return groupMembers[i]
}
return nil
}
private func getChatIndex(_ id: String) -> Int? {
@@ -667,14 +678,17 @@ final class ChatModel: ObservableObject {
}
// update current chat
if chatId == groupInfo.id {
if let i = groupMembers.firstIndex(where: { $0.groupMemberId == member.groupMemberId }) {
if let i = groupMembersIndexes[member.groupMemberId] {
withAnimation(.default) {
self.groupMembers[i].wrapped = member
self.groupMembers[i].created = Date.now
}
return false
} else {
withAnimation { groupMembers.append(GMember(member)) }
withAnimation {
groupMembers.append(GMember(member))
groupMembersIndexes[member.groupMemberId] = groupMembers.count - 1
}
return true
}
} else {
@@ -702,7 +716,7 @@ final class ChatModel: ObservableObject {
}
i += 1
}
return UnreadChatItemCounts(totalBelow: totalBelow, unreadBelow: unreadBelow)
return UnreadChatItemCounts(isNearBottom: totalBelow < 16, unreadBelow: unreadBelow)
}
func topItemInView(itemsInView: Set<String>) -> ChatItem? {
@@ -740,7 +754,7 @@ struct NTFContactRequest {
}
struct UnreadChatItemCounts: Equatable {
var totalBelow: Int
var isNearBottom: Bool
var unreadBelow: Int
}
@@ -70,7 +70,6 @@ struct CIGroupInvitationView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme))
.cornerRadius(18)
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
@@ -22,7 +22,6 @@ struct CIInvalidJSONView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.onTapGesture { showJSON = true }
.appSheet(isPresented: $showJSON) {
@@ -132,7 +132,6 @@ struct CIRcvDecryptionError: View {
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -152,7 +151,6 @@ struct CIRcvDecryptionError: View {
.onTapGesture(perform: { onClick() })
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -25,7 +25,6 @@ struct DeletedItemView: View {
.padding(.leading, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme))
.cornerRadius(18)
.textSelection(.disabled)
}
}
@@ -76,7 +76,6 @@ struct FramedItemView: View {
}
}
.background(chatItemFrameColorMaybeImageOrVideo(chatItem, theme))
.cornerRadius(18)
.onPreferenceChange(DetermineWidth.Key.self) { msgWidth = $0 }
if let (title, text) = chatItem.meta.itemStatus.statusInfo {
@@ -70,7 +70,6 @@ struct CIMsgError: View {
.padding(.leading, 12)
.padding(.vertical, 6)
.background(Color(uiColor: .tertiarySystemGroupedBackground))
.cornerRadius(18)
.textSelection(.disabled)
.onTapGesture(perform: onTap)
}
@@ -23,7 +23,6 @@ struct MarkedDeletedItemView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(chatItem, theme))
.cornerRadius(18)
.textSelection(.disabled)
}
@@ -232,7 +232,7 @@ struct ChatItemInfoView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(chatItemFrameColor(ci, theme))
.cornerRadius(18)
.modifier(ChatItemClipped())
.contextMenu {
if itemVersion.msgContent.text != "" {
Button {
@@ -302,7 +302,7 @@ struct ChatItemInfoView: View {
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(quotedMsgFrameColor(qi, theme))
.cornerRadius(18)
.modifier(ChatItemClipped())
.contextMenu {
if qi.text != "" {
Button {
@@ -415,7 +415,7 @@ struct ChatItemInfoView: View {
}
@ViewBuilder private func memberDeliveryStatusesView(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> some View {
VStack(alignment: .leading, spacing: 12) {
LazyVStack(alignment: .leading, spacing: 12) {
let mss = membersStatuses(memberDeliveryStatuses)
if !mss.isEmpty {
ForEach(mss, id: \.0.groupMemberId) { memberStatus in
@@ -428,7 +428,7 @@ struct ChatItemInfoView: View {
}
}
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, CIStatus, Bool?)] {
private func membersStatuses(_ memberDeliveryStatuses: [MemberDeliveryStatus]) -> [(GroupMember, GroupSndStatus, Bool?)] {
memberDeliveryStatuses.compactMap({ mds in
if let mem = chatModel.getGroupMember(mds.groupMemberId) {
return (mem.wrapped, mds.memberDeliveryStatus, mds.sentViaProxy)
@@ -438,7 +438,7 @@ struct ChatItemInfoView: View {
})
}
private func memberDeliveryStatusView(_ member: GroupMember, _ status: CIStatus, _ sentViaProxy: Bool?) -> some View {
private func memberDeliveryStatusView(_ member: GroupMember, _ status: GroupSndStatus, _ sentViaProxy: Bool?) -> some View {
HStack{
ProfileImage(imageStr: member.image, size: 30)
.padding(.trailing, 2)
@@ -450,23 +450,19 @@ struct ChatItemInfoView: View {
.foregroundColor(theme.colors.secondary).opacity(0.67)
}
let v = Group {
if let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary) {
switch status {
case .sndRcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
default:
let (icon, statusColor) = status.statusIcon(theme.colors.secondary, theme.colors.primary)
switch status {
case .rcvd:
ZStack(alignment: .trailing) {
Image(systemName: icon)
.foregroundColor(statusColor)
.foregroundColor(statusColor.opacity(0.67))
.padding(.trailing, 6)
Image(systemName: icon)
.foregroundColor(statusColor.opacity(0.67))
}
} else {
Image(systemName: "ellipsis")
.foregroundColor(Color.secondary)
default:
Image(systemName: icon)
.foregroundColor(statusColor)
}
}
+6 -3
View File
@@ -121,6 +121,7 @@ struct ChatView: View {
chatModel.chatItemStatuses = [:]
chatModel.reversedChatItems = []
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
membersLoaded = false
}
}
@@ -255,6 +256,7 @@ struct ChatView: View {
await MainActor.run {
if chatModel.chatId == groupInfo.id {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
membersLoaded = true
updateView()
}
@@ -410,7 +412,7 @@ struct ChatView: View {
init() {
unreadChatItemCounts = UnreadChatItemCounts(
totalBelow: .zero,
isNearBottom: true,
unreadBelow: .zero
)
events
@@ -425,9 +427,9 @@ struct ChatView: View {
itemsInView
}
}
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.map { ChatModel.shared.unreadChatItemCounts(itemsInView: $0) }
.removeDuplicates()
.throttle(for: .seconds(0.2), scheduler: DispatchQueue.main, latest: true)
.assign(to: \.unreadChatItemCounts, on: self)
.store(in: &bag)
}
@@ -479,7 +481,7 @@ struct ChatView: View {
scrollModel.scrollToItem(id: latestUnreadItem.id)
}
}
} else if counts.totalBelow > 16 {
} else if !counts.isNearBottom {
circleButton {
Image(systemName: "chevron.down")
.foregroundColor(theme.colors.primary)
@@ -754,6 +756,7 @@ struct ChatView: View {
playbackState: $playbackState,
playbackTime: $playbackTime
)
.modifier(ChatItemClipped(ci))
.contextMenu { menu(ci, range, live: composeState.liveMessage != nil) }
.accessibilityLabel("")
if ci.content.msgContent != nil && (ci.meta.itemDeleted == nil || revealed) && ci.reactions.count > 0 {
@@ -207,6 +207,7 @@ struct GroupChatInfoView: View {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
chatModel.groupMembers = groupMembers.map { GMember.init($0) }
chatModel.populateGroupMembersIndexes()
}
}
}
@@ -232,8 +233,7 @@ struct GroupChatInfoView: View {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
.lineLimit(1)
let s = Text(member.memberStatus.shortText)
(user ? Text ("you: ") + s : s)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
.lineLimit(1)
.font(.caption)
.foregroundColor(theme.colors.secondary)
@@ -266,6 +266,16 @@ struct GroupChatInfoView: View {
}
}
private func memberConnStatus(_ member: GroupMember) -> LocalizedStringKey {
if member.activeConn?.connDisabled ?? false {
return "disabled"
} else if member.activeConn?.connInactive ?? false {
return "inactive"
} else {
return member.memberStatus.shortText
}
}
@ViewBuilder private func memberInfo(_ member: GroupMember) -> some View {
if member.blocked {
Text("blocked")
@@ -141,12 +141,6 @@ struct GroupMemberInfoView: View {
} else {
infoRow("Role", member.memberRole.text)
}
// TODO invited by - need to get contact by contact id
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
}
if let connStats = connectionStats {
@@ -183,6 +177,10 @@ struct GroupMemberInfoView: View {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", member.localDisplayName)
infoRow("Database ID", "\(member.groupMemberId)")
if let conn = member.activeConn {
let connLevelDesc = conn.connLevel == 0 ? NSLocalizedString("direct", comment: "connection level description") : String.localizedStringWithFormat(NSLocalizedString("indirect (%d)", comment: "connection level description"), conn.connLevel)
infoRow("Connection", connLevelDesc)
}
Button ("Debug delivery") {
Task {
do {
@@ -55,6 +55,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
// 1. Style
tableView.separatorStyle = .none
tableView.transform = .verticalFlip
tableView.backgroundColor = .clear
// 2. Register cells
if #available(iOS 16.0, *) {
@@ -180,6 +181,7 @@ struct ReverseList<Item: Identifiable & Hashable & Sendable, Content: View>: UIV
/// Updates content of the cell
/// For reference: https://noahgilmore.com/blog/swiftui-self-sizing-cells/
func set(content: Hosted, parent: UIViewController) {
hostingController.view.backgroundColor = .clear
hostingController.rootView = content
if let hostingView = hostingController.view {
hostingView.invalidateIntrinsicContentSize()
@@ -36,6 +36,7 @@ struct ServersSummaryView: View {
viewBody()
.navigationTitle("Servers info")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
shareButton()
@@ -264,6 +265,7 @@ struct ServersSummaryView: View {
)
.navigationBarTitle("SMP server")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
Text(serverAddress(srvSumm.smpServer))
@@ -332,6 +334,7 @@ struct ServersSummaryView: View {
)
.navigationBarTitle("XFTP server")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
HStack {
Text(serverAddress(srvSumm.xftpServer))
@@ -360,7 +363,7 @@ struct ServersSummaryView: View {
Button {
alert = SomeAlert(
alert: Alert(
title: Text("Reset all servers statistics?"),
title: Text("Reset all statistics?"),
message: Text("Servers statistics will be reset - this cannot be undone!"),
primaryButton: .destructive(Text("Reset")) {
Task {
@@ -422,7 +425,7 @@ struct SubscriptionStatusPercentageView: View {
var body: some View {
let onionHosts = networkUseOnionHostsGroupDefault.get()
let (_, _, _, statusPercent) = subscriptionStatusColorAndPercentage(m.networkInfo.online, onionHosts, subs, sess)
Text("\(Int(floor(statusPercent * 100)))%")
Text(verbatim: "\(Int(floor(statusPercent * 100)))%")
.foregroundColor(.secondary)
.font(.caption)
}
@@ -470,6 +473,7 @@ struct SMPServerSummaryView: View {
NavigationLink {
ProtocolServersView(serverProtocol: .smp)
.navigationTitle("Your SMP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
@@ -563,6 +567,7 @@ struct SMPStatsView: View {
DetailedSMPStatsView(stats: stats, statsStartedAt: statsStartedAt)
.navigationTitle("Detailed statistics")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Details")
}
@@ -590,21 +595,21 @@ struct DetailedSMPStatsView: View {
infoRowTwoValues("Sent via proxy", "attempts", stats._sentViaProxy, stats._sentViaProxyAttempts)
infoRowTwoValues("Proxied", "attempts", stats._sentProxied, stats._sentProxiedAttempts)
Text("Send errors")
indentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs))
indentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs))
indentedInfoRow("expired", numOrDash(stats._sentExpiredErrs))
indentedInfoRow("other", numOrDash(stats._sentOtherErrs))
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._sentAuthErrs)).padding(.leading, 24)
infoRow(Text(verbatim: "QUOTA"), numOrDash(stats._sentQuotaErrs)).padding(.leading, 24)
infoRow("expired", numOrDash(stats._sentExpiredErrs)).padding(.leading, 24)
infoRow("other", numOrDash(stats._sentOtherErrs)).padding(.leading, 24)
}
Section("Received messages") {
infoRow("Received total", numOrDash(stats._recvMsgs))
Text("Receive errors")
indentedInfoRow("duplicates", numOrDash(stats._recvDuplicates))
indentedInfoRow("decryption errors", numOrDash(stats._recvCryptoErrs))
indentedInfoRow("other errors", numOrDash(stats._recvErrs))
infoRow("duplicates", numOrDash(stats._recvDuplicates)).padding(.leading, 24)
infoRow("decryption errors", numOrDash(stats._recvCryptoErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._recvErrs)).padding(.leading, 24)
infoRowTwoValues("Acknowledged", "attempts", stats._ackMsgs, stats._ackAttempts)
Text("Acknowledgement errors")
indentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs))
indentedInfoRow("other errors", numOrDash(stats._ackOtherErrs))
infoRow(Text(verbatim: "NO_MSG errors"), numOrDash(stats._ackNoMsgErrs)).padding(.leading, 24)
infoRow("other errors", numOrDash(stats._ackOtherErrs)).padding(.leading, 24)
}
Section {
infoRow("Created", numOrDash(stats._connCreated))
@@ -613,7 +618,7 @@ struct DetailedSMPStatsView: View {
infoRowTwoValues("Deleted", "attempts", stats._connDeleted, stats._connDelAttempts)
infoRow("Deletion errors", numOrDash(stats._connDelErrs))
infoRowTwoValues("Subscribed", "attempts", stats._connSubscribed, stats._connSubAttempts)
infoRow("Subscription results ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscriptions ignored", numOrDash(stats._connSubIgnored))
infoRow("Subscription errors", numOrDash(stats._connSubErrs))
} header: {
Text("Connections")
@@ -626,29 +631,19 @@ struct DetailedSMPStatsView: View {
private func infoRowTwoValues(_ title: LocalizedStringKey, _ title2: LocalizedStringKey, _ value: Int, _ value2: Int) -> some View {
HStack {
Text(title) + Text(" / ").font(.caption2) + Text(title2).font(.caption2)
Text(title) + Text(verbatim: " / ").font(.caption2) + Text(title2).font(.caption2)
Spacer()
Group {
if value == 0 && value2 == 0 {
Text("-")
Text(verbatim: "-")
} else {
Text(numOrDash(value)) + Text(" / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
Text(numOrDash(value)) + Text(verbatim: " / ").font(.caption2) + Text(numOrDash(value2)).font(.caption2)
}
}
.foregroundStyle(.secondary)
}
}
private func indentedInfoRow(_ title: LocalizedStringKey, _ value: String) -> some View {
HStack {
Text(title)
.padding(.leading, 24)
Spacer()
Text(value)
.foregroundStyle(.secondary)
}
}
struct XFTPServerSummaryView: View {
var summary: XFTPServerSummary
var statsStartedAt: Date
@@ -662,6 +657,7 @@ struct XFTPServerSummaryView: View {
NavigationLink {
ProtocolServersView(serverProtocol: .xftp)
.navigationTitle("Your XFTP servers")
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Open server settings")
}
@@ -692,6 +688,7 @@ struct XFTPStatsView: View {
DetailedXFTPStatsView(stats: stats, statsStartedAt: statsStartedAt)
.navigationTitle("Detailed statistics")
.navigationBarTitleDisplayMode(.large)
.modifier(ThemedBackground(grouped: true))
} label: {
Text("Details")
}
@@ -725,8 +722,8 @@ struct DetailedXFTPStatsView: View {
infoRow("Size", prettySize(stats._downloadsSize))
infoRowTwoValues("Chunks downloaded", "attempts", stats._downloads, stats._downloadAttempts)
Text("Download errors")
indentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs))
indentedInfoRow("other", numOrDash(stats._downloadErrs))
infoRow(Text(verbatim: "AUTH"), numOrDash(stats._downloadAuthErrs)).padding(.leading, 24)
infoRow("other", numOrDash(stats._downloadErrs)).padding(.leading, 24)
} header: {
Text("Downloaded files")
} footer: {
@@ -0,0 +1,68 @@
//
// ChatItemClipShape.swift
// SimpleX (iOS)
//
// Created by Levitating Pineapple on 04/07/2024.
// Copyright © 2024 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
/// Modifier, which provides clipping mask for ``ChatItemWithMenu`` view
/// and it's previews: (drag interaction, context menu, etc.)
/// Supports [Dynamic Type](https://developer.apple.com/documentation/uikit/uifont/scaling_fonts_automatically)
/// by retaining pill shape, even when ``ChatItem``'s height is less that twice its corner radius
struct ChatItemClipped: ViewModifier {
struct ClipShape: Shape {
let maxCornerRadius: Double
func path(in rect: CGRect) -> Path {
Path(
roundedRect: rect,
cornerRadius: min((rect.height / 2), maxCornerRadius),
style: .circular
)
}
}
init() {
clipShape = ClipShape(
maxCornerRadius: 18
)
}
init(_ chatItem: ChatItem) {
clipShape = ClipShape(
maxCornerRadius: {
switch chatItem.content {
case
.sndMsgContent,
.rcvMsgContent,
.rcvDecryptionError,
.rcvGroupInvitation,
.sndGroupInvitation,
.sndDeleted,
.rcvDeleted,
.rcvIntegrityError,
.sndModerated,
.rcvModerated,
.rcvBlocked,
.invalidJSON: 18
default: 8
}
}()
)
}
private let clipShape: ClipShape
func body(content: Content) -> some View {
content
.contentShape(.dragPreview, clipShape)
.contentShape(.contextMenuPreview, clipShape)
.clipShape(clipShape)
}
}
@@ -194,6 +194,7 @@ struct AddGroupView: View {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {
m.groupMembers = groupMembers.map { GMember.init($0) }
m.populateGroupMembersIndexes()
}
}
let c = Chat(chatInfo: .group(groupInfo: gInfo), chatItems: [])
@@ -305,13 +305,16 @@ struct ChatThemePreview: View {
let view = VStack {
if withMessages {
let alice = ChatItem.getSample(1, CIDirection.directRcv, Date.now, NSLocalizedString("Good afternoon!", comment: "message preview"))
let bob = ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir))
HStack {
ChatItemView(chat: Chat.sampleData, chatItem: alice, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
Spacer()
}
HStack {
Spacer()
ChatItemView(chat: Chat.sampleData, chatItem: ChatItem.getSample(2, CIDirection.directSnd, Date.now, NSLocalizedString("Good morning!", comment: "message preview"), quotedItem: CIQuote.getSample(alice.id, alice.meta.itemTs, alice.content.text, chatDir: alice.chatDir)), revealed: Binding.constant(false))
ChatItemView(chat: Chat.sampleData, chatItem: bob, revealed: Binding.constant(false))
.modifier(ChatItemClipped())
.frame(alignment: .trailing)
}
} else {
@@ -747,7 +750,7 @@ struct ThemeDestinationPicker: View {
let values = [(nil, "All profiles")] + m.users.filter { $0.user.activeUser }.map { ($0.user.userId, $0.user.chatViewName)}
if values.contains(where: { (userId, text) in userId == themeUserDestination?.0 }) {
Picker("Apply to mode", selection: $themeUserDest) {
Picker("Apply to", selection: $themeUserDest) {
ForEach(values, id: \.0) { (_, text) in
Text(text)
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "bg",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "cs",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "de",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "en",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "es",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "fi",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "fr",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "hu",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "it",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "ja",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "nl",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "pl",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "ru",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "th",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "tr",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "uk",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
File diff suppressed because it is too large Load Diff
@@ -3,10 +3,10 @@
"project" : "SimpleX.xcodeproj",
"targetLocale" : "zh-Hans",
"toolInfo" : {
"toolBuildNumber" : "15A240d",
"toolBuildNumber" : "15F31d",
"toolID" : "com.apple.dt.xcode",
"toolName" : "Xcode",
"toolVersion" : "15.0"
"toolVersion" : "15.4"
},
"version" : "1.0"
}
+44 -32
View File
@@ -195,6 +195,7 @@
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */; };
8CC4ED902BD7B8530078AEE8 /* CallAudioDeviceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */; };
8CC956EE2BC0041000412A11 /* NetworkObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CC956ED2BC0041000412A11 /* NetworkObserver.swift */; };
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */; };
CEEA861D2C2ABCB50084E1EA /* ReverseList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */; };
D7197A1829AE89660055C05A /* WebRTC in Frameworks */ = {isa = PBXBuildFile; productRef = D7197A1729AE89660055C05A /* WebRTC */; };
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = D72A9087294BD7A70047C86D /* NativeTextEditor.swift */; };
@@ -202,11 +203,12 @@
D741547A29AF90B00022400A /* PushKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D741547929AF90B00022400A /* PushKit.framework */; };
D77B92DC2952372200A5A1CC /* SwiftyGif in Frameworks */ = {isa = PBXBuildFile; productRef = D77B92DB2952372200A5A1CC /* SwiftyGif */; };
D7F0E33929964E7E0068AF69 /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = D7F0E33829964E7E0068AF69 /* LZString */; };
E52FF8DA2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D52C34676600BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a */; };
E52FF8DB2C34676700BF81EB /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D62C34676600BF81EB /* libgmpxx.a */; };
E52FF8DC2C34676700BF81EB /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D72C34676700BF81EB /* libffi.a */; };
E52FF8DD2C34676700BF81EB /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D82C34676700BF81EB /* libgmp.a */; };
E52FF8DE2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E52FF8D92C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a */; };
E50581002C3DDD7F009C3F71 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E50580FB2C3DDD7F009C3F71 /* libffi.a */; };
E50581012C3DDD7F009C3F71 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E50580FC2C3DDD7F009C3F71 /* libgmp.a */; };
E50581022C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E50580FD2C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN-ghc9.6.3.a */; };
E50581032C3DDD7F009C3F71 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E50580FE2C3DDD7F009C3F71 /* libgmpxx.a */; };
E50581042C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN.a in Frameworks */ = {isa = PBXBuildFile; fileRef = E50580FF2C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN.a */; };
E50581062C3DDD9D009C3F71 /* Yams in Frameworks */ = {isa = PBXBuildFile; productRef = E50581052C3DDD9D009C3F71 /* Yams */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -501,16 +503,17 @@
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeModeEditor.swift; sourceTree = "<group>"; };
8CC4ED8F2BD7B8530078AEE8 /* CallAudioDeviceManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallAudioDeviceManager.swift; sourceTree = "<group>"; };
8CC956ED2BC0041000412A11 /* NetworkObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkObserver.swift; sourceTree = "<group>"; };
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatItemClipShape.swift; sourceTree = "<group>"; };
CEEA861C2C2ABCB50084E1EA /* ReverseList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReverseList.swift; sourceTree = "<group>"; };
D72A9087294BD7A70047C86D /* NativeTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NativeTextEditor.swift; sourceTree = "<group>"; };
D741547729AF89AF0022400A /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/StoreKit.framework; sourceTree = DEVELOPER_DIR; };
D741547929AF90B00022400A /* PushKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = PushKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS16.1.sdk/System/Library/Frameworks/PushKit.framework; sourceTree = DEVELOPER_DIR; };
D7AA2C3429A936B400737B40 /* MediaEncryption.playground */ = {isa = PBXFileReference; lastKnownFileType = file.playground; name = MediaEncryption.playground; path = Shared/MediaEncryption.playground; sourceTree = SOURCE_ROOT; xcLanguageSpecificationIdentifier = xcode.lang.swift; };
E52FF8D52C34676600BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a"; sourceTree = "<group>"; };
E52FF8D62C34676600BF81EB /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
E52FF8D72C34676700BF81EB /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
E52FF8D82C34676700BF81EB /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
E52FF8D92C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a"; sourceTree = "<group>"; };
E50580FB2C3DDD7F009C3F71 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
E50580FC2C3DDD7F009C3F71 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
E50580FD2C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN-ghc9.6.3.a"; sourceTree = "<group>"; };
E50580FE2C3DDD7F009C3F71 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
E50580FF2C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN.a"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -549,13 +552,14 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
E52FF8DE2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a in Frameworks */,
E52FF8DB2C34676700BF81EB /* libgmpxx.a in Frameworks */,
E50581032C3DDD7F009C3F71 /* libgmpxx.a in Frameworks */,
5CE2BA93284534B000EC33A6 /* libiconv.tbd in Frameworks */,
E50581002C3DDD7F009C3F71 /* libffi.a in Frameworks */,
E50581012C3DDD7F009C3F71 /* libgmp.a in Frameworks */,
5CE2BA94284534BB00EC33A6 /* libz.tbd in Frameworks */,
E52FF8DC2C34676700BF81EB /* libffi.a in Frameworks */,
E52FF8DD2C34676700BF81EB /* libgmp.a in Frameworks */,
E52FF8DA2C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a in Frameworks */,
E50581022C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN-ghc9.6.3.a in Frameworks */,
E50581062C3DDD9D009C3F71 /* Yams in Frameworks */,
E50581042C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN.a in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -622,11 +626,11 @@
5C764E5C279C70B7000C6508 /* Libraries */ = {
isa = PBXGroup;
children = (
E52FF8D72C34676700BF81EB /* libffi.a */,
E52FF8D82C34676700BF81EB /* libgmp.a */,
E52FF8D62C34676600BF81EB /* libgmpxx.a */,
E52FF8D52C34676600BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7-ghc9.6.3.a */,
E52FF8D92C34676700BF81EB /* libHSsimplex-chat-5.8.2.0-D50x9PQRTdqAAO9AQK2dA7.a */,
E50580FB2C3DDD7F009C3F71 /* libffi.a */,
E50580FC2C3DDD7F009C3F71 /* libgmp.a */,
E50580FE2C3DDD7F009C3F71 /* libgmpxx.a */,
E50580FD2C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN-ghc9.6.3.a */,
E50580FF2C3DDD7F009C3F71 /* libHSsimplex-chat-6.0.0.0-IhofDzGnTMcDdW5i3Fb7xN.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -683,6 +687,7 @@
8C7F8F0D2C19C0C100D16888 /* ViewModifiers.swift */,
8C74C3ED2C1B942300039E77 /* ChatWallpaper.swift */,
8C9BC2642C240D5100875A27 /* ThemeModeEditor.swift */,
CE984D4A2C36C5D500E3AEFF /* ChatItemClipShape.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -1062,6 +1067,7 @@
);
name = SimpleXChat;
packageProductDependencies = (
E50581052C3DDD9D009C3F71 /* Yams */,
);
productName = SimpleXChat;
productReference = 5CE2BA682845308900EC33A6 /* SimpleXChat.framework */;
@@ -1225,6 +1231,7 @@
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
CE984D4B2C36C5D500E3AEFF /* ChatItemClipShape.swift in Sources */,
64D0C2C629FAC1EC00B38D5F /* AddContactLearnMore.swift in Sources */,
5C3A88D127DF57800060F1C2 /* FramedItemView.swift in Sources */,
5C65F343297D45E100B67AF3 /* VersionView.swift in Sources */,
@@ -1608,7 +1615,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 226;
CURRENT_PROJECT_VERSION = 227;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1633,7 +1640,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 5.8.2;
MARKETING_VERSION = 6.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1657,7 +1664,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 226;
CURRENT_PROJECT_VERSION = 227;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -1682,7 +1689,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.8.2;
MARKETING_VERSION = 6.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
SDKROOT = iphoneos;
@@ -1743,7 +1750,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 226;
CURRENT_PROJECT_VERSION = 227;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -1758,7 +1765,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.8.2;
MARKETING_VERSION = 6.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1780,7 +1787,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 226;
CURRENT_PROJECT_VERSION = 227;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -1795,7 +1802,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.8.2;
MARKETING_VERSION = 6.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -1817,7 +1824,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 226;
CURRENT_PROJECT_VERSION = 227;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1843,7 +1850,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.8.2;
MARKETING_VERSION = 6.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -1868,7 +1875,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 226;
CURRENT_PROJECT_VERSION = 227;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -1894,7 +1901,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 5.8.2;
MARKETING_VERSION = 6.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2031,6 +2038,11 @@
package = D7F0E33729964E7D0068AF69 /* XCRemoteSwiftPackageReference "lzstring-swift" */;
productName = LZString;
};
E50581052C3DDD9D009C3F71 /* Yams */ = {
isa = XCSwiftPackageProductDependency;
package = 8C73C1162C21E17B00892670 /* XCRemoteSwiftPackageReference "Yams" */;
productName = Yams;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = 5CA059BE279559F40002BEB4 /* Project object */;
+65 -3
View File
@@ -1611,11 +1611,12 @@ public struct Connection: Decodable, Hashable {
public var pqSndEnabled: Bool?
public var pqRcvEnabled: Bool?
public var authErrCounter: Int
public var quotaErrCounter: Int
public var connectionStats: ConnectionStats? = nil
private enum CodingKeys: String, CodingKey {
case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter
case connId, agentConnId, peerChatVRange, connStatus, connLevel, viaGroupLink, customUserProfileId, connectionCode, pqSupport, pqEncryption, pqSndEnabled, pqRcvEnabled, authErrCounter, quotaErrCounter
}
public var id: ChatId { get { ":\(connId)" } }
@@ -1624,6 +1625,10 @@ public struct Connection: Decodable, Hashable {
authErrCounter >= 10 // authErrDisableCount in core
}
public var connInactive: Bool {
quotaErrCounter >= 5 // quotaErrInactiveCount in core
}
public var connPQEnabled: Bool {
pqSndEnabled == true && pqRcvEnabled == true
}
@@ -1637,7 +1642,8 @@ public struct Connection: Decodable, Hashable {
viaGroupLink: false,
pqSupport: false,
pqEncryption: false,
authErrCounter: 0
authErrCounter: 0,
quotaErrCounter: 0
)
}
@@ -2846,6 +2852,62 @@ public enum SndCIStatusProgress: String, Decodable, Hashable {
case complete
}
public enum GroupSndStatus: Decodable, Hashable {
case new
case forwarded
case inactive
case sent
case rcvd(msgRcptStatus: MsgReceiptStatus)
case error(agentError: SndError)
case warning(agentError: SndError)
case invalid(text: String)
public func statusIcon(_ metaColor: Color/* = .secondary*/, _ primaryColor: Color = .accentColor) -> (String, Color) {
switch self {
case .new: return ("ellipsis", metaColor)
case .forwarded: return ("chevron.forward.2", metaColor)
case .inactive: return ("person.badge.minus", metaColor)
case .sent: return ("checkmark", metaColor)
case let .rcvd(msgRcptStatus):
switch msgRcptStatus {
case .ok: return ("checkmark", metaColor)
case .badMsgHash: return ("checkmark", .red)
}
case .error: return ("multiply", .red)
case .warning: return ("exclamationmark.triangle.fill", .orange)
case .invalid: return ("questionmark", metaColor)
}
}
public var statusInfo: (String, String)? {
switch self {
case .new: return nil
case .forwarded: return (
NSLocalizedString("Message forwarded", comment: "item status text"),
NSLocalizedString("No direct connection yet, message is forwarded by admin.", comment: "item status description")
)
case .inactive: return (
NSLocalizedString("Member inactive", comment: "item status text"),
NSLocalizedString("Message may be delivered later if member becomes active.", comment: "item status description")
)
case .sent: return nil
case .rcvd: return nil
case let .error(agentError): return (
NSLocalizedString("Message delivery error", comment: "item status text"),
agentError.errorInfo
)
case let .warning(agentError): return (
NSLocalizedString("Message delivery warning", comment: "item status text"),
agentError.errorInfo
)
case let .invalid(text): return (
NSLocalizedString("Invalid status", comment: "item status text"),
text
)
}
}
}
public enum CIDeleted: Decodable, Hashable {
case deleted(deletedTs: Date?)
case blocked(deletedTs: Date?)
@@ -4012,6 +4074,6 @@ public struct ChatItemVersion: Decodable, Hashable {
public struct MemberDeliveryStatus: Decodable, Hashable {
public var groupMemberId: Int64
public var memberDeliveryStatus: CIStatus
public var memberDeliveryStatus: GroupSndStatus
public var sentViaProxy: Bool?
}
@@ -29,6 +29,7 @@ import androidx.core.widget.doAfterTextChanged
import androidx.core.widget.doOnTextChanged
import chat.simplex.common.R
import chat.simplex.common.helpers.toURI
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.ui.theme.CurrentColors
import chat.simplex.common.views.chat.*
@@ -107,7 +108,7 @@ actual fun PlatformTextField(
editText.maxLines = 16
editText.inputType = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or editText.inputType
editText.setTextColor(textColor.toArgb())
editText.textSize = textStyle.value.fontSize.value
editText.textSize = textStyle.value.fontSize.value * appPrefs.fontScale.get()
val drawable = androidAppContext.getDrawable(R.drawable.send_msg_view_background)!!
DrawableCompat.setTint(drawable, tintColor.toArgb())
editText.background = drawable
@@ -135,7 +136,7 @@ actual fun PlatformTextField(
editText
}) {
it.setTextColor(textColor.toArgb())
it.textSize = textStyle.value.fontSize.value
it.textSize = textStyle.value.fontSize.value * appPrefs.fontScale.get()
DrawableCompat.setTint(it.background, tintColor.toArgb())
it.isFocusable = composeState.value.preview !is ComposePreview.VoicePreview
it.isFocusableInTouchMode = it.isFocusable
@@ -554,7 +554,7 @@ fun CallPermissionsView(pipActive: Boolean, hasVideo: Boolean, cancel: () -> Uni
}
} else {
ColumnWithScrollBar(Modifier.fillMaxSize()) {
Spacer(Modifier.height(AppBarHeight))
Spacer(Modifier.height(AppBarHeight * fontSizeSqrtMultiplier))
AppBarTitle(stringResource(MR.strings.permissions_required))
Spacer(Modifier.weight(1f))
@@ -6,6 +6,7 @@ import androidx.compose.material.Divider
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.onRightClick
import chat.simplex.common.views.helpers.*
@@ -19,8 +20,14 @@ actual fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>
) {
var modifier = Modifier.fillMaxWidth()
if (oneHandUI.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
if (!disabled) modifier = modifier
.combinedClickable(onClick = click, onLongClick = { showMenu.value = true })
.onRightClick { showMenu.value = true }
@@ -137,6 +137,9 @@ fun AppearanceScope.AppearanceLayout(
}
}
SectionDividerSpaced(maxBottomPadding = true)
FontScaleSection()
SectionBottomSpacer()
}
}
@@ -18,6 +18,7 @@ import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
import chat.simplex.common.views.usersettings.SetDeliveryReceiptsView
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.CreateFirstProfile
@@ -36,6 +37,7 @@ import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlin.math.sqrt
data class SettingsViewState(
val userPickerState: MutableStateFlow<AnimatedViewState>,
@@ -333,38 +335,38 @@ fun EndPartOfScreen() {
fun DesktopScreen(settingsState: SettingsViewState) {
Box {
// 56.dp is a size of unused space of settings drawer
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH + 56.dp)) {
Box(Modifier.width(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier + 56.dp)) {
StartPartOfScreen(settingsState)
}
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH)) {
Box(Modifier.widthIn(max = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)) {
ModalManager.start.showInView()
SwitchingUsersView()
}
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH).clipToBounds()) {
Row(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
Box(Modifier.widthIn(min = DEFAULT_MIN_CENTER_MODAL_WIDTH).weight(1f)) {
CenterPartOfScreen()
}
if (ModalManager.end.hasModalsOpen()) {
VerticalDivider()
}
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH).clipToBounds()) {
Box(Modifier.widthIn(max = DEFAULT_END_MODAL_WIDTH * fontSizeSqrtMultiplier).clipToBounds()) {
EndPartOfScreen()
}
}
val (userPickerState, scaffoldState ) = settingsState
val scope = rememberCoroutineScope()
if (scaffoldState.drawerState.isOpen) {
if (scaffoldState.drawerState.isOpen || (ModalManager.start.hasModalsOpen && !ModalManager.center.hasModalsOpen)) {
Box(
Modifier
.fillMaxSize()
.padding(start = DEFAULT_START_MODAL_WIDTH)
.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = {
ModalManager.start.closeModals()
scope.launch { settingsState.scaffoldState.drawerState.close() }
})
)
}
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH))
VerticalDivider(Modifier.padding(start = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier))
tryOrShowError("UserPicker", error = {}) {
UserPicker(chatModel, userPickerState) {
scope.launch { if (scaffoldState.drawerState.isOpen) scaffoldState.drawerState.close() else scaffoldState.drawerState.open() }
@@ -780,6 +780,7 @@ interface SomeChat {
val id: ChatId
val apiId: Long
val ready: Boolean
val chatDeleted: Boolean
val sendMsgEnabled: Boolean
val ntfsEnabled: Boolean
val incognito: Boolean
@@ -860,6 +861,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contact.id
override val apiId get() = contact.apiId
override val ready get() = contact.ready
override val chatDeleted get() = contact.chatDeleted
override val sendMsgEnabled get() = contact.sendMsgEnabled
override val ntfsEnabled get() = contact.ntfsEnabled
override val incognito get() = contact.incognito
@@ -884,6 +886,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = groupInfo.id
override val apiId get() = groupInfo.apiId
override val ready get() = groupInfo.ready
override val chatDeleted get() = groupInfo.chatDeleted
override val sendMsgEnabled get() = groupInfo.sendMsgEnabled
override val ntfsEnabled get() = groupInfo.ntfsEnabled
override val incognito get() = groupInfo.incognito
@@ -908,6 +911,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = noteFolder.id
override val apiId get() = noteFolder.apiId
override val ready get() = noteFolder.ready
override val chatDeleted get() = noteFolder.chatDeleted
override val sendMsgEnabled get() = noteFolder.sendMsgEnabled
override val ntfsEnabled get() = noteFolder.ntfsEnabled
override val incognito get() = noteFolder.incognito
@@ -932,6 +936,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactRequest.id
override val apiId get() = contactRequest.apiId
override val ready get() = contactRequest.ready
override val chatDeleted get() = contactRequest.chatDeleted
override val sendMsgEnabled get() = contactRequest.sendMsgEnabled
override val ntfsEnabled get() = contactRequest.ntfsEnabled
override val incognito get() = contactRequest.incognito
@@ -956,6 +961,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = contactConnection.id
override val apiId get() = contactConnection.apiId
override val ready get() = contactConnection.ready
override val chatDeleted get() = contactConnection.chatDeleted
override val sendMsgEnabled get() = contactConnection.sendMsgEnabled
override val ntfsEnabled get() = false
override val incognito get() = contactConnection.incognito
@@ -981,6 +987,7 @@ sealed class ChatInfo: SomeChat, NamedChat {
override val id get() = ""
override val apiId get() = 0L
override val ready get() = false
override val chatDeleted get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
override val incognito get() = false
@@ -1057,6 +1064,7 @@ data class Contact(
val chatTs: Instant?,
val contactGroupMemberId: Long? = null,
val contactGrpInvSent: Boolean,
override val chatDeleted: Boolean,
val uiThemes: ThemeModeOverrides? = null,
): SomeChat, NamedChat {
override val chatType get() = ChatType.Direct
@@ -1130,6 +1138,7 @@ data class Contact(
updatedAt = Clock.System.now(),
chatTs = Clock.System.now(),
contactGrpInvSent = false,
chatDeleted = false,
uiThemes = null,
)
}
@@ -1138,7 +1147,8 @@ data class Contact(
@Serializable
enum class ContactStatus {
@SerialName("active") Active,
@SerialName("deleted") Deleted;
@SerialName("deleted") Deleted,
@SerialName("deletedByUser") DeletedByUser;
}
@Serializable
@@ -1172,18 +1182,22 @@ data class Connection(
val pqSndEnabled: Boolean? = null,
val pqRcvEnabled: Boolean? = null,
val connectionStats: ConnectionStats? = null,
val authErrCounter: Int
val authErrCounter: Int,
val quotaErrCounter: Int
) {
val id: ChatId get() = ":$connId"
val connDisabled: Boolean
get() = authErrCounter >= 10 // authErrDisableCount in core
val connInactive: Boolean
get() = quotaErrCounter >= 5 // quotaErrInactiveCount in core
val connPQEnabled: Boolean
get() = pqSndEnabled == true && pqRcvEnabled == true
companion object {
val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false, authErrCounter = 0)
val sampleData = Connection(connId = 1, agentConnId = "abc", connStatus = ConnStatus.Ready, connLevel = 0, viaGroupLink = false, peerChatVRange = VersionRange(1, 1), customUserProfileId = null, pqSupport = false, pqEncryption = false, authErrCounter = 0, quotaErrCounter = 0)
}
}
@@ -1277,6 +1291,7 @@ data class GroupInfo (
override val id get() = "#$groupId"
override val apiId get() = groupId
override val ready get() = membership.memberActive
override val chatDeleted get() = false
override val sendMsgEnabled get() = membership.memberActive
override val ntfsEnabled get() = chatSettings.enableNtfs == MsgFilter.All
override val incognito get() = membership.memberIncognito
@@ -1582,6 +1597,7 @@ class NoteFolder(
override val chatType get() = ChatType.Local
override val id get() = "*$noteFolderId"
override val apiId get() = noteFolderId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = true
override val ntfsEnabled get() = false
@@ -1618,6 +1634,7 @@ class UserContactRequest (
override val chatType get() = ChatType.ContactRequest
override val id get() = "<@$contactRequestId"
override val apiId get() = contactRequestId
override val chatDeleted get() = false
override val ready get() = true
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -1657,6 +1674,7 @@ class PendingContactConnection(
override val chatType get() = ChatType.ContactConnection
override val id get () = ":$pccConnId"
override val apiId get() = pccConnId
override val chatDeleted get() = false
override val ready get() = false
override val sendMsgEnabled get() = false
override val ntfsEnabled get() = false
@@ -2352,6 +2370,48 @@ enum class SndCIStatusProgress {
@SerialName("complete") Complete;
}
@Serializable
sealed class GroupSndStatus {
@Serializable @SerialName("new") class New: GroupSndStatus()
@Serializable @SerialName("forwarded") class Forwarded: GroupSndStatus()
@Serializable @SerialName("inactive") class Inactive: GroupSndStatus()
@Serializable @SerialName("sent") class Sent: GroupSndStatus()
@Serializable @SerialName("rcvd") class Rcvd(val msgRcptStatus: MsgReceiptStatus): GroupSndStatus()
@Serializable @SerialName("error") class Error(val agentError: SndError): GroupSndStatus()
@Serializable @SerialName("warning") class Warning(val agentError: SndError): GroupSndStatus()
@Serializable @SerialName("invalid") class Invalid(val text: String): GroupSndStatus()
fun statusIcon(
primaryColor: Color,
metaColor: Color = CurrentColors.value.colors.secondary,
paleMetaColor: Color = CurrentColors.value.colors.secondary
): Pair<ImageResource, Color> =
when (this) {
is New -> MR.images.ic_more_horiz to metaColor
is Forwarded -> MR.images.ic_chevron_right_2 to metaColor
is Inactive -> MR.images.ic_person_off to metaColor
is Sent -> MR.images.ic_check_filled to metaColor
is Rcvd -> when(this.msgRcptStatus) {
MsgReceiptStatus.Ok -> MR.images.ic_double_check to metaColor
MsgReceiptStatus.BadMsgHash -> MR.images.ic_double_check to Color.Red
}
is Error -> MR.images.ic_close to Color.Red
is Warning -> MR.images.ic_warning_filled to WarningOrange
is Invalid -> MR.images.ic_question_mark to metaColor
}
val statusInto: Pair<String, String>? get() = when (this) {
is New -> null
is Forwarded -> generalGetString(MR.strings.message_forwarded_title) to generalGetString(MR.strings.message_forwarded_desc)
is Inactive -> generalGetString(MR.strings.member_inactive_title) to generalGetString(MR.strings.member_inactive_desc)
is Sent -> null
is Rcvd -> null
is Error -> generalGetString(MR.strings.message_delivery_error_title) to agentError.errorInfo
is Warning -> generalGetString(MR.strings.message_delivery_warning_title) to agentError.errorInfo
is Invalid -> "Invalid status" to this.text
}
}
@Serializable
sealed class CIDeleted {
@Serializable @SerialName("deleted") class Deleted(val deletedTs: Instant?): CIDeleted()
@@ -3466,7 +3526,7 @@ data class ChatItemVersion(
@Serializable
data class MemberDeliveryStatus(
val groupMemberId: Long,
val memberDeliveryStatus: CIStatus,
val memberDeliveryStatus: GroupSndStatus,
val sentViaProxy: Boolean?
)
@@ -127,6 +127,7 @@ class AppPreferences {
val showSlowApiCalls = mkBoolPreference(SHARED_PREFS_SHOW_SLOW_API_CALLS, false)
val terminalAlwaysVisible = mkBoolPreference(SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE, false)
val networkUseSocksProxy = mkBoolPreference(SHARED_PREFS_NETWORK_USE_SOCKS_PROXY, false)
val networkShowSubscriptionPercentage = mkBoolPreference(SHARED_PREFS_NETWORK_SHOW_SUBSCRIPTION_PERCENTAGE, false)
val networkProxyHostPort = mkStrPreference(SHARED_PREFS_NETWORK_PROXY_HOST_PORT, "localhost:9050")
private val _networkSessionMode = mkStrPreference(SHARED_PREFS_NETWORK_SESSION_MODE, TransportSessionMode.default.name)
val networkSessionMode: SharedPreference<TransportSessionMode> = SharedPreference(
@@ -197,6 +198,8 @@ class AppPreferences {
}, settingsThemes)
val themeOverrides = mkThemeOverridesPreference()
val profileImageCornerRadius = mkFloatPreference(SHARED_PREFS_PROFILE_IMAGE_CORNER_RADIUS, 22.5f)
val fontScale = mkFloatPreference(SHARED_PREFS_FONT_SCALE, 1f)
val densityScale = mkFloatPreference(SHARED_PREFS_DENSITY_SCALE, 1f)
val whatsNewVersion = mkStrPreference(SHARED_PREFS_WHATS_NEW_VERSION, null)
val lastMigratedVersionCode = mkIntPreference(SHARED_PREFS_LAST_MIGRATED_VERSION_CODE, 0)
@@ -210,13 +213,16 @@ class AppPreferences {
val desktopWindowState = mkStrPreference(SHARED_PREFS_DESKTOP_WINDOW_STATE, null)
val showDeleteConversationNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE, true)
val showDeleteContactNotice = mkBoolPreference(SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE, true)
val showSentViaProxy = mkBoolPreference(SHARED_PREFS_SHOW_SENT_VIA_RPOXY, false)
val iosCallKitEnabled = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_ENABLED, true)
val iosCallKitCallsInRecents = mkBoolPreference(SHARED_PREFS_IOS_CALL_KIT_CALLS_IN_RECENTS, false)
val oneHandUI = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI, false)
private fun mkIntPreference(prefName: String, default: Int) =
SharedPreference(
get = fun() = settings.getInt(prefName, default),
@@ -338,6 +344,7 @@ class AppPreferences {
private const val SHARED_PREFS_SHOW_SLOW_API_CALLS = "ShowSlowApiCalls"
private const val SHARED_PREFS_TERMINAL_ALWAYS_VISIBLE = "TerminalAlwaysVisible"
private const val SHARED_PREFS_NETWORK_USE_SOCKS_PROXY = "NetworkUseSocksProxy"
private const val SHARED_PREFS_NETWORK_SHOW_SUBSCRIPTION_PERCENTAGE = "ShowSubscriptionPercentage"
private const val SHARED_PREFS_NETWORK_PROXY_HOST_PORT = "NetworkProxyHostPort"
private const val SHARED_PREFS_NETWORK_SESSION_MODE = "NetworkSessionMode"
private const val SHARED_PREFS_NETWORK_SMP_PROXY_MODE = "NetworkSMPProxyMode"
@@ -369,6 +376,7 @@ class AppPreferences {
private const val SHARED_PREFS_ENCRYPTION_STARTED_AT = "EncryptionStartedAt"
private const val SHARED_PREFS_NEW_DATABASE_INITIALIZED = "NewDatabaseInitialized"
private const val SHARED_PREFS_CONFIRM_DB_UPGRADES = "ConfirmDBUpgrades"
private const val SHARED_PREFS_ONE_HAND_UI = "OneHandUI"
private const val SHARED_PREFS_SELF_DESTRUCT = "LocalAuthenticationSelfDestruct"
private const val SHARED_PREFS_SELF_DESTRUCT_DISPLAY_NAME = "LocalAuthenticationSelfDestructDisplayName"
private const val SHARED_PREFS_PQ_EXPERIMENTAL_ENABLED = "PQExperimentalEnabled" // no longer used
@@ -378,6 +386,8 @@ class AppPreferences {
private const val SHARED_PREFS_THEMES_OLD = "Themes"
private const val SHARED_PREFS_THEME_OVERRIDES = "ThemeOverrides"
private const val SHARED_PREFS_PROFILE_IMAGE_CORNER_RADIUS = "ProfileImageCornerRadius"
private const val SHARED_PREFS_FONT_SCALE = "FontScale"
private const val SHARED_PREFS_DENSITY_SCALE = "DensityScale"
private const val SHARED_PREFS_WHATS_NEW_VERSION = "WhatsNewVersion"
private const val SHARED_PREFS_LAST_MIGRATED_VERSION_CODE = "LastMigratedVersionCode"
private const val SHARED_PREFS_CUSTOM_DISAPPEARING_MESSAGE_TIME = "CustomDisappearingMessageTime"
@@ -387,6 +397,8 @@ class AppPreferences {
private const val SHARED_PREFS_CONNECT_REMOTE_VIA_MULTICAST_AUTO = "ConnectRemoteViaMulticastAuto"
private const val SHARED_PREFS_OFFER_REMOTE_MULTICAST = "OfferRemoteMulticast"
private const val SHARED_PREFS_DESKTOP_WINDOW_STATE = "DesktopWindowState"
private const val SHARED_PREFS_SHOW_DELETE_CONVERSATION_NOTICE = "showDeleteConversationNotice"
private const val SHARED_PREFS_SHOW_DELETE_CONTACT_NOTICE = "showDeleteContactNotice"
private const val SHARED_PREFS_SHOW_SENT_VIA_RPOXY = "showSentViaProxy"
private const val SHARED_PREFS_IOS_CALL_KIT_ENABLED = "iOSCallKitEnabled"
@@ -411,6 +423,18 @@ object ChatController {
fun hasChatCtrl() = ctrl != -1L && ctrl != null
suspend fun getAgentServersSummary(rh: Long?): PresentedServersSummary? {
val userId = currentUserId("getAgentServersSummary")
val r = sendCmd(rh, CC.GetAgentServersSummary(userId), log = false)
if (r is CR.AgentServersSummary) return r.serversSummary
Log.e(TAG, "getAgentServersSummary bad response: ${r.responseType} ${r.details}")
return null
}
suspend fun resetAgentServersStats(rh: Long?): Boolean = sendCommandOkResp(rh, CC.ResetAgentServersStats())
private suspend fun currentUserId(funcName: String): Long = changingActiveUserMutex.withLock {
val userId = chatModel.currentUser.value?.userId
if (userId == null) {
@@ -569,20 +593,24 @@ object ChatController {
}
}
suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null): CR {
suspend fun sendCmd(rhId: Long?, cmd: CC, otherCtrl: ChatCtrl? = null, log: Boolean = true): CR {
val ctrl = otherCtrl ?: ctrl ?: throw Exception("Controller is not initialized")
return withContext(Dispatchers.IO) {
val c = cmd.cmdString
chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
if (log) {
chatModel.addTerminalItem(TerminalItem.cmd(rhId, cmd.obfuscated))
Log.d(TAG, "sendCmd: ${cmd.cmdType}")
}
val json = if (rhId == null) chatSendCmd(ctrl, c) else chatSendRemoteCmd(ctrl, rhId.toInt(), c)
val r = APIResponse.decodeStr(json)
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d(TAG, "sendCmd response json $json")
if (log) {
Log.d(TAG, "sendCmd response type ${r.resp.responseType}")
if (r.resp is CR.Response || r.resp is CR.Invalid) {
Log.d(TAG, "sendCmd response json $json")
}
chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp))
}
chatModel.addTerminalItem(TerminalItem.resp(rhId, r.resp))
r.resp
}
}
@@ -920,6 +948,14 @@ object ChatController {
}
}
suspend fun reconnectServer(rh: Long?, server: String): Boolean {
val userId = currentUserId("reconnectServer")
return sendCommandOkResp(rh, CC.ReconnectServer(userId, server))
}
suspend fun reconnectAllServers(rh: Long?): Boolean = sendCommandOkResp(rh, CC.ReconnectAllServers())
suspend fun apiSetSettings(rh: Long?, type: ChatType, id: Long, settings: ChatSettings): Boolean {
val r = sendCmd(rh, CC.APISetChatSettings(type, id, settings))
return when (r) {
@@ -1119,16 +1155,16 @@ object ChatController {
}
}
suspend fun deleteChat(chat: Chat, notify: Boolean? = null) {
suspend fun deleteChat(chat: Chat, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val cInfo = chat.chatInfo
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, notify = notify)) {
if (apiDeleteChat(rh = chat.remoteHostId, type = cInfo.chatType, id = cInfo.apiId, chatDeleteMode = chatDeleteMode)) {
chatModel.removeChat(chat.remoteHostId, cInfo.id)
}
}
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, notify: Boolean? = null): Boolean {
suspend fun apiDeleteChat(rh: Long?, type: ChatType, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Boolean {
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, notify))
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val success = when {
r is CR.ContactDeleted && type == ChatType.Direct -> true
r is CR.ContactConnectionDeleted && type == ChatType.ContactConnection -> true
@@ -1149,6 +1185,22 @@ object ChatController {
return success
}
suspend fun apiDeleteContact(rh: Long?, id: Long, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)): Contact? {
val type = ChatType.Direct
chatModel.deletedChats.value += rh to type.type + id
val r = sendCmd(rh, CC.ApiDeleteChat(type, id, chatDeleteMode))
val contact = when {
r is CR.ContactDeleted -> r.contact
else -> {
val titleId = MR.strings.error_deleting_contact
apiErrorAlert("apiDeleteChat", generalGetString(titleId), r)
null
}
}
chatModel.deletedChats.value -= rh to type.type + id
return contact
}
fun clearChat(chat: Chat, close: (() -> Unit)? = null) {
withBGApi {
val updatedChatInfo = apiClearChat(chat.remoteHostId, chat.chatInfo.chatType, chat.chatInfo.apiId)
@@ -1887,6 +1939,10 @@ object ChatController {
val cInfo = r.chatItem.chatInfo
val cItem = r.chatItem.chatItem
if (active(r.user)) {
if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) {
val updatedContact = cInfo.contact.copy(chatDeleted = false)
chatModel.updateContact(rhId, updatedContact)
}
chatModel.addChatItem(rhId, cInfo, cItem)
} else if (cItem.isRcvNew && cInfo.ntfsEnabled) {
chatModel.increaseUnreadCounter(rhId, r.user)
@@ -2577,6 +2633,8 @@ sealed class CC {
class APISetNetworkConfig(val networkConfig: NetCfg): CC()
class APIGetNetworkConfig: CC()
class APISetNetworkInfo(val networkInfo: UserNetworkInfo): CC()
class ReconnectServer(val userId: Long, val server: String): CC()
class ReconnectAllServers: CC()
class APISetChatSettings(val type: ChatType, val id: Long, val chatSettings: ChatSettings): CC()
class ApiSetMemberSettings(val groupId: Long, val groupMemberId: Long, val memberSettings: GroupMemberSettings): CC()
class APIContactInfo(val contactId: Long): CC()
@@ -2598,7 +2656,7 @@ sealed class CC {
class APIConnectPlan(val userId: Long, val connReq: String): CC()
class APIConnect(val userId: Long, val incognito: Boolean, val connReq: String): CC()
class ApiConnectContactViaAddress(val userId: Long, val incognito: Boolean, val contactId: Long): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val notify: Boolean?): CC()
class ApiDeleteChat(val type: ChatType, val id: Long, val chatDeleteMode: ChatDeleteMode): CC()
class ApiClearChat(val type: ChatType, val id: Long): CC()
class ApiListContacts(val userId: Long): CC()
class ApiUpdateProfile(val userId: Long, val profile: Profile): CC()
@@ -2648,6 +2706,8 @@ sealed class CC {
class ApiStandaloneFileInfo(val url: String): CC()
// misc
class ShowVersion(): CC()
class ResetAgentServersStats(): CC()
class GetAgentServersSummary(val userId: Long): CC()
val cmdString: String get() = when (this) {
is Console -> cmd
@@ -2724,6 +2784,8 @@ sealed class CC {
is APISetNetworkConfig -> "/_network ${json.encodeToString(networkConfig)}"
is APIGetNetworkConfig -> "/network"
is APISetNetworkInfo -> "/_network info ${json.encodeToString(networkInfo)}"
is ReconnectServer -> "/reconnect $userId $server"
is ReconnectAllServers -> "/reconnect"
is APISetChatSettings -> "/_settings ${chatRef(type, id)} ${json.encodeToString(chatSettings)}"
is ApiSetMemberSettings -> "/_member settings #$groupId $groupMemberId ${json.encodeToString(memberSettings)}"
is APIContactInfo -> "/_info @$contactId"
@@ -2745,11 +2807,7 @@ sealed class CC {
is APIConnectPlan -> "/_connect plan $userId $connReq"
is APIConnect -> "/_connect $userId incognito=${onOff(incognito)} $connReq"
is ApiConnectContactViaAddress -> "/_connect contact $userId incognito=${onOff(incognito)} $contactId"
is ApiDeleteChat -> if (notify != null) {
"/_delete ${chatRef(type, id)} notify=${onOff(notify)}"
} else {
"/_delete ${chatRef(type, id)}"
}
is ApiDeleteChat -> "/_delete ${chatRef(type, id)} ${chatDeleteMode.cmdString}"
is ApiClearChat -> "/_clear chat ${chatRef(type, id)}"
is ApiListContacts -> "/_contacts $userId"
is ApiUpdateProfile -> "/_profile $userId ${json.encodeToString(profile)}"
@@ -2804,6 +2862,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "/_download $userId $url ${file.filePath}"
is ApiStandaloneFileInfo -> "/_download info $url"
is ShowVersion -> "/version"
is ResetAgentServersStats -> "/reset servers stats"
is GetAgentServersSummary -> "/get servers summary $userId"
}
val cmdType: String get() = when (this) {
@@ -2864,6 +2924,8 @@ sealed class CC {
is APISetNetworkConfig -> "apiSetNetworkConfig"
is APIGetNetworkConfig -> "apiGetNetworkConfig"
is APISetNetworkInfo -> "apiSetNetworkInfo"
is ReconnectServer -> "reconnectServer"
is ReconnectAllServers -> "reconnectAllServers"
is APISetChatSettings -> "apiSetChatSettings"
is ApiSetMemberSettings -> "apiSetMemberSettings"
is APIContactInfo -> "apiContactInfo"
@@ -2933,6 +2995,8 @@ sealed class CC {
is ApiDownloadStandaloneFile -> "apiDownloadStandaloneFile"
is ApiStandaloneFileInfo -> "apiStandaloneFileInfo"
is ShowVersion -> "showVersion"
is ResetAgentServersStats -> "resetAgentServersStats"
is GetAgentServersSummary -> "getAgentServersSummary"
}
class ItemRange(val from: Long, val to: Long)
@@ -2962,8 +3026,6 @@ sealed class CC {
null
}
private fun onOff(b: Boolean): String = if (b) "on" else "off"
private fun maybePwd(pwd: String?): String = if (pwd == "" || pwd == null) "" else " " + json.encodeToString(pwd)
companion object {
@@ -2973,6 +3035,8 @@ sealed class CC {
}
}
fun onOff(b: Boolean): String = if (b) "on" else "off"
@Serializable
data class NewUser(
val profile: Profile?,
@@ -3203,7 +3267,7 @@ data class NetCfg(
val tcpKeepAlive: KeepAliveOpts?,
val smpPingInterval: Long, // microseconds
val smpPingCount: Int,
val logTLSErrors: Boolean = false
val logTLSErrors: Boolean = false,
) {
val useSocksProxy: Boolean get() = socksProxy != null
val enableKeepAlive: Boolean get() = tcpKeepAlive != null
@@ -3428,6 +3492,154 @@ data class TimedMessagesPreference(
}
}
@Serializable
data class PresentedServersSummary(
val statsStartedAt: Instant,
val allUsersSMP: SMPServersSummary,
val allUsersXFTP: XFTPServersSummary,
val currentUserSMP: SMPServersSummary,
val currentUserXFTP: XFTPServersSummary
)
@Serializable
data class SMPServersSummary(
val smpTotals: SMPTotals,
val currentlyUsedSMPServers: List<SMPServerSummary>,
val previouslyUsedSMPServers: List<SMPServerSummary>,
val onlyProxiedSMPServers: List<SMPServerSummary>
)
@Serializable
data class SMPTotals(
val sessions: ServerSessions,
val subs: SMPServerSubs,
val stats: AgentSMPServerStatsData
)
@Serializable
data class SMPServerSummary(
val smpServer: String,
val known: Boolean? = null,
val sessions: ServerSessions? = null,
val subs: SMPServerSubs? = null,
val stats: AgentSMPServerStatsData? = null
) {
val hasSubs: Boolean
get() = subs != null
val sessionsOrNew: ServerSessions
get() = sessions ?: ServerSessions.newServerSessions
val subsOrNew: SMPServerSubs
get() = subs ?: SMPServerSubs.newSMPServerSubs
}
@Serializable
data class ServerSessions(
val ssConnected: Int,
val ssErrors: Int,
val ssConnecting: Int
) {
companion object {
val newServerSessions = ServerSessions(
ssConnected = 0,
ssErrors = 0,
ssConnecting = 0
)
}
}
@Serializable
data class SMPServerSubs(
val ssActive: Int,
val ssPending: Int
) {
companion object {
val newSMPServerSubs = SMPServerSubs(
ssActive = 0,
ssPending = 0
)
}
val total: Int
get() = ssActive + ssPending
val shareOfActive: Float
get() = if (total != 0) ssActive.toFloat() / total else 0f
}
@Serializable
data class AgentSMPServerStatsData(
val _sentDirect: Int,
val _sentViaProxy: Int,
val _sentProxied: Int,
val _sentDirectAttempts: Int,
val _sentViaProxyAttempts: Int,
val _sentProxiedAttempts: Int,
val _sentAuthErrs: Int,
val _sentQuotaErrs: Int,
val _sentExpiredErrs: Int,
val _sentOtherErrs: Int,
val _recvMsgs: Int,
val _recvDuplicates: Int,
val _recvCryptoErrs: Int,
val _recvErrs: Int,
val _ackMsgs: Int,
val _ackAttempts: Int,
val _ackNoMsgErrs: Int,
val _ackOtherErrs: Int,
val _connCreated: Int,
val _connSecured: Int,
val _connCompleted: Int,
val _connDeleted: Int,
val _connDelAttempts: Int,
val _connDelErrs: Int,
val _connSubscribed: Int,
val _connSubAttempts: Int,
val _connSubIgnored: Int,
val _connSubErrs: Int
)
@Serializable
data class XFTPServersSummary(
val xftpTotals: XFTPTotals,
val currentlyUsedXFTPServers: List<XFTPServerSummary>,
val previouslyUsedXFTPServers: List<XFTPServerSummary>
)
@Serializable
data class XFTPTotals(
val sessions: ServerSessions,
val stats: AgentXFTPServerStatsData
)
@Serializable
data class XFTPServerSummary(
val xftpServer: String,
val known: Boolean? = null,
val sessions: ServerSessions? = null,
val stats: AgentXFTPServerStatsData? = null,
val rcvInProgress: Boolean,
val sndInProgress: Boolean,
val delInProgress: Boolean
) {}
@Serializable
data class AgentXFTPServerStatsData(
val _uploads: Int,
val _uploadsSize: Long,
val _uploadAttempts: Int,
val _uploadErrs: Int,
val _downloads: Int,
val _downloadsSize: Long,
val _downloadAttempts: Int,
val _downloadAuthErrs: Int,
val _downloadErrs: Int,
val _deletions: Int,
val _deleteAttempts: Int,
val _deleteErrs: Int
)
sealed class CustomTimeUnit {
object Second: CustomTimeUnit()
object Minute: CustomTimeUnit()
@@ -4439,6 +4651,7 @@ sealed class CR {
@Serializable @SerialName("chatError") class ChatRespError(val user_: UserRef?, val chatError: ChatError): CR()
@Serializable @SerialName("archiveImported") class ArchiveImported(val archiveErrors: List<ArchiveError>): CR()
@Serializable @SerialName("appSettings") class AppSettingsR(val appSettings: AppSettings): CR()
@Serializable @SerialName("agentServersSummary") class AgentServersSummary(val user: UserRef, val serversSummary: PresentedServersSummary): CR()
// general
@Serializable class Response(val type: String, val json: String): CR()
@Serializable class Invalid(val str: String): CR()
@@ -4598,6 +4811,7 @@ sealed class CR {
is ContactPQAllowed -> "contactPQAllowed"
is ContactPQEnabled -> "contactPQEnabled"
is VersionInfo -> "versionInfo"
is AgentServersSummary -> "agentServersSummary"
is CmdOk -> "cmdOk"
is ChatCmdError -> "chatCmdError"
is ChatRespError -> "chatError"
@@ -4776,6 +4990,7 @@ sealed class CR {
is RemoteCtrlStopped -> "rcsState: $rcsState\nrcsStopReason: $rcStopReason"
is ContactPQAllowed -> withUser(user, "contact: ${contact.id}\npqEncryption: $pqEncryption")
is ContactPQEnabled -> withUser(user, "contact: ${contact.id}\npqEnabled: $pqEnabled")
is AgentServersSummary -> withUser(user, json.encodeToString(serversSummary))
is VersionInfo -> "version ${json.encodeToString(versionInfo)}\n\n" +
"chat migrations: ${json.encodeToString(chatMigrations.map { it.upName })}\n\n" +
"agent migrations: ${json.encodeToString(agentMigrations.map { it.upName })}"
@@ -4801,6 +5016,19 @@ fun chatError(r: CR): ChatErrorType? {
)
}
@Serializable
sealed class ChatDeleteMode {
@Serializable @SerialName("full") class Full(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("entity") class Entity(val notify: Boolean): ChatDeleteMode()
@Serializable @SerialName("messages") class Messages: ChatDeleteMode()
val cmdString: String get() = when (this) {
is ChatDeleteMode.Full -> "full notify=${onOff(notify)}"
is ChatDeleteMode.Entity -> "entity notify=${onOff(notify)}"
is ChatDeleteMode.Messages -> "messages"
}
}
@Serializable
sealed class ConnectionPlan {
@Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan()
@@ -5651,6 +5879,7 @@ data class AppSettings(
var uiDarkColorScheme: String? = null,
var uiCurrentThemeIds: Map<String, String>? = null,
var uiThemes: List<ThemeOverrides>? = null,
var oneHandUI: Boolean? = null
) {
fun prepareForExport(): AppSettings {
val empty = AppSettings()
@@ -5680,6 +5909,7 @@ data class AppSettings(
if (uiDarkColorScheme != def.uiDarkColorScheme) { empty.uiDarkColorScheme = uiDarkColorScheme }
if (uiCurrentThemeIds != def.uiCurrentThemeIds) { empty.uiCurrentThemeIds = uiCurrentThemeIds }
if (uiThemes != def.uiThemes) { empty.uiThemes = uiThemes }
if (oneHandUI != def.oneHandUI) { empty.oneHandUI = oneHandUI }
return empty
}
@@ -5717,6 +5947,7 @@ data class AppSettings(
uiDarkColorScheme?.let { def.systemDarkTheme.set(it) }
uiCurrentThemeIds?.let { def.currentThemeIds.set(it) }
uiThemes?.let { def.themeOverrides.set(it.skipDuplicates()) }
oneHandUI?.let { def.oneHandUI.set(it) }
}
companion object {
@@ -5747,6 +5978,7 @@ data class AppSettings(
uiDarkColorScheme = DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = null,
uiThemes = null,
oneHandUI = false
)
val current: AppSettings
@@ -5778,6 +6010,7 @@ data class AppSettings(
uiDarkColorScheme = def.systemDarkTheme.get() ?: DefaultTheme.SIMPLEX.themeName,
uiCurrentThemeIds = def.currentThemeIds.get(),
uiThemes = def.themeOverrides.get(),
oneHandUI = def.oneHandUI.get()
)
}
}
@@ -6,6 +6,8 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import chat.simplex.common.model.ChatController
import chat.simplex.common.model.ChatController.appPrefs
@@ -777,6 +779,7 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
typography = Typography,
shapes = Shapes,
content = {
val density = Density(LocalDensity.current.density * desktopDensityScaleMultiplier, LocalDensity.current.fontScale * fontSizeMultiplier)
val rememberedAppColors = remember {
// Explicitly creating a new object here so we don't mutate the initial [appColors]
// provided, and overwrite the values set in it.
@@ -791,6 +794,7 @@ fun SimpleXTheme(darkTheme: Boolean? = null, content: @Composable () -> Unit) {
LocalContentColor provides MaterialTheme.colors.onBackground,
LocalAppColors provides rememberedAppColors,
LocalAppWallpaper provides rememberedWallpaper,
LocalDensity provides density,
content = content)
}
)
@@ -12,13 +12,17 @@ import SectionView
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.*
import chat.simplex.common.views.call.CallMediaType
import chat.simplex.common.views.chatlist.*
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -45,9 +49,21 @@ import kotlinx.datetime.Clock
import kotlinx.serialization.encodeToString
import java.io.File
sealed class ContactDeleteMode {
class Full: ContactDeleteMode()
class Entity: ContactDeleteMode()
fun toChatDeleteMode(notify: Boolean): ChatDeleteMode =
when (this) {
is Full -> ChatDeleteMode.Full(notify)
is Entity -> ChatDeleteMode.Entity(notify)
}
}
@Composable
fun ChatInfoView(
chatModel: ChatModel,
openedFromChatView: Boolean,
contact: Contact,
connectionStats: ConnectionStats?,
customUserProfile: Profile?,
@@ -68,6 +84,7 @@ fun ChatInfoView(
val chatRh = chat.remoteHostId
val sendReceipts = remember(contact.id) { mutableStateOf(SendReceipts.fromBool(contact.chatSettings.sendRcpts, currentUser.sendRcptsContacts)) }
ChatInfoLayout(
openedFromChatView = openedFromChatView,
chat,
contact,
currentUser,
@@ -166,7 +183,8 @@ fun ChatInfoView(
)
}
}
}
},
close = close
)
}
}
@@ -201,53 +219,127 @@ sealed class SendReceipts {
fun deleteContactDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
text = AnnotatedString(generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)),
buttons = {
Column {
if (chatInfo is ChatInfo.Direct && chatInfo.contact.ready && chatInfo.contact.active) {
// Delete and notify contact
if (chatInfo is ChatInfo.Direct) {
AlertManager.shared.showAlertDialogButtonsColumn(
title = generalGetString(MR.strings.delete_contact_question),
buttons = {
Column {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = true)
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Full())
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete
if (!chatInfo.contact.chatDeleted) {
// Delete contact, keep conversation
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close, contactDeleteMode = ContactDeleteMode.Entity())
}) {
Text(generalGetString(MR.strings.delete_contact_keep_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, notify = false)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
fun notifyDeleteContactDialog(
chat: Chat,
chatModel: ChatModel,
close: (() -> Unit)? = null,
contactDeleteMode: ContactDeleteMode = ContactDeleteMode.Full()
) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactActive = chatInfo.contact.ready && chatInfo.contact.active
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactActive) generalGetString(MR.strings.notify_delete_contact_question) else generalGetString(MR.strings.confirm_delete_contact_question),
text = when (contactDeleteMode) {
is ContactDeleteMode.Full -> generalGetString(MR.strings.delete_contact_all_messages_deleted_cannot_undo_warning)
is ContactDeleteMode.Entity -> generalGetString(MR.strings.delete_contact_cannot_undo_warning)
},
buttons = {
Column {
if (contactActive) {
// Delete and notify contact
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = true))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_and_notify_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Delete without notification
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_without_notification), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = contactDeleteMode.toChatDeleteMode(notify = false))
if (contactDeleteMode is ContactDeleteMode.Entity && chatModel.controller.appPrefs.showDeleteContactNotice.get()) {
showDeleteContactNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.delete_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteContactNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.contact_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_view_conversation_with_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteContactNotice.set(false)
},
)
}
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, notify: Boolean? = null) {
fun deleteContact(chat: Chat, chatModel: ChatModel, close: (() -> Unit)?, chatDeleteMode: ChatDeleteMode = ChatDeleteMode.Full(notify = true)) {
val chatInfo = chat.chatInfo
withBGApi {
val chatRh = chat.remoteHostId
val r = chatModel.controller.apiDeleteChat(chatRh, chatInfo.chatType, chatInfo.apiId, notify)
if (r) {
chatModel.removeChat(chatRh, chatInfo.id)
val ct = chatModel.controller.apiDeleteContact(chatRh, chatInfo.apiId, chatDeleteMode)
if (ct != null) {
when (chatDeleteMode) {
is ChatDeleteMode.Full ->
chatModel.removeChat(chatRh, chatInfo.id)
is ChatDeleteMode.Entity ->
chatModel.updateContact(chatRh, ct)
is ChatDeleteMode.Messages ->
chatModel.clearChat(chatRh, ChatInfo.Direct(ct))
}
if (chatModel.chatId.value == chatInfo.id) {
chatModel.chatId.value = null
ModalManager.end.closeModals()
@@ -280,6 +372,7 @@ fun clearNoteFolderDialog(chat: Chat, close: (() -> Unit)? = null) {
@Composable
fun ChatInfoLayout(
openedFromChatView: Boolean,
chat: Chat,
contact: Contact,
currentUser: User,
@@ -300,6 +393,7 @@ fun ChatInfoLayout(
syncContactConnection: () -> Unit,
syncContactConnectionForce: () -> Unit,
verifyClicked: () -> Unit,
close: () -> Unit,
) {
val cStats = connStats.value
val scrollState = rememberScrollState()
@@ -319,7 +413,31 @@ fun ChatInfoLayout(
}
LocalAliasEditor(chat.id, localAlias, updateValue = onLocalAliasChanged)
SectionSpacer()
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (contact.activeConn == null && contact.profile.contactLink != null && contact.active) {
ConnectButton(openedFromChatView, chat, contact, close)
} else if (!contact.active && !contact.chatDeleted) {
OpenButton(openedFromChatView, chat, contact, close)
} else {
MessageButton(openedFromChatView, chat, contact, close)
}
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
}
SectionSpacer()
if (customUserProfile != null) {
SectionView(generalGetString(MR.strings.incognito).uppercase()) {
SectionItemViewSpaceBetween {
@@ -535,6 +653,155 @@ fun LocalAliasEditor(
}
}
// when contact is a "contact card"
@Composable
private fun ConnectButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_connect_button),
disabled = false,
onClick = {
AlertManager.privacySensitive.showAlertDialogButtonsColumn(
title = String.format(generalGetString(MR.strings.connect_with_contact_name_question), contact.chatViewName),
buttons = {
Column {
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = false, close)
}) {
Text(generalGetString(MR.strings.connect_use_current_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
infoConnectContactViaAddress(openedFromChatView, chat, contact, incognito = true, close)
}) {
Text(generalGetString(MR.strings.connect_use_new_incognito_profile), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
SectionItemView({
AlertManager.privacySensitive.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
},
hostDevice = hostDevice(chat.remoteHostId),
)
}
)
}
private fun infoConnectContactViaAddress(openedFromChatView: Boolean, chat: Chat, contact: Contact, incognito: Boolean, close: () -> Unit) {
withBGApi {
val ok = connectContactViaAddress(chatModel, chat.remoteHostId, contact.contactId, incognito = incognito)
if (ok) {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
}
@Composable
private fun OpenButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_open_button),
disabled = false,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
private fun MessageButton(openedFromChatView: Boolean, chat: Chat, contact: Contact, close: () -> Unit) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = !contact.sendMsgEnabled,
onClick = {
if (openedFromChatView) {
close.invoke()
} else {
if (contact.chatDeleted) {
chatModel.updateContact(chat.remoteHostId, contact.copy(chatDeleted = false))
}
close.invoke()
withBGApi {
openDirectChat(chat.remoteHostId, contact.contactId, chatModel)
}
}
}
)
}
@Composable
fun CallButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_call_filled),
title = generalGetString(MR.strings.info_view_call_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Audio)
}
)
}
@Composable
fun VideoButton(chat: Chat, contact: Contact) {
InfoViewActionButton(
icon = painterResource(MR.images.ic_videocam_filled),
title = generalGetString(MR.strings.info_view_video_button),
disabled = !contact.ready || !contact.active || !contact.mergedPreferences.calls.enabled.forUser || chatModel.activeCall.value != null,
onClick = {
startChatCall(chat, CallMediaType.Video)
}
)
}
@Composable
fun InfoViewActionButton(icon: Painter, title: String, disabled: Boolean, onClick: () -> Unit) {
Surface(
Modifier
.width(96.dp)
.height(66.dp),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colors.secondaryVariant,
) {
val modifier = if (disabled) Modifier else Modifier.clickable { onClick () }
Column(
modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
icon,
contentDescription = null,
Modifier.size(26.dp),
tint = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
Text(
title,
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal),
color = if (disabled) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
}
@Composable
private fun NetworkStatusRow(networkStatus: NetworkStatus) {
Row(
@@ -823,6 +1090,7 @@ fun queueInfoText(info: Pair<RcvMsgInfo?, QueueInfo>): String {
fun PreviewChatInfoLayout() {
SimpleXTheme {
ChatInfoLayout(
openedFromChatView = false,
chat = Chat(
remoteHostId = null,
chatInfo = ChatInfo.Direct.sampleData,
@@ -847,6 +1115,7 @@ fun PreviewChatInfoLayout() {
syncContactConnection = {},
syncContactConnectionForce = {},
verifyClicked = {},
close = {},
)
}
}
@@ -330,7 +330,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
@Composable
fun MemberDeliveryStatusView(member: GroupMember, status: CIStatus, sentViaProxy: Boolean?) {
fun MemberDeliveryStatusView(member: GroupMember, status: GroupSndStatus, sentViaProxy: Boolean?) {
SectionItemView(
padding = PaddingValues(horizontal = 0.dp)
) {
@@ -355,7 +355,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
)
}
}
val statusIcon = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary)
val (icon, statusColor) = status.statusIcon(MaterialTheme.colors.primary, CurrentColors.value.colors.secondary)
var modifier = Modifier.size(36.dp).clip(RoundedCornerShape(20.dp))
val info = status.statusInto
if (info != null) {
@@ -367,20 +367,11 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
}
Box(modifier, contentAlignment = Alignment.Center) {
if (statusIcon != null) {
val (icon, statusColor) = statusIcon
Icon(
painterResource(icon),
contentDescription = null,
tint = statusColor
)
} else {
Icon(
painterResource(MR.images.ic_more_horiz),
contentDescription = null,
tint = CurrentColors.value.colors.secondary
)
}
Icon(
painterResource(icon),
contentDescription = null,
tint = statusColor
)
}
}
}
@@ -520,7 +511,7 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
}
}
private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List<MemberDeliveryStatus>): List<Triple<GroupMember, CIStatus, Boolean?>> {
private fun membersStatuses(chatModel: ChatModel, memberDeliveryStatuses: List<MemberDeliveryStatus>): List<Triple<GroupMember, GroupSndStatus, Boolean?>> {
return memberDeliveryStatuses.mapNotNull { mds ->
chatModel.getGroupMember(mds.groupMemberId)?.let { mem ->
Triple(mem, mds.memberDeliveryStatus, mds.sentViaProxy)
@@ -189,7 +189,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
code = chatModel.controller.apiGetContactCode(chatRh, chat.chatInfo.apiId)?.second
preloadedCode = code
}
ChatInfoView(chatModel, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
ChatInfoView(chatModel, openedFromChatView = true, (chat.chatInfo as ChatInfo.Direct).contact, contactInfo?.first, contactInfo?.second, chat.chatInfo.localAlias, code, close)
} else if (chat?.chatInfo is ChatInfo.Group) {
var link: Pair<String, GroupMemberRole>? by remember(chat.id) { mutableStateOf(preloadedLink) }
KeyChangeEffect(chat.id) {
@@ -306,18 +306,7 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
onComplete.invoke()
}
},
startCall = out@{ media ->
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chatRh, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
},
startCall = out@{ media -> startChatCall(chat, media) },
endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
@@ -521,6 +510,19 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
fun startChatCall(chat: Chat, media: CallMediaType) {
withBGApi {
val cInfo = chat.chatInfo
if (cInfo is ChatInfo.Direct) {
val contactInfo = chatModel.controller.apiContactInfo(chat.remoteHostId, cInfo.contact.contactId)
val profile = contactInfo?.second ?: chatModel.currentUser.value?.profile?.toProfile() ?: return@withBGApi
chatModel.activeCall.value = Call(remoteHostId = chat.remoteHostId, contact = cInfo.contact, callState = CallState.WaitCapabilities, localMedia = media, userProfile = profile)
chatModel.showCallView.value = true
chatModel.callCommand.add(WCallCommand.Capabilities(media))
}
}
}
@Composable
fun ChatLayout(
chat: Chat,
@@ -821,9 +823,9 @@ fun ChatInfoToolbar(
buttons = barButtons
)
Divider(Modifier.padding(top = AppBarHeight))
Divider(Modifier.padding(top = AppBarHeight * fontSizeSqrtMultiplier))
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight)) {
Box(Modifier.fillMaxWidth().wrapContentSize(Alignment.TopEnd).offset(y = AppBarHeight * fontSizeSqrtMultiplier)) {
DefaultDropdownMenu(showMenu) {
menuItems.forEach { it() }
}
@@ -837,9 +839,9 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
verticalAlignment = Alignment.CenterVertically
) {
if (cInfo.incognito) {
IncognitoImage(size = 36.dp, Indigo)
IncognitoImage(size = 36.dp * fontSizeSqrtMultiplier, Indigo)
}
ChatInfoImage(cInfo, size = imageSize, iconColor)
ChatInfoImage(cInfo, size = imageSize * fontSizeSqrtMultiplier, iconColor)
Column(
Modifier.padding(start = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally
@@ -865,7 +867,7 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
@Composable
private fun ContactVerifiedShield() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(18.dp * fontSizeSqrtMultiplier).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
data class CIListState(val scrolled: Boolean, val itemCount: Int, val keyboardState: KeyboardState)
@@ -1283,7 +1285,7 @@ val MEMBER_IMAGE_SIZE: Dp = 38.dp
@Composable
fun MemberImage(member: GroupMember) {
ProfileImage(MEMBER_IMAGE_SIZE, member.memberProfile.image, backgroundColor = MaterialTheme.colors.background)
ProfileImage(MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier, member.memberProfile.image, backgroundColor = MaterialTheme.colors.background)
}
@Composable
@@ -13,10 +13,12 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.font.FontStyle
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.ChatModel.filesToDelete
@@ -884,7 +886,7 @@ fun ComposeView(
&& !nextSendGrpInv.value
IconButton(
attachmentClicked,
Modifier.padding(bottom = if (appPlatform.isAndroid) 0.dp else 7.dp),
Modifier.padding(bottom = if (appPlatform.isAndroid) 0.dp else with(LocalDensity.current) { 7.sp.toDp() }),
enabled = attachmentEnabled
) {
Icon(
@@ -15,10 +15,12 @@ import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.item.ItemAction
import chat.simplex.common.views.helpers.*
@@ -99,7 +101,7 @@ fun SendMsgView(
if (showDeleteTextButton.value) {
DeleteTextButton(composeState)
}
Box(Modifier.align(Alignment.BottomEnd).padding(bottom = if (appPlatform.isAndroid) 0.dp else 5.dp)) {
Box(Modifier.align(Alignment.BottomEnd).padding(bottom = if (appPlatform.isAndroid) 0.dp else with(LocalDensity.current) { 5.sp.toDp() } * fontSizeSqrtMultiplier)) {
val sendButtonSize = remember { Animatable(36f) }
val sendButtonAlpha = remember { Animatable(1f) }
val scope = rememberCoroutineScope()
@@ -390,6 +390,16 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -
}
}
fun memberConnStatus(): String {
return if (member.activeConn?.connDisabled == true) {
generalGetString(MR.strings.member_info_member_disabled)
} else if (member.activeConn?.connDisabled == true) {
generalGetString(MR.strings.member_info_member_inactive)
} else {
member.memberStatus.shortText
}
}
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -412,8 +422,8 @@ private fun MemberRow(member: GroupMember, user: Boolean = false, onClick: (() -
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
val s = member.memberStatus.shortText
val statusDescr = if (user) String.format(generalGetString(MR.strings.group_info_member_you), s) else s
val statusDescr =
if (user) String.format(generalGetString(MR.strings.group_info_member_you), member.memberStatus.shortText) else memberConnStatus()
Text(
statusDescr,
color = MaterialTheme.colors.secondary,
@@ -246,10 +246,10 @@ fun GroupMemberInfoLayout(
verifyClicked: () -> Unit,
) {
val cStats = connStats.value
fun knownDirectChat(contactId: Long): Chat? {
fun knownDirectChat(contactId: Long): Pair<Chat, Contact>? {
val chat = getContactChat(contactId)
return if (chat != null && chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.directOrUsed) {
chat
chat to chat.chatInfo.contact
} else {
null
}
@@ -309,17 +309,37 @@ fun GroupMemberInfoLayout(
val contactId = member.memberContactId
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val knownChat = if (contactId != null) knownDirectChat(contactId) else null
if (knownChat != null) {
val (chat, contact) = knownChat
OpenChatButton(onClick = { openDirectChat(contact.contactId) })
Spacer(Modifier.width(10.dp))
CallButton(chat, contact)
Spacer(Modifier.width(10.dp))
VideoButton(chat, contact)
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else {
OpenChatButton(onClick = { createMemberContact() })
}
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_call_filled), generalGetString(MR.strings.info_view_call_button), disabled = true, onClick = {})
Spacer(Modifier.width(10.dp))
InfoViewActionButton(painterResource(MR.images.ic_videocam_filled), generalGetString(MR.strings.info_view_video_button), disabled = true, onClick = {})
}
}
SectionSpacer()
if (member.memberActive) {
SectionView {
if (contactId != null && knownDirectChat(contactId) != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (groupInfo.fullGroupPreferences.directMessages.on(groupInfo.membership)) {
if (contactId != null) {
OpenChatButton(onClick = { openDirectChat(contactId) })
} else if (member.activeConn?.peerChatVRange?.isCompatibleRange(CREATE_MEMBER_CONTACT_VRANGE) == true) {
OpenChatButton(onClick = { createMemberContact() })
}
}
if (connectionCode != null) {
VerifyCodeButton(member.verified, verifyClicked)
}
@@ -358,13 +378,6 @@ fun GroupMemberInfoLayout(
} else {
InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text)
}
val conn = member.activeConn
if (conn != null) {
val connLevelDesc =
if (conn.connLevel == 0) stringResource(MR.strings.conn_level_desc_direct)
else String.format(generalGetString(MR.strings.conn_level_desc_indirect), conn.connLevel)
InfoRow(stringResource(MR.strings.info_row_connection), connLevelDesc)
}
}
if (cStats != null) {
SectionDividerSpaced()
@@ -401,6 +414,13 @@ fun GroupMemberInfoLayout(
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
InfoRow(stringResource(MR.strings.info_row_local_name), member.localDisplayName)
InfoRow(stringResource(MR.strings.info_row_database_id), member.groupMemberId.toString())
val conn = member.activeConn
if (conn != null) {
val connLevelDesc =
if (conn.connLevel == 0) stringResource(MR.strings.conn_level_desc_direct)
else String.format(generalGetString(MR.strings.conn_level_desc_indirect), conn.connLevel)
InfoRow(stringResource(MR.strings.info_row_connection), connLevelDesc)
}
SectionItemView({
withBGApi {
val info = controller.apiGroupMemberQueueInfo(rhId, groupInfo.apiId, member.groupMemberId)
@@ -513,12 +533,11 @@ fun RemoveMemberButton(onClick: () -> Unit) {
@Composable
fun OpenChatButton(onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_chat),
stringResource(MR.strings.button_send_direct_message),
click = onClick,
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
InfoViewActionButton(
icon = painterResource(MR.images.ic_chat_bubble_filled),
title = generalGetString(MR.strings.info_view_message_button),
disabled = false,
onClick = onClick
)
}
@@ -1,6 +1,7 @@
package chat.simplex.common.views.chatlist
import SectionItemView
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
@@ -32,7 +33,7 @@ import kotlinx.coroutines.delay
import kotlinx.datetime.Clock
@Composable
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>, oneHandUI: State<Boolean>) {
val showMenu = remember { mutableStateOf(false) }
val showMarkRead = remember(chat.chatStats.unreadCount, chat.chatStats.unreadChat) {
chat.chatStats.unreadCount > 0 || chat.chatStats.unreadChat
@@ -47,6 +48,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
val showChatPreviews = chatModel.showChatPreviews.value
val inProgress = remember { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
@@ -75,6 +77,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.Group ->
@@ -94,6 +97,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.Local -> {
ChatListNavLinkLayout(
@@ -112,6 +116,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
is ChatInfo.ContactRequest ->
@@ -131,6 +136,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.ContactConnection ->
ChatListNavLinkLayout(
@@ -151,6 +157,7 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
is ChatInfo.InvalidJSON ->
ChatListNavLinkLayout(
@@ -167,12 +174,13 @@ fun ChatListNavLinkView(chat: Chat, nextChatSelected: State<Boolean>) {
disabled,
selectedChat,
nextChatSelected,
oneHandUI
)
}
}
@Composable
private fun ErrorChatListItem() {
fun ErrorChatListItem() {
Box(Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 6.dp)) {
Text(stringResource(MR.strings.error_showing_content), color = MaterialTheme.colors.error, fontStyle = FontStyle.Italic)
}
@@ -407,13 +415,73 @@ fun DeleteContactAction(chat: Chat, chatModel: ChatModel, showMenu: MutableState
stringResource(MR.strings.delete_contact_menu_action),
painterResource(MR.images.ic_delete),
onClick = {
deleteContactDialog(chat, chatModel)
deleteContactConversationDialog(chat, chatModel)
showMenu.value = false
},
color = Color.Red
)
}
fun deleteContactConversationDialog(chat: Chat, chatModel: ChatModel, close: (() -> Unit)? = null) {
val chatInfo = chat.chatInfo
if (chatInfo is ChatInfo.Direct) {
val contactDeletedByUser = chatInfo.contact.contactStatus == ContactStatus.DeletedByUser
AlertManager.shared.showAlertDialogButtonsColumn(
title = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_question) else generalGetString(MR.strings.delete_contact_question),
text = if (contactDeletedByUser) generalGetString(MR.strings.delete_conversation_all_messages_deleted_cannot_undo_warning) else null,
buttons = {
Column {
if (contactDeletedByUser) {
// Delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Full(notify = false))
}) {
Text(generalGetString(MR.strings.delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
} else {
// Delete contact
SectionItemView({
AlertManager.shared.hideAlert()
notifyDeleteContactDialog(chat, chatModel, close)
}) {
Text(generalGetString(MR.strings.button_delete_contact), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
// Only delete conversation
SectionItemView({
AlertManager.shared.hideAlert()
deleteContact(chat, chatModel, close, chatDeleteMode = ChatDeleteMode.Messages())
if (chatModel.controller.appPrefs.showDeleteConversationNotice.get()) {
showDeleteConversationNotice(chatInfo.contact)
}
}) {
Text(generalGetString(MR.strings.only_delete_conversation), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.error)
}
}
// Cancel
SectionItemView({
AlertManager.shared.hideAlert()
}) {
Text(stringResource(MR.strings.cancel_verb), Modifier.fillMaxWidth(), textAlign = TextAlign.Center, color = MaterialTheme.colors.primary)
}
}
}
)
}
}
private fun showDeleteConversationNotice(contact: Contact) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.conversation_deleted),
text = String.format(generalGetString(MR.strings.you_can_still_send_messages_to_contact), contact.displayName),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.dont_show_again),
onDismiss = {
chatModel.controller.appPrefs.showDeleteConversationNotice.set(false)
},
)
}
@Composable
fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
ItemAction(
@@ -843,6 +911,7 @@ expect fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>
)
@Preview/*(
@@ -885,7 +954,8 @@ fun PreviewChatListNavLinkDirect() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -930,7 +1000,8 @@ fun PreviewChatListNavLinkGroup() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -952,7 +1023,8 @@ fun PreviewChatListNavLinkContactRequest() {
showMenu = remember { mutableStateOf(false) },
disabled = false,
selectedChat = remember { mutableStateOf(false) },
nextChatSelected = remember { mutableStateOf(false) }
nextChatSelected = remember { mutableStateOf(false) },
oneHandUI = remember { mutableStateOf(false) }
)
}
}
@@ -1,5 +1,6 @@
package chat.simplex.common.views.chatlist
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.*
@@ -10,8 +11,10 @@ import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.focus.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.platform.*
import androidx.compose.ui.text.TextRange
@@ -35,7 +38,10 @@ import chat.simplex.res.MR
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.serialization.json.Json
import java.net.URI
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
@Composable
fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerformLA: (Boolean) -> Unit, stopped: Boolean) {
@@ -47,6 +53,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
if (animated) newChatSheetState.value = AnimatedViewState.HIDING
else newChatSheetState.value = AnimatedViewState.GONE
}
val oneHandUI = remember { chatModel.controller.appPrefs.oneHandUI }
LaunchedEffect(Unit) {
if (shouldShowWhatsNew(chatModel)) {
delay(1000L)
@@ -69,7 +77,8 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
val searchText = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue("")) }
val scope = rememberCoroutineScope()
val (userPickerState, scaffoldState ) = settingsState
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListToolbar(searchText, scaffoldState.drawerState, userPickerState, stopped)} },
Scaffold(topBar = { Box(Modifier.padding(end = endPadding)) { ChatListTopBar(stopped) } },
bottomBar = { Box(Modifier.padding(end = endPadding)) { ChatListBottomToolbar(scaffoldState.drawerState, userPickerState) } },
scaffoldState = scaffoldState,
drawerContent = {
tryOrShowError("Settings", error = { ErrorSettingsView() }) {
@@ -82,13 +91,20 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
drawerGesturesEnabled = appPlatform.isAndroid,
floatingActionButton = {
if (searchText.value.text.isEmpty() && !chatModel.desktopNoUserNoRemote && chatModel.chatRunning.value == true) {
var bottom = DEFAULT_PADDING
if (oneHandUI.state.value) {
bottom = DEFAULT_BOTTOM_PADDING
} else {
bottom -= 16.dp
}
FloatingActionButton(
onClick = {
if (!stopped) {
if (newChatSheetState.value.isVisible()) hideNewChatSheet(true) else showNewChatSheet()
}
},
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = DEFAULT_PADDING - 16.dp),
Modifier.padding(end = DEFAULT_PADDING - 16.dp + endPadding, bottom = bottom).size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
@@ -98,18 +114,23 @@ fun ChatListView(chatModel: ChatModel, settingsState: SettingsViewState, setPerf
backgroundColor = if (!stopped) MaterialTheme.colors.primary else MaterialTheme.colors.secondary,
contentColor = Color.White
) {
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group))
Icon(if (!newChatSheetState.collectAsState().value.isVisible()) painterResource(MR.images.ic_edit_filled) else painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group), Modifier.size(24.dp * fontSizeSqrtMultiplier))
}
}
}
) {
Box(Modifier.padding(it).padding(end = endPadding)) {
var modifier = Modifier.padding(it).padding(end = endPadding)
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier) {
Box(
modifier = Modifier
.fillMaxSize()
) {
if (!chatModel.desktopNoUserNoRemote) {
ChatList(chatModel, searchText = searchText)
ChatList(chatModel, searchText = searchText, oneHandUI = oneHandUI)
}
if (chatModel.chats.isEmpty() && !chatModel.switchingUsersAndHosts.value && !chatModel.desktopNoUserNoRemote) {
Text(stringResource(
@@ -181,7 +202,9 @@ private fun ConnectButton(text: String, onClick: () -> Unit) {
}
@Composable
private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>, stopped: Boolean) {
private fun ChatListTopBar(stopped: Boolean) {
val serversSummary: MutableState<PresentedServersSummary?> = remember { mutableStateOf(null) }
val barButtons = arrayListOf<@Composable RowScope.() -> Unit>()
if (stopped) {
barButtons.add {
@@ -199,40 +222,38 @@ private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: Dr
}
}
}
val scope = rememberCoroutineScope()
val clipboard = LocalClipboardManager.current
DefaultTopAppBar(
navigationButton = {
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
}
},
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)) {
Text(
stringResource(MR.strings.your_chats),
color = MaterialTheme.colors.onBackground,
fontWeight = FontWeight.SemiBold,
)
if (chatModel.chats.size > 0) {
val enabled = remember { derivedStateOf { searchInList.value.text.isEmpty() } }
if (enabled.value) {
ToggleFilterEnabledButton()
} else {
ToggleFilterDisabledButton()
SubscriptionStatusIndicator(
serversSummary = serversSummary,
click = {
ModalManager.start.closeModals()
ModalManager.start.showModalCloseable(
endButtons = {
val summary = serversSummary.value
if (summary != null) {
ShareButton {
val json = Json {
prettyPrint = true
}
val text = json.encodeToString(PresentedServersSummary.serializer(), summary)
clipboard.shareText(text)
}
}
}
) { ServersSummaryView(chatModel.currentRemoteHost.value, serversSummary) }
}
}
)
}
},
onTitleClick = null,
@@ -243,28 +264,164 @@ private fun ChatListToolbar(searchInList: State<TextFieldValue>, drawerState: Dr
Divider(Modifier.padding(top = AppBarHeight))
}
@Composable
fun SettingsButton(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>) {
val scope = rememberCoroutineScope()
if (chatModel.users.isEmpty() && !chatModel.desktopNoUserNoRemote) {
NavigationButtonMenu { scope.launch { if (drawerState.isOpen) drawerState.close() else drawerState.open() } }
} else {
val users by remember { derivedStateOf { chatModel.users.filter { u -> u.user.activeUser || !u.user.hidden } } }
val allRead = users
.filter { u -> !u.user.activeUser && !u.user.hidden }
.all { u -> u.unreadCount == 0 }
UserProfileButton(chatModel.currentUser.value?.profile?.image, allRead) {
if (users.size == 1 && chatModel.remoteHosts.isEmpty()) {
scope.launch { drawerState.open() }
} else {
userPickerState.value = AnimatedViewState.VISIBLE
}
}
}
}
@Composable
fun toolbarIcon(icon: Painter) {
Icon(
icon,
contentDescription = null,
Modifier.size(24.dp * fontSizeSqrtMultiplier),
tint = MaterialTheme.colors.secondary
)
}
@Composable
fun ChatListToolbarButton(icon: @Composable () -> Unit, title: String, onClick: () -> Unit) {
Surface(
Modifier
.size(56.dp * fontSizeSqrtMultiplier),
shape = RoundedCornerShape(10.dp),
color = Color.Transparent,
) {
Column(
Modifier
.clickable { onClick () },
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
icon()
Text(
title,
style = MaterialTheme.typography.subtitle2.copy(fontWeight = FontWeight.Normal, fontSize = 12.sp * fontSizeSqrtMultiplier),
color = MaterialTheme.colors.secondary
)
}
}
}
@Composable
private fun ChatListBottomToolbar(drawerState: DrawerState, userPickerState: MutableStateFlow<AnimatedViewState>) {
Box(
Modifier
.fillMaxWidth()
.height(BottomAppBarHeight)
.background(MaterialTheme.colors.background.mixWith(MaterialTheme.colors.onBackground, 0.97f))
) {
Divider()
Row(
Modifier
.fillMaxHeight()
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
) {
SettingsButton(drawerState, userPickerState)
ChatListToolbarButton(
icon = { toolbarIcon(painterResource(MR.images.ic_chat_bubble_filled)) },
title = generalGetString(MR.strings.your_chats),
onClick = { }
)
}
}
}
@Composable
fun SubscriptionStatusIndicator(serversSummary: MutableState<PresentedServersSummary?>, click: (() -> Unit)) {
var subs by remember { mutableStateOf(SMPServerSubs.newSMPServerSubs) }
var sess by remember { mutableStateOf(ServerSessions.newServerSessions) }
var timer: Job? by remember { mutableStateOf(null) }
val fetchInterval: Duration = 1.seconds
val scope = rememberCoroutineScope()
fun setServersSummary() {
withBGApi {
serversSummary.value = chatModel.controller.getAgentServersSummary(chatModel.remoteHostId())
serversSummary.value?.let {
subs = it.allUsersSMP.smpTotals.subs
sess = it.allUsersSMP.smpTotals.sessions
}
}
}
LaunchedEffect(Unit) {
setServersSummary()
timer = timer ?: scope.launch {
while (true) {
delay(fetchInterval.inWholeMilliseconds)
setServersSummary()
}
}
}
fun stopTimer() {
timer?.cancel()
timer = null
}
DisposableEffect(Unit) {
onDispose {
stopTimer()
}
}
SimpleButtonFrame(click = click) {
SubscriptionStatusIndicatorView(subs = subs, sess = sess)
}
}
@Composable
fun UserProfileButton(image: String?, allRead: Boolean, onButtonClicked: () -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
IconButton(onClick = onButtonClicked) {
ChatListToolbarButton(
icon = {
Box {
ProfileImage(
image = image,
size = 37.dp,
color = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f)
size = 24.dp * fontSizeSqrtMultiplier,
color = MaterialTheme.colors.secondaryVariant.mixWith(
MaterialTheme.colors.onBackground,
0.97f
)
)
if (!allRead) {
unreadBadge()
}
}
}
if (appPlatform.isDesktop) {
val h by remember { chatModel.currentRemoteHost }
if (h != null) {
Spacer(Modifier.width(12.dp))
HostDisconnectButton {
stopRemoteHostAndReloadHosts(h!!, true)
}
},
onClick = onButtonClicked,
title = generalGetString(MR.strings.toolbar_settings),
)
if (appPlatform.isDesktop) {
val h by remember { chatModel.currentRemoteHost }
if (h != null) {
Spacer(Modifier.width(12.dp))
HostDisconnectButton {
stopRemoteHostAndReloadHosts(h!!, true)
}
}
}
@@ -289,32 +446,17 @@ private fun BoxScope.unreadBadge(text: String? = "") {
private fun ToggleFilterEnabledButton() {
val pref = remember { ChatController.appPrefs.showUnreadAndFavorites }
IconButton(onClick = { pref.set(!pref.get()) }) {
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.primary,
tint = if (pref.state.value) MaterialTheme.colors.background else MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.background(color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = MaterialTheme.colors.primary, shape = RoundedCornerShape(50))
.border(width = 1.dp, color = if (pref.state.value) MaterialTheme.colors.primary else Color.Unspecified, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
)
}
}
@Composable
private fun ToggleFilterDisabledButton() {
IconButton({}, enabled = false) {
Icon(
painterResource(MR.images.ic_filter_list),
null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.padding(3.dp)
.border(width = 1.dp, color = MaterialTheme.colors.secondary, shape = RoundedCornerShape(50))
.padding(3.dp)
.size(16.dp)
.size(sp16)
)
}
}
@@ -334,11 +476,17 @@ fun connectIfOpenedViaUri(rhId: Long?, uri: URI, chatModel: ChatModel) {
}
@Composable
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>) {
Row(verticalAlignment = Alignment.CenterVertically) {
private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState<TextFieldValue>, searchShowingSimplexLink: MutableState<Boolean>, searchChatFilteredBySimplexLink: MutableState<String?>, oneHandUI: SharedPreference<Boolean>) {
var modifier = Modifier.fillMaxWidth();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
val focusRequester = remember { FocusRequester() }
var focused by remember { mutableStateOf(false) }
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF), tint = MaterialTheme.colors.secondary)
Icon(painterResource(MR.images.ic_search), null, Modifier.padding(horizontal = DEFAULT_PADDING_HALF).size(24.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.secondary)
SearchTextField(
Modifier.weight(1f).onFocusChanged { focused = it.hasFocus }.focusRequester(focusRequester),
placeholder = stringResource(MR.strings.search_or_paste_simplex_link),
@@ -357,33 +505,11 @@ private fun ChatListSearchBar(listState: LazyListState, searchText: MutableState
hideSearchOnBack()
}
} else {
Row {
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
val clipboard = LocalClipboardManager.current
val clipboardHasText = remember(focused) { chatModel.clipboardHasText }.value
if (clipboardHasText) {
IconButton(
onClick = { searchText.value = searchText.value.copy(clipboard.getText()?.text ?: return@IconButton) },
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_article), null, tint = MaterialTheme.colors.secondary)
}
}
Spacer(Modifier.width(padding))
IconButton(
onClick = {
val fixedRhId = chatModel.currentRemoteHost.value
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close ->
NewChatView(fixedRhId, selection = NewChatOption.CONNECT, showQRCodeScanner = true, close = close)
}
},
Modifier.size(30.dp).desktopPointerHoverIconHand()
) {
Icon(painterResource(MR.images.ic_qr_code), null, tint = MaterialTheme.colors.secondary)
}
Spacer(Modifier.width(padding))
val padding = if (appPlatform.isDesktop) 0.dp else 7.dp
if (chatModel.chats.size > 0) {
ToggleFilterEnabledButton()
}
Spacer(Modifier.width(padding))
}
val focusManager = LocalFocusManager.current
val keyboardState = getKeyboardState()
@@ -446,9 +572,35 @@ private fun ErrorSettingsView() {
private var lazyListState = 0 to 0
enum class ScrollDirection {
Up, Down, Idle
}
@Composable
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>) {
private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldValue>, oneHandUI: SharedPreference<Boolean>) {
val listState = rememberLazyListState(lazyListState.first, lazyListState.second)
var scrollDirection by remember { mutableStateOf(ScrollDirection.Idle) }
var previousIndex by remember { mutableStateOf(0) }
var previousScrollOffset by remember { mutableStateOf(0) }
LaunchedEffect(listState.firstVisibleItemIndex, listState.firstVisibleItemScrollOffset) {
val currentIndex = listState.firstVisibleItemIndex
val currentScrollOffset = listState.firstVisibleItemScrollOffset
val threshold = 25
scrollDirection = when {
currentIndex > previousIndex -> ScrollDirection.Down
currentIndex < previousIndex -> ScrollDirection.Up
currentScrollOffset > previousScrollOffset + threshold -> ScrollDirection.Down
currentScrollOffset < previousScrollOffset - threshold -> ScrollDirection.Up
currentScrollOffset == previousScrollOffset -> ScrollDirection.Idle
else -> scrollDirection
}
previousIndex = currentIndex
previousScrollOffset = currentScrollOffset
}
DisposableEffect(Unit) {
onDispose { lazyListState = listState.firstVisibleItemIndex to listState.firstVisibleItemScrollOffset }
}
@@ -469,7 +621,9 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
Modifier
.offset {
val y = if (searchText.value.text.isEmpty()) {
if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
if (oneHandUI.state.value && scrollDirection == ScrollDirection.Up) {
0
} else if (listState.firstVisibleItemIndex == 0) -listState.firstVisibleItemScrollOffset else -1000
} else {
0
}
@@ -477,7 +631,7 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
}
.background(MaterialTheme.colors.background)
) {
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink)
ChatListSearchBar(listState, searchText, searchShowingSimplexLink, searchChatFilteredBySimplexLink, oneHandUI)
Divider()
}
}
@@ -485,11 +639,17 @@ private fun ChatList(chatModel: ChatModel, searchText: MutableState<TextFieldVal
val nextChatSelected = remember(chat.id, chats) { derivedStateOf {
chatModel.chatId.value != null && chats.getOrNull(index + 1)?.id == chatModel.chatId.value
} }
ChatListNavLinkView(chat, nextChatSelected)
ChatListNavLinkView(chat, nextChatSelected, oneHandUI.state)
}
}
if (chats.isEmpty() && chatModel.chats.isNotEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
var modifier = Modifier.fillMaxSize();
if (oneHandUI.state.value) {
modifier = modifier.scale(scaleX = 1f, scaleY = -1f)
}
Box(modifier, contentAlignment = Alignment.Center) {
Text(generalGetString(MR.strings.no_filtered_chats), color = MaterialTheme.colors.secondary)
}
}
@@ -508,17 +668,18 @@ private fun filteredChats(
} else {
val s = if (searchShowingSimplexLink.value) "" else searchText.trim().lowercase()
if (s.isEmpty() && !showUnreadAndFavorites)
chats
chats.filter { chat -> !chat.chatInfo.chatDeleted }
else {
chats.filter { chat ->
when (val cInfo = chat.chatInfo) {
is ChatInfo.Direct -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
}
is ChatInfo.Direct -> !chat.chatInfo.chatDeleted && (
if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat)
} else {
(viewNameContains(cInfo, s) ||
cInfo.contact.profile.displayName.lowercase().contains(s) ||
cInfo.contact.fullName.lowercase().contains(s))
})
is ChatInfo.Group -> if (s.isEmpty()) {
chat.id == chatModel.chatId.value || filtered(chat) || cInfo.groupInfo.membership.memberStatus == GroupMemberStatus.MemInvited
} else {
@@ -47,10 +47,11 @@ fun ChatPreviewView(
@Composable
fun inactiveIcon() {
val sp18 = with(LocalDensity.current) { 18.sp.toDp() }
Icon(
painterResource(MR.images.ic_cancel_filled),
stringResource(MR.strings.icon_descr_group_inactive),
Modifier.size(18.dp).background(MaterialTheme.colors.background, CircleShape),
Modifier.size(sp18).background(MaterialTheme.colors.background, CircleShape),
tint = MaterialTheme.colors.secondary
)
}
@@ -87,10 +88,11 @@ fun ChatPreviewView(
@Composable
fun VerifiedIcon() {
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(19.dp).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
val sp19 = with(LocalDensity.current) { 19.sp.toDp() }
Icon(painterResource(MR.images.ic_verified_user), null, Modifier.size(sp19).padding(end = 3.dp, top = 1.dp), tint = MaterialTheme.colors.secondary)
}
fun messageDraft(draft: ComposeState): Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>> {
fun messageDraft(draft: ComposeState, sp20: Dp): Pair<AnnotatedString.Builder.() -> Unit, Map<String, InlineTextContent>> {
fun attachment(): Pair<ImageResource, String?>? =
when (draft.preview) {
is ComposePreview.FilePreview -> MR.images.ic_draft_filled to draft.preview.fileName
@@ -115,12 +117,12 @@ fun ChatPreviewView(
"editIcon" to InlineTextContent(
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_edit_note), null, tint = MaterialTheme.colors.primary)
Icon(painterResource(MR.images.ic_edit_note), null, Modifier.size(sp20), tint = MaterialTheme.colors.primary)
},
"attachmentIcon" to InlineTextContent(
Placeholder(20.sp, 20.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(MR.images.ic_edit_note), null, tint = MaterialTheme.colors.secondary)
Icon(if (attachment?.first != null) painterResource(attachment.first) else painterResource(MR.images.ic_edit_note), null, Modifier.size(sp20), tint = MaterialTheme.colors.secondary)
}
)
return inlineContentBuilder to inlineContent
@@ -167,8 +169,9 @@ fun ChatPreviewView(
val ci = chat.chatItems.lastOrNull()
if (ci != null) {
if (showChatPreviews || (chatModelDraftChatId == chat.id && chatModelDraft != null)) {
val sp20 = with(LocalDensity.current) { 20.sp.toDp() }
val (text: CharSequence, inlineTextContent) = when {
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft) }
chatModelDraftChatId == chat.id && chatModelDraft != null -> remember(chatModelDraft) { chatModelDraft.message to messageDraft(chatModelDraft, sp20) }
ci.meta.itemDeleted == null -> ci.text to null
else -> markedDeletedText(ci.meta) to null
}
@@ -198,7 +201,7 @@ fun ChatPreviewView(
} else {
when (cInfo) {
is ChatInfo.Direct ->
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null) {
if (cInfo.contact.activeConn == null && cInfo.contact.profile.contactLink != null && cInfo.contact.active) {
Text(stringResource(MR.strings.contact_tap_to_connect), color = MaterialTheme.colors.primary)
} else if (!cInfo.ready && cInfo.contact.activeConn != null) {
if (cInfo.contact.nextSendGrpInv) {
@@ -220,10 +223,11 @@ fun ChatPreviewView(
@Composable
fun progressView() {
val sp15 = with(LocalDensity.current) { 15.sp.toDp() }
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
.size(sp15),
color = MaterialTheme.colors.secondary,
strokeWidth = 1.5.dp
)
@@ -231,6 +235,7 @@ fun ChatPreviewView(
@Composable
fun chatStatusImage() {
val sp19 = with(LocalDensity.current) { 19.sp.toDp() }
if (cInfo is ChatInfo.Direct) {
if (cInfo.contact.active && cInfo.contact.activeConn != null) {
val descr = contactNetworkStatus?.statusString
@@ -244,7 +249,7 @@ fun ChatPreviewView(
contentDescription = descr,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(19.dp)
.size(sp19)
)
else ->
@@ -266,7 +271,7 @@ fun ChatPreviewView(
Row {
Box(contentAlignment = Alignment.BottomEnd) {
ChatInfoImage(cInfo, size = 72.dp)
ChatInfoImage(cInfo, size = 72.dp * fontSizeSqrtMultiplier)
Box(Modifier.padding(end = 6.dp, bottom = 6.dp)) {
chatPreviewImageOverlayIcon()
}
@@ -295,9 +300,13 @@ fun ChatPreviewView(
)
val n = chat.chatStats.unreadCount
val showNtfsIcon = !chat.chatInfo.ntfsEnabled && (chat.chatInfo is ChatInfo.Direct || chat.chatInfo is ChatInfo.Group)
val sp17 = with(LocalDensity.current) { 17.sp.toDp() }
val sp21 = with(LocalDensity.current) { 21.sp.toDp() }
val sp23 = with(LocalDensity.current) { 23.sp.toDp() }
val sp46 = with(LocalDensity.current) { 46.sp.toDp() }
if (n > 0 || chat.chatStats.unreadChat) {
Box(
Modifier.padding(top = 24.dp),
Modifier.padding(top = sp23, end = with(LocalDensity.current) { 3.sp.toDp() }),
contentAlignment = Alignment.Center
) {
Text(
@@ -313,7 +322,7 @@ fun ChatPreviewView(
}
} else if (showNtfsIcon) {
Box(
Modifier.padding(top = 24.dp),
Modifier.padding(top = sp21),
contentAlignment = Alignment.Center
) {
Icon(
@@ -323,12 +332,12 @@ fun ChatPreviewView(
modifier = Modifier
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.size(17.dp)
.size(sp17)
)
}
} else if (chat.chatInfo.chatSettings?.favorite == true) {
Box(
Modifier.padding(top = 24.dp),
Modifier.padding(top = sp21),
contentAlignment = Alignment.Center
) {
Icon(
@@ -338,12 +347,12 @@ fun ChatPreviewView(
modifier = Modifier
.padding(horizontal = 3.dp)
.padding(vertical = 1.dp)
.size(17.dp)
.size(sp17)
)
}
}
Box(
Modifier.padding(top = 50.dp),
Modifier.padding(top = sp46),
contentAlignment = Alignment.Center
) {
chatStatusImage()
@@ -355,12 +364,13 @@ fun ChatPreviewView(
@Composable
fun IncognitoIcon(incognito: Boolean) {
if (incognito) {
val sp21 = with(LocalDensity.current) { 21.sp.toDp() }
Icon(
painterResource(MR.images.ic_theater_comedy),
contentDescription = null,
tint = MaterialTheme.colors.secondary,
modifier = Modifier
.size(21.dp)
.size(sp21)
)
}
}
@@ -0,0 +1,979 @@
package chat.simplex.common.views.chatlist
import InfoRow
import InfoRowTwoValues
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.AgentSMPServerStatsData
import chat.simplex.common.model.AgentXFTPServerStatsData
import chat.simplex.common.model.ChatController.chatModel
import chat.simplex.common.model.ChatModel.controller
import chat.simplex.common.model.PresentedServersSummary
import chat.simplex.common.model.RemoteHostInfo
import chat.simplex.common.model.SMPServerSubs
import chat.simplex.common.model.SMPServerSummary
import chat.simplex.common.model.SMPTotals
import chat.simplex.common.model.ServerAddress.Companion.parseServerAddress
import chat.simplex.common.model.ServerProtocol
import chat.simplex.common.model.ServerSessions
import chat.simplex.common.model.XFTPServerSummary
import chat.simplex.common.model.localTimestamp
import chat.simplex.common.platform.ColumnWithScrollBar
import chat.simplex.common.platform.appPlatform
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.ProtocolServersView
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.datetime.Instant
import numOrDash
import java.text.DecimalFormat
import kotlin.math.floor
import kotlin.math.roundToInt
enum class SubscriptionColorType {
ACTIVE, ACTIVE_SOCKS_PROXY, DISCONNECTED, ACTIVE_DISCONNECTED
}
data class SubscriptionStatus(
val color: SubscriptionColorType,
val variableValue: Float,
val opacity: Float,
val statusPercent: Float
)
fun subscriptionStatusColorAndPercentage(
online: Boolean,
socksProxy: String?,
subs: SMPServerSubs,
sess: ServerSessions
): SubscriptionStatus {
fun roundedToQuarter(n: Float): Float = when {
n >= 1 -> 1f
n <= 0 -> 0f
else -> (n * 4).roundToInt() / 4f
}
val activeColor: SubscriptionColorType = if (socksProxy != null) SubscriptionColorType.ACTIVE_SOCKS_PROXY else SubscriptionColorType.ACTIVE
val noConnColorAndPercent = SubscriptionStatus(SubscriptionColorType.DISCONNECTED, 1f, 1f, 0f)
val activeSubsRounded = roundedToQuarter(subs.shareOfActive)
return if (online && subs.total > 0) {
if (subs.ssActive == 0) {
if (sess.ssConnected == 0)
noConnColorAndPercent
else
SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
} else { // ssActive > 0
if (sess.ssConnected == 0)
// This would mean implementation error
SubscriptionStatus(SubscriptionColorType.ACTIVE_DISCONNECTED, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
else
SubscriptionStatus(activeColor, activeSubsRounded, subs.shareOfActive, subs.shareOfActive)
}
} else noConnColorAndPercent
}
@Composable
private fun SubscriptionStatusIndicatorPercentage(percentageText: String) {
Text(
percentageText,
color = MaterialTheme.colors.secondary,
fontSize = 12.sp,
style = MaterialTheme.typography.caption
)
}
@Composable
fun SubscriptionStatusIndicatorView(subs: SMPServerSubs, sess: ServerSessions, leadingPercentage: Boolean = false) {
val netCfg = rememberUpdatedState(chatModel.controller.getNetCfg())
val statusColorAndPercentage = subscriptionStatusColorAndPercentage(chatModel.networkInfo.value.online, netCfg.value.socksProxy, subs, sess)
val pref = remember { chatModel.controller.appPrefs.networkShowSubscriptionPercentage }
val percentageText = "${(floor(statusColorAndPercentage.statusPercent * 100)).toInt()}%"
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON)
) {
if (pref.state.value && leadingPercentage) SubscriptionStatusIndicatorPercentage(percentageText)
val sp16 = with(LocalDensity.current) { 16.sp.toDp() }
SubscriptionStatusIcon(
color = when(statusColorAndPercentage.color) {
SubscriptionColorType.ACTIVE -> MaterialTheme.colors.primary
SubscriptionColorType.ACTIVE_SOCKS_PROXY -> Indigo
SubscriptionColorType.ACTIVE_DISCONNECTED -> WarningOrange
SubscriptionColorType.DISCONNECTED -> MaterialTheme.colors.secondary
},
modifier = Modifier.size(sp16),
variableValue = statusColorAndPercentage.variableValue)
if (pref.state.value && !leadingPercentage) SubscriptionStatusIndicatorPercentage(percentageText)
}
}
enum class PresentedUserCategory {
CURRENT_USER, ALL_USERS
}
enum class PresentedServerType {
SMP, XFTP
}
@Composable
private fun ServerSessionsView(sess: ServerSessions) {
SectionView(generalGetString(MR.strings.servers_info_transport_sessions_section_header).uppercase()) {
InfoRow(
generalGetString(MR.strings.servers_info_sessions_connected),
numOrDash(sess.ssConnected)
)
InfoRow(
generalGetString(MR.strings.servers_info_sessions_errors),
numOrDash(sess.ssErrors)
)
InfoRow(
generalGetString(MR.strings.servers_info_sessions_connecting),
numOrDash(sess.ssConnecting)
)
}
}
private fun serverAddress(server: String): String {
val address = parseServerAddress(server)
return address?.hostnames?.first() ?: server
}
@Composable
private fun SMPServerView(srvSumm: SMPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionItemView(
click = {
ModalManager.start.showCustomModal { close ->
SMPServerSummaryView(
rh = rh,
close = close,
summary = srvSumm,
statsStartedAt = statsStartedAt
)
}
}
) {
Text(
serverAddress(srvSumm.smpServer),
modifier = Modifier.weight(10f, fill = true)
)
if (srvSumm.subs != null) {
Spacer(Modifier.fillMaxWidth().weight(1f))
SubscriptionStatusIndicatorView(subs = srvSumm.subs, sess = srvSumm.sessionsOrNew, leadingPercentage = true)
} else if (srvSumm.sessions != null) {
Spacer(Modifier.fillMaxWidth().weight(1f))
Icon(painterResource(MR.images.ic_arrow_upward), contentDescription = null, tint = SessIconColor(srvSumm.sessions))
}
}
}
@Composable
private fun SessIconColor(sess: ServerSessions): Color {
val online = chatModel.networkInfo.value.online
return if (online && sess.ssConnected > 0) SessionActiveColor() else MaterialTheme.colors.secondary
}
@Composable
private fun SessionActiveColor(): Color {
val netCfg = rememberUpdatedState(chatModel.controller.getNetCfg())
return if (netCfg.value.socksProxy != null) Indigo else MaterialTheme.colors.primary
}
@Composable
private fun SMPServersListView(servers: List<SMPServerSummary>, statsStartedAt: Instant, header: String? = null, footer: String? = null, rh: RemoteHostInfo?) {
val sortedServers = servers.sortedWith(compareBy<SMPServerSummary> { !it.hasSubs }
.thenBy { serverAddress(it.smpServer) })
SectionView(header) {
sortedServers.map { svr -> SMPServerView(srvSumm = svr, statsStartedAt = statsStartedAt, rh = rh) }
}
if (footer != null) {
SectionTextFooter(
footer
)
}
}
fun prettySize(sizeInKB: Long): String {
if (sizeInKB == 0L) {
return "-"
}
val sizeInBytes = sizeInKB * 1024
val units = arrayOf("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
var size = sizeInBytes.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
val formatter = DecimalFormat("#,##0.#")
return "${formatter.format(size)} ${units[unitIndex]}"
}
@Composable
private fun XFTPServerView(srvSumm: XFTPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionItemView(
click = {
ModalManager.start.showCustomModal { close ->
XFTPServerSummaryView(
rh = rh,
close = close,
summary = srvSumm,
statsStartedAt = statsStartedAt
)
}
}
) {
Text(
serverAddress(srvSumm.xftpServer),
modifier = Modifier.weight(10f, fill = true)
)
if (srvSumm.rcvInProgress || srvSumm.sndInProgress || srvSumm.delInProgress) {
Spacer(Modifier.fillMaxWidth().weight(1f))
XFTPServerInProgressIcon(srvSumm)
}
}
}
@Composable
private fun XFTPServerInProgressIcon(srvSumm: XFTPServerSummary) {
return when {
srvSumm.rcvInProgress && !srvSumm.sndInProgress && !srvSumm.delInProgress -> Icon(painterResource(MR.images.ic_arrow_downward),"download", tint = SessionActiveColor())
!srvSumm.rcvInProgress && srvSumm.sndInProgress && !srvSumm.delInProgress -> Icon(painterResource(MR.images.ic_arrow_upward), "upload", tint = SessionActiveColor())
!srvSumm.rcvInProgress && !srvSumm.sndInProgress && srvSumm.delInProgress -> Icon(painterResource(MR.images.ic_delete), "deletion", tint = SessionActiveColor())
else -> Icon(painterResource(MR.images.ic_expand_all), "upload and download", tint = SessionActiveColor())
}
}
@Composable
private fun XFTPServersListView(servers: List<XFTPServerSummary>, statsStartedAt: Instant, header: String? = null, rh: RemoteHostInfo?) {
val sortedServers = servers.sortedBy { serverAddress(it.xftpServer) }
SectionView(header) {
sortedServers.map { svr -> XFTPServerView(svr, statsStartedAt, rh) }
}
}
@Composable
private fun SMPStatsView(stats: AgentSMPServerStatsData, statsStartedAt: Instant, remoteHostInfo: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) {
InfoRow(
generalGetString(MR.strings.servers_info_messages_sent),
numOrDash(stats._sentDirect + stats._sentViaProxy)
)
InfoRow(
generalGetString(MR.strings.servers_info_messages_received),
numOrDash(stats._recvMsgs)
)
SectionItemView(
click = {
ModalManager.start.showCustomModal { close -> DetailedSMPStatsView(
rh = remoteHostInfo,
close = close,
stats = stats,
statsStartedAt = statsStartedAt)
}
}
) {
Text(text = generalGetString(MR.strings.servers_info_details), color = MaterialTheme.colors.onBackground)
}
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_private_data_disclaimer), localTimestamp(statsStartedAt))
)
}
@Composable
private fun SMPSubscriptionsSection(totals: SMPTotals) {
Column {
Row(
Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2)
) {
Text(
generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(),
color = MaterialTheme.colors.secondary,
style = MaterialTheme.typography.body2,
fontSize = 12.sp
)
SubscriptionStatusIndicatorView(totals.subs, totals.sessions)
}
Column(Modifier.padding(PaddingValues()).fillMaxWidth()) {
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_connections_subscribed),
numOrDash(totals.subs.ssActive)
)
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_total),
numOrDash(totals.subs.total)
)
}
}
}
@Composable
private fun SMPSubscriptionsSection(subs: SMPServerSubs, summary: SMPServerSummary, rh: RemoteHostInfo?) {
Column {
Row(
Modifier.padding(start = DEFAULT_PADDING, bottom = 5.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(DEFAULT_SPACE_AFTER_ICON * 2)
) {
Text(
generalGetString(MR.strings.servers_info_subscriptions_section_header).uppercase(),
color = MaterialTheme.colors.secondary,
style = MaterialTheme.typography.body2,
fontSize = 12.sp
)
SubscriptionStatusIndicatorView(subs, summary.sessionsOrNew)
}
Column(Modifier.padding(PaddingValues()).fillMaxWidth()) {
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_connections_subscribed),
numOrDash(subs.ssActive)
)
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_connections_pending),
numOrDash(subs.ssPending)
)
InfoRow(
generalGetString(MR.strings.servers_info_subscriptions_total),
numOrDash(subs.total)
)
ReconnectServerButton(rh, summary.smpServer)
}
}
}
@Composable
private fun ReconnectServerButton(rh: RemoteHostInfo?, server: String) {
SectionItemView(click = { reconnectServerAlert(rh, server) }) {
Text(
stringResource(MR.strings.reconnect),
color = MaterialTheme.colors.primary
)
}
}
private fun reconnectServerAlert(rh: RemoteHostInfo?, server: String) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.servers_info_reconnect_server_title),
text = generalGetString(MR.strings.servers_info_reconnect_server_message),
onConfirm = {
withBGApi {
val success = controller.reconnectServer(rh?.remoteHostId, server)
if (!success) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.servers_info_modal_error_title),
text = generalGetString(MR.strings.servers_info_reconnect_server_error)
)
}
}
}
)
}
@Composable
fun XFTPStatsView(stats: AgentXFTPServerStatsData, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.servers_info_statistics_section_header).uppercase()) {
InfoRow(
generalGetString(MR.strings.servers_info_uploaded),
prettySize(stats._uploadsSize)
)
InfoRow(
generalGetString(MR.strings.servers_info_downloaded),
prettySize(stats._downloadsSize)
)
SectionItemView (
click = {
ModalManager.start.showCustomModal { close -> DetailedXFTPStatsView(
rh = rh,
close = close,
stats = stats,
statsStartedAt = statsStartedAt)
}
}
) {
Text(text = generalGetString(MR.strings.servers_info_details), color = MaterialTheme.colors.onBackground)
}
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_private_data_disclaimer), localTimestamp(statsStartedAt))
)
}
@Composable
private fun IndentedInfoRow(title: String, desc: String) {
InfoRow(title, desc, padding = PaddingValues(start = 24.dp + DEFAULT_PADDING, end = DEFAULT_PADDING))
}
@Composable
fun DetailedSMPStatsLayout(stats: AgentSMPServerStatsData, statsStartedAt: Instant) {
SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_header).uppercase()) {
InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_sent_messages_total), numOrDash(stats._sentDirect + stats._sentViaProxy))
InfoRowTwoValues(generalGetString(MR.strings.sent_directly), generalGetString(MR.strings.attempts_label), stats._sentDirect, stats._sentDirectAttempts)
InfoRowTwoValues(generalGetString(MR.strings.sent_via_proxy), generalGetString(MR.strings.attempts_label), stats._sentViaProxy, stats._sentViaProxyAttempts)
InfoRowTwoValues(generalGetString(MR.strings.proxied), generalGetString(MR.strings.attempts_label), stats._sentProxied, stats._sentProxiedAttempts)
SectionItemView {
Text(generalGetString(MR.strings.send_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow("AUTH", numOrDash(stats._sentAuthErrs))
IndentedInfoRow("QUOTA", numOrDash(stats._sentQuotaErrs))
IndentedInfoRow(generalGetString(MR.strings.expired_label), numOrDash(stats._sentExpiredErrs))
IndentedInfoRow(generalGetString(MR.strings.other_label), numOrDash(stats._sentOtherErrs))
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.servers_info_detailed_statistics_received_messages_header).uppercase()) {
InfoRow(generalGetString(MR.strings.servers_info_detailed_statistics_received_total), numOrDash(stats._recvMsgs))
SectionItemView {
Text(generalGetString(MR.strings.servers_info_detailed_statistics_receive_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow(generalGetString(MR.strings.duplicates_label), numOrDash(stats._recvDuplicates))
IndentedInfoRow(generalGetString(MR.strings.decryption_errors), numOrDash(stats._recvCryptoErrs))
IndentedInfoRow(generalGetString(MR.strings.other_errors), numOrDash(stats._recvErrs))
InfoRowTwoValues(generalGetString(MR.strings.acknowledged), generalGetString(MR.strings.attempts_label), stats._ackMsgs, stats._ackAttempts)
SectionItemView {
Text(generalGetString(MR.strings.acknowledgement_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow("NO_MSG errors", numOrDash(stats._ackNoMsgErrs))
IndentedInfoRow(generalGetString(MR.strings.other_errors), numOrDash(stats._ackOtherErrs))
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.connections).uppercase()) {
InfoRow(generalGetString(MR.strings.created), numOrDash(stats._connCreated))
InfoRow(generalGetString(MR.strings.secured), numOrDash(stats._connSecured))
InfoRow(generalGetString(MR.strings.completed), numOrDash(stats._connCompleted))
InfoRowTwoValues(generalGetString(MR.strings.deleted), generalGetString(MR.strings.attempts_label), stats._connDeleted, stats._connDelAttempts)
InfoRow(generalGetString(MR.strings.deletion_errors), numOrDash(stats._connDelErrs))
InfoRowTwoValues(generalGetString(MR.strings.subscribed), generalGetString(MR.strings.attempts_label), stats._connSubscribed, stats._connSubAttempts)
InfoRow(generalGetString(MR.strings.subscription_results_ignored), numOrDash(stats._connSubIgnored))
InfoRow(generalGetString(MR.strings.subscription_errors), numOrDash(stats._connSubErrs))
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_starting_from), localTimestamp(statsStartedAt))
)
SectionBottomSpacer()
}
@Composable
fun DetailedXFTPStatsLayout(stats: AgentXFTPServerStatsData, statsStartedAt: Instant) {
SectionView(generalGetString(MR.strings.uploaded_files).uppercase()) {
InfoRow(generalGetString(MR.strings.size), prettySize(stats._uploadsSize))
InfoRowTwoValues(generalGetString(MR.strings.chunks_uploaded), generalGetString(MR.strings.attempts_label), stats._uploads, stats._uploadAttempts)
InfoRow(generalGetString(MR.strings.upload_errors), numOrDash(stats._uploadErrs))
InfoRowTwoValues(generalGetString(MR.strings.chunks_deleted), generalGetString(MR.strings.attempts_label), stats._deletions, stats._deleteAttempts)
InfoRow(generalGetString(MR.strings.deletion_errors), numOrDash(stats._deleteErrs))
}
SectionDividerSpaced()
SectionView(generalGetString(MR.strings.downloaded_files).uppercase()) {
InfoRow(generalGetString(MR.strings.size), prettySize(stats._downloadsSize))
InfoRowTwoValues(generalGetString(MR.strings.chunks_downloaded), generalGetString(MR.strings.attempts_label), stats._downloads, stats._downloadAttempts)
SectionItemView {
Text(generalGetString(MR.strings.download_errors), color = MaterialTheme.colors.onBackground)
}
IndentedInfoRow("AUTH", numOrDash(stats._downloadAuthErrs))
IndentedInfoRow(generalGetString(MR.strings.other_label), numOrDash(stats._downloadErrs))
}
SectionTextFooter(
String.format(stringResource(MR.strings.servers_info_starting_from), localTimestamp(statsStartedAt))
)
SectionBottomSpacer()
}
@Composable
fun XFTPServerSummaryLayout(summary: XFTPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.server_address).uppercase()) {
SelectionContainer {
Text(
summary.xftpServer,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
)
}
if (summary.known == true) {
SectionItemView(click = {
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.XFTP, close) }
}) {
Text(generalGetString(MR.strings.open_server_settings_button))
}
}
if (summary.stats != null) {
SectionDividerSpaced()
XFTPStatsView(stats = summary.stats, rh = rh, statsStartedAt = statsStartedAt)
}
if (summary.sessions != null) {
SectionDividerSpaced()
ServerSessionsView(summary.sessions)
}
}
SectionBottomSpacer()
}
@Composable
fun SMPServerSummaryLayout(summary: SMPServerSummary, statsStartedAt: Instant, rh: RemoteHostInfo?) {
SectionView(generalGetString(MR.strings.server_address).uppercase()) {
SelectionContainer {
Text(
summary.smpServer,
Modifier.padding(start = DEFAULT_PADDING, top = 5.dp, end = DEFAULT_PADDING, bottom = 10.dp),
style = TextStyle(
fontFamily = FontFamily.Monospace, fontSize = 16.sp,
color = MaterialTheme.colors.secondary
)
)
}
if (summary.known == true) {
SectionItemView(click = {
ModalManager.start.showCustomModal { close -> ProtocolServersView(chatModel, rhId = rh?.remoteHostId, ServerProtocol.SMP, close) }
}) {
Text(generalGetString(MR.strings.open_server_settings_button))
}
}
if (summary.stats != null) {
SectionDividerSpaced()
SMPStatsView(stats = summary.stats, remoteHostInfo = rh, statsStartedAt = statsStartedAt)
}
if (summary.subs != null) {
SectionDividerSpaced()
SMPSubscriptionsSection(subs = summary.subs, summary = summary, rh = rh)
}
if (summary.sessions != null) {
SectionDividerSpaced()
ServerSessionsView(summary.sessions)
}
}
SectionBottomSpacer()
}
@Composable
fun ModalData.SMPServerSummaryView(
rh: RemoteHostInfo?,
close: () -> Unit,
summary: SMPServerSummary,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.smp_server),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
SMPServerSummaryLayout(summary, statsStartedAt, rh)
}
}
}
@Composable
fun ModalData.DetailedXFTPStatsView(
rh: RemoteHostInfo?,
close: () -> Unit,
stats: AgentXFTPServerStatsData,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info_detailed_statistics),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
DetailedXFTPStatsLayout(stats, statsStartedAt)
}
}
}
@Composable
fun ModalData.DetailedSMPStatsView(
rh: RemoteHostInfo?,
close: () -> Unit,
stats: AgentSMPServerStatsData,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info_detailed_statistics),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
DetailedSMPStatsLayout(stats, statsStartedAt)
}
}
}
@Composable
fun ModalData.XFTPServerSummaryView(
rh: RemoteHostInfo?,
close: () -> Unit,
summary: XFTPServerSummary,
statsStartedAt: Instant
) {
ModalView(
close = close
) {
ColumnWithScrollBar(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.xftp_server),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
XFTPServerSummaryLayout(summary, statsStartedAt, rh)
}
}
}
@Composable
fun ModalData.ServersSummaryView(rh: RemoteHostInfo?, serversSummary: MutableState<PresentedServersSummary?>) {
Column(
Modifier.fillMaxSize(),
) {
var showUserSelection by remember { mutableStateOf(false) }
val selectedUserCategory =
remember { stateGetOrPut("selectedUserCategory") { PresentedUserCategory.ALL_USERS } }
val selectedServerType =
remember { stateGetOrPut("serverTypeSelection") { PresentedServerType.SMP } }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
if (chatModel.users.count { u -> u.user.activeUser || !u.user.hidden } == 1
) {
selectedUserCategory.value = PresentedUserCategory.CURRENT_USER
} else {
showUserSelection = true
}
}
Column(
Modifier.fillMaxSize(),
) {
Box(contentAlignment = Alignment.Center) {
val bottomPadding = DEFAULT_PADDING
AppBarTitle(
stringResource(MR.strings.servers_info),
hostDevice(rh?.remoteHostId),
bottomPadding = bottomPadding
)
}
if (serversSummary.value == null) {
Box(
modifier = Modifier
.fillMaxSize()
) {
Text(generalGetString(MR.strings.servers_info_missing), Modifier.align(Alignment.Center), color = MaterialTheme.colors.secondary)
}
} else {
val userOptions by remember {
mutableStateOf(
listOf(
PresentedUserCategory.ALL_USERS to generalGetString(MR.strings.all_users),
PresentedUserCategory.CURRENT_USER to generalGetString(MR.strings.current_user),
)
)
}
val serverTypeTabTitles = PresentedServerType.entries.map {
when (it) {
PresentedServerType.SMP ->
stringResource(MR.strings.messages_section_title)
PresentedServerType.XFTP ->
stringResource(MR.strings.servers_info_files_tab)
}
}
val serverTypePagerState = rememberPagerState(
initialPage = selectedServerType.value.ordinal,
initialPageOffsetFraction = 0f
) { PresentedServerType.entries.size }
KeyChangeEffect(serverTypePagerState.currentPage) {
selectedServerType.value = PresentedServerType.values()[serverTypePagerState.currentPage]
}
TabRow(
selectedTabIndex = serverTypePagerState.currentPage,
backgroundColor = Color.Transparent,
contentColor = MaterialTheme.colors.primary,
) {
serverTypeTabTitles.forEachIndexed { index, it ->
Tab(
selected = serverTypePagerState.currentPage == index,
onClick = {
scope.launch {
serverTypePagerState.animateScrollToPage(index)
}
},
text = { Text(it, fontSize = 13.sp) },
selectedContentColor = MaterialTheme.colors.primary,
unselectedContentColor = MaterialTheme.colors.secondary,
)
}
}
HorizontalPager(
state = serverTypePagerState,
Modifier.fillMaxSize(),
verticalAlignment = Alignment.Top,
userScrollEnabled = appPlatform.isAndroid
) { index ->
ColumnWithScrollBar(
Modifier
.fillMaxSize(),
verticalArrangement = Arrangement.Top
) {
Spacer(Modifier.height(DEFAULT_PADDING))
if (showUserSelection) {
ExposedDropDownSettingRow(
generalGetString(MR.strings.servers_info_target),
userOptions,
selectedUserCategory,
icon = null,
enabled = remember { mutableStateOf(true) },
onSelected = {
selectedUserCategory.value = it
}
)
SectionDividerSpaced()
}
when (index) {
PresentedServerType.SMP.ordinal -> {
serversSummary.value?.let {
val smpSummary =
if (selectedUserCategory.value == PresentedUserCategory.CURRENT_USER) it.currentUserSMP else it.allUsersSMP;
val totals = smpSummary.smpTotals
val currentlyUsedSMPServers = smpSummary.currentlyUsedSMPServers
val previouslyUsedSMPServers = smpSummary.previouslyUsedSMPServers
val proxySMPServers = smpSummary.onlyProxiedSMPServers
val statsStartedAt = it.statsStartedAt
SMPStatsView(totals.stats, statsStartedAt, rh)
SectionDividerSpaced()
SMPSubscriptionsSection(totals)
SectionDividerSpaced()
if (currentlyUsedSMPServers.isNotEmpty()) {
SMPServersListView(
servers = currentlyUsedSMPServers,
statsStartedAt = statsStartedAt,
header = generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(),
rh = rh
)
SectionDividerSpaced()
}
if (previouslyUsedSMPServers.isNotEmpty()) {
SMPServersListView(
servers = previouslyUsedSMPServers,
statsStartedAt = statsStartedAt,
header = generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(),
rh = rh
)
SectionDividerSpaced()
}
if (proxySMPServers.isNotEmpty()) {
SMPServersListView(
servers = proxySMPServers,
statsStartedAt = statsStartedAt,
header = generalGetString(MR.strings.servers_info_proxied_servers_section_header).uppercase(),
footer = generalGetString(MR.strings.servers_info_proxied_servers_section_footer),
rh = rh
)
SectionDividerSpaced()
}
ServerSessionsView(totals.sessions)
}
}
PresentedServerType.XFTP.ordinal -> {
serversSummary.value?.let {
val xftpSummary =
if (selectedUserCategory.value == PresentedUserCategory.CURRENT_USER) it.currentUserXFTP else it.allUsersXFTP
val totals = xftpSummary.xftpTotals
val statsStartedAt = it.statsStartedAt
val currentlyUsedXFTPServers = xftpSummary.currentlyUsedXFTPServers
val previouslyUsedXFTPServers = xftpSummary.previouslyUsedXFTPServers
XFTPStatsView(totals.stats, statsStartedAt, rh)
SectionDividerSpaced()
if (currentlyUsedXFTPServers.isNotEmpty()) {
XFTPServersListView(
currentlyUsedXFTPServers,
statsStartedAt,
generalGetString(MR.strings.servers_info_connected_servers_section_header).uppercase(),
rh
)
SectionDividerSpaced()
}
if (previouslyUsedXFTPServers.isNotEmpty()) {
XFTPServersListView(
previouslyUsedXFTPServers,
statsStartedAt,
generalGetString(MR.strings.servers_info_previously_connected_servers_section_header).uppercase(),
rh
)
SectionDividerSpaced()
}
ServerSessionsView(totals.sessions)
}
}
}
SectionDividerSpaced()
SectionView {
ReconnectAllServersButton(rh)
ResetStatisticsButton(rh)
}
SectionBottomSpacer()
}
}
}
}
}
}
@Composable
private fun ReconnectAllServersButton(rh: RemoteHostInfo?) {
SectionItemView(click = { reconnectAllServersAlert(rh) }) {
Text(
stringResource(MR.strings.servers_info_reconnect_all_servers_button),
color = MaterialTheme.colors.primary
)
}
}
private fun reconnectAllServersAlert(rh: RemoteHostInfo?) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.servers_info_reconnect_servers_title),
text = generalGetString(MR.strings.servers_info_reconnect_servers_message),
onConfirm = {
withBGApi {
val success = controller.reconnectAllServers(rh?.remoteHostId)
if (!success) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.servers_info_modal_error_title),
text = generalGetString(MR.strings.servers_info_reconnect_servers_error)
)
}
}
}
)
}
@Composable
private fun ResetStatisticsButton(rh: RemoteHostInfo?) {
SectionItemView(click = { resetStatisticsAlert(rh) }) {
Text(
stringResource(MR.strings.servers_info_reset_stats),
color = MaterialTheme.colors.primary
)
}
}
private fun resetStatisticsAlert(rh: RemoteHostInfo?) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.servers_info_reset_stats_alert_title),
text = generalGetString(MR.strings.servers_info_reset_stats_alert_message),
confirmText = generalGetString(MR.strings.servers_info_reset_stats_alert_confirm),
destructive = true,
onConfirm = {
withBGApi {
val success = controller.resetAgentServersStats(rh?.remoteHostId)
if (!success) {
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.servers_info_modal_error_title),
text = generalGetString(MR.strings.servers_info_reset_stats_alert_error_title)
)
}
}
}
)
}
@@ -153,6 +153,8 @@ fun UserPicker(
) {
Column(
Modifier
.align(Alignment.BottomStart)
.padding(bottom = BottomAppBarHeight)
.widthIn(min = 260.dp)
.width(IntrinsicSize.Min)
.height(IntrinsicSize.Min)
@@ -309,7 +311,7 @@ fun UserProfileRow(u: User, enabled: Boolean = chatModel.chatRunning.value == tr
) {
ProfileImage(
image = u.image,
size = 54.dp
size = 54.dp * fontSizeSqrtMultiplier
)
Text(
u.displayName,
@@ -354,7 +356,7 @@ fun RemoteHostRow(h: RemoteHostInfo) {
.padding(start = 17.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_smartphone_300), h.hostDeviceName, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Text(
h.hostDeviceName,
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
@@ -395,7 +397,7 @@ fun LocalDeviceRow(active: Boolean) {
.padding(start = 17.dp, end = DEFAULT_PADDING),
verticalAlignment = Alignment.CenterVertically
) {
Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_desktop), stringResource(MR.strings.this_device), Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Text(
stringResource(MR.strings.this_device),
modifier = Modifier.padding(start = 26.dp, end = 8.dp),
@@ -409,7 +411,7 @@ fun LocalDeviceRow(active: Boolean) {
private fun UseFromDesktopPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.settings_section_title_use_from_desktop).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_desktop), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
@@ -419,7 +421,7 @@ private fun UseFromDesktopPickerItem(onClick: () -> Unit) {
private fun LinkAMobilePickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.link_a_mobile)
Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_smartphone_300), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
@@ -429,7 +431,7 @@ private fun LinkAMobilePickerItem(onClick: () -> Unit) {
private fun CreateInitialProfile(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.create_chat_profile)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_manage_accounts), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
@@ -439,7 +441,7 @@ private fun CreateInitialProfile(onClick: () -> Unit) {
private fun SettingsPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.settings_section_title_settings).lowercase().capitalize(Locale.current)
Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_settings), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
@@ -449,7 +451,7 @@ private fun SettingsPickerItem(onClick: () -> Unit) {
private fun CancelPickerItem(onClick: () -> Unit) {
SectionItemView(onClick, padding = PaddingValues(start = DEFAULT_PADDING + 7.dp, end = DEFAULT_PADDING), minHeight = 68.dp) {
val text = generalGetString(MR.strings.cancel_verb)
Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp), tint = MaterialTheme.colors.onBackground)
Icon(painterResource(MR.images.ic_close), text, Modifier.size(20.dp * fontSizeSqrtMultiplier), tint = MaterialTheme.colors.onBackground)
Spacer(Modifier.width(DEFAULT_PADDING + 6.dp))
Text(text, color = MenuTextColor)
}
@@ -459,7 +461,7 @@ private fun CancelPickerItem(onClick: () -> Unit) {
fun HostDisconnectButton(onClick: (() -> Unit)?) {
val interactionSource = remember { MutableInteractionSource() }
val hovered = interactionSource.collectIsHoveredAsState().value
IconButton(onClick ?: {}, Modifier.requiredSize(20.dp), enabled = onClick != null) {
IconButton(onClick ?: {}, Modifier.requiredSize(20.dp * fontSizeSqrtMultiplier), enabled = onClick != null) {
Icon(
painterResource(if (onClick == null) MR.images.ic_desktop else if (hovered) MR.images.ic_wifi_off else MR.images.ic_wifi),
null,
@@ -22,15 +22,13 @@ fun CloseSheetBar(close: (() -> Unit)?, showClose: Boolean = true, tintColor: Co
Column(
Modifier
.fillMaxWidth()
.heightIn(min = AppBarHeight)
.padding(horizontal = AppBarHorizontalPadding),
.heightIn(min = AppBarHeight * fontSizeSqrtMultiplier)
.padding(horizontal = AppBarHorizontalPadding)
) {
Row(
Modifier
.padding(top = 4.dp), // Like in DefaultAppBar
content = {
Row(
Modifier.fillMaxWidth().height(TextFieldDefaults.MinHeight),
Modifier.fillMaxWidth().height(AppBarHeight * fontSizeSqrtMultiplier),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
@@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.*
import androidx.compose.ui.unit.Dp
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.unit.dp
@@ -15,7 +16,7 @@ import chat.simplex.res.MR
@Composable
fun DefaultTopAppBar(
navigationButton: @Composable RowScope.() -> Unit,
navigationButton: (@Composable RowScope.() -> Unit)? = null,
title: (@Composable () -> Unit)?,
onTitleClick: (() -> Unit)? = null,
showSearch: Boolean,
@@ -44,10 +45,10 @@ fun DefaultTopAppBar(
}
@Composable
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) {
fun NavigationButtonBack(onButtonClicked: (() -> Unit)?, tintColor: Color = if (onButtonClicked != null) MaterialTheme.colors.primary else MaterialTheme.colors.secondary, height: Dp = 24.dp) {
IconButton(onButtonClicked ?: {}, enabled = onButtonClicked != null) {
Icon(
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), tint = tintColor
painterResource(MR.images.ic_arrow_back_ios_new), stringResource(MR.strings.back), Modifier.height(height), tint = tintColor
)
}
}
@@ -84,7 +85,7 @@ private fun TopAppBar(
Box(
modifier
.fillMaxWidth()
.height(AppBarHeight)
.height(AppBarHeight * fontSizeSqrtMultiplier)
.background(backgroundColor)
.padding(horizontal = 4.dp),
contentAlignment = Alignment.CenterStart,
@@ -125,5 +126,6 @@ private fun TopAppBar(
val AppBarHeight = 56.dp
val AppBarHorizontalPadding = 4.dp
val BottomAppBarHeight = 60.dp
private val TitleInsetWithoutIcon = DEFAULT_PADDING - AppBarHorizontalPadding
val TitleInsetWithIcon = 72.dp
@@ -8,12 +8,14 @@ import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import kotlinx.coroutines.flow.MutableStateFlow
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.min
import kotlin.math.sqrt
@Composable
fun ModalView(
@@ -89,7 +91,7 @@ class ModalManager(private val placement: ModalPlacement? = null) {
if (placement == ModalPlacement.CENTER) {
ChatModel.chatId.value = null
} else if (placement == ModalPlacement.END) {
desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH)
desktopExpandWindowToWidth(DEFAULT_START_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()) + DEFAULT_MIN_CENTER_MODAL_WIDTH + DEFAULT_END_MODAL_WIDTH * sqrt(appPrefs.fontScale.get()))
}
}
@@ -100,6 +102,9 @@ class ModalManager(private val placement: ModalPlacement? = null) {
fun hasModalsOpen() = modalCount.value > 0
val hasModalsOpen: Boolean
@Composable get () = remember { modalCount }.value > 0
fun closeModal() {
if (modalViews.isNotEmpty()) {
if (modalViews.lastOrNull()?.first == false) modalViews.removeAt(modalViews.lastIndex)
@@ -276,8 +276,8 @@ fun TextIconSpaced(extraPadding: Boolean = false) {
}
@Composable
fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground) {
SectionItemViewSpaceBetween {
fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color? = null, textColor: Color = MaterialTheme.colors.onBackground, padding: PaddingValues = PaddingValues(horizontal = DEFAULT_PADDING)) {
SectionItemViewSpaceBetween(padding = padding) {
Row {
val iconSize = with(LocalDensity.current) { 21.sp.toDp() }
if (icon != null) Icon(icon, title, Modifier.padding(end = 8.dp).size(iconSize), tint = iconTint ?: MaterialTheme.colors.secondary)
@@ -287,6 +287,61 @@ fun InfoRow(title: String, value: String, icon: Painter? = null, iconTint: Color
}
}
fun numOrDash(n: Number): String = if (n.toLong() == 0L) "-" else n.toString()
@Composable
fun InfoRowTwoValues(
title: String,
title2: String,
value: Int,
value2: Int,
textColor: Color = MaterialTheme.colors.onBackground
) {
SectionItemViewSpaceBetween {
Row(
verticalAlignment = Alignment.Bottom
) {
Text(
text = title,
color = textColor,
)
Text(
text = " / ",
fontSize = 12.sp,
)
Text(
text = title2,
color = textColor,
fontSize = 12.sp,
)
}
Row(verticalAlignment = Alignment.Bottom) {
if (value == 0 && value2 == 0) {
Text(
text = "-",
color = MaterialTheme.colors.secondary
)
} else {
Text(
text = numOrDash(value),
color = MaterialTheme.colors.secondary,
)
Text(
text = " / ",
color = MaterialTheme.colors.secondary,
fontSize = 12.sp,
)
Text(
text = numOrDash(value2),
color = MaterialTheme.colors.secondary,
fontSize = 12.sp,
)
}
}
}
}
@Composable
fun InfoRowEllipsis(title: String, value: String, onClick: () -> Unit) {
SectionItemViewSpaceBetween(onClick) {
@@ -0,0 +1,41 @@
package chat.simplex.common.views.helpers
import androidx.compose.foundation.layout.Box
import androidx.compose.material.Icon
import androidx.compose.ui.graphics.Color
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
@Composable
fun SubscriptionStatusIcon(
color: Color,
variableValue: Float,
modifier: Modifier = Modifier
) {
@Composable
fun ZeroIcon() {
Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color.copy(alpha = 0.33f), modifier = modifier)
}
when {
variableValue <= 0f -> ZeroIcon()
variableValue > 0f && variableValue <= 0.25f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_1_bar), null, tint = color, modifier = modifier)
}
variableValue > 0.25f && variableValue <= 0.5f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_2_bar), null, tint = color, modifier = modifier)
}
variableValue > 0.5f && variableValue <= 0.75f -> Box {
ZeroIcon()
Icon(painterResource(MR.images.ic_radiowaves_up_forward_3_bar), null, tint = color, modifier = modifier)
}
else -> Icon(painterResource(MR.images.ic_radiowaves_up_forward_4_bar), null, tint = color, modifier = modifier)
}
}
@@ -8,6 +8,7 @@ import androidx.compose.ui.platform.*
import androidx.compose.ui.text.*
import androidx.compose.ui.unit.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.ThemeOverrides
import chat.simplex.common.views.chatlist.connectIfOpenedViaUri
@@ -519,6 +520,15 @@ fun includeMoreFailedComposables() {
lastExecutedComposables.clear()
}
val fontSizeMultiplier: Float
@Composable get() = remember { appPrefs.fontScale.state }.value
val fontSizeSqrtMultiplier: Float
@Composable get() = sqrt(remember { appPrefs.fontScale.state }.value)
val desktopDensityScaleMultiplier: Float
@Composable get() = if (appPlatform.isDesktop) remember { appPrefs.densityScale.state }.value else 1f
@Composable
fun DisposableEffectOnGone(always: () -> Unit = {}, whenDispose: () -> Unit = {}, whenGone: () -> Unit) {
DisposableEffect(Unit) {
@@ -17,6 +17,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatController.getNetCfg
import chat.simplex.common.model.ChatController.startChat
import chat.simplex.common.model.ChatController.startChatWithTemporaryDatabase
@@ -38,6 +39,7 @@ import kotlinx.serialization.*
import java.io.File
import java.net.URLEncoder
import kotlin.math.max
import kotlin.math.sqrt
@Serializable
data class MigrationFileLinkData(
@@ -426,7 +428,8 @@ fun LargeProgressView(value: Float, title: String, description: String) {
Box(Modifier.padding(DEFAULT_PADDING).fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(
progress = value,
(if (appPlatform.isDesktop) Modifier.size(DEFAULT_START_MODAL_WIDTH) else Modifier.size(windowWidth() - DEFAULT_PADDING * 2))
(if (appPlatform.isDesktop) Modifier.size(DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier) else Modifier.size(windowWidth() - DEFAULT_PADDING *
2))
.rotate(-90f),
color = MaterialTheme.colors.primary,
strokeWidth = 25.dp
@@ -44,6 +44,11 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.INVITE, close = close) }
},
scanPaste = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
ModalManager.center.showModalCloseable { close -> NewChatView(chatModel.currentRemoteHost.value, NewChatOption.CONNECT, showQRCodeScanner = true, close = close) }
},
createGroup = {
closeNewChatSheet(false)
ModalManager.center.closeModals()
@@ -55,15 +60,17 @@ fun NewChatSheet(chatModel: ChatModel, newChatSheetState: StateFlow<AnimatedView
private val titles = listOf(
MR.strings.add_contact_tab,
MR.strings.scan_paste_link,
MR.strings.create_group_button
)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_group)
private val icons = listOf(MR.images.ic_add_link, MR.images.ic_qr_code, MR.images.ic_group)
@Composable
private fun NewChatSheetLayout(
newChatSheetState: StateFlow<AnimatedViewState>,
stopped: Boolean,
addContact: () -> Unit,
scanPaste: () -> Unit,
createGroup: () -> Unit,
closeNewChatSheet: (animated: Boolean) -> Unit,
) {
@@ -102,7 +109,7 @@ private fun NewChatSheetLayout(
verticalArrangement = Arrangement.Bottom,
horizontalAlignment = Alignment.End
) {
val actions = remember { listOf(addContact, createGroup) }
val actions = remember { listOf(addContact, scanPaste, createGroup) }
val backgroundColor = if (isInDarkTheme())
blendARGB(MaterialTheme.colors.primary, Color.Black, 0.7F)
else
@@ -118,11 +125,11 @@ private fun NewChatSheetLayout(
Box(contentAlignment = Alignment.CenterEnd) {
Button(
actions[index],
shape = RoundedCornerShape(21.dp),
shape = RoundedCornerShape(21.dp * fontSizeSqrtMultiplier),
colors = ButtonDefaults.textButtonColors(backgroundColor = backgroundColor),
elevation = null,
contentPadding = PaddingValues(horizontal = DEFAULT_PADDING_HALF, vertical = DEFAULT_PADDING_HALF),
modifier = Modifier.height(42.dp)
modifier = Modifier.height(42.dp * fontSizeSqrtMultiplier)
) {
Text(
stringResource(titles[index]),
@@ -133,7 +140,7 @@ private fun NewChatSheetLayout(
Icon(
painterResource(icons[index]),
stringResource(titles[index]),
Modifier.size(42.dp),
Modifier.size(42.dp * fontSizeSqrtMultiplier),
tint = if (isInDarkTheme()) MaterialTheme.colors.primary else MaterialTheme.colors.primary
)
}
@@ -145,7 +152,7 @@ private fun NewChatSheetLayout(
}
FloatingActionButton(
onClick = { if (!stopped) closeNewChatSheet(true) },
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING),
Modifier.padding(end = DEFAULT_PADDING, bottom = DEFAULT_PADDING + BottomAppBarHeight).size(AppBarHeight * fontSizeSqrtMultiplier),
elevation = FloatingActionButtonDefaults.elevation(
defaultElevation = 0.dp,
pressedElevation = 0.dp,
@@ -157,11 +164,11 @@ private fun NewChatSheetLayout(
) {
Icon(
painterResource(MR.images.ic_edit_filled), stringResource(MR.strings.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = 1 - animatedFloat.value }
Modifier.graphicsLayer { alpha = 1 - animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier)
)
Icon(
painterResource(MR.images.ic_close), stringResource(MR.strings.add_contact_or_create_group),
Modifier.graphicsLayer { alpha = animatedFloat.value }
Modifier.graphicsLayer { alpha = animatedFloat.value }.size(24.dp * fontSizeSqrtMultiplier)
)
}
}
@@ -264,6 +271,7 @@ private fun PreviewNewChatSheet() {
MutableStateFlow(AnimatedViewState.VISIBLE),
stopped = false,
addContact = {},
scanPaste = {},
createGroup = {},
closeNewChatSheet = {},
)
@@ -140,7 +140,7 @@ fun ModalData.NewChatView(rh: RemoteHostInfo?, selection: NewChatOption, showQRC
}
}
HorizontalPager(state = pagerState, Modifier.fillMaxSize(), verticalAlignment = Alignment.Top) { index ->
HorizontalPager(state = pagerState, Modifier.fillMaxSize(), verticalAlignment = Alignment.Top, userScrollEnabled = appPlatform.isAndroid) { index ->
// LALAL SCROLLBAR DOESN'T WORK
ColumnWithScrollBar(
Modifier
@@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.*
import dev.icerock.moko.resources.compose.painterResource
@@ -82,6 +83,66 @@ object AppearanceScope {
}
}
@Composable
fun FontScaleSection() {
val localFontScale = remember { mutableStateOf(appPrefs.fontScale.get()) }
SectionView(stringResource(MR.strings.appearance_font_size).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(60.dp)
.background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22))
.clip(RoundedCornerShape(percent = 22))
.clickable {
localFontScale.value = 1f
appPrefs.fontScale.set(localFontScale.value)
},
contentAlignment = Alignment.Center) {
CompositionLocalProvider(
LocalDensity provides Density(LocalDensity.current.density, localFontScale.value)
) {
Text("Aa", color = if (localFontScale.value == 1f) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground)
}
}
Spacer(Modifier.width(10.dp))
// Text("${(localFontScale.value * 100).roundToInt()}%", Modifier.width(70.dp), textAlign = TextAlign.Center, fontSize = 12.sp)
if (appPlatform.isAndroid) {
Slider(
localFontScale.value,
valueRange = 0.75f..1.25f,
steps = 11,
onValueChange = {
val diff = it % 0.05f
localFontScale.value = String.format(Locale.US, "%.2f", it + (if (diff >= 0.025f) -diff + 0.05f else -diff)).toFloatOrNull() ?: 1f
},
onValueChangeFinished = {
appPrefs.fontScale.set(localFontScale.value)
},
colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
)
)
} else {
Slider(
localFontScale.value,
valueRange = 0.7f..1.5f,
steps = 9,
onValueChange = {
val diff = it % 0.1f
localFontScale.value = String.format(Locale.US, "%.1f", it + (if (diff >= 0.05f) -diff + 0.1f else -diff)).toFloatOrNull() ?: 1f
},
onValueChangeFinished = {
appPrefs.fontScale.set(localFontScale.value)
},
colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
)
)
}
}
}
}
@Composable
fun ChatThemePreview(
theme: DefaultTheme,
@@ -225,8 +286,8 @@ object AppearanceScope {
}
if (appPlatform.isDesktop) {
val itemWidth = (DEFAULT_START_MODAL_WIDTH - DEFAULT_PADDING * 2 - DEFAULT_PADDING_HALF * 3) / 4
val itemHeight = (DEFAULT_START_MODAL_WIDTH - DEFAULT_PADDING * 2) / 4
val itemWidth = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2 - DEFAULT_PADDING_HALF * 3) / 4
val itemHeight = (DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier - DEFAULT_PADDING * 2) / 4
val rows = ceil((PresetWallpaper.entries.size + 2) / 4f).roundToInt()
LazyVerticalGrid(
columns = GridCells.Fixed(4),
@@ -43,6 +43,10 @@ fun DeveloperView(
SectionSpacer()
SectionView(stringResource(MR.strings.developer_options_section).uppercase()) {
SettingsPreferenceItem(painterResource(MR.images.ic_drive_folder_upload), stringResource(MR.strings.confirm_database_upgrades), m.controller.appPrefs.confirmDBUpgrades)
if (appPlatform.isAndroid) {
SettingsPreferenceItem(painterResource(MR.images.ic_back_hand), stringResource(MR.strings.one_hand_ui), m.controller.appPrefs.oneHandUI)
}
if (appPlatform.isDesktop) {
TerminalAlwaysVisibleItem(m.controller.appPrefs.terminalAlwaysVisible) { checked ->
if (checked) {
@@ -42,6 +42,7 @@ fun NetworkAndServersView() {
// It's not a state, just a one-time value. Shouldn't be used in any state-related situations
val netCfg = remember { chatModel.controller.getNetCfg() }
val networkUseSocksProxy: MutableState<Boolean> = remember { mutableStateOf(netCfg.useSocksProxy) }
val networkShowSubscriptionPercentage: MutableState<Boolean> = remember { mutableStateOf(chatModel.controller.appPrefs.networkShowSubscriptionPercentage.get()) }
val developerTools = chatModel.controller.appPrefs.developerTools.get()
val onionHosts = remember { mutableStateOf(netCfg.onionHosts) }
val sessionMode = remember { mutableStateOf(netCfg.sessionMode) }
@@ -53,6 +54,7 @@ fun NetworkAndServersView() {
currentRemoteHost = currentRemoteHost,
developerTools = developerTools,
networkUseSocksProxy = networkUseSocksProxy,
networkShowSubscriptionPercentage = networkShowSubscriptionPercentage,
onionHosts = onionHosts,
sessionMode = sessionMode,
smpProxyMode = smpProxyMode,
@@ -117,6 +119,9 @@ fun NetworkAndServersView() {
)
}
},
toggleNetworkShowSubscriptionPercentage = { enable ->
networkShowSubscriptionPercentage.value = enable
},
useOnion = {
if (onionHosts.value == it) return@NetworkAndServersLayout
val prevValue = onionHosts.value
@@ -230,12 +235,14 @@ fun NetworkAndServersView() {
currentRemoteHost: RemoteHostInfo?,
developerTools: Boolean,
networkUseSocksProxy: MutableState<Boolean>,
networkShowSubscriptionPercentage: MutableState<Boolean>,
onionHosts: MutableState<OnionHosts>,
sessionMode: MutableState<TransportSessionMode>,
smpProxyMode: MutableState<SMPProxyMode>,
smpProxyFallback: MutableState<SMPProxyFallback>,
proxyPort: State<Int>,
toggleSocksProxy: (Boolean) -> Unit,
toggleNetworkShowSubscriptionPercentage: (Boolean) -> Unit,
useOnion: (OnionHosts) -> Unit,
updateSessionMode: (TransportSessionMode) -> Unit,
updateSMPProxyMode: (SMPProxyMode) -> Unit,
@@ -256,6 +263,7 @@ fun NetworkAndServersView() {
SettingsActionItem(painterResource(MR.images.ic_dns), stringResource(MR.strings.xftp_servers), { ModalManager.start.showCustomModal { close -> ProtocolServersView(m, m.remoteHostId, ServerProtocol.XFTP, close) } })
if (currentRemoteHost == null) {
SettingsPreferenceItem(painterResource(MR.images.ic_radiowaves_up_forward_4_bar),stringResource(MR.strings.subscription_percentage), chatModel.controller.appPrefs.networkShowSubscriptionPercentage)
UseSocksProxySwitch(networkUseSocksProxy, proxyPort, toggleSocksProxy, showModal, chatModel.controller.appPrefs.networkProxyHostPort, false)
UseOnionHosts(onionHosts, networkUseSocksProxy, showModal, useOnion)
if (developerTools) {
@@ -677,8 +685,10 @@ fun PreviewNetworkAndServersLayout() {
currentRemoteHost = null,
developerTools = true,
networkUseSocksProxy = remember { mutableStateOf(true) },
networkShowSubscriptionPercentage = remember { mutableStateOf(false) },
proxyPort = remember { mutableStateOf(9050) },
toggleSocksProxy = {},
toggleNetworkShowSubscriptionPercentage = {},
onionHosts = remember { mutableStateOf(OnionHosts.PREFER) },
sessionMode = remember { mutableStateOf(TransportSessionMode.User) },
smpProxyMode = remember { mutableStateOf(SMPProxyMode.Never) },
@@ -174,11 +174,14 @@ fun SettingsLayout(
Box(
Modifier
.fillMaxWidth()
.height(AppBarHeight * fontSizeSqrtMultiplier)
.background(MaterialTheme.colors.background)
.background(if (isInDarkTheme()) ToolbarDark else ToolbarLight)
.padding(start = 4.dp, top = 8.dp)
.padding(start = 4.dp, top = 8.dp),
contentAlignment = Alignment.CenterStart
) {
NavigationButtonBack(closeSettings)
val sp24 = with(LocalDensity.current) { 24.sp.toDp() }
NavigationButtonBack(closeSettings, height = sp24)
}
}
}
@@ -324,6 +324,11 @@
<string name="forward_chat_item">Forward</string>
<string name="download_file">Download</string>
<string name="message_forwarded_title">Message forwarded</string>
<string name="message_forwarded_desc">No direct connection yet, message is forwarded by admin.</string>
<string name="member_inactive_title">Member inactive</string>
<string name="member_inactive_desc">Message may be delivered later if member becomes active.</string>
<!-- CIMetaView.kt -->
<string name="icon_descr_edited">edited</string>
<string name="icon_descr_sent_msg_status_sent">sent</string>
@@ -336,6 +341,7 @@
<string name="welcome">Welcome!</string>
<string name="this_text_is_available_in_settings">This text is available in settings</string>
<string name="your_chats">Chats</string>
<string name="toolbar_settings">Settings</string>
<string name="contact_connection_pending">connecting…</string>
<string name="member_contact_send_direct_message">send direct message</string>
<string name="group_preview_you_are_invited">you are invited to group</string>
@@ -427,10 +433,28 @@
<string name="notifications">Notifications</string>
<!-- Chat Info Actions - ChatInfoView.kt -->
<string name="info_view_connect_button">connect</string>
<string name="info_view_open_button">open</string>
<string name="info_view_message_button">message</string>
<string name="info_view_call_button">call</string>
<string name="info_view_video_button">video</string>
<string name="delete_contact_question">Delete contact?</string>
<string name="delete_contact_all_messages_deleted_cannot_undo_warning">Contact and all messages will be deleted - this cannot be undone!</string>
<string name="delete_contact_cannot_undo_warning">Contact will be deleted - this cannot be undone!</string>
<string name="delete_conversation_question">Delete conversation?</string>
<string name="delete_conversation_all_messages_deleted_cannot_undo_warning">Conversation and all messages will be deleted - this cannot be undone!</string>
<string name="delete_conversation">Delete conversation</string>
<string name="only_delete_conversation">Only delete conversation</string>
<string name="delete_contact_keep_conversation">Delete contact, keep conversation</string>
<string name="notify_delete_contact_question">Notify contact?</string>
<string name="confirm_delete_contact_question">Confirm contact deletion?</string>
<string name="delete_and_notify_contact">Delete and notify contact</string>
<string name="delete_without_notification">Delete without notification</string>
<string name="button_delete_contact">Delete contact</string>
<string name="conversation_deleted">Conversation deleted!</string>
<string name="you_can_still_send_messages_to_contact">You can still send messages to %1$s from the Contacts tab.</string>
<string name="contact_deleted">Contact deleted!</string>
<string name="you_can_still_view_conversation_with_contact">You can still view conversation with %1$s in the Chats tab.</string>
<string name="text_field_set_contact_placeholder">Set contact name…</string>
<string name="icon_descr_server_status_connected">Connected</string>
<string name="icon_descr_server_status_disconnected">Disconnected</string>
@@ -617,6 +641,7 @@
<!-- NewChatView.kt -->
<string name="new_chat">New chat</string>
<string name="add_contact_tab">Add contact</string>
<string name="scan_paste_link">Scan / Paste link</string>
<string name="one_time_link">One-time invitation link</string>
<string name="one_time_link_short">1-time link</string>
<string name="simplex_address">SimpleX address</string>
@@ -685,6 +710,7 @@
<string name="smp_servers_per_user">The servers for new connections of your current chat profile</string>
<string name="smp_save_servers_question">Save servers?</string>
<string name="xftp_servers">XFTP servers</string>
<string name="subscription_percentage">Subscription percentage</string>
<string name="install_simplex_chat_for_terminal">Install SimpleX Chat for terminal</string>
<string name="star_on_github">Star on GitHub</string>
<string name="contribute">Contribute</string>
@@ -1213,6 +1239,7 @@
<string name="database_downgrade">Database downgrade</string>
<string name="incompatible_database_version">Incompatible database version</string>
<string name="confirm_database_upgrades">Confirm database upgrades</string>
<string name="one_hand_ui">One-hand UI</string>
<string name="terminal_always_visible">Show console in new window</string>
<string name="chat_list_always_visible">Show chat list in new window</string>
<string name="invalid_migration_confirmation">Invalid migration confirmation</string>
@@ -1459,6 +1486,8 @@
<string name="unblock_member_desc">Messages from %s will be shown!</string>
<string name="member_blocked_by_admin">Blocked by admin</string>
<string name="member_info_member_blocked">blocked</string>
<string name="member_info_member_disabled">disabled</string>
<string name="member_info_member_inactive">inactive</string>
<string name="member_info_section_title_member">MEMBER</string>
<string name="role_in_group">Role</string>
<string name="change_role">Change role</string>
@@ -1604,6 +1633,8 @@
<string name="color_wallpaper_background">Wallpaper background</string>
<string name="color_wallpaper_tint">Wallpaper accent</string>
<string name="theme_remove_image">Remove image</string>
<string name="appearance_font_size">Font size</string>
<string name="appearance_zoom">Zoom</string>
<!-- Wallpapers -->
<string name="wallpaper_preview_hello_alice">Good afternoon!</string>
@@ -2075,4 +2106,85 @@
<string name="network_type_network_wifi">WiFi</string>
<string name="network_type_ethernet">Wired ethernet</string>
<string name="network_type_other">Other</string>
<!-- ServersSummaryView.kt -->
<string name="servers_info">Servers info</string>
<string name="servers_info_files_tab">Files</string>
<string name="servers_info_missing">No info, try to reload</string>
<string name="servers_info_target">Showing info for</string>
<string name="all_users">All users</string>
<string name="current_user">Current user</string>
<string name="servers_info_transport_sessions_section_header">Transport sessions</string>
<string name="servers_info_sessions_connected">Connected</string>
<string name="servers_info_sessions_connecting">Connecting</string>
<string name="servers_info_sessions_errors">Errors</string>
<string name="servers_info_statistics_section_header">Statistics</string>
<string name="servers_info_messages_sent">Messages sent</string>
<string name="servers_info_messages_received">Messages received</string>
<string name="servers_info_details">Details</string>
<string name="servers_info_private_data_disclaimer">Starting from %s.\nAll data is private to your device.</string>
<string name="servers_info_subscriptions_section_header">Messages subscriptions</string>
<string name="servers_info_subscriptions_connections_subscribed">Connections subscribed</string>
<string name="servers_info_subscriptions_connections_pending">Pending</string>
<string name="servers_info_subscriptions_total">Total</string>
<string name="servers_info_connected_servers_section_header">Connected servers</string>
<string name="servers_info_previously_connected_servers_section_header">Previously connected servers</string>
<string name="servers_info_proxied_servers_section_header">Proxied servers</string>
<string name="servers_info_proxied_servers_section_footer">You are not connected to these servers. Private routing is used to deliver messages to them.</string>
<string name="servers_info_reconnect_servers_title">Reconnect servers?</string>
<string name="servers_info_reconnect_servers_message">Reconnect all connected servers to force message delivery. It uses additional traffic.</string>
<string name="servers_info_reconnect_server_title">Reconnect server?</string>
<string name="servers_info_reconnect_server_message">Reconnect server to force message delivery. It uses additional traffic.</string>
<string name="servers_info_reconnect_servers_error">Error reconnecting servers</string>
<string name="servers_info_reconnect_server_error">Error reconnecting server</string>
<string name="servers_info_modal_error_title">Error</string>
<string name="servers_info_reconnect_all_servers_button">Reconnect all servers</string>
<string name="servers_info_reset_stats">Reset all statistics</string>
<string name="servers_info_reset_stats_alert_title">Reset all statistics?</string>
<string name="servers_info_reset_stats_alert_message">Servers statistics will be reset - this cannot be undone!</string>
<string name="servers_info_reset_stats_alert_confirm">Reset</string>
<string name="servers_info_reset_stats_alert_error_title">Error resetting statistics</string>
<string name="servers_info_uploaded">Uploaded</string>
<string name="servers_info_downloaded">Downloaded</string>
<string name="servers_info_detailed_statistics">Detailed statistics</string>
<string name="servers_info_detailed_statistics_sent_messages_header">Sent messages</string>
<string name="servers_info_detailed_statistics_sent_messages_total">Sent total</string>
<string name="servers_info_detailed_statistics_received_messages_header">Received messages</string>
<string name="servers_info_detailed_statistics_received_total">Received total</string>
<string name="servers_info_detailed_statistics_receive_errors">Receive errors</string>
<string name="servers_info_starting_from">Starting from %s.</string>
<string name="smp_server">SMP server</string>
<string name="xftp_server">XFTP server</string>
<string name="reconnect">Reconnect</string>
<string name="attempts_label">attempts</string>
<string name="sent_directly">Sent directly</string>
<string name="sent_via_proxy">Sent via proxy</string>
<string name="proxied">Proxied</string>
<string name="send_errors">Send errors</string>
<string name="expired_label">expired</string>
<string name="other_label">other</string>
<string name="duplicates_label">duplicates</string>
<string name="decryption_errors">decryption errors</string>
<string name="other_errors">other errors</string>
<string name="acknowledged">Acknowledged</string>
<string name="acknowledgement_errors">Acknowledgement errors</string>
<string name="connections">Connections</string>
<string name="created">Created</string>
<string name="secured">Secured</string>
<string name="completed">Completed</string>
<string name="deleted">Deleted</string>
<string name="deletion_errors">Deletion errors</string>
<string name="subscribed">Subscribed</string>
<string name="subscription_results_ignored">Subscriptions ignored</string>
<string name="subscription_errors">Subscription errors</string>
<string name="uploaded_files">Uploaded files</string>
<string name="size">Size</string>
<string name="chunks_uploaded">Chunks uploaded</string>
<string name="upload_errors">Upload errors</string>
<string name="chunks_deleted">Chunks deleted</string>
<string name="chunks_downloaded">Chunks downloaded</string>
<string name="downloaded_files">Downloaded files</string>
<string name="download_errors">Download errors</string>
<string name="server_address">Server address</string>
<string name="open_server_settings_button">Open server settings</string>
</resources>
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M241.78-244.5 134-136.5q-13.5 13.5-31.25 6.52Q85-136.97 85-156.5V-818q0-22.97 17.27-40.23 17.26-17.27 40.23-17.27h675q22.97 0 40.23 17.27Q875-840.97 875-818v516q0 22.97-17.27 40.23-17.26 17.27-40.23 17.27H241.78Z"/></svg>

After

Width:  |  Height:  |  Size: 337 B

@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 960" width="20px" height="20px">
<g transform="matrix(1, 0, 0, 1, -160, 960)">
<path
d="m592.5-482-314-314q-10.5-10.5-10.75-26.25t10.75-27.25q11-11.5 27-11.5t27.5 11.5L661-522q8.5 8.5 12.25 18.75T677-482q0 11-3.75 21.25T661-442L332.5-113.5q-11.5 11.5-27.25 11t-26.75-12q-11-11-11.25-26.5t11.25-27l314-314Z" />
</g>
<g transform="matrix(1, 0, 0, 1, 160, 960)">
<path
d="m592.5-482-314-314q-10.5-10.5-10.75-26.25t10.75-27.25q11-11.5 27-11.5t27.5 11.5L661-522q8.5 8.5 12.25 18.75T677-482q0 11-3.75 21.25T661-442L332.5-113.5q-11.5 11.5-27.25 11t-26.75-12q-11-11-11.25-26.5t11.25-27l314-314Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 715 B

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24" fill="#000000"><path d="m790.5-88-79-78.5H222q-23.97 0-40.73-16.77Q164.5-200.03 164.5-224v-33.52q0-37.48 18.75-63.98t48.25-40q60-27 115.25-41.75T458-420L87.09-790.91q-8.59-8.59-8.34-20.59t9-20.5q8.75-8.5 20.75-8.5t20.61 8.61l702.78 703.28q8.61 8.43 8.61 20.27T831.75-88Q823-79.5 811-79.5T790.5-88ZM222-224h432L515.48-362.5q-8.48-.5-17.48-.75t-17.98-.25q-56.54 0-109.53 11.5T254.5-310q-14 7-23.25 21.73T222-257.26V-224Zm505.5-137.5q31 13.5 49.5 40.25T795.5-257v9.5L652-391q18 6 37 13.75t38.5 15.75ZM548-495l-46-45.8q30.23-7.2 49.11-31.2Q570-596 570-628q0-38-26-64t-64-26q-32 0-56 19t-31 49l-46-46q18.5-38 54.5-58.75T480-775.5q62 0 104.75 42.75T627.5-628q0 42.5-20.75 78.5T548-495Zm106 271H222h432ZM448-596Z"/></svg>

After

Width:  |  Height:  |  Size: 802 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742189)">
<path d="M 15.124 -6.711 C 18.033 -6.711 20.399 -9.129 20.399 -11.986 C 20.399 -14.96 18.082 -17.295 15.124 -17.295 C 12.232 -17.295 9.766 -14.862 9.766 -11.889 C 9.766 -9.129 12.281 -6.711 15.124 -6.711 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 492 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742188)">
<path d="M 15.124 -6.711 C 18.033 -6.711 20.399 -9.129 20.399 -11.986 C 20.399 -14.96 18.082 -17.295 15.124 -17.295 C 12.232 -17.295 9.766 -14.862 9.766 -11.889 C 9.766 -9.129 12.281 -6.711 15.124 -6.711 Z M 10.714 -30.066 C 10.714 -28.585 11.962 -27.352 13.428 -27.352 C 22.787 -27.352 30.392 -19.732 30.392 -10.388 C 30.392 -8.908 31.64 -7.675 33.106 -7.675 C 34.571 -7.675 35.819 -8.908 35.819 -10.388 C 35.819 -22.746 25.718 -32.764 13.428 -32.764 C 11.962 -32.764 10.714 -31.565 10.714 -30.066 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 787 B

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742188)">
<path d="M 15.124 -6.711 C 18.033 -6.711 20.399 -9.129 20.399 -11.986 C 20.399 -14.96 18.082 -17.295 15.124 -17.295 C 12.232 -17.295 9.766 -14.862 9.766 -11.889 C 9.766 -9.129 12.281 -6.711 15.124 -6.711 Z M 10.714 -30.066 C 10.714 -28.585 11.962 -27.352 13.428 -27.352 C 22.787 -27.352 30.392 -19.732 30.392 -10.388 C 30.392 -8.908 31.64 -7.675 33.106 -7.675 C 34.571 -7.675 35.819 -8.908 35.819 -10.388 C 35.819 -22.746 25.718 -32.764 13.428 -32.764 C 11.962 -32.764 10.714 -31.565 10.714 -30.066 Z M 10.714 -45.251 C 10.714 -43.771 11.962 -42.538 13.428 -42.538 C 31.216 -42.538 45.578 -28.161 45.578 -10.388 C 45.578 -8.908 46.826 -7.675 48.291 -7.675 C 49.756 -7.675 51.004 -8.908 51.004 -10.388 C 51.004 -31.141 34.147 -47.95 13.428 -47.95 C 11.962 -47.95 10.714 -46.75 10.714 -45.251 Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<svg height="24" viewBox="-44.134 -43.207 588.823 585.294" width="24" xmlns="http://www.w3.org/2000/svg">
<g id="Light-S" transform="matrix(8.710668563842773, 0, 0, 8.710668563842773, -85.06543731689453, 558.4567260742188)">
<path d="M15.1235-6.71093C18.0332-6.71093 20.3989-9.1289 20.3989-11.9863C20.3989-14.96 18.082-17.2954 15.1235-17.2954C12.2324-17.2954 9.76562-14.8623 9.76562-11.8887C9.76562-9.1289 12.2813-6.71093 15.1235-6.71093ZM10.7143-30.0659C10.7143-28.5854 11.9624-27.3525 13.4277-27.3525C22.7871-27.3525 30.3921-19.7324 30.3921-10.3882C30.3921-8.90771 31.6401-7.6748 33.1055-7.6748C34.5708-7.6748 35.8189-8.90771 35.8189-10.3882C35.8189-22.7456 25.7178-32.7642 13.4277-32.7642C11.9624-32.7642 10.7143-31.5649 10.7143-30.0659ZM10.7143-45.2515C10.7143-43.771 11.9624-42.5381 13.4277-42.5381C31.2158-42.5381 45.5776-28.1611 45.5776-10.3882C45.5776-8.90771 46.8257-7.6748 48.291-7.6748C49.7564-7.6748 51.0044-8.90771 51.0044-10.3882C51.0044-31.1406 34.1465-47.9497 13.4277-47.9497C11.9624-47.9497 10.7143-46.7505 10.7143-45.2515ZM10.7143-61.4136C10.7143-59.9331 11.9624-58.7002 13.4277-58.7002C40.0874-58.7002 61.7397-37.0327 61.7397-10.3882C61.7397-8.90771 62.9878-7.6748 64.4531-7.6748C65.9185-7.6748 67.1665-8.90771 67.1665-10.3882C67.1665-40.0273 43.0181-64.1118 13.4277-64.1118C11.9624-64.1118 10.7143-62.9126 10.7143-61.4136Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -14,6 +14,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.*
import chat.simplex.common.model.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_START_MODAL_WIDTH
import chat.simplex.common.ui.theme.SimpleXTheme
@@ -26,6 +27,7 @@ import kotlinx.coroutines.*
import java.awt.event.WindowEvent
import java.awt.event.WindowFocusListener
import java.io.File
import kotlin.math.sqrt
import kotlin.system.exitProcess
val simplexWindowState = SimplexWindowState()
@@ -195,7 +197,8 @@ private fun ApplicationScope.AppWindow(closedByError: MutableState<Boolean>) {
if (remember { ChatController.appPrefs.developerTools.state }.value && remember { ChatController.appPrefs.terminalAlwaysVisible.state }.value && remember { ChatController.appPrefs.appLanguage.state }.value != "") {
var hiddenUntilRestart by remember { mutableStateOf(false) }
if (!hiddenUntilRestart) {
val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH, height = 768.dp)
val cWindowState = rememberWindowState(placement = WindowPlacement.Floating, width = DEFAULT_START_MODAL_WIDTH * fontSizeSqrtMultiplier, height =
768.dp)
Window(state = cWindowState, onCloseRequest = { hiddenUntilRestart = true }, title = stringResource(MR.strings.chat_console)) {
SimpleXTheme {
TerminalView(ChatModel) { hiddenUntilRestart = true }
@@ -35,6 +35,7 @@ actual fun ChatListNavLinkLayout(
disabled: Boolean,
selectedChat: State<Boolean>,
nextChatSelected: State<Boolean>,
oneHandUI: State<Boolean>
) {
var modifier = Modifier.fillMaxWidth()
if (!disabled) modifier = modifier
@@ -3,18 +3,29 @@ package chat.simplex.common.views.usersettings
import SectionBottomSpacer
import SectionDividerSpaced
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatController.appPrefs
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.DEFAULT_PADDING
import chat.simplex.common.views.helpers.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.delay
import java.util.Locale
import kotlin.math.roundToInt
@Composable
actual fun AppearanceView(m: ChatModel) {
@@ -55,6 +66,56 @@ fun AppearanceScope.AppearanceLayout(
SectionDividerSpaced(maxTopPadding = true)
ProfileImageSection()
SectionDividerSpaced(maxBottomPadding = true)
FontScaleSection()
SectionDividerSpaced(maxBottomPadding = true)
DensityScaleSection()
SectionBottomSpacer()
}
}
@Composable
fun DensityScaleSection() {
val localDensityScale = remember { mutableStateOf(appPrefs.densityScale.get()) }
SectionView(stringResource(MR.strings.appearance_zoom).uppercase(), padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
Row(Modifier.padding(top = 10.dp), verticalAlignment = Alignment.CenterVertically) {
Box(Modifier.size(60.dp)
.background(MaterialTheme.colors.surface, RoundedCornerShape(percent = 22))
.clip(RoundedCornerShape(percent = 22))
.clickable {
localDensityScale.value = 1f
appPrefs.densityScale.set(localDensityScale.value)
},
contentAlignment = Alignment.Center) {
CompositionLocalProvider(
LocalDensity provides Density(LocalDensity.current.density * localDensityScale.value, LocalDensity.current.fontScale)
) {
Text("${localDensityScale.value}",
color = if (localDensityScale.value == 1f) MaterialTheme.colors.primary else MaterialTheme.colors.onBackground,
fontSize = 12.sp,
maxLines = 1
)
}
}
Spacer(Modifier.width(10.dp))
Slider(
localDensityScale.value,
valueRange = 1f..2f,
steps = 11,
onValueChange = {
val diff = it % 0.1f
localDensityScale.value = String.format(Locale.US, "%.1f", it + (if (diff >= 0.05f) -diff + 0.1f else -diff)).toFloatOrNull() ?: 1f
},
onValueChangeFinished = {
appPrefs.densityScale.set(localDensityScale.value)
},
colors = SliderDefaults.colors(
activeTickColor = Color.Transparent,
inactiveTickColor = Color.Transparent,
)
)
}
}
}
+4 -4
View File
@@ -26,11 +26,11 @@ android.enableJetifier=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=5.8.2
android.version_code=223
android.version_name=6.0-beta.0
android.version_code=225
desktop.version_name=5.8.2
desktop.version_code=55
desktop.version_name=6.0-beta.0
desktop.version_code=56
kotlin.version=1.9.23
gradle.plugin.version=8.2.0
@@ -0,0 +1,51 @@
---
layout: layouts/article.html
title: "The Future of Privacy: Enforcing Privacy Standards"
date: 2024-07-04
previewBody: blog_previews/20240704.html
image: images/20240704-privacy.jpg
imageWide: true
permalink: "/blog/20240704-future-of-privacy-enforcing-privacy-standards.html"
---
# The Future of Privacy: Enforcing Privacy Standards
**Published:** Jul 4, 2024
Recent anti-privacy legislations and proposals in [Europe](https://www.theverge.com/2024/6/19/24181214/eu-chat-control-law-propose-scanning-encrypted-messages-csam), [the US](https://theconversation.com/section-702-foreign-surveillance-law-lives-on-but-privacy-fight-continues-229253) and [Australia](https://www.theguardian.com/technology/article/2024/jun/20/meredith-walker-signal-boss-government-encryption-laws) threaten to infringe our fundamental right to privacy and to create grave risks to the [safety](https://simplex.chat/blog/20240601-protecting-children-safety-requires-e2e-encryption.html) of children and vulnerable people. It's time we shift the focus: privacy should be a non-negotiable duty of technology providers, not just a right users must constantly fight to protect, and not something that users can be asked to consent away as a condition of access to a service.
Tech giants are trying to normalize surveillance capitalism, often with little to no consequences globally. These companies are contributing to a growing ecosystem where opting out of invasive data hoarding practices is becoming increasingly challenging, if not outright impossible. We are being gaslit by the technology executives who try to justify profiteering from AI theft, from [Microsoft](https://www.computing.co.uk/news/4330395/microsoft-ai-chief-makes-questionable-claims-about-copyright-online-content) claiming all our content is fair game for their exploitation to unethical startups like [Perplexity](https://www.theverge.com/2024/6/27/24187405/perplexity-ai-twitter-lie-plagiarism) turning the word “[privacy](https://x.com/perplexity_ai/status/1789007907092066559)” into a marketable farce.
## The AI Hypes Impact on Privacy
The exaggeration of AIs actual capabilities and the continuous promotion of its “intelligence” is creating a rat race where tech companies and well-funded startups are evading accountability, as they eagerly collect and exploit more data than ever. 
They're prioritizing AI development over user privacy and rights, setting a dangerous precedent for current and future online engagements. They've already normalized the use of AI to scan and analyze supposedly private communications - from emails to instant messages - repackaging this intrusion as "productivity tools”. Meanwhile, most consumers actually want [more data privacy](https://iapp.org/news/a/most-consumers-want-data-privacy-and-will-act-to-defend-it), not less, and are increasingly concerned by the lack of it.
The legal push towards “client-side scanning”, attacks on end-to-end encryption and the support for pro-surveillance legislation gives credibility to these highly intrusive practices that literally endanger lives. And we know that moral obligations mean nothing to corporations benefiting from these exploitative models, so we have to ensure that our demands for privacy are legally enforceable and non-negotiable. 
## Legal Action
We are encouraged to see more legal pressure on companies that exploit user data on a daily basis. For example, the European Center for Digital Rights (Noyb) [complaints](https://noyb.eu/en/noyb-urges-11-dpas-immediately-stop-metas-abuse-personal-data-ai) against Metas abuse of personal data to train their AI and, and the demands from the Norwegian Consumer Council to data protection authority to ensure that applicable laws are enforced against Meta considering there is “[no way to remove personal data from AI models once Meta has begun the training](https://www.forbrukerradet.no/side/legal-complaint-against-metas-use-of-personal-content-for-ai-training/)”.
Noyb is taking a strong stance against [other companies](https://noyb.eu/en/project/cases) with similar exploitative models, including facial recognition surveillance tools often misused by law enforcement agencies. Consider [supporting](https://noyb.eu/en/support) their ongoing efforts &mdash; we strongly believe legal action is one of the most effective means to hold these companies accountable for their persistent abuses, which are otherwise shielded by heavily funded self-serving lobby groups.
## Privacy as a Legal Obligation
We must shift from a defensive stance to a proactive one by proposing privacy legislation that puts users in direct control of their private data.
This legislation should:
1. Establish non-negotiable provider duties for protecting user privacy, with hefty fines and consequences for service operators who do not comply.
2. Prevent providers from circumventing these duties through user consent clauses &mdash; it should be legally prohibited to ask for a consent to share user data or to use it for anything other than providing a service.
3. Prevent providers from asking for any more personal information from the users than technically necessary and legally required. For example, asking for a phone number as a condition of access to a service should be made illegal in most cases &mdash; it does not provide a sufficient security, exposes users' private information and allows simple aggregation of users' data across multiple services.
4. Create a strong legal framework that cannot be resisted or modified
By codifying these principles into law, we can establish a strong technological framework that is built to create more value for end users, while protecting their privacy against data exploitation, criminal use and identity theft. We will continue the fight against illogical legislative proposals designed to normalize mass surveillance, but our efforts should equally gear towards creating and supporting new models and technological foundations that bring us far closer to the reality we urgently need.
## Collective Action
There is great work being done by advocacy organizations, and service providers need to contribute to this fight as well by shifting the narrative and reclaiming the term “privacy” from the tech giants who co-opted and corrupted it. We must play a bigger role in supporting users in setting stronger boundaries, making demands, and refusing anything less than genuine privacy and data ownership, while getting comfortable with holding providers accountable for any violations.
Privacy should be seen as a fundamental obligation of technology providers, and legislators must actively enforce this expectation. The more consumers make this demand, the more pressure we put on anti-privacy lobbyists with rogue motives, the easier it will be to hold abusers accountable, and the more likely we can collectively ensure that a privacy-first web becomes a reality.
You can support privacy today by signing [the petition](https://www.globalencryption.org/2024/05/joint-statement-on-the-dangers-of-the-may-2024-council-of-the-eu-compromise-proposal-on-eu-csam/) prepared by Global Encryption Coalition in support of communication privacy. You can also write to your elected representatives, explaining them how data privacy and encrypted communications protect children safety and reduce crime.
Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Some files were not shown because too many files have changed in this diff Show More