Merge origin/master into sh/namespace

The names (simplex_name / RSLV) feature and master's badge feature both
extended the contact/group profile row layer. Resolution keeps both, with
simplex_name ordered last (chronological - it is the newer column):
- Profile/LocalProfile gain badge + simplex_name; simplex_name last in the
  data types, record builds, schema, and SQL row types/SELECTs/INSERTs
- SQL row types, SELECTs and INSERT/UPDATE lists carry both badge_* and
  simplex_name columns (simplex_name after badge)
- migration lists ordered by date (master 0601/0602 before names 0603+)
- SQLite chat_schema.sql regenerated; Postgres chat_schema.sql hand-merged

Verified: lib + test suite build; SchemaDump, Operators, Protocol and
direct/group profile round-trip tests pass.
This commit is contained in:
shum
2026-06-23 12:28:56 +00:00
191 changed files with 11177 additions and 1653 deletions
@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "badge-investor.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffee9b"/>
<stop offset="5%" stop-color="#ffee9b"/>
<stop offset="50%" stop-color="#ffc233"/>
<stop offset="100%" stop-color="#e67700"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "badge-legend.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#26dee8"/>
<stop offset="50%" stop-color="#3064ea"/>
<stop offset="100%" stop-color="#001064"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,21 @@
{
"images" : [
{
"filename" : "badge-supporter.svg",
"idiom" : "universal",
"scale" : "1x"
},
{
"idiom" : "universal",
"scale" : "2x"
},
{
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#29f5ff"/>
<stop offset="95%" stop-color="#527eed"/>
<stop offset="100%" stop-color="#3669e9"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -47,7 +47,7 @@ struct ChatInfoToolbar: View {
}
.padding(.trailing, 4)
let t = Text(cInfo.displayName).font(.headline)
(cInfo.contact?.verified == true ? contactVerifiedShield + t : t)
NameWithBadge((cInfo.contact?.verified == true ? contactVerifiedShield + t : t), cInfo.nameBadge, .headline)
.lineLimit(1)
.if (cInfo.fullName != "" && cInfo.displayName != cInfo.fullName) { v in
VStack(spacing: 0) {
+8 -16
View File
@@ -374,25 +374,17 @@ struct ChatInfoView: View {
// show actual display name, alias can be edited in this view
let displayName = contact.profile.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let fullName = cInfo.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
if contact.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ textSpace
+ Text(displayName)
.font(.largeTitle)
)
let badge = cInfo.nameBadge
// the shield is smaller (.title2) than the name (.largeTitle), so on the shared baseline it
// sits low; raise it by half the cap-height difference to center it with the capitals
let shieldRaise = (UIFont.preferredFont(forTextStyle: .largeTitle).capHeight - UIFont.preferredFont(forTextStyle: .title2).capHeight) / 2
let nameText = contact.verified
? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2).baselineOffset(shieldRaise) + textSpace + Text(displayName).font(.largeTitle)
: Text(displayName).font(.largeTitle)
NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } }
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
} else {
Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
}
if fullName != "" && fullName != displayName && fullName != cInfo.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(cInfo.fullName)
.font(.title2)
@@ -16,6 +16,7 @@ struct CIFileView: View {
@EnvironmentObject var theme: AppTheme
let file: CIFile?
let edited: Bool
let senderProfile: LocalProfile?
var smallViewSize: CGFloat?
var body: some View {
@@ -85,7 +86,7 @@ struct CIFileView: View {
if let file = file {
switch (file.fileStatus) {
case .rcvInvitation, .rcvAborted:
if fileSizeValid(file) {
if fileSizeValid(file, senderProfile) {
Task {
logger.debug("CIFileView fileAction - in .rcvInvitation, .rcvAborted, in Task")
if let user = m.currentUser {
@@ -93,7 +94,7 @@ struct CIFileView: View {
}
}
} else {
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol), countStyle: .binary)
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
@@ -165,7 +166,7 @@ struct CIFileView: View {
case .sndError: fileIcon("doc.fill", innerIcon: "xmark", innerIconSize: 10)
case .sndWarning: fileIcon("doc.fill", innerIcon: "exclamationmark.triangle.fill", innerIconSize: 10)
case .rcvInvitation:
if fileSizeValid(file) {
if fileSizeValid(file, senderProfile) {
fileIcon("arrow.down.doc.fill", color: theme.colors.primary)
} else {
fileIcon("doc.fill", color: .orange, innerIcon: "exclamationmark", innerIconSize: 12)
@@ -227,9 +228,9 @@ struct CIFileView: View {
}
}
func fileSizeValid(_ file: CIFile?) -> Bool {
func fileSizeValid(_ file: CIFile?, _ senderProfile: LocalProfile?) -> Bool {
if let file = file {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
return file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile)
}
return false
}
@@ -14,6 +14,7 @@ import SimpleXChat
struct CIImageView: View {
@EnvironmentObject var m: ChatModel
let chatItem: ChatItem
let senderProfile: LocalProfile?
var scrollToItem: ((ChatItem.ID) -> Void)? = nil
var preview: UIImage?
let maxWidth: CGFloat
@@ -51,10 +52,18 @@ struct CIImageView: View {
if let file = file {
switch file.fileStatus {
case .rcvInvitation, .rcvAborted:
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
if fileSizeValid(file, senderProfile) {
Task {
if let user = m.currentUser {
await receiveFile(user: user, fileId: file.fileId)
}
}
} else {
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
)
}
case .rcvAccepted:
switch file.fileProtocol {
@@ -16,6 +16,7 @@ import Combine
struct CIVideoView: View {
@EnvironmentObject var m: ChatModel
private let chatItem: ChatItem
private let senderProfile: LocalProfile?
private let preview: UIImage?
@State private var duration: Int
@State private var progress: Int = 0
@@ -35,8 +36,9 @@ struct CIVideoView: View {
private var sizeMultiplier: CGFloat { smallView ? 0.38 : 1 }
@State private var blurred: Bool = UserDefaults.standard.integer(forKey: DEFAULT_PRIVACY_MEDIA_BLUR_RADIUS) > 0
init(chatItem: ChatItem, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
init(chatItem: ChatItem, senderProfile: LocalProfile?, preview: UIImage?, duration: Int, maxWidth: CGFloat, videoWidth: CGFloat?, smallView: Bool = false, showFullscreenPlayer: Binding<Bool>) {
self.chatItem = chatItem
self.senderProfile = senderProfile
self.preview = preview
self._duration = State(initialValue: duration)
self.maxWidth = maxWidth
@@ -421,10 +423,18 @@ struct CIVideoView: View {
// TODO encrypt: where file size is checked?
private func receiveFileIfValidSize(file: CIFile, receiveFile: @escaping (User, Int64, Bool, Bool) async -> Void) {
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false)
if fileSizeValid(file, senderProfile) {
Task {
if let user = m.currentUser {
await receiveFile(user, file.fileId, false, false)
}
}
} else {
let prettyMaxFileSize = ByteCountFormatter.string(fromByteCount: getMaxFileSize(file.fileProtocol, senderProfile), countStyle: .binary)
AlertManager.shared.showAlertMsg(
title: "Large file!",
message: "Your contact sent a file that is larger than currently supported maximum size (\(prettyMaxFileSize))."
)
}
}
@@ -127,7 +127,7 @@ struct FramedItemView: View {
} else {
switch (chatItem.content.msgContent) {
case let .image(text, _):
CIImageView(chatItem: chatItem, scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
CIImageView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), scrollToItem: scrollToItem, preview: preview, maxWidth: maxWidth, imgWidth: imgWidth, showFullScreenImage: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -142,7 +142,7 @@ struct FramedItemView: View {
ciMsgContentView(chatItem)
}
case let .video(text, _, duration):
CIVideoView(chatItem: chatItem, preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery)
CIVideoView(chatItem: chatItem, senderProfile: ciSenderProfile(chatItem, chat.chatInfo), preview: preview, duration: duration, maxWidth: maxWidth, videoWidth: videoWidth, showFullscreenPlayer: $showFullscreenGallery)
.overlay(DetermineWidth())
if text == "" && !chatItem.meta.isLive {
Color.clear
@@ -349,7 +349,7 @@ struct FramedItemView: View {
}
@ViewBuilder private func ciFileView(_ ci: ChatItem, _ text: String) -> some View {
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited)
CIFileView(file: chatItem.file, edited: chatItem.meta.itemEdited, senderProfile: ciSenderProfile(chatItem, chat.chatInfo))
.overlay(DetermineWidth())
if text != "" || ci.meta.isLive {
ciMsgContentView (chatItem)
@@ -191,9 +191,14 @@ private func handleTextTaps(
}
}
}
if let index, let (uri, browser) = attributedStringLink(s, for: index) {
if let index, let (uri, browser, simplex) = attributedStringLink(s, for: index) {
if browser {
openBrowserAlert(uri: uri)
} else if simplex, let url = URL(string: uri) {
// SimpleX links target this same app (simplex: scheme / simplex.chat universal link),
// so UIApplication.shared.open is dropped by iOS while the app is in the foreground.
// Route to the in-app connect flow instead (same sink onOpenURL feeds).
ChatModel.shared.appOpenUrl = url
} else if let url = URL(string: uri) {
UIApplication.shared.open(url)
} else {
@@ -203,9 +208,10 @@ private func handleTextTaps(
})
}
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool)? {
func attributedStringLink(_ s: NSAttributedString, for index: CFIndex) -> (String, Bool, Bool)? {
var linkURL: String?
var browser: Bool = false
var simplex: Bool = false
s.enumerateAttributes(in: NSRange(location: 0, length: s.length)) { attrs, range, stop in
if index >= range.location && index < range.location + range.length {
if let nameInfo = attrs[nameAttrKey] as? SimplexNameInfo {
@@ -213,6 +219,7 @@ private func handleTextTaps(
} else if let url = attrs[linkAttrKey] as? String {
linkURL = url
browser = attrs[webLinkAttrKey] != nil
simplex = attrs[simplexLinkAttrKey] != nil
} else if let showSecrets, let i = attrs[secretAttrKey] as? Int {
if showSecrets.wrappedValue.contains(i) {
showSecrets.wrappedValue.remove(i)
@@ -225,7 +232,7 @@ private func handleTextTaps(
stop.pointee = true
}
}
return if let linkURL { (linkURL, browser) } else { nil }
return if let linkURL { (linkURL, browser, simplex) } else { nil }
}
}
@@ -250,6 +257,8 @@ private let linkAttrKey = NSAttributedString.Key("chat.simplex.app.link")
private let webLinkAttrKey = NSAttributedString.Key("chat.simplex.app.webLink")
private let simplexLinkAttrKey = NSAttributedString.Key("chat.simplex.app.simplexLink")
private let secretAttrKey = NSAttributedString.Key("chat.simplex.app.secret")
private let commandAttrKey = NSAttributedString.Key("chat.simplex.app.command")
@@ -392,6 +401,7 @@ func messageText(
attrs = linkAttrs()
if !preview {
attrs[linkAttrKey] = simplexUri
attrs[simplexLinkAttrKey] = true
handleTaps = true
}
if let s = text ?? (privacySimplexLinkModeDefault.get() == .description ? linkType.description : nil) {
@@ -387,23 +387,31 @@ struct ChatItemInfoView: View {
Text("you")
.italic()
.foregroundColor(theme.colors.onBackground)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
NameWithBadge(
Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary),
forwardedFromItem.chatInfo.nameBadge
)
.lineLimit(1)
}
} else if case let .groupRcv(groupMember) = forwardedFromItem.chatItem.chatDir {
VStack(alignment: .leading) {
Text(groupMember.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.secondary)
.lineLimit(1)
NameWithBadge(
Text(groupMember.chatViewName).foregroundColor(theme.colors.onBackground),
groupMember.nameBadge
)
.lineLimit(1)
NameWithBadge(
Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.secondary),
forwardedFromItem.chatInfo.nameBadge
)
.lineLimit(1)
}
} else {
Text(forwardedFromItem.chatInfo.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
NameWithBadge(
Text(forwardedFromItem.chatInfo.chatViewName).foregroundColor(theme.colors.onBackground),
forwardedFromItem.chatInfo.nameBadge
)
.lineLimit(1)
}
}
}
@@ -451,7 +459,7 @@ struct ChatItemInfoView: View {
HStack{
MemberProfileImage(member, size: 30)
.padding(.trailing, 2)
Text(member.chatViewName)
NameWithBadge(Text(member.chatViewName), member.nameBadge)
.lineLimit(1)
Spacer()
if sentViaProxy == true {
+9 -8
View File
@@ -895,8 +895,9 @@ struct ChatView: View {
}
} else {
let voiceNoFrame = voiceWithoutFrame(ci)
let channelReceived = !ci.chatDir.sent && cInfo.isChannel
let maxWidth = cInfo.chatType == .group
? voiceNoFrame
? voiceNoFrame || channelReceived
? (g.size.width - 28) - 42
: (g.size.width - 28) * 0.84 - 42
: voiceNoFrame
@@ -981,8 +982,8 @@ struct ChatView: View {
let v = VStack(spacing: 8) {
ChatInfoImage(chat: chat, size: alertProfileImageSize)
Text(chat.chatInfo.displayName)
.font(.title3)
let badge = chat.chatInfo.nameBadge
NameWithBadge(Text(chat.chatInfo.displayName).font(.title3), badge, .title3) { if let badge { showBadgeInfoAlert(chat.chatInfo.displayName, badge) } }
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
@@ -1998,12 +1999,12 @@ struct ChatView: View {
let (name, role) = if ci.meta.showGroupAsSender {
(groupInfo.chatViewName, NSLocalizedString("group", comment: "shown on group welcome message"))
} else {
(member.chatViewName, member.memberRole.text)
(member.chatViewName, member.memberRole.text(isChannel: groupInfo.isChannel))
}
Group {
if #available(iOS 16.0, *) {
MemberLayout(spacing: 16, msgWidth: msgWidth) {
Text(name)
NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1)
.lineLimit(1)
Text(role)
.fontWeight(.semibold)
@@ -2012,7 +2013,7 @@ struct ChatView: View {
}
} else {
HStack(spacing: 16) {
Text(name)
NameWithBadge(Text(name), ci.meta.showGroupAsSender ? nil : member.nameBadge, .caption1)
.lineLimit(1)
Text(role)
.fontWeight(.semibold)
@@ -2026,7 +2027,7 @@ struct ChatView: View {
alignment: chatItem.chatDir.sent ? .trailing : .leading
)
} else {
Text(memberNames(member, prevMember, memCount))
NameWithBadge(Text(memberNames(member, prevMember, memCount)), memCount == 1 ? member.nameBadge : nil, .caption1)
.lineLimit(2)
}
}
@@ -2311,7 +2312,7 @@ struct ChatView: View {
} else {
saveButton(file: fileSource)
}
} else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file) {
} else if let file = ci.file, case .rcvInvitation = file.fileStatus, fileSizeValid(file, ciSenderProfile(ci, chat.chatInfo)) {
downloadButton(file: file)
}
if ci.meta.editable && !mc.isVoice && !live {
@@ -23,6 +23,7 @@ struct ComposeFileView: View {
.foregroundColor(Color(uiColor: .tertiaryLabel))
.padding(.leading, 4)
Text(fileName)
.lineLimit(1)
Spacer()
if cancelEnabled {
Button { cancelFile() } label: {
@@ -1247,7 +1247,9 @@ struct ComposeView: View {
}
private var maxFileSize: Int64 {
getMaxFileSize(.xftp)
// the user's active badge raises the limit, but not in incognito chats where no badge is presented
let incognito = chat.chatInfo.profileChangeProhibited ? chat.chatInfo.incognito : incognitoDefault
return getMaxFileSize(.xftp, incognito ? nil : chatModel.currentUser?.profile)
}
// Spec: spec/client/compose.md#sendLiveMessage
@@ -163,10 +163,13 @@ struct ContextProfilePickerView: View {
} label: {
HStack {
ProfileImage(imageStr: user.image, size: 38)
Text(user.chatViewName)
.fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
NameWithBadge(
Text(user.chatViewName)
.fontWeight(selectedUser == user && !incognitoDefault ? .medium : .regular)
.foregroundColor(theme.colors.onBackground),
user.profile.localBadge
)
.lineLimit(1)
Spacer()
@@ -183,7 +183,7 @@ struct AddGroupMembersViewCommon: View {
private func rolePicker() -> some View {
Picker("New member role", selection: $selectedRole) {
ForEach(GroupMemberRole.supportedRoles.filter({ $0 <= groupInfo.membership.memberRole })) { role in
Text(role.text)
Text(role.text(isChannel: groupInfo.isChannel))
}
}
.frame(height: 36)
@@ -220,9 +220,12 @@ struct AddGroupMembersViewCommon: View {
HStack{
ProfileImage(imageStr: contact.image, size: 30)
.padding(.trailing, 2)
Text(ChatInfo.direct(contact: contact).chatViewName)
.foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground)
.lineLimit(1)
NameWithBadge(
Text(ChatInfo.direct(contact: contact).chatViewName)
.foregroundColor(prohibitedToInviteIncognito ? theme.colors.secondary : theme.colors.onBackground),
contact.active ? contact.profile.localBadge : nil
)
.lineLimit(1)
Spacer()
Image(systemName: icon)
.foregroundColor(iconColor)
@@ -56,7 +56,7 @@ struct ChannelMembersView: View {
MemberProfileImage(member, size: 38)
.padding(.trailing, 2)
VStack(alignment: .leading) {
displayName
NameWithBadge(displayName, member.nameBadge)
.lineLimit(1)
if user {
Text("you")
@@ -66,7 +66,7 @@ struct ChannelMembersView: View {
}
Spacer()
if showRole {
Text(member.memberRole.text)
Text(member.memberRole.text(isChannel: groupInfo.isChannel))
.foregroundColor(theme.colors.secondary)
}
}
@@ -24,26 +24,24 @@ struct ChannelRelaysView: View {
var body: some View {
List {
relaysList()
// TODO [relays] re-enable when relay management ships
// if groupInfo.isOwner {
// Section {
// Button {
// showAddRelay = true
// } label: {
// Label("Add relay", systemImage: "plus")
// }
// }
// }
if groupInfo.isOwner {
Section {
Button {
showAddRelay = true
} label: {
Label("Add relay", systemImage: "plus")
}
}
}
}
.sheet(isPresented: $showAddRelay) {
// Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays
// regardless of relayStatus, so all current rows must be excluded from the add list.
let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId })
AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
Task { await chatModel.loadGroupMembers(groupInfo) }
}
}
// TODO [relays] re-enable when relay management ships
// .sheet(isPresented: $showAddRelay) {
// // Backend gate (APIAddGroupRelays) rejects any chatRelayId already in group_relays
// // regardless of relayStatus, so all current rows must be excluded from the add list.
// let existingRelayIds = Set(groupRelays.compactMap { $0.userChatRelay.chatRelayId })
// AddGroupRelayView(groupInfo: groupInfo, existingRelayIds: existingRelayIds) {
// Task { await chatModel.loadGroupMembers(groupInfo) }
// }
// }
.onAppear {
Task {
await chatModel.loadGroupMembers(groupInfo)
@@ -82,20 +80,18 @@ struct ChannelRelaysView: View {
: subscriberRelayStatusText(member.wrapped)
relayMemberRow(member.wrapped, statusText: statusText)
}
// TODO [relays] re-enable when relay management ships
// if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
// link.swipeActions(edge: .trailing) {
// Button {
// showRemoveMemberAlert(groupInfo, member.wrapped)
// } label: {
// Label("Remove relay", systemImage: "trash")
// }
// .tint(.red)
// }
// } else {
// link
// }
link
if groupInfo.isOwner && member.wrapped.canBeRemoved(groupInfo: groupInfo) {
link.swipeActions(edge: .trailing) {
Button {
showRemoveMemberAlert(groupInfo, member.wrapped)
} label: {
Label("Remove relay", systemImage: "trash")
}
.tint(.red)
}
} else {
link
}
}
} footer: {
Text("Chat relays forward messages to channel subscribers.")
@@ -0,0 +1,169 @@
//
// ChannelWebAccessView.swift
// SimpleX (iOS)
//
// Created by simplex.chat on 31/05/2026.
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
struct ChannelWebAccessView: View {
@EnvironmentObject var theme: AppTheme
@Environment(\.dismiss) var dismiss: DismissAction
@Binding var groupInfo: GroupInfo
@State private var webPage: String
@State private var allowEmbedding: Bool
@State private var saving = false
@State private var groupRelays: [GroupRelay] = []
init(groupInfo: Binding<GroupInfo>) {
_groupInfo = groupInfo
let access = groupInfo.wrappedValue.groupProfile.publicGroup?.publicGroupAccess
_webPage = State(initialValue: access?.groupWebPage ?? "")
_allowEmbedding = State(initialValue: access?.allowEmbedding ?? false)
}
var body: some View {
List {
if let code = embedCode {
webpageInfo("Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.")
Section {
ScrollView {
Text(code)
.font(.system(.caption, design: .monospaced))
.textSelection(.enabled)
}
.frame(maxHeight: 88)
Button {
UIPasteboard.general.string = code
} label: {
Label("Copy code", systemImage: "doc.on.doc")
}
} header: {
Text("Webpage code")
} footer: {
Text("Add this code to your webpage. It will display the preview of your channel / group.")
}
} else {
webpageInfo("Used chat relays do not support webpages.")
}
Section {
TextField("https://", text: $webPage)
.keyboardType(.URL)
.autocapitalization(.none)
.disableAutocorrection(true)
} header: {
Text("Enter webpage URL")
} footer: {
Text("It will be shown to subscribers and used to allow loading the preview.")
}
Section {
Toggle("Allow anyone to embed", isOn: $allowEmbedding)
} footer: {
Text(allowEmbedding ? "Any webpage can show the preview." : "Only your page above can show the preview.")
}
Section {
Button {
saveAccess()
} label: {
HStack {
Text(groupInfo.isChannel ? "Save and notify subscribers" : "Save and notify members")
if saving { Spacer(); ProgressView() }
}
}
.disabled(!hasChanges || saving)
}
}
.modifier(ThemedBackground(grouped: true))
.onAppear {
Task {
let relays = await apiGetGroupRelays(groupInfo.groupId)
await MainActor.run { groupRelays = relays }
}
}
.onDisappear {
if hasChanges {
showAlert(
title: NSLocalizedString("Save webpage settings?", comment: "alert title"),
message: NSLocalizedString("Webpage settings were changed. If you save, the updated settings will be sent to subscribers.", comment: "alert message"),
buttonTitle: NSLocalizedString("Save", comment: "alert button"),
buttonAction: saveAccess,
cancelButton: true
)
}
}
}
private func webpageInfo(_ text: LocalizedStringKey) -> some View {
Section {
Text(text).foregroundColor(theme.colors.secondary)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 0, trailing: 16))
}
private var hasChanges: Bool {
let access = groupInfo.groupProfile.publicGroup?.publicGroupAccess
let currentWebPage = access?.groupWebPage ?? ""
let currentEmbedding = access?.allowEmbedding ?? false
return webPage != currentWebPage || allowEmbedding != currentEmbedding
}
private var relayDomains: [String] {
groupRelays.compactMap { $0.relayCap.webDomain }
}
private var embedCode: String? {
if let pg = groupInfo.groupProfile.publicGroup,
!relayDomains.isEmpty {
"""
<div data-simplex-channel-preview
data-channel-link="\(pg.groupLink)"
data-channel-id="\(pg.publicGroupId)"
data-relay-domains="\(relayDomains.joined(separator: ","))"
data-app-download-buttons="on"
data-color-scheme="light"
></div>
<script src="https://simplex.chat/js/channel-preview.js"></script>
"""
} else {
nil
}
}
private func saveAccess() {
saving = true
Task {
do {
var gp = groupInfo.groupProfile
if var pg = gp.publicGroup {
let trimmedPage = webPage.trimmingCharacters(in: .whitespacesAndNewlines)
let existingAccess = pg.publicGroupAccess
pg.publicGroupAccess = PublicGroupAccess(
groupWebPage: trimmedPage.isEmpty ? nil : trimmedPage,
groupDomain: existingAccess?.groupDomain,
domainWebPage: existingAccess?.domainWebPage ?? false,
allowEmbedding: allowEmbedding
)
gp.publicGroup = pg
}
let gInfo = try await apiUpdateGroup(groupInfo.groupId, gp)
await MainActor.run {
groupInfo = gInfo
ChatModel.shared.updateGroup(gInfo)
saving = false
}
} catch {
logger.error("ChannelWebAccessView apiUpdateGroup error: \(responseError(error))")
await MainActor.run { saving = false }
}
}
}
}
@@ -244,6 +244,12 @@ struct GroupChatInfoView: View {
}
}
if groupInfo.useRelays && groupInfo.isOwner {
Section(header: Text("Advanced options").foregroundColor(theme.colors.secondary)) {
channelWebAccessButton()
}
}
if developerTools {
Section(header: Text("For console").foregroundColor(theme.colors.secondary)) {
infoRow("Local name", chat.chatInfo.localDisplayName)
@@ -502,7 +508,7 @@ struct GroupChatInfoView: View {
// TODO server connection status
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(member.memberIncognito ? .indigo : theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge)
.lineLimit(1)
(user ? Text ("you: ") + Text(member.memberStatus.shortText) : Text(memberConnStatus(member)))
.lineLimit(1)
@@ -575,7 +581,7 @@ struct GroupChatInfoView: View {
} else {
let role = member.memberRole
if [.owner, .admin, .moderator, .observer].contains(role) {
Text(member.memberRole.text)
Text(member.memberRole.text(isChannel: groupInfo.isChannel))
.foregroundColor(theme.colors.secondary)
}
}
@@ -657,6 +663,17 @@ struct GroupChatInfoView: View {
}
}
private func channelWebAccessButton() -> some View {
let title: LocalizedStringKey = groupInfo.isChannel ? "Channel webpage" : "Group webpage"
return NavigationLink {
ChannelWebAccessView(groupInfo: $groupInfo)
.navigationBarTitle(title)
.navigationBarTitleDisplayMode(.large)
} label: {
Label(title, systemImage: "globe")
}
}
private func groupLinkDestinationView() -> some View {
GroupLinkView(
groupId: groupInfo.groupId,
@@ -84,7 +84,7 @@ struct GroupLinkView: View {
if !isChannel {
Picker("Initial role", selection: $groupLinkMemberRole) {
ForEach([GroupMemberRole.member, GroupMemberRole.observer]) { role in
Text(role.text)
Text(role.text(isChannel: isChannel))
}
}
.frame(height: 36)
@@ -178,15 +178,15 @@ struct GroupMemberInfoView: View {
let label: LocalizedStringKey = groupInfo.useRelays ? "Channel" : groupInfo.businessChat == nil ? "Group" : "Chat"
infoRow(label, groupInfo.displayName)
if !groupInfo.useRelays, let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
if let roles = member.canChangeRoleTo(groupInfo: groupInfo) {
Picker("Change role", selection: $newRole) {
ForEach(roles) { role in
Text(role.text)
Text(role.text(isChannel: groupInfo.isChannel))
}
}
.frame(height: 36)
} else {
infoRow("Role", member.memberRole.text)
infoRow("Role", member.memberRole.text(isChannel: groupInfo.isChannel))
}
if let link = member.relayLink {
infoRow("Relay link", String.localizedStringWithFormat(NSLocalizedString("via %@", comment: "relay hostname"), hostFromRelayLink(link)))
@@ -522,25 +522,14 @@ struct GroupMemberInfoView: View {
// show alias if set, alias cannot be edited in this view
let displayName = mem.displayName.trimmingCharacters(in: .whitespacesAndNewlines)
let fullName = mem.fullName.trimmingCharacters(in: .whitespacesAndNewlines)
if mem.verified {
(
Text(Image(systemName: "checkmark.shield"))
.foregroundColor(theme.colors.secondary)
.font(.title2)
+ textSpace
+ Text(displayName)
.font(.largeTitle)
)
let badge = mem.nameBadge
let nameText = mem.verified
? Text(Image(systemName: "checkmark.shield")).foregroundColor(theme.colors.secondary).font(.title2) + textSpace + Text(displayName).font(.largeTitle)
: Text(displayName).font(.largeTitle)
NameWithBadge(nameText, badge, .largeTitle) { if let badge { showBadgeInfoAlert(displayName, badge) } }
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
} else {
Text(displayName)
.font(.largeTitle)
.multilineTextAlignment(.center)
.lineLimit(2)
.padding(.bottom, 2)
}
if fullName != "" && fullName != displayName && fullName != mem.memberProfile.displayName.trimmingCharacters(in: .whitespacesAndNewlines) {
Text(mem.fullName)
.font(.title2)
@@ -644,8 +633,7 @@ struct GroupMemberInfoView: View {
blockForAllButton(mem)
}
}
// TODO [relays] re-enable when relay management ships
if canRemove && mem.memberRole != .relay {
if canRemove {
if mem.memberStatus != .memRemoved && (mem.memberStatus != .memLeft || mem.memberRole == .relay) {
removeMemberButton(mem)
} else if mem.memberRole != .relay {
@@ -739,15 +727,17 @@ struct GroupMemberInfoView: View {
private func changeMemberRoleAlert(_ mem: GroupMember) -> Alert {
Alert(
title: Text("Change member role?"),
title: Text("Change role?"),
message: (
mem.memberCurrent
? (
groupInfo.businessChat == nil
? Text("Member role will be changed to \"\(newRole.text)\". All group members will be notified.")
: Text("Member role will be changed to \"\(newRole.text)\". All chat members will be notified.")
groupInfo.isChannel
? Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All subscribers will be notified.")
: groupInfo.businessChat == nil
? Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All group members will be notified.")
: Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". All chat members will be notified.")
)
: Text("Member role will be changed to \"\(newRole.text)\". The member will receive a new invitation.")
: Text("Role will be changed to \"\(newRole.text(isChannel: groupInfo.isChannel))\". The member will receive a new invitation.")
),
primaryButton: .default(Text("Change")) {
Task {
@@ -20,7 +20,7 @@ struct MemberSupportChatToolbar: View {
MemberProfileImage(groupMember, size: imageSize)
.padding(.trailing, 4)
let t = Text(groupMember.chatViewName).font(.headline)
(groupMember.verified ? memberVerifiedShield + t : t)
NameWithBadge((groupMember.verified ? memberVerifiedShield + t : t), groupMember.nameBadge, .headline)
.lineLimit(1)
}
.foregroundColor(theme.colors.onBackground)
@@ -172,7 +172,7 @@ struct MemberSupportView: View {
.padding(.trailing, 2)
VStack(alignment: .leading) {
let t = Text(member.chatViewName).foregroundColor(theme.colors.onBackground)
(member.verified ? memberVerifiedShield + t : t)
NameWithBadge((member.verified ? memberVerifiedShield + t : t), member.nameBadge)
.lineLimit(1)
Text(memberStatus(member))
.lineLimit(1)
@@ -205,7 +205,7 @@ struct MemberSupportView: View {
} else if member.memberPending {
return member.memberStatus.text
} else {
return LocalizedStringKey(member.memberRole.text)
return LocalizedStringKey(member.memberRole.text(isChannel: groupInfo.isChannel))
}
}
@@ -26,7 +26,9 @@ struct ChatHelp: View {
Button("connect to SimpleX Chat developers.") {
dismissSettingsSheet()
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
// simplexTeamURL targets this same app; route to the in-app connect flow
// (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
ChatModel.shared.appOpenUrl = simplexTeamURL
}
}
.padding(.top, 2)
@@ -173,7 +173,9 @@ struct ChatPreviewView: View {
: !contact.sndReady
? theme.colors.secondary
: nil
previewTitle(contact.verified == true ? verifiedIcon + t : t).foregroundColor(color)
NameWithBadge((contact.verified == true ? verifiedIcon + t : t).foregroundColor(color), chat.chatInfo.nameBadge, .title3)
.lineLimit(1)
.frame(alignment: .topLeading)
case let .group(groupInfo, _):
let color = if deleting {
theme.colors.secondary
@@ -424,11 +426,11 @@ struct ChatPreviewView: View {
}
case let .image(_, image):
smallContentPreview(size: dynamicMediaSize) {
CIImageView(chatItem: ci, preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
CIImageView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), maxWidth: dynamicMediaSize, smallView: true, showFullScreenImage: $showFullscreenGallery)
}
case let .video(_,image, duration):
smallContentPreview(size: dynamicMediaSize) {
CIVideoView(chatItem: ci, preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
CIVideoView(chatItem: ci, senderProfile: ciSenderProfile(ci, chat.chatInfo), preview: imageFromBase64(image), duration: duration, maxWidth: dynamicMediaSize, videoWidth: nil, smallView: true, showFullscreenPlayer: $showFullscreenGallery)
}
case let .voice(_, duration):
smallContentPreviewVoice(size: dynamicMediaSize) {
@@ -436,7 +438,7 @@ struct ChatPreviewView: View {
}
case .file:
smallContentPreviewFile(size: dynamicMediaSize) {
CIFileView(file: ci.file, edited: ci.meta.itemEdited, smallViewSize: dynamicMediaSize)
CIFileView(file: ci.file, edited: ci.meta.itemEdited, senderProfile: ciSenderProfile(ci, chat.chatInfo), smallViewSize: dynamicMediaSize)
}
case let .chat(_, chatLink, ownerSig):
smallContentPreview(size: dynamicMediaSize, borderColor: chatLink.image != nil ? .secondary : .clear) {
@@ -22,12 +22,16 @@ struct ContactRequestView: View {
.padding(.leading, 4)
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
Text(contactRequest.chatViewName)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(theme.colors.primary)
.padding(.leading, 8)
.frame(alignment: .topLeading)
NameWithBadge(
Text(contactRequest.chatViewName)
.font(.title3)
.fontWeight(.bold)
.foregroundColor(theme.colors.primary),
chat.chatInfo.nameBadge,
.title3
)
.padding(.leading, 8)
.frame(alignment: .topLeading)
Spacer()
formatTimestampText(contactRequest.updatedAt)
.font(.subheadline)
@@ -129,7 +129,8 @@ struct UserPicker: View {
}
}
.padding(.trailing, 6)
Text(u.user.displayName).font(.title2).lineLimit(1)
NameWithBadge(Text(u.user.displayName).font(.title2), u.user.profile.localBadge, .title2)
.lineLimit(1)
}
.padding(rowPadding)
.modifier(ListRow {
@@ -200,10 +200,9 @@ struct ContactListNavLink: View {
private func previewTitle(_ contact: Contact, titleColor: Color) -> some View {
let t = Text(chat.chatInfo.chatViewName).foregroundColor(titleColor)
return (
contact.verified == true
? verifiedIcon + t
: t
return NameWithBadge(
contact.verified == true ? verifiedIcon + t : t,
chat.chatInfo.nameBadge
)
.lineLimit(1)
}
@@ -318,8 +317,7 @@ struct ContactListNavLink: View {
HStack{
ProfileImage(imageStr: chat.chatInfo.image, size: 30)
Text(chat.chatInfo.chatViewName)
.foregroundColor(color)
NameWithBadge(Text(chat.chatInfo.chatViewName).foregroundColor(color), chat.chatInfo.nameBadge)
.lineLimit(1)
Spacer()
@@ -0,0 +1,174 @@
//
// NameBadge.swift
// SimpleX
//
// Copyright © 2026 SimpleX Chat. All rights reserved.
//
import SwiftUI
import SimpleXChat
// The badge is sized to a fraction of the font size (em), NOT the font's cap-height metric: the metric
// underestimates the rendered capital letters, so a cap-height-tall badge looks too small. These ratios
// are calibrated visually to match caps - the same constants as the Compose (Android/desktop) app.
private let fontCapHeightRatio: CGFloat = 0.85
// fraction of the badge height pushed below the text baseline (like the undershoot of round letters)
private let badgeBaselineOffsetRatio: CGFloat = 0.05
// A contact/member name with the supporter badge right after it. The name keeps its own styling
// (font, weight, color, even a verification shield concatenated into the Text); the badge is sized to
// the given text style and sits on the name's baseline. Use this everywhere a name may carry a badge.
// Pass onTap to make the badge open the info alert. The badge hides itself for a nil/long-expired badge.
struct NameWithBadge: View {
let name: Text
var badge: LocalBadge?
var textStyle: UIFont.TextStyle = .body
var onTap: (() -> Void)? = nil
init(_ name: Text, _ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) {
self.name = name
self.badge = badge
self.textStyle = textStyle
self.onTap = onTap
}
var body: some View {
HStack(alignment: .firstTextBaseline, spacing: 0) {
name
NameBadge(badge, textStyle, onTap: onTap)
}
}
}
// The badge glyph alone, sized to the given text style and sitting on the text baseline in an
// HStack(alignment: .firstTextBaseline). Renders nothing for a nil badge or a long-expired one
// (ExpiredOld); a failed or unknown-key badge shows a warning glyph. Prefer NameWithBadge; use this
// directly only where the name is not a single Text. Pass onTap to open the badge info alert.
struct NameBadge: View {
var badge: LocalBadge?
var textStyle: UIFont.TextStyle = .body
var onTap: (() -> Void)? = nil
init(_ badge: LocalBadge?, _ textStyle: UIFont.TextStyle = .body, onTap: (() -> Void)? = nil) {
self.badge = badge
self.textStyle = textStyle
self.onTap = onTap
}
var body: some View {
if let badge, badge.status != .expiredOld {
// the leading padding is the gap to the name; it lives here so an absent badge adds no gap.
// the alignment guide pushes the badge bottom slightly below the baseline (round-letter undershoot)
let v = glyph(badge)
.frame(height: badgeHeight)
.alignmentGuide(.firstTextBaseline) { $0.height * (1 - badgeBaselineOffsetRatio) }
.padding(.leading, badgeGap)
if let onTap {
v.onTapGesture(perform: onTap)
} else {
v
}
}
}
private var badgeHeight: CGFloat {
UIFont.preferredFont(forTextStyle: textStyle).pointSize * fontCapHeightRatio
}
// the gap to the name, matching the verification shield's gap (textSpace - one space in the name's font)
private var badgeGap: CGFloat {
let font = UIFont.preferredFont(forTextStyle: textStyle)
return (" " as NSString).size(withAttributes: [.font: font]).width
}
@ViewBuilder private func glyph(_ badge: LocalBadge) -> some View {
switch badge.status {
case .failed, .unknownKey:
Image(systemName: "exclamationmark.triangle.fill")
.resizable().scaledToFit()
.foregroundColor(.orange)
default:
Image(badgeImageName(badge.badge.badgeType))
.resizable().scaledToFit()
.opacity(badge.status == .expired ? 0.4 : 1)
}
}
}
private func badgeImageName(_ t: BadgeType) -> String {
switch t {
case .legend: "badge-legend"
case .investor: "badge-investor"
default: "badge-supporter" // supporter + unknown
}
}
// The badge as an inline attachment for a UIKit label, for the custom alert where the name is a UILabel
// and the SwiftUI NameBadge can't be used. Sized to the font's cap height with its bottom on the baseline,
// preceded by a space for the gap to the name. Returns nil for a nil/long-expired badge. Mirrors NameBadge's glyph.
func nameBadgeAttachment(_ badge: LocalBadge?, font: UIFont) -> NSAttributedString? {
guard let badge, badge.status != .expiredOld else { return nil }
var image: UIImage?
switch badge.status {
case .failed, .unknownKey:
image = UIImage(systemName: "exclamationmark.triangle.fill")?
.withTintColor(.systemOrange, renderingMode: .alwaysOriginal)
default:
image = UIImage(named: badgeImageName(badge.badge.badgeType))
if badge.status == .expired, let img = image {
// a recently expired badge is dimmed, matching NameBadge's 0.4 opacity
image = UIGraphicsImageRenderer(size: img.size).image { _ in
img.draw(at: .zero, blendMode: .normal, alpha: 0.4)
}
}
}
guard let image else { return nil }
let attachment = NSTextAttachment()
attachment.image = image
let h = font.pointSize * fontCapHeightRatio
// text coordinates: a negative y drops the image below the baseline by badgeBaselineOffsetRatio of its height
attachment.bounds = CGRect(x: 0, y: -h * badgeBaselineOffsetRatio, width: h * image.size.width / image.size.height, height: h)
let s = NSMutableAttributedString(string: " ") // the gap to the name
s.append(NSAttributedString(attachment: attachment))
return s
}
func showBadgeInfoAlert(_ name: String, _ badge: LocalBadge) {
switch badge.status {
case .failed:
showAlert(
NSLocalizedString("Unverified badge", comment: "badge alert title"),
message: NSLocalizedString("This badge could not be verified and may not be genuine.", comment: "badge alert")
)
case .unknownKey:
showAlert(
NSLocalizedString("Badge cannot be verified", comment: "badge alert title"),
message: NSLocalizedString("The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.", comment: "badge alert")
)
default:
// a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title
let t = badge.badge.badgeType.text
let title = t.prefix(1).uppercased() + t.dropFirst()
if case .investor = badge.badge.badgeType {
let message = String.localizedStringWithFormat(NSLocalizedString("%@ invested in SimpleX Chat crowdfunding.", comment: "badge alert"), name)
showAlert(title, message: message) {
[ UIAlertAction(title: NSLocalizedString("Learn more", comment: "badge alert button"), style: .default) { _ in
if let url = URL(string: "https://simplex.chat/crowdfunding") {
UIApplication.shared.open(url)
}
},
okAlertAction ]
}
} else {
// supporter, legend and unknown types use the supporter wording
let supports =
if badge.status == .expired, let expiry = badge.badge.badgeExpiry {
String.localizedStringWithFormat(NSLocalizedString("%1$@ supported SimpleX Chat. The badge expired on %2$@.", comment: "badge alert"), name, expiry.formatted(date: .abbreviated, time: .omitted))
} else {
String.localizedStringWithFormat(NSLocalizedString("%@ supports SimpleX Chat.", comment: "badge alert"), name)
}
let v7 = NSLocalizedString("You can support SimpleX starting from v7 of the app.", comment: "badge alert")
showAlert(title, message: supports + "\n\n" + v7)
}
}
}
+13 -1
View File
@@ -7,6 +7,7 @@
//
import SwiftUI
import SimpleXChat
func getTopViewController() -> UIViewController? {
let keyWindowScene = UIApplication.shared.connectedScenes.first { $0.activationState == .foregroundActive } as? UIWindowScene
@@ -134,6 +135,7 @@ class OpenChatAlertViewController: UIViewController {
private let profileName: String
private let profileFullName: String
private let profileImage: UIView
private let profileBadge: LocalBadge?
private let subtitle: String?
private let information: String?
private let cancelTitle: String
@@ -145,6 +147,7 @@ class OpenChatAlertViewController: UIViewController {
profileName: String,
profileFullName: String,
profileImage: UIView,
profileBadge: LocalBadge? = nil,
subtitle: String? = nil,
information: String? = nil,
cancelTitle: String = "Cancel",
@@ -155,6 +158,7 @@ class OpenChatAlertViewController: UIViewController {
self.profileName = profileName
self.profileFullName = profileFullName
self.profileImage = profileImage
self.profileBadge = profileBadge
self.subtitle = subtitle
self.information = information
self.cancelTitle = cancelTitle
@@ -190,12 +194,18 @@ class OpenChatAlertViewController: UIViewController {
// Name label
let nameLabel = UILabel()
nameLabel.text = profileName
nameLabel.font = UIFont.preferredFont(forTextStyle: .headline)
nameLabel.textColor = .label
nameLabel.numberOfLines = 2
nameLabel.textAlignment = .center
nameLabel.translatesAutoresizingMaskIntoConstraints = false
if let badge = nameBadgeAttachment(profileBadge, font: nameLabel.font) {
let s = NSMutableAttributedString(string: profileName)
s.append(badge)
nameLabel.attributedText = s
} else {
nameLabel.text = profileName
}
var profileViews = [profileImage, nameLabel]
@@ -365,6 +375,7 @@ func showOpenChatAlert<Content: View>(
profileName: String,
profileFullName: String,
profileImage: Content,
profileBadge: LocalBadge? = nil,
theme: AppTheme,
subtitle: String? = nil,
information: String? = nil,
@@ -383,6 +394,7 @@ func showOpenChatAlert<Content: View>(
profileName: profileName,
profileFullName: profileFullName,
profileImage: hostedView,
profileBadge: profileBadge,
subtitle: subtitle,
information: information,
cancelTitle: cancelTitle,
@@ -560,9 +560,11 @@ private struct ActiveProfilePicker: View {
HStack {
ProfileImage(imageStr: user.image, size: 30)
.padding(.trailing, 2)
Text(user.chatViewName)
.foregroundColor(theme.colors.onBackground)
.lineLimit(1)
NameWithBadge(
Text(user.chatViewName).foregroundColor(theme.colors.onBackground),
user.profile.localBadge
)
.lineLimit(1)
Spacer()
if selectedProfile == user, !incognitoEnabled {
Image(systemName: "checkmark")
@@ -1160,6 +1162,7 @@ private func showPrepareContactAlert(
: "person.crop.circle.fill",
size: alertProfileImageSize
),
profileBadge: contactShortLinkData.localBadge,
theme: theme,
information: ownerVerificationMessage(ownerVerification),
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
@@ -1253,6 +1256,7 @@ private func showOpenKnownContactAlert(
iconName: contact.chatIconName,
size: alertProfileImageSize
),
profileBadge: contact.active ? contact.profile.localBadge : nil,
theme: theme,
cancelTitle: NSLocalizedString("Cancel", comment: "new chat action"),
confirmTitle:
@@ -420,7 +420,9 @@ struct SettingsView: View {
Button("Send questions and ideas") {
dismiss()
DispatchQueue.main.async {
UIApplication.shared.open(simplexTeamURL)
// simplexTeamURL targets this same app; route to the in-app connect flow
// (UIApplication.shared.open is dropped for self-owned URLs in the foreground)
ChatModel.shared.appOpenUrl = simplexTeamURL
}
}
}
@@ -535,12 +537,14 @@ func settingsRow<Content : View>(_ icon: String, color: Color/* = .secondary*/,
struct ProfilePreview: View {
var profileOf: NamedChat
var color = Color(uiColor: .tertiarySystemGroupedBackground)
var badge: LocalBadge? = nil
var body: some View {
HStack {
ProfileImage(imageStr: profileOf.image, size: 44, color: color)
.padding(.trailing, 6)
profileName(profileOf).lineLimit(1)
NameWithBadge(profileName(profileOf), badge, .title2)
.lineLimit(1)
}
}
}
+36 -28
View File
@@ -59,6 +59,7 @@
5C7505A227B65FDB00BE3227 /* CIMetaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A127B65FDB00BE3227 /* CIMetaView.swift */; };
5C7505A527B679EE00BE3227 /* NavLinkPlain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */; };
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */; };
CE11BADE0000000000000002 /* NameBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE11BADE0000000000000001 /* NameBadge.swift */; };
5C764E89279CBCB3000C6508 /* ChatModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C764E88279CBCB3000C6508 /* ChatModel.swift */; };
5C8F01CD27A6F0D8007D2C8D /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 5C8F01CC27A6F0D8007D2C8D /* CodeScanner */; };
5C93292F29239A170090FFF9 /* ProtocolServersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C93292E29239A170090FFF9 /* ProtocolServersView.swift */; };
@@ -169,6 +170,7 @@
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 648679AA2BC96A74006456E7 /* ChatItemForwardingView.swift */; };
6495D7042F48CFC50060512B /* ChannelMembersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7032F48CFC50060512B /* ChannelMembersView.swift */; };
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */; };
E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */; };
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6495D7072F48D0000060512B /* AddGroupRelayView.swift */; };
649BCDA0280460FD00C3A862 /* ComposeImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCD9F280460FD00C3A862 /* ComposeImageView.swift */; };
649BCDA22805D6EF00C3A862 /* CIImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 649BCDA12805D6EF00C3A862 /* CIImageView.swift */; };
@@ -183,8 +185,8 @@
64C3B0212A0D359700E19930 /* CustomTimePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64C3B0202A0D359700E19930 /* CustomTimePicker.swift */; };
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829982D54AEED006B9E89 /* libgmp.a */; };
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C829992D54AEEE006B9E89 /* libffi.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */; };
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a */; };
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a */; };
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 64C8299C2D54AEEE006B9E89 /* libgmpxx.a */; };
64D0C2C029F9688300B38D5F /* UserAddressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2BF29F9688300B38D5F /* UserAddressView.swift */; };
64D0C2C229FA57AB00B38D5F /* UserAddressLearnMore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */; };
@@ -413,6 +415,7 @@
5C7505A127B65FDB00BE3227 /* CIMetaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIMetaView.swift; sourceTree = "<group>"; };
5C7505A427B679EE00BE3227 /* NavLinkPlain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavLinkPlain.swift; sourceTree = "<group>"; };
5C7505A727B6D34800BE3227 /* ChatInfoToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatInfoToolbar.swift; sourceTree = "<group>"; };
CE11BADE0000000000000001 /* NameBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NameBadge.swift; sourceTree = "<group>"; };
5C764E88279CBCB3000C6508 /* ChatModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatModel.swift; sourceTree = "<group>"; };
5C84FE9129A216C800D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/Localizable.strings; sourceTree = "<group>"; };
5C84FE9329A2179C00D95B1A /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = "nl.lproj/SimpleX--iOS--InfoPlist.strings"; sourceTree = "<group>"; };
@@ -547,6 +550,7 @@
6493D667280ED77F007A76FB /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
6495D7032F48CFC50060512B /* ChannelMembersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelMembersView.swift; sourceTree = "<group>"; };
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelRelaysView.swift; sourceTree = "<group>"; };
E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelWebAccessView.swift; sourceTree = "<group>"; };
6495D7072F48D0000060512B /* AddGroupRelayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGroupRelayView.swift; sourceTree = "<group>"; };
649BCD9F280460FD00C3A862 /* ComposeImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposeImageView.swift; sourceTree = "<group>"; };
649BCDA12805D6EF00C3A862 /* CIImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CIImageView.swift; sourceTree = "<group>"; };
@@ -561,8 +565,8 @@
64C3B0202A0D359700E19930 /* CustomTimePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomTimePicker.swift; sourceTree = "<group>"; };
64C829982D54AEED006B9E89 /* libgmp.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmp.a; sourceTree = "<group>"; };
64C829992D54AEEE006B9E89 /* libffi.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libffi.a; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a"; sourceTree = "<group>"; };
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a"; sourceTree = "<group>"; };
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = "libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a"; sourceTree = "<group>"; };
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libgmpxx.a; sourceTree = "<group>"; };
64D0C2BF29F9688300B38D5F /* UserAddressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressView.swift; sourceTree = "<group>"; };
64D0C2C129FA57AB00B38D5F /* UserAddressLearnMore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserAddressLearnMore.swift; sourceTree = "<group>"; };
@@ -731,8 +735,8 @@
64C8299D2D54AEEE006B9E89 /* libgmp.a in Frameworks */,
64C8299E2D54AEEE006B9E89 /* libffi.a in Frameworks */,
64C829A12D54AEEE006B9E89 /* libgmpxx.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a in Frameworks */,
64C8299F2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a in Frameworks */,
64C829A02D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a in Frameworks */,
CE38A29C2C3FCD72005ED185 /* SwiftyGif in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -818,8 +822,8 @@
64C829992D54AEEE006B9E89 /* libffi.a */,
64C829982D54AEED006B9E89 /* libgmp.a */,
64C8299C2D54AEEE006B9E89 /* libgmpxx.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-6.5.4.1-4Ybrr1jdwOoLdJnIO0aOG7.a */,
64C8299A2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3-ghc9.6.3.a */,
64C8299B2D54AEEE006B9E89 /* libHSsimplex-chat-7.0.0.1-7ImjE3hKkx0K479AGK5BI3.a */,
);
path = Libraries;
sourceTree = "<group>";
@@ -880,6 +884,7 @@
CEDB245A2C9CD71800FBC5F6 /* StickyScrollView.swift */,
CEFB2EDE2CA1BCC7004B1ECE /* SheetRepresentable.swift */,
CEA6E91B2CBD21B0002B5DB4 /* UserDefault.swift */,
CE11BADE0000000000000001 /* NameBadge.swift */,
);
path = Helpers;
sourceTree = "<group>";
@@ -1175,6 +1180,7 @@
64A779FD2DC3AFF200FDEF2F /* MemberSupportChatToolbar.swift */,
6495D7032F48CFC50060512B /* ChannelMembersView.swift */,
6495D7052F48CFFD0060512B /* ChannelRelaysView.swift */,
E5E418032F83D2CA00252B9E /* ChannelWebAccessView.swift */,
6495D7072F48D0000060512B /* AddGroupRelayView.swift */,
);
path = Group;
@@ -1559,6 +1565,7 @@
648679AB2BC96A74006456E7 /* ChatItemForwardingView.swift in Sources */,
3CDBCF4827FF621E00354CDD /* CILinkView.swift in Sources */,
5C7505A827B6D34800BE3227 /* ChatInfoToolbar.swift in Sources */,
CE11BADE0000000000000002 /* NameBadge.swift in Sources */,
B76E6C312C5C41D900EC11AA /* ContactListNavLink.swift in Sources */,
5C10D88A28F187F300E58BF0 /* FullScreenMediaView.swift in Sources */,
D72A9088294BD7A70047C86D /* NativeTextEditor.swift in Sources */,
@@ -1636,6 +1643,7 @@
8C9BC2652C240D5200875A27 /* ThemeModeEditor.swift in Sources */,
647B15E82F4C8D2500EB431E /* AddChannelView.swift in Sources */,
6495D7062F48CFFD0060512B /* ChannelRelaysView.swift in Sources */,
E5E418022F83D2CA00252B9E /* ChannelWebAccessView.swift in Sources */,
6495D7082F48D0000060512B /* AddGroupRelayView.swift in Sources */,
5CB346E92869E8BA001FD2EF /* PushEnvironment.swift in Sources */,
5C55A91F283AD0E400C4E99E /* CallManager.swift in Sources */,
@@ -2073,7 +2081,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2098,7 +2106,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES_THIN;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
@@ -2123,7 +2131,7 @@
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_ENTITLEMENTS = "SimpleX (iOS).entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
@@ -2148,7 +2156,7 @@
"@executable_path/Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
OTHER_LDFLAGS = "-Wl,-stack_size,0x1000000";
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.app;
PRODUCT_NAME = SimpleX;
@@ -2165,11 +2173,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2185,11 +2193,11 @@
buildSettings = {
ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.Tests-iOS";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2210,7 +2218,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
GCC_OPTIMIZATION_LEVEL = s;
@@ -2225,7 +2233,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2247,7 +2255,7 @@
CODE_SIGN_ENTITLEMENTS = "SimpleX NSE/SimpleX NSE.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_BITCODE = NO;
ENABLE_CODE_COVERAGE = NO;
@@ -2262,7 +2270,7 @@
"@executable_path/../../Frameworks",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-NSE";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@@ -2284,7 +2292,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2310,7 +2318,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2335,7 +2343,7 @@
CLANG_TIDY_BUGPRONE_REDUNDANT_BRANCH_CONDITION = YES;
CLANG_TIDY_MISC_REDUNDANT_EXPRESSION = YES;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEFINES_MODULE = YES;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
DYLIB_COMPATIBILITY_VERSION = 1;
@@ -2362,7 +2370,7 @@
"$(PROJECT_DIR)/Libraries/sim",
);
LLVM_LTO = YES;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = chat.simplex.SimpleXChat;
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
SDKROOT = iphoneos;
@@ -2389,7 +2397,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2404,7 +2412,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -2423,7 +2431,7 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CODE_SIGN_ENTITLEMENTS = "SimpleX SE/SimpleX SE.entitlements";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 334;
CURRENT_PROJECT_VERSION = 336;
DEVELOPMENT_TEAM = 5NN7GUYB6T;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
GCC_C_LANGUAGE_STANDARD = gnu17;
@@ -2438,7 +2446,7 @@
"@executable_path/../../Frameworks",
);
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
MARKETING_VERSION = 6.5.4;
MARKETING_VERSION = 7.0;
PRODUCT_BUNDLE_IDENTIFIER = "chat.simplex.app.SimpleX-SE";
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
@@ -1,66 +0,0 @@
{
"originHash" : "60aeecb7917535a5e44ade0dbb5411ab112a959283e565a04c212c8af4e7dee9",
"pins" : [
{
"identity" : "codescanner",
"kind" : "remoteSourceControl",
"location" : "https://github.com/twostraws/CodeScanner",
"state" : {
"revision" : "34da57fb63b47add20de8a85da58191523ccce57",
"version" : "2.5.0"
}
},
{
"identity" : "elegant-emoji-picker",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Finalet/Elegant-Emoji-Picker",
"state" : {
"branch" : "main",
"revision" : "71d2d46092b4d550cc593614efc06438f845f6e6"
}
},
{
"identity" : "ink",
"kind" : "remoteSourceControl",
"location" : "https://github.com/johnsundell/ink",
"state" : {
"revision" : "bcc9f219900a62c4210e6db726035d7f03ae757b",
"version" : "0.6.0"
}
},
{
"identity" : "lzstring-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/Ibrahimhass/lzstring-swift",
"state" : {
"revision" : "7f62f21de5b18582a950e1753b775cc614722407"
}
},
{
"identity" : "swiftygif",
"kind" : "remoteSourceControl",
"location" : "https://github.com/kirualex/SwiftyGif",
"state" : {
"revision" : "5e8619335d394901379c9add5c4c1c2f420b3800"
}
},
{
"identity" : "webrtc",
"kind" : "remoteSourceControl",
"location" : "https://github.com/simplex-chat/WebRTC.git",
"state" : {
"revision" : "34bedc50f9c58dccf4967ea59c7e6a47d620803b"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams",
"state" : {
"revision" : "9234124cff5e22e178988c18d8b95a8ae8007f76",
"version" : "5.1.2"
}
}
],
"version" : 3
}
+114 -10
View File
@@ -136,6 +136,8 @@ public struct Profile: Codable, NamedChat, Hashable {
public var contactLink: String?
public var preferences: Preferences?
public var peerType: ChatPeerType?
// the badge proof from the wire profile - opaque to the UI, round-tripped to the core (apiPrepareContact)
public var badge: BadgeProof?
public var localAlias: String { get { "" } }
var profileViewName: String {
@@ -158,6 +160,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable {
contactLink: String? = nil,
preferences: Preferences? = nil,
peerType: ChatPeerType? = nil,
localBadge: LocalBadge? = nil,
localAlias: String
) {
self.profileId = profileId
@@ -168,6 +171,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable {
self.contactLink = contactLink
self.preferences = preferences
self.peerType = peerType
self.localBadge = localBadge
self.localAlias = localAlias
}
@@ -179,6 +183,7 @@ public struct LocalProfile: Codable, NamedChat, Hashable {
public var contactLink: String?
public var preferences: Preferences?
public var peerType: ChatPeerType?
public var localBadge: LocalBadge?
public var localAlias: String
var profileViewName: String {
@@ -201,6 +206,70 @@ public enum ChatPeerType: String, Codable {
case bot
}
// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status.
// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter.
public enum BadgeType: Hashable {
case supporter
case legend
case investor
case unknown(String)
// the disclosed (signed) type name, shown to the user for verified badges
public var text: String {
switch self {
case .supporter: "supporter"
case .legend: "legend"
case .investor: "investor"
case let .unknown(s): s
}
}
}
extension BadgeType: Codable {
public init(from decoder: Decoder) throws {
switch try decoder.singleValueContainer().decode(String.self) {
case "supporter": self = .supporter
case "legend": self = .legend
case "investor": self = .investor
case let s: self = .unknown(s)
}
}
public func encode(to encoder: Encoder) throws {
var c = encoder.singleValueContainer()
try c.encode(text)
}
}
public enum BadgeStatus: String, Codable {
case active
case expired
// expired over a month ago - the badge is not shown at all
case expiredOld
case failed
// signed with a key index this app version does not know - shown as a warning
case unknownKey
}
public struct BadgeInfo: Codable, Hashable {
public var badgeType: BadgeType
public var badgeExpiry: Date?
public var badgeExtra: String
}
public struct LocalBadge: Codable, Hashable {
public var badge: BadgeInfo
public var status: BadgeStatus
}
// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact)
public struct BadgeProof: Codable, Hashable {
public var badgeKeyIdx: Int
public var presHeader: String
public var proof: String
public var badgeInfo: BadgeInfo
}
public func toLocalProfile (_ profileId: Int64, _ profile: Profile, _ localAlias: String) -> LocalProfile {
LocalProfile(
profileId: profileId,
@@ -1459,6 +1528,17 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable {
}
}
// the badge shown for a chat's name: an active contact's or a contact request's (groups have none)
public var nameBadge: LocalBadge? {
get {
switch self {
case let .direct(contact): return contact.active ? contact.profile.localBadge : nil
case let .contactRequest(contactRequest): return contactRequest.profile.localBadge
default: return nil
}
}
}
public var displayName: String {
get {
switch self {
@@ -2265,7 +2345,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable {
public var userContactLinkId_: Int64?
public var cReqChatVRange: VersionRange
var localDisplayName: ContactName
var profile: Profile
public var profile: LocalProfile
var createdAt: Date
public var updatedAt: Date
@@ -2283,7 +2363,7 @@ public struct UserContactRequest: Decodable, NamedChat, Hashable {
userContactLinkId_: 1,
cReqChatVRange: VersionRange(1, 1),
localDisplayName: "alice",
profile: Profile.sampleData,
profile: LocalProfile.sampleData,
createdAt: .now,
updatedAt: .now
)
@@ -2534,6 +2614,13 @@ public enum GroupType: Codable, Hashable {
}
public struct PublicGroupAccess: Codable, Hashable {
public init(groupWebPage: String? = nil, groupDomain: String? = nil, domainWebPage: Bool = false, allowEmbedding: Bool = false) {
self.groupWebPage = groupWebPage
self.groupDomain = groupDomain
self.domainWebPage = domainWebPage
self.allowEmbedding = allowEmbedding
}
public var groupWebPage: String?
public var groupDomain: String?
public var domainWebPage: Bool = false
@@ -2627,6 +2714,8 @@ public struct ContactShortLinkData: Codable, Hashable {
public var profile: Profile
public var message: MsgContent?
public var business: Bool
// set by the core when building the connection plan: the link profile's badge, verified and crypto-free
public var localBadge: LocalBadge?
}
public struct GroupSummary: Decodable, Hashable {
@@ -2652,6 +2741,7 @@ public enum RelayStatus: String, Decodable, Equatable, Hashable {
case new
case invited
case accepted
case acknowledgedRoster
case active
case inactive
case rejected
@@ -2727,6 +2817,7 @@ extension RelayStatus {
case .new: "new"
case .invited: "invited"
case .accepted: "accepted"
case .acknowledgedRoster: "acknowledged roster"
case .active: "active"
case .inactive: "inactive"
case .rejected: "rejected"
@@ -2783,6 +2874,7 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public var fullName: String { get { memberProfile.fullName } }
public var image: String? { get { memberProfile.image } }
public var contactLink: String? { get { memberProfile.contactLink } }
public var nameBadge: LocalBadge? { memberProfile.localBadge }
public var verified: Bool { activeConn?.connectionCode != nil }
public var blocked: Bool { blockedByAdmin || !memberSettings.showMessages }
@@ -2889,8 +2981,16 @@ public struct GroupMember: Identifiable, Decodable, Hashable {
public func canChangeRoleTo(groupInfo: GroupInfo) -> [GroupMemberRole]? {
if memberRole == .relay || !canBeRemoved(groupInfo: groupInfo) || memberStatus == .memRemoved || memberStatus == .memLeft || memberPending { return nil }
if groupInfo.useRelays && !groupInfo.isOwner { return nil }
let userRole = groupInfo.membership.memberRole
return GroupMemberRole.supportedRoles.filter { $0 <= userRole }
if groupInfo.useRelays {
// TODO [relays]: for now owners can only set observer/member in channels.
// Restore the full Owner-excluded picker when moderator/admin promotion is supported:
// return GroupMemberRole.supportedRoles.filter { $0 <= userRole && $0 != .owner }
return [.observer, .member]
} else {
return GroupMemberRole.supportedRoles.filter { $0 <= userRole }
}
}
public func canBlockForAll(groupInfo: GroupInfo) -> Bool {
@@ -2978,12 +3078,16 @@ public enum GroupMemberRole: String, Identifiable, CaseIterable, Comparable, Cod
public static var supportedRoles: [GroupMemberRole] = [.observer, .member, .moderator, .admin, .owner]
public var text: String {
public func text(isChannel: Bool) -> String {
switch self {
case .relay: return NSLocalizedString("relay", comment: "member role")
case .observer: return NSLocalizedString("observer", comment: "member role")
case .observer: return isChannel
? NSLocalizedString("subscriber", comment: "member role")
: NSLocalizedString("observer", comment: "member role")
case .author: return NSLocalizedString("author", comment: "member role")
case .member: return NSLocalizedString("member", comment: "member role")
case .member: return isChannel
? NSLocalizedString("contributor", comment: "member role")
: NSLocalizedString("member", comment: "member role")
case .moderator: return NSLocalizedString("moderator", comment: "member role")
case .admin: return NSLocalizedString("admin", comment: "member role")
case .owner: return NSLocalizedString("owner", comment: "member role")
@@ -5504,7 +5608,7 @@ public enum RcvGroupEvent: Decodable, Hashable {
case .userAccepted: return NSLocalizedString("accepted you", comment: "rcv group event chat item")
case .memberLeft: return NSLocalizedString("left", comment: "rcv group event chat item")
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text)
return String.localizedStringWithFormat(NSLocalizedString("changed role of %@ to %@", comment: "rcv group event chat item"), profile.profileViewName, role.text(isChannel: isChannel))
case let .memberBlocked(_, profile, blocked):
if blocked {
return String.localizedStringWithFormat(NSLocalizedString("blocked %@", comment: "rcv group event chat item"), profile.profileViewName)
@@ -5512,7 +5616,7 @@ public enum RcvGroupEvent: Decodable, Hashable {
return String.localizedStringWithFormat(NSLocalizedString("unblocked %@", comment: "rcv group event chat item"), profile.profileViewName)
}
case let .userRole(role):
return String.localizedStringWithFormat(NSLocalizedString("changed your role to %@", comment: "rcv group event chat item"), role.text)
return String.localizedStringWithFormat(NSLocalizedString("changed your role to %@", comment: "rcv group event chat item"), role.text(isChannel: isChannel))
case let .memberDeleted(_, profile):
return String.localizedStringWithFormat(NSLocalizedString("removed %@", comment: "rcv group event chat item"), profile.profileViewName)
case .userDeleted: return NSLocalizedString("removed you", comment: "rcv group event chat item")
@@ -5558,9 +5662,9 @@ public enum SndGroupEvent: Decodable, Hashable {
func text(isChannel: Bool) -> String {
switch self {
case let .memberRole(_, profile, role):
return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text)
return String.localizedStringWithFormat(NSLocalizedString("you changed role of %@ to %@", comment: "snd group event chat item"), profile.profileViewName, role.text(isChannel: isChannel))
case let .userRole(role):
return String.localizedStringWithFormat(NSLocalizedString("you changed role for yourself to %@", comment: "snd group event chat item"), role.text)
return String.localizedStringWithFormat(NSLocalizedString("you changed role for yourself to %@", comment: "snd group event chat item"), role.text(isChannel: isChannel))
case let .memberBlocked(_, profile, blocked):
if blocked {
return String.localizedStringWithFormat(NSLocalizedString("you blocked %@", comment: "snd group event chat item"), profile.profileViewName)
+23 -4
View File
@@ -29,6 +29,10 @@ public let MAX_VIDEO_SIZE_AUTO_RCV: Int64 = 1_047_552 // 1023KB
// Spec: spec/services/files.md#MAX_FILE_SIZE_XFTP
public let MAX_FILE_SIZE_XFTP: Int64 = 1_073_741_824 // 1GB
// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge
public let MAX_FILE_SIZE_XFTP_SUPPORTER: Int64 = 2_147_483_648 // 2GB
public let MAX_FILE_SIZE_XFTP_LEGEND: Int64 = 5_368_709_120 // 5GB
public let MAX_FILE_SIZE_LOCAL: Int64 = Int64.max
public let MAX_FILE_SIZE_SMP: Int64 = 8000000
@@ -273,11 +277,26 @@ public func cleanupFile(_ aChatItem: AChatItem) {
}
}
public func getMaxFileSize(_ fileProtocol: FileProtocol) -> Int64 {
public func getMaxFileSize(_ fileProtocol: FileProtocol, _ senderProfile: LocalProfile? = nil) -> Int64 {
switch fileProtocol {
case .xftp: return MAX_FILE_SIZE_XFTP
case .smp: return MAX_FILE_SIZE_SMP
case .local: return MAX_FILE_SIZE_LOCAL
case .smp: MAX_FILE_SIZE_SMP
case .local: MAX_FILE_SIZE_LOCAL
// a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB
case .xftp:
if let badge = senderProfile?.localBadge, badge.status == .active {
badge.badge.badgeType == .legend ? MAX_FILE_SIZE_XFTP_LEGEND : MAX_FILE_SIZE_XFTP_SUPPORTER
} else {
MAX_FILE_SIZE_XFTP
}
}
}
// the profile of whoever sent a received chat item - the group member, or the direct chat's contact
public func ciSenderProfile(_ ci: ChatItem, _ chatInfo: ChatInfo) -> LocalProfile? {
switch (ci.chatDir, chatInfo) {
case let (.groupRcv(groupMember), _): return groupMember.memberProfile
case let (.directRcv, .direct(contact)): return contact.profile
default: return nil
}
}
@@ -95,8 +95,9 @@ fun UserPickerUserBox(
}
}
val user = userInfo.user
Text(
NameWithBadge(
user.displayName,
user.profile.localBadge,
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
@@ -1461,6 +1461,17 @@ data class Chat(
@Serializable
sealed class ChatInfo: SomeChat, NamedChat {
// the badge shown for a chat's name: an active contact's or a contact request's (groups have none).
// a badge that expired over a month ago (ExpiredOld) is not shown at all.
val nameBadge: LocalBadge? get() {
val badge = when {
this is Direct && contact.active -> contact.profile.localBadge
this is ContactRequest -> contactRequest.profile.localBadge
else -> null
}
return if (badge?.status == BadgeStatus.ExpiredOld) null else badge
}
@Serializable @SerialName("direct")
data class Direct(val contact: Contact): ChatInfo() {
override val chatType get() = ChatType.Direct
@@ -2025,7 +2036,10 @@ data class Profile(
override val localAlias : String = "",
val contactLink: String? = null,
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null
val peerType: ChatPeerType? = null,
// the badge proof from the wire profile: not interpreted by the UI (display uses crypto-free LocalBadge),
// but preserved so passing a link profile back to the core (apiPrepareContact) keeps the proof
val badge: BadgeProof? = null
): NamedChat {
val profileViewName: String
get() {
@@ -2053,7 +2067,8 @@ data class LocalProfile(
override val localAlias: String,
val contactLink: String? = null,
val preferences: ChatPreferences? = null,
val peerType: ChatPeerType? = null
val peerType: ChatPeerType? = null,
val localBadge: LocalBadge? = null
): NamedChat {
val profileViewName: String = localAlias.ifEmpty { if (fullName == "" || displayName == fullName) displayName else "$displayName ($fullName)" }
@@ -2077,6 +2092,70 @@ enum class ChatPeerType {
@SerialName("bot") Bot
}
// Supporter badge. The credential/proof bytes stay core-side; the UI only sees the disclosed type + status.
// Unknown types keep their string so a verified badge's real name can be shown, while the icon falls back to supporter.
@Serializable(with = BadgeTypeSerializer::class)
sealed class BadgeType {
@Serializable @SerialName("supporter") object Supporter: BadgeType()
@Serializable @SerialName("legend") object Legend: BadgeType()
@Serializable @SerialName("investor") object Investor: BadgeType()
@Serializable @SerialName("unknown") data class Unknown(val type: String): BadgeType()
// the disclosed (signed) type name, shown to the user for verified badges
val text: String
get() = when (this) {
is Supporter -> "supporter"
is Legend -> "legend"
is Investor -> "investor"
is Unknown -> type
}
}
object BadgeTypeSerializer : KSerializer<BadgeType> {
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("BadgeType", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): BadgeType =
when (val v = decoder.decodeString()) {
"supporter" -> BadgeType.Supporter
"legend" -> BadgeType.Legend
"investor" -> BadgeType.Investor
else -> BadgeType.Unknown(v)
}
override fun serialize(encoder: Encoder, value: BadgeType) = encoder.encodeString(value.text)
}
@Serializable
enum class BadgeStatus {
@SerialName("active") Active,
@SerialName("expired") Expired,
// expired over a month ago - the badge is not shown at all
@SerialName("expiredOld") ExpiredOld,
@SerialName("failed") Failed,
// signed with a key index this app version does not know - shown as a warning
@SerialName("unknownKey") UnknownKey
}
@Serializable
data class BadgeInfo(
val badgeType: BadgeType,
val badgeExpiry: Instant? = null,
val badgeExtra: String = ""
)
@Serializable
data class LocalBadge(
val badge: BadgeInfo,
val status: BadgeStatus
)
// the wire proof carried on a profile - opaque to the UI, only round-tripped back to the core (apiPrepareContact)
@Serializable
data class BadgeProof(
val badgeKeyIdx: Int,
val presHeader: String,
val proof: String,
val badgeInfo: BadgeInfo
)
@Serializable
data class UserProfileUpdateSummary(
val updateSuccesses: Int,
@@ -2309,7 +2388,9 @@ enum class MemberCriteria {
data class ContactShortLinkData (
val profile: Profile,
val message: MsgContent?,
val business: Boolean
val business: Boolean,
// set by the core when building the connection plan: the link profile's badge, verified and crypto-free
val localBadge: LocalBadge? = null
)
@Serializable
@@ -2334,6 +2415,7 @@ enum class RelayStatus {
@SerialName("new") New,
@SerialName("invited") Invited,
@SerialName("accepted") Accepted,
@SerialName("acknowledgedRoster") AcknowledgedRoster,
@SerialName("active") Active,
@SerialName("inactive") Inactive,
@SerialName("rejected") Rejected;
@@ -2342,6 +2424,7 @@ enum class RelayStatus {
New -> generalGetString(MR.strings.relay_status_new)
Invited -> generalGetString(MR.strings.relay_status_invited)
Accepted -> generalGetString(MR.strings.relay_status_accepted)
AcknowledgedRoster -> generalGetString(MR.strings.relay_status_acknowledged_roster)
Active -> generalGetString(MR.strings.relay_status_active)
Inactive -> generalGetString(MR.strings.relay_status_inactive)
Rejected -> generalGetString(MR.strings.relay_status_rejected)
@@ -2440,6 +2523,11 @@ data class GroupMember (
override val image: String? get() = memberProfile.image
val contactLink: String? = memberProfile.contactLink
val verified get() = activeConn?.connectionCode != null
// the badge shown for a member's name; a badge that expired over a month ago (ExpiredOld) is not shown
val nameBadge: LocalBadge? get() {
val badge = memberProfile.localBadge
return if (badge?.status == BadgeStatus.ExpiredOld) null else badge
}
val blocked get() = blockedByAdmin || !memberSettings.showMessages
override val localAlias: String = memberProfile.localAlias
@@ -2529,8 +2617,15 @@ data class GroupMember (
fun canChangeRoleTo(groupInfo: GroupInfo): List<GroupMemberRole>? =
if (memberRole == GroupMemberRole.Relay || !canBeRemoved(groupInfo) || memberStatus == GroupMemberStatus.MemRemoved || memberStatus == GroupMemberStatus.MemLeft || memberPending) null
else if (groupInfo.useRelays && !groupInfo.isOwner) null
else groupInfo.membership.memberRole.let { userRole ->
GroupMemberRole.selectableRoles.filter { it <= userRole }
if (groupInfo.useRelays)
// TODO [relays]: for now owners can only set observer/member in channels.
// Restore the full Owner-excluded picker when moderator/admin promotion is supported:
// GroupMemberRole.selectableRoles.filter { it <= userRole && it != GroupMemberRole.Owner }
listOf(GroupMemberRole.Observer, GroupMemberRole.Member)
else
GroupMemberRole.selectableRoles.filter { it <= userRole }
}
fun canBlockForAll(groupInfo: GroupInfo): Boolean {
@@ -2608,11 +2703,11 @@ enum class GroupMemberRole(val memberRole: String) {
val selectableRoles: List<GroupMemberRole> = listOf(Observer, Member, Moderator, Admin, Owner)
}
val text: String get() = when (this) {
fun text(isChannel: Boolean): String = when (this) {
Relay -> generalGetString(MR.strings.group_member_role_relay)
Observer -> generalGetString(MR.strings.group_member_role_observer)
Observer -> generalGetString(if (isChannel) MR.strings.group_member_role_observer_channel else MR.strings.group_member_role_observer)
Author -> generalGetString(MR.strings.group_member_role_author)
Member -> generalGetString(MR.strings.group_member_role_member)
Member -> generalGetString(if (isChannel) MR.strings.group_member_role_member_channel else MR.strings.group_member_role_member)
Moderator -> generalGetString(MR.strings.group_member_role_moderator)
Admin -> generalGetString(MR.strings.group_member_role_admin)
Owner -> generalGetString(MR.strings.group_member_role_owner)
@@ -2758,7 +2853,7 @@ class UserContactRequest (
val contactRequestId: Long,
val cReqChatVRange: VersionRange,
override val localDisplayName: String,
val profile: Profile,
val profile: LocalProfile,
override val createdAt: Instant,
override val updatedAt: Instant
): SomeChat, NamedChat {
@@ -2784,7 +2879,7 @@ class UserContactRequest (
contactRequestId = 1,
cReqChatVRange = VersionRange(1, 1),
localDisplayName = "alice",
profile = Profile.sampleData,
profile = LocalProfile.sampleData,
createdAt = Clock.System.now(),
updatedAt = Clock.System.now()
)
@@ -5006,13 +5101,13 @@ sealed class RcvGroupEvent() {
is MemberAccepted -> String.format(generalGetString(MR.strings.rcv_group_event_member_accepted), profile.profileViewName)
is UserAccepted -> generalGetString(MR.strings.rcv_group_event_user_accepted)
is MemberLeft -> generalGetString(MR.strings.rcv_group_event_member_left)
is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text)
is MemberRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_member_role), profile.profileViewName, role.text(isChannel = isChannel))
is MemberBlocked -> if (blocked) {
String.format(generalGetString(MR.strings.rcv_group_event_member_blocked), profile.profileViewName)
} else {
String.format(generalGetString(MR.strings.rcv_group_event_member_unblocked), profile.profileViewName)
}
is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text)
is UserRole -> String.format(generalGetString(MR.strings.rcv_group_event_changed_your_role), role.text(isChannel = isChannel))
is MemberDeleted -> String.format(generalGetString(MR.strings.rcv_group_event_member_deleted), profile.profileViewName)
is UserDeleted -> generalGetString(MR.strings.rcv_group_event_user_deleted)
is GroupDeleted -> generalGetString(if (isChannel) MR.strings.rcv_channel_event_channel_deleted else MR.strings.rcv_group_event_group_deleted)
@@ -5051,8 +5146,8 @@ sealed class SndGroupEvent() {
val text: String get() = text(isChannel = false)
fun text(isChannel: Boolean): String = when (this) {
is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text)
is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text)
is MemberRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_member_role), profile.profileViewName, role.text(isChannel = isChannel))
is UserRole -> String.format(generalGetString(MR.strings.snd_group_event_changed_role_for_yourself), role.text(isChannel = isChannel))
is MemberBlocked -> if (blocked) {
String.format(generalGetString(MR.strings.snd_group_event_member_blocked), profile.profileViewName)
} else {
@@ -2289,7 +2289,7 @@ object ChatController {
return when {
r is API.Result && r.res is CR.GroupUpdated -> r.res.toGroup
r is API.Error -> {
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "$r.err")
AlertManager.shared.showAlertMsg(generalGetString(errorTitle), "${r.err.string}")
null
}
else -> {
@@ -715,19 +715,32 @@ fun ChatInfoHeader(cInfo: ChatInfo, contact: Contact) {
) {
ChatInfoImage(cInfo, size = 192.dp, iconColor = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
val displayName = contact.profile.displayName.trim()
val badge = cInfo.nameBadge
val text = buildAnnotatedString {
if (contact.verified) {
appendInlineContent(id = "shieldIcon")
}
append(displayName)
}
val inlineContent: Map<String, InlineTextContent> = mapOf(
"shieldIcon" to InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
if (badge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
)
}
val nameFontSize = MaterialTheme.typography.h1.fontSize
val uriHandler = LocalUriHandler.current
val inlineContent: Map<String, InlineTextContent> = buildMap {
put(
"shieldIcon",
InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
}
)
if (badge != null) {
put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) })
}
}
val clipboard = LocalClipboardManager.current
val copyNameToClipboard = fun (name: String) {
clipboard.setText(AnnotatedString(name))
@@ -170,9 +170,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
@Composable
fun ForwardedFromSender(forwardedFromItem: AChatItem) {
@Composable
fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground) {
Text(
fun ItemText(text: String, fontStyle: FontStyle = FontStyle.Normal, color: Color = MaterialTheme.colors.onBackground, badge: LocalBadge? = null) {
NameWithBadge(
text,
badge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.body1,
@@ -191,13 +192,13 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
if (forwardedFromItem.chatItem.chatDir.sent) {
ItemText(text = stringResource(MR.strings.sender_you_pronoun), fontStyle = FontStyle.Italic)
Spacer(Modifier.height(7.dp))
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge)
} else if (forwardedFromItem.chatItem.chatDir is CIDirection.GroupRcv) {
ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName)
ItemText(text = forwardedFromItem.chatItem.chatDir.groupMember.chatViewName, badge = forwardedFromItem.chatItem.chatDir.groupMember.nameBadge)
Spacer(Modifier.height(7.dp))
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary)
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.secondary, badge = forwardedFromItem.chatInfo.nameBadge)
} else {
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground)
ItemText(forwardedFromItem.chatInfo.chatViewName, color = MaterialTheme.colors.onBackground, badge = forwardedFromItem.chatInfo.nameBadge)
}
}
}
@@ -344,9 +345,10 @@ fun ChatItemInfoView(chatRh: Long?, ci: ChatItem, ciInfo: ChatItemInfo, devTools
) {
MemberProfileImage(size = 36.dp, member)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Text(
NameWithBadge(
member.chatViewName,
modifier = Modifier.weight(10f, fill = true),
member.nameBadge,
Modifier.weight(10f, fill = true),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
@@ -21,6 +21,7 @@ import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.intl.Locale
import androidx.compose.ui.text.style.TextOverflow
@@ -1564,8 +1565,8 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo
if ((cInfo as? ChatInfo.Direct)?.contact?.verified == true) {
ContactVerifiedShield()
}
Text(
cInfo.displayName, fontWeight = FontWeight.SemiBold,
NameWithBadge(
cInfo.displayName, cInfo.nameBadge, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
@@ -1999,7 +2000,7 @@ fun BoxScope.ChatItemsList(
Column(
Modifier
.padding(top = 8.dp)
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.padding(start = 8.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.fillMaxWidth()
.then(swipeableModifier),
verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -2016,8 +2017,10 @@ fun BoxScope.ChatItemsList(
} else {
null to 1
}
Text(
// the name and the badge are one element, so SpaceBetween separates them from the role, not from each other
NameWithBadge(
memberNames(member, prevMember, memCount),
if (prevMember == null && memCount == 1) member.nameBadge else null,
Modifier
.padding(start = (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + DEFAULT_PADDING_HALF)
.weight(1f, false),
@@ -2032,7 +2035,7 @@ fun BoxScope.ChatItemsList(
val tailRendered = style is ShapeStyle.Bubble && style.tailVisible
Text(
member.memberRole.text,
member.memberRole.text(isChannel = chatInfo.isChannel),
Modifier.padding(start = DEFAULT_PADDING_HALF * 1.5f, end = DEFAULT_PADDING_HALF + if (tailRendered) msgTailWidthDp else 0.dp),
fontSize = 13.5.sp,
fontWeight = FontWeight.Medium,
@@ -2076,7 +2079,7 @@ fun BoxScope.ChatItemsList(
}
Row(
Modifier
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
.then(swipeableOrSelectionModifier)
) {
@@ -2089,7 +2092,7 @@ fun BoxScope.ChatItemsList(
Column(
Modifier
.padding(top = 8.dp)
.padding(start = 8.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.padding(start = 8.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.fillMaxWidth()
.then(swipeableModifier),
verticalArrangement = Arrangement.spacedBy(4.dp),
@@ -2159,7 +2162,7 @@ fun BoxScope.ChatItemsList(
}
Row(
Modifier
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.padding(start = 8.dp + (MEMBER_IMAGE_SIZE * fontSizeSqrtMultiplier) + 4.dp, end = if (voiceWithTransparentBack || chatInfo.isChannel) 12.dp else adjustTailPaddingOffset(66.dp, start = false))
.chatItemOffset(cItem, itemSeparation.largeGap, revealed = revealed.value)
.then(swipeableOrSelectionModifier)
) {
@@ -2284,8 +2287,18 @@ fun BoxScope.ChatItemsList(
.background(MaterialTheme.appColors.receivedMessage)
) {
ChatInfoImage(chatInfo, size = alertProfileImageSize, iconColor = MaterialTheme.colors.secondaryVariant.mixWith(MaterialTheme.colors.onBackground, 0.97f))
val bannerBadge = chatInfo.nameBadge
val uriHandler = LocalUriHandler.current
Text(
chatInfo.displayName,
buildAnnotatedString {
append(chatInfo.displayName)
if (bannerBadge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
},
inlineContent =
if (bannerBadge != null) mapOf("nameBadge" to nameBadgeInline(bannerBadge, MaterialTheme.typography.h3.fontSize) { showBadgeInfoAlert(chatInfo.displayName, bannerBadge, uriHandler) }) else emptyMap(),
style = MaterialTheme.typography.h3,
color = MaterialTheme.colors.onBackground,
textAlign = TextAlign.Center,
@@ -146,9 +146,10 @@ fun ComposeContextProfilePickerView(
) {
ProfileImage(size = USER_ROW_AVATAR_SIZE, image = user.image)
TextIconSpaced(false)
Text(
NameWithBadge(
user.chatViewName,
modifier = Modifier.align(Alignment.CenterVertically),
user.profile.localBadge,
Modifier.align(Alignment.CenterVertically),
fontWeight = if (selectedUser.value.userId == user.userId && !incognitoDefault) FontWeight.Medium else FontWeight.Normal
)
@@ -33,8 +33,7 @@ fun ComposeFileView(fileName: String, cancelFile: () -> Unit, cancelEnabled: Boo
.size(36.dp),
tint = if (isInDarkTheme()) FileDark else FileLight
)
Text(fileName)
Spacer(Modifier.weight(1f))
Text(fileName, maxLines = 1, modifier = Modifier.weight(1f))
if (cancelEnabled) {
IconButton(onClick = cancelFile, modifier = Modifier.padding(0.dp)) {
Icon(
@@ -113,7 +113,9 @@ data class ComposeState(
val inProgress: Boolean = false,
val progressByTimeout: Boolean = false,
val useLinkPreviews: Boolean,
val mentions: MentionedMembers = emptyMap()
val mentions: MentionedMembers = emptyMap(),
// the max file size the user may attach, raised by their active badge unless the chat is incognito; kept in sync on chat switch
val maxFileSize: Long = getMaxFileSize(FileProtocol.XFTP)
) {
constructor(editingItem: ChatItem, liveMessage: LiveMessage? = null, useLinkPreviews: Boolean): this(
ComposeMessage(
@@ -251,8 +253,6 @@ data class ComposeState(
}
}
private val maxFileSize = getMaxFileSize(FileProtocol.XFTP)
sealed class RecordingState {
object NotStarted: RecordingState()
class Started(val filePath: String, val progressMs: Int = 0): RecordingState()
@@ -306,6 +306,7 @@ private fun isVideoUri(uri: URI): Boolean {
fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
if (uri != null) {
val maxFileSize = value.maxFileSize
val fileSize = getFileSize(uri)
if (fileSize != null && fileSize <= maxFileSize) {
val fileName = getFileName(uri)
@@ -324,11 +325,12 @@ fun MutableState<ComposeState>.processPickedFile(uri: URI?, text: String?) {
}
suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text: String?) {
val maxFileSize = value.maxFileSize
val content = ArrayList<UploadContent>()
val imagesPreview = ArrayList<String>()
uris.forEach { uri ->
var bitmap: ImageBitmap?
when {
val uploadContent: UploadContent? = when {
isImage(uri) -> {
// Image
val drawable = getDrawableFromUri(uri)
@@ -338,16 +340,19 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
// It's a gif or webp
val fileSize = getFileSize(uri)
if (fileSize != null && fileSize <= maxFileSize) {
content.add(UploadContent.AnimatedImage(uri))
UploadContent.AnimatedImage(uri)
} else {
bitmap = null
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.maximum_supported_file_size), formatBytes(maxFileSize))
)
null
}
} else if (bitmap != null) {
content.add(UploadContent.SimpleImage(uri))
UploadContent.SimpleImage(uri)
} else {
null
}
}
else -> {
@@ -355,11 +360,22 @@ suspend fun MutableState<ComposeState>.processPickedMedia(uris: List<URI>, text:
val res = getBitmapFromVideo(uri, withAlertOnException = true)
bitmap = res.preview
val durationMs = res.duration
content.add(UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0))
UploadContent.Video(uri, durationMs?.div(1000)?.toInt() ?: 0)
}
}
if (bitmap != null) {
// content and imagesPreview must stay index-aligned and equal-length: both consumers
// (ComposeImageView and sendMessageAsync) cross-index one list by the other's index.
// Only pair them when a preview bitmap exists; otherwise skip the media entirely.
if (bitmap != null && uploadContent != null) {
content.add(uploadContent)
imagesPreview.add(resizeImageToStrSize(bitmap, maxDataSize = 14000))
} else if (uploadContent is UploadContent.Video && !AlertManager.shared.hasAlertsShown()) {
// A corrupted/undecodable video can yield a null preview frame without throwing, so
// getBitmapFromVideo shows no alert. Skip it (other picked media still send) and tell
// the user instead of dropping it silently. hasAlertsShown guards against stacking the
// alert across multiple bad items and against duplicating the one already shown on the
// exception path. Image decode failures are already surfaced by getBitmapFromUri above.
showVideoDecodingException()
}
}
if (imagesPreview.isNotEmpty()) {
@@ -493,7 +509,7 @@ fun ComposeView(
if (live) {
composeState.value = composeState.value.copy(inProgress = false, progressByTimeout = false)
} else {
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews)
composeState.value = ComposeState(useLinkPreviews = useLinkPreviews, maxFileSize = composeState.value.maxFileSize)
resetLinkPreview()
}
recState.value = RecordingState.NotStarted
@@ -1100,7 +1116,7 @@ fun ComposeView(
if (composeState.value.contextItem != ComposeContextItem.NoContextItem || composeState.value.preview != ComposePreview.NoPreview) return
val lastEditable = chatsCtx.chatItems.value.findLast { it.meta.editable }
if (lastEditable != null) {
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews)
composeState.value = ComposeState(editingItem = lastEditable, useLinkPreviews = useLinkPreviews).copy(maxFileSize = composeState.value.maxFileSize)
}
}
@@ -1328,6 +1344,11 @@ fun ComposeView(
chatModel.removeLiveDummy()
CIFile.cachedRemoteFileRequests.clear()
}
// keep the attach size limit in sync with the chat: the user's active badge raises it, but not in incognito chats where no badge is presented
LaunchedEffect(chat.chatInfo) {
val incognito = if (chat.chatInfo.profileChangeProhibited) chat.chatInfo.incognito else chatModel.controller.appPrefs.incognito.get()
composeState.value = composeState.value.copy(maxFileSize = getMaxFileSize(FileProtocol.XFTP, if (incognito) null else chatModel.currentUser.value?.profile))
}
if (appPlatform.isDesktop) {
// Don't enable this on Android, it breaks it, This method only works on desktop. For Android there is a `KeyChangeEffect(chatModel.chatId.value)`
DisposableEffect(Unit) {
@@ -228,7 +228,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
) {
val values = GroupMemberRole.selectableRoles
.filter { it <= groupInfo.membership.memberRole }
.map { it to it.text }
.map { it to it.text(isChannel = groupInfo.isChannel) }
ExposedDropDownSettingRow(
generalGetString(MR.strings.new_member_role),
values,
@@ -353,9 +353,10 @@ fun ContactCheckRow(
) {
ProfileImage(size = 36.dp, contact.image)
Spacer(Modifier.width(DEFAULT_SPACE_AFTER_ICON))
Text(
NameWithBadge(
contact.chatViewName,
modifier = Modifier.weight(10f, fill = true),
if (contact.active) contact.profile.localBadge else null,
Modifier.weight(10f, fill = true),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (prohibitedToInviteIncognito) MaterialTheme.colors.secondary else Color.Unspecified
@@ -46,7 +46,7 @@ fun ChannelMembersView(
val subscriberCount = groupInfo.groupSummary.publicMemberCount ?: (members.size + 1).toLong()
SectionView(title = subscriberCountStr(subscriberCount)) {
SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
ChannelMemberRow(groupInfo.membership, user = true, showRole = true)
ChannelMemberRow(groupInfo.membership, user = true, showRole = true, isChannel = groupInfo.isChannel)
}
members.forEachIndexed { index, member ->
Divider()
@@ -55,7 +55,7 @@ fun ChannelMembersView(
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner)
ChannelMemberRow(member, user = false, showRole = member.memberRole >= GroupMemberRole.Owner, isChannel = groupInfo.isChannel)
}
}
}
@@ -71,7 +71,7 @@ fun ChannelMembersView(
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
ChannelMemberRow(member, user = false, showRole = false)
ChannelMemberRow(member, user = false, showRole = false, isChannel = groupInfo.isChannel)
}
}
}
@@ -81,7 +81,7 @@ fun ChannelMembersView(
}
@Composable
private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean) {
private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boolean, isChannel: Boolean) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -94,8 +94,9 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole
if (member.verified) {
MemberVerifiedShield()
}
Text(
NameWithBadge(
member.chatViewName,
member.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
@@ -111,7 +112,7 @@ private fun ChannelMemberRow(member: GroupMember, user: Boolean, showRole: Boole
}
if (showRole) {
Text(
member.memberRole.text,
member.memberRole.text(isChannel = isChannel),
color = MaterialTheme.colors.secondary
)
}
@@ -87,8 +87,6 @@ private fun ChannelRelaysLayout(
minHeight = 54.dp,
padding = PaddingValues(horizontal = DEFAULT_PADDING)
) {
// TODO [relays] re-enable when relay management ships
/*
if (groupInfo.isOwner && member.canBeRemoved(groupInfo)) {
DefaultDropdownMenu(showMenu) {
ItemAction(generalGetString(MR.strings.button_remove_relay), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = {
@@ -97,7 +95,6 @@ private fun ChannelRelaysLayout(
})
}
}
*/
val statusText = if (groupInfo.isOwner) {
ownerRelayStatusText(member, groupRelays)
} else {
@@ -109,8 +106,6 @@ private fun ChannelRelaysLayout(
}
SectionTextFooter(generalGetString(MR.strings.chat_relays_forward_messages))
}
// TODO [relays] re-enable when relay management ships
/*
if (groupInfo.isOwner) {
SectionView {
SectionItemView(click = {
@@ -139,7 +134,6 @@ private fun ChannelRelaysLayout(
}
}
}
*/
SectionBottomSpacer()
}
}
@@ -0,0 +1,186 @@
package chat.simplex.common.views.chat.group
import SectionBottomSpacer
import SectionDividerSpaced
import SectionItemView
import SectionTextFooter
import SectionView
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import kotlinx.coroutines.*
@Composable
fun ChannelWebPageView(
rhId: Long?,
groupInfo: GroupInfo,
chatModel: ChatModel,
close: () -> Unit
) {
val isChannel = groupInfo.isChannel
val access = groupInfo.groupProfile.publicGroup?.publicGroupAccess
val webPage = rememberSaveable { mutableStateOf(access?.groupWebPage ?: "") }
val allowEmbedding = rememberSaveable { mutableStateOf(access?.allowEmbedding ?: false) }
val groupRelays = remember { mutableStateListOf<GroupRelay>() }
val dataUnchanged = webPage.value.trim() == (access?.groupWebPage ?: "") &&
allowEmbedding.value == (access?.allowEmbedding ?: false)
val save: () -> Unit = {
withBGApi {
val trimmedPage = webPage.value.trim()
val newAccess = PublicGroupAccess(
groupWebPage = trimmedPage.ifEmpty { null },
groupDomain = access?.groupDomain,
domainWebPage = access?.domainWebPage ?: false,
allowEmbedding = allowEmbedding.value
)
val gp = groupInfo.groupProfile.copy(
publicGroup = groupInfo.groupProfile.publicGroup?.copy(publicGroupAccess = newAccess)
)
val gInfo = chatModel.controller.apiUpdateGroup(rhId, groupInfo.groupId, gp, isChannel)
if (gInfo != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateGroup(rhId, gInfo)
}
close()
}
}
}
val closeWithAlert = {
if (dataUnchanged) {
close()
} else {
AlertManager.shared.showAlertDialogStacked(
title = generalGetString(MR.strings.save_preferences_question),
confirmText = generalGetString(if (isChannel) MR.strings.save_and_notify_channel_subscribers else MR.strings.save_and_notify_group_members),
dismissText = generalGetString(MR.strings.exit_without_saving),
onConfirm = save,
onDismiss = close,
)
}
}
LaunchedEffect(Unit) {
val relays = chatModel.controller.apiGetGroupRelays(groupInfo.groupId)
groupRelays.clear()
groupRelays.addAll(relays)
}
BackHandler(onBack = closeWithAlert)
ModalView(close = closeWithAlert, cardScreen = true) {
ChannelWebPageLayout(
isChannel = isChannel,
webPage = webPage,
allowEmbedding = allowEmbedding,
groupRelays = groupRelays,
groupInfo = groupInfo,
dataUnchanged = dataUnchanged,
save = save
)
}
}
@Composable
private fun ChannelWebPageLayout(
isChannel: Boolean,
webPage: MutableState<String>,
allowEmbedding: MutableState<Boolean>,
groupRelays: List<GroupRelay>,
groupInfo: GroupInfo,
dataUnchanged: Boolean,
save: () -> Unit
) {
val clipboard = LocalClipboardManager.current
ColumnWithScrollBar {
AppBarTitle(stringResource(if (isChannel) MR.strings.channel_webpage else MR.strings.group_webpage))
val embedCode = embedCode(groupRelays, groupInfo)
if (embedCode != null) {
SectionTextFooter(stringResource(MR.strings.webpage_info))
SectionDividerSpaced()
SectionView(stringResource(MR.strings.webpage_code)) {
SectionItemView {
Text(
embedCode,
style = MaterialTheme.typography.body2.copy(fontFamily = FontFamily.Monospace, fontSize = 12.sp),
maxLines = 6,
overflow = TextOverflow.Ellipsis
)
}
SectionItemView({
clipboard.setText(AnnotatedString(embedCode))
showToast(generalGetString(MR.strings.copied))
}) {
Icon(painterResource(MR.images.ic_content_copy), null, tint = MaterialTheme.colors.primary)
Spacer(Modifier.width(8.dp))
Text(stringResource(MR.strings.copy_code), color = MaterialTheme.colors.primary)
}
}
SectionTextFooter(stringResource(MR.strings.webpage_code_footer))
} else {
SectionTextFooter(stringResource(MR.strings.relays_no_web_support))
}
SectionDividerSpaced()
SectionView(stringResource(MR.strings.enter_webpage_url)) {
PlainTextEditor(webPage, placeholder = stringResource(MR.strings.web_page_url_placeholder))
}
SectionTextFooter(stringResource(MR.strings.webpage_url_footer))
SectionDividerSpaced()
SectionView {
PreferenceToggle(stringResource(MR.strings.allow_anyone_to_embed), checked = allowEmbedding.value) {
allowEmbedding.value = it
}
}
SectionTextFooter(stringResource(if (allowEmbedding.value) MR.strings.embed_any_webpage_can_show else MR.strings.embed_only_your_page))
SectionDividerSpaced()
SectionView {
SectionItemView(save, disabled = dataUnchanged) {
Text(
stringResource(MR.strings.save_verb),
color = if (dataUnchanged) MaterialTheme.colors.secondary else MaterialTheme.colors.primary
)
}
}
SectionBottomSpacer()
}
}
private fun embedCode(groupRelays: List<GroupRelay>, groupInfo: GroupInfo): String? {
val pg = groupInfo.groupProfile.publicGroup ?: return null
val relayDomains = groupRelays.mapNotNull { it.relayCap.webDomain }
if (relayDomains.isEmpty()) return null
val domains = relayDomains.joinToString(",")
return """<div data-simplex-channel-preview
data-channel-link="${pg.groupLink}"
data-channel-id="${pg.publicGroupId}"
data-relay-domains="$domains"
data-app-download-buttons="on"
data-color-scheme="light"
></div>
<script src="https://simplex.chat/js/channel-preview.js"></script>"""
}
@@ -175,6 +175,9 @@ fun ModalData.GroupChatInfoView(
manageGroupLink = {
ModalManager.end.showModal(cardScreen = true) { GroupLinkView(chatModel, rhId, groupInfo, groupLink, onGroupLinkUpdated, isChannel = groupInfo.useRelays, shareGroupInfo = groupInfo) }
},
manageWebPage = {
ModalManager.end.showCustomModal { close -> ChannelWebPageView(rhId, groupInfo, chatModel, close) }
},
onSearchClicked = onSearchClicked,
deletingItems = deletingItems
)
@@ -506,6 +509,7 @@ fun ModalData.GroupChatInfoLayout(
clearChat: () -> Unit,
leaveGroup: () -> Unit,
manageGroupLink: () -> Unit,
manageWebPage: () -> Unit,
close: () -> Unit = { ModalManager.closeAllModalsEverywhere()},
onSearchClicked: () -> Unit,
deletingItems: State<Boolean>
@@ -727,7 +731,7 @@ fun ModalData.GroupChatInfoLayout(
}
}
SectionItemView(minHeight = 54.dp, padding = PaddingValues(horizontal = DEFAULT_PADDING)) {
MemberRow(groupInfo.membership, user = true)
MemberRow(groupInfo.membership, user = true, isChannel = groupInfo.isChannel)
}
}
}
@@ -761,7 +765,7 @@ fun ModalData.GroupChatInfoLayout(
val selectionOffset by animateDpAsState(if (selectedItems.value != null) 20.dp + 22.dp * fontSizeMultiplier else 0.dp)
DropDownMenuForMember(chat.remoteHostId, member, groupInfo, selectedItems, showMenu)
Box(Modifier.padding(start = selectionOffset)) {
MemberRow(member)
MemberRow(member, isChannel = groupInfo.isChannel)
}
}
}
@@ -796,6 +800,13 @@ fun ModalData.GroupChatInfoLayout(
}
}
if (groupInfo.useRelays && groupInfo.isOwner) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.advanced_options)) {
ChannelWebPageButton(groupInfo, manageWebPage)
}
}
if (developerTools) {
SectionDividerSpaced()
SectionView(title = stringResource(MR.strings.section_title_for_console)) {
@@ -1046,7 +1057,7 @@ private fun AddMembersButton(titleId: StringResource, tint: Color = MaterialThem
}
@Composable
fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false) {
fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = true, showlocalAliasAndFullName: Boolean = false, selected: Boolean = false, isChannel: Boolean = false) {
@Composable
fun MemberInfo() {
if (member.blocked) {
@@ -1054,7 +1065,7 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
} else {
val role = member.memberRole
if (role in listOf(GroupMemberRole.Owner, GroupMemberRole.Admin, GroupMemberRole.Moderator, GroupMemberRole.Observer)) {
Text(role.text, color = MaterialTheme.colors.secondary)
Text(role.text(isChannel = isChannel), color = MaterialTheme.colors.secondary)
}
}
}
@@ -1088,8 +1099,10 @@ fun MemberRow(member: GroupMember, user: Boolean = false, infoPage: Boolean = tr
if (member.verified) {
MemberVerifiedShield()
}
Text(
if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
NameWithBadge(
if (showlocalAliasAndFullName) member.localAliasAndFullName else member.chatViewName,
member.nameBadge,
maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
@@ -1207,6 +1220,16 @@ private fun ChannelLinkButton(onClick: () -> Unit) {
)
}
@Composable
private fun ChannelWebPageButton(groupInfo: GroupInfo, onClick: () -> Unit) {
SettingsActionItem(
painterResource(MR.images.ic_travel_explore),
stringResource(if (groupInfo.isChannel) MR.strings.channel_webpage else MR.strings.group_webpage),
onClick,
iconColor = MaterialTheme.colors.secondary
)
}
@Composable
private fun ChannelLinkQRCodeSection(groupLink: String) {
val clipboard = LocalClipboardManager.current
@@ -1411,6 +1434,7 @@ fun PreviewGroupChatInfoLayout() {
clearChat = {},
leaveGroup = {},
manageGroupLink = {},
manageWebPage = {},
onSearchClicked = {},
deletingItems = remember { mutableStateOf(true) }
)
@@ -308,7 +308,7 @@ private fun RoleSelectionRow(groupInfo: GroupInfo, selectedRole: MutableState<Gr
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text }
val values = listOf(GroupMemberRole.Member, GroupMemberRole.Observer).map { it to it.text(isChannel = groupInfo.isChannel) }
ExposedDropDownSettingRow(
generalGetString(MR.strings.initial_member_role),
values,
@@ -18,6 +18,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
@@ -420,8 +421,7 @@ fun GroupMemberInfoLayout(
@Composable
fun ModeratorDestructiveSection() {
val canBlockForAll = member.canBlockForAll(groupInfo)
// TODO [relays] re-enable when relay management ships
val canRemove = member.canBeRemoved(groupInfo) && member.memberRole != GroupMemberRole.Relay
val canRemove = member.canBeRemoved(groupInfo)
if (canBlockForAll || canRemove) {
SectionDividerSpaced()
SectionView {
@@ -596,15 +596,11 @@ fun GroupMemberInfoLayout(
else if (groupInfo.businessChat == null) MR.strings.info_row_group
else MR.strings.info_row_chat
InfoRow(stringResource(titleId), groupInfo.displayName)
if (!groupInfo.useRelays) {
val roles = remember { member.canChangeRoleTo(groupInfo) }
if (roles != null) {
RoleSelectionRow(roles, newRole, onRoleSelected)
} else {
InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text)
}
val roles = remember { member.canChangeRoleTo(groupInfo) }
if (roles != null) {
RoleSelectionRow(roles, newRole, onRoleSelected, groupInfo.isChannel)
} else {
InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text)
InfoRow(stringResource(MR.strings.role_in_group), member.memberRole.text(isChannel = groupInfo.isChannel))
}
val relayLink = member.relayLink
if (relayLink != null) {
@@ -735,19 +731,32 @@ fun GroupMemberInfoHeader(member: GroupMember) {
) {
MemberProfileImage(size = 192.dp, member, color = if (isInDarkTheme()) GroupDark else SettingsSecondaryLight)
val displayName = member.displayName.trim() // alias if set
val badge = member.nameBadge
val text = buildAnnotatedString {
if (member.verified) {
appendInlineContent(id = "shieldIcon")
}
append(displayName)
}
val inlineContent: Map<String, InlineTextContent> = mapOf(
"shieldIcon" to InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
if (badge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
)
}
val nameFontSize = MaterialTheme.typography.h1.fontSize
val uriHandler = LocalUriHandler.current
val inlineContent: Map<String, InlineTextContent> = buildMap {
put(
"shieldIcon",
InlineTextContent(
Placeholder(24.sp, 24.sp, PlaceholderVerticalAlign.TextCenter)
) {
Icon(painterResource(MR.images.ic_verified_user), null, tint = MaterialTheme.colors.secondary)
}
)
if (badge != null) {
put("nameBadge", nameBadgeInline(badge, nameFontSize) { showBadgeInfoAlert(displayName, badge, uriHandler) })
}
}
val clipboard = LocalClipboardManager.current
val copyNameToClipboard = fun(name: String) {
clipboard.setText(AnnotatedString(name))
@@ -875,14 +884,15 @@ fun ConnectViaAddressButton(onClick: () -> Unit) {
private fun RoleSelectionRow(
roles: List<GroupMemberRole>,
selectedRole: MutableState<GroupMemberRole>,
onSelected: (GroupMemberRole) -> Unit
onSelected: (GroupMemberRole) -> Unit,
isChannel: Boolean
) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
val values = remember { roles.map { it to it.text } }
val values = remember { roles.map { it to it.text(isChannel = isChannel) } }
ExposedDropDownSettingRow(
generalGetString(MR.strings.change_role),
values,
@@ -943,12 +953,14 @@ fun updateMemberRoleDialog(
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_member_role_question),
text = if (memberCurrent) {
if (groupInfo.businessChat == null)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text)
if (groupInfo.isChannel)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_channel), newRole.text(isChannel = groupInfo.isChannel))
else if (groupInfo.businessChat == null)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text(isChannel = groupInfo.isChannel))
else
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text(isChannel = groupInfo.isChannel))
} else
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_invitation), newRole.text),
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_invitation), newRole.text(isChannel = groupInfo.isChannel)),
confirmText = generalGetString(MR.strings.change_verb),
onDismiss = onDismiss,
onConfirm = onConfirm,
@@ -964,9 +976,9 @@ fun updateMembersRoleDialog(
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.change_member_role_question),
text = if (groupInfo.businessChat == null)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text)
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification), newRole.text(isChannel = groupInfo.isChannel))
else
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text),
String.format(generalGetString(MR.strings.member_role_will_be_changed_with_notification_chat), newRole.text(isChannel = groupInfo.isChannel)),
confirmText = generalGetString(MR.strings.change_verb),
onConfirm = onConfirm,
)
@@ -137,8 +137,8 @@ fun MemberSupportChatToolbarTitle(member: GroupMember, imageSize: Dp = 40.dp, ic
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.displayName, fontWeight = FontWeight.SemiBold,
NameWithBadge(
member.displayName, member.nameBadge, fontWeight = FontWeight.SemiBold,
maxLines = 1, overflow = TextOverflow.Ellipsis
)
}
@@ -150,7 +150,7 @@ private fun ModalData.MemberSupportViewLayout(
) {
Box(contentAlignment = Alignment.CenterStart) {
DropDownMenuForSupportChat(chat.remoteHostId, member, groupInfo, showMenu)
SupportChatRow(member)
SupportChatRow(member, isChannel = groupInfo.isChannel)
}
}
}
@@ -163,7 +163,7 @@ private fun ModalData.MemberSupportViewLayout(
}
@Composable
fun SupportChatRow(member: GroupMember) {
fun SupportChatRow(member: GroupMember, isChannel: Boolean) {
fun memberStatus(): String {
return if (member.activeConn?.connStatus is ConnStatus.Failed) {
generalGetString(MR.strings.member_info_member_failed)
@@ -174,7 +174,7 @@ fun SupportChatRow(member: GroupMember) {
} else if (member.memberPending) {
member.memberStatus.text
} else {
member.memberRole.text
member.memberRole.text(isChannel = isChannel)
}
}
@@ -234,8 +234,8 @@ fun SupportChatRow(member: GroupMember) {
if (member.verified) {
MemberVerifiedShield()
}
Text(
member.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
NameWithBadge(
member.chatViewName, member.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (member.memberIncognito) Indigo else Color.Unspecified
)
}
@@ -33,6 +33,7 @@ fun CIFileView(
edited: Boolean,
showMenu: MutableState<Boolean>,
smallView: Boolean = false,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val saveFileLauncher = rememberSaveFileLauncher(ciFile = file)
@@ -71,12 +72,12 @@ fun CIFileView(
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted -> {
if (fileSizeValid(file)) {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
}
}
@@ -151,7 +152,7 @@ fun CIFileView(
is CIFileStatus.SndError -> fileIcon(innerIcon = painterResource(MR.images.ic_close))
is CIFileStatus.SndWarning -> fileIcon(innerIcon = painterResource(MR.images.ic_warning_filled))
is CIFileStatus.RcvInvitation ->
if (fileSizeValid(file))
if (fileSizeValid(file, senderProfile))
fileIcon(innerIcon = painterResource(MR.images.ic_arrow_downward), color = MaterialTheme.colors.primary, topPadding = 10.sp.toDp())
else
fileIcon(innerIcon = painterResource(MR.images.ic_priority_high), color = WarningOrange)
@@ -225,7 +226,9 @@ fun CIFileView(
}
}
fun fileSizeValid(file: CIFile): Boolean = file.fileSize <= getMaxFileSize(file.fileProtocol)
// whether a received file is within the size we accept from its sender
fun fileSizeValid(file: CIFile, senderProfile: LocalProfile?): Boolean =
file.fileSize <= getMaxFileSize(file.fileProtocol, senderProfile)
fun showFileErrorAlert(err: FileError, temporary: Boolean = false) {
val title: String = generalGetString(if (temporary) MR.strings.temporary_file_error else MR.strings.file_error)
@@ -35,6 +35,7 @@ fun CIImageView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
smallView: Boolean,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
@@ -160,13 +161,6 @@ fun CIImageView(
}
}
fun fileSizeValid(): Boolean {
if (file != null) {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
suspend fun imageAndFilePath(file: CIFile?): Triple<ImageBitmap, ByteArray, String>? {
val res = getLoadedImage(file)
if (res != null) {
@@ -213,12 +207,12 @@ fun CIImageView(
if (file != null) {
when {
file.fileStatus is CIFileStatus.RcvInvitation || file.fileStatus is CIFileStatus.RcvAborted ->
if (fileSizeValid()) {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
}
file.fileStatus is CIFileStatus.RcvAccepted ->
@@ -34,6 +34,7 @@ fun CIVideoView(
imageProvider: () -> ImageGalleryProvider,
showMenu: MutableState<Boolean>,
smallView: Boolean = false,
senderProfile: LocalProfile?,
receiveFile: (Long) -> Unit
) {
val blurred = remember { mutableStateOf(appPrefs.privacyMediaBlurRadius.get() > 0) }
@@ -84,7 +85,7 @@ fun CIVideoView(
if (file != null) {
when (file.fileStatus) {
CIFileStatus.RcvInvitation, CIFileStatus.RcvAborted ->
receiveFileIfValidSize(file, receiveFile)
receiveFileIfValidSize(file, senderProfile, receiveFile)
CIFileStatus.RcvAccepted ->
when (file.fileProtocol) {
FileProtocol.XFTP ->
@@ -114,7 +115,7 @@ fun CIVideoView(
DurationProgress(file, remember { mutableStateOf(false) }, remember { mutableStateOf(duration * 1000L) }, remember { mutableStateOf(0L) }/*, soundEnabled*/)
}
if (showDownloadButton(file?.fileStatus) && !blurred.value && file != null) {
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, receiveFile) }
PlayButton(error = false, sizeMultiplier, { showMenu.value = true }) { receiveFileIfValidSize(file, senderProfile, receiveFile) }
}
}
}
@@ -546,20 +547,13 @@ private fun fileStatusIcon(file: CIFile?, smallView: Boolean) {
private fun showDownloadButton(status: CIFileStatus?): Boolean =
status is CIFileStatus.RcvInvitation || status is CIFileStatus.RcvAborted
private fun fileSizeValid(file: CIFile?): Boolean {
if (file != null) {
return file.fileSize <= getMaxFileSize(file.fileProtocol)
}
return false
}
private fun receiveFileIfValidSize(file: CIFile, receiveFile: (Long) -> Unit) {
if (fileSizeValid(file)) {
private fun receiveFileIfValidSize(file: CIFile, senderProfile: LocalProfile?, receiveFile: (Long) -> Unit) {
if (fileSizeValid(file, senderProfile)) {
receiveFile(file.fileId)
} else {
AlertManager.shared.showAlertMsg(
generalGetString(MR.strings.large_file),
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol)))
String.format(generalGetString(MR.strings.contact_sent_large_file), formatBytes(getMaxFileSize(file.fileProtocol, senderProfile)))
)
}
}
@@ -449,7 +449,7 @@ fun ChatItemView(
}
if (cItem.file != null && (getLoadedFilePath(cItem.file) != null || (chatModel.connectedToRemote() && cachedRemoteReqs[cItem.file.fileSource] != false && cItem.file.loaded))) {
SaveContentItemAction(cItem, saveFileLauncher, showMenu)
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file)) {
} else if (cItem.file != null && cItem.file.fileStatus is CIFileStatus.RcvInvitation && fileSizeValid(cItem.file, ciSenderProfile(cItem, chat.chatInfo))) {
ItemAction(stringResource(MR.strings.download_file), painterResource(MR.images.ic_arrow_downward), onClick = {
withBGApi {
Log.d(TAG, "ChatItemView downloadFileAction")
@@ -201,7 +201,7 @@ fun FramedItemView(
@Composable
fun ciFileView(ci: ChatItem, text: String) {
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, receiveFile)
CIFileView(ci.file, ci.meta.itemEdited, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
if (text != "" || ci.meta.isLive) {
CIMarkdownText(chatsCtx, ci, chat, chatTTL, linkMode = linkMode, uriHandler, showViaProxy = showViaProxy, showTimestamp = showTimestamp)
}
@@ -312,7 +312,7 @@ fun FramedItemView(
} else {
when (val mc = ci.content.msgContent) {
is MsgContent.MCImage -> {
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, receiveFile)
CIImageView(image = mc.image, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, false, ciSenderProfile(ci, chatInfo), receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
@@ -320,7 +320,7 @@ fun FramedItemView(
}
}
is MsgContent.MCVideo -> {
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, receiveFile = receiveFile)
CIVideoView(image = mc.image, mc.duration, file = ci.file, imageProvider ?: return@PriorityLayout, showMenu, smallView = false, senderProfile = ciSenderProfile(ci, chatInfo), receiveFile = receiveFile)
if (mc.text == "" && !ci.meta.isLive) {
metaColor = Color.White
} else {
@@ -254,9 +254,9 @@ suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentT
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
// groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled
val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId)
val currentMembers = chatModel.groupMembers.value
val currentMembersById = chatModel.groupMembers.value.associateBy { it.id }
val newMembers = groupMembers.map { newMember ->
val currentMember = currentMembers.find { it.id == newMember.id }
val currentMember = currentMembersById[newMember.id]
val currentMemberStats = currentMember?.activeConn?.connectionStats
val newMemberConn = newMember.activeConn
if (currentMemberStats != null && newMemberConn != null && newMemberConn.connectionStats == null) {
@@ -152,7 +152,15 @@ fun ChatPreviewView(
} else {
Color.Unspecified
}
chatPreviewTitleText(color = color)
NameWithBadge(
cInfo.chatViewName,
cInfo.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
fontWeight = FontWeight.Bold,
color = color
)
}
}
is ChatInfo.Group -> {
@@ -316,13 +324,13 @@ fun ChatPreviewView(
}
}
is MsgContent.MCImage -> SmallContentPreview {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIImageView(image = mc.image, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIImageView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
}
is MsgContent.MCVideo -> SmallContentPreview {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true) {
CIVideoView(image = mc.image, mc.duration, file = ci.file, provider, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIVideoView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -334,7 +342,7 @@ fun ChatPreviewView(
}
}
is MsgContent.MCFile -> SmallContentPreviewFile {
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true) {
CIFileView(ci.file, false, remember { mutableStateOf(false) }, smallView = true, senderProfile = ciSenderProfile(ci, chat.chatInfo)) {
val user = chatModel.currentUser.value ?: return@CIFileView
withBGApi { chatModel.controller.receiveFile(chat.remoteHostId, user, it) }
}
@@ -27,8 +27,9 @@ fun ContactRequestView(contactRequest: ChatInfo.ContactRequest) {
.padding(start = 8.dp, end = 8.sp.toDp())
.weight(1F)
) {
Text(
NameWithBadge(
contactRequest.chatViewName,
contactRequest.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = MaterialTheme.typography.h3,
@@ -104,8 +104,8 @@ private fun SharePreviewView(chat: Chat, disabled: Boolean) {
} else {
ProfileImage(size = 42.dp, chat.chatInfo.image)
}
Text(
chat.chatInfo.chatViewName, maxLines = 1, overflow = TextOverflow.Ellipsis,
NameWithBadge(
chat.chatInfo.chatViewName, chat.chatInfo.nameBadge, maxLines = 1, overflow = TextOverflow.Ellipsis,
color = if (disabled) MaterialTheme.colors.secondary else if (chat.chatInfo.incognito) Indigo else Color.Unspecified
)
}
@@ -210,7 +210,7 @@ fun UserPicker(
}
} else if (currentUser != null) {
SectionItemView({ onUserClicked(currentUser) }, 80.dp, padding = PaddingValues(start = 16.dp, end = DEFAULT_PADDING), disabled = stopped) {
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped)
ProfilePreview(currentUser.profile, iconColor = iconColor, stopped = stopped, badge = currentUser.profile.localBadge)
}
}
}
@@ -468,10 +468,11 @@ fun UserProfileRow(u: User, enabled: Boolean = remember { chatModel.chatRunning
image = u.image,
size = 54.dp * fontSizeSqrtMultiplier
)
Text(
// the end padding is on the row, not the name, so the badge stays right after the name
NameWithBadge(
u.displayName,
modifier = Modifier
.padding(start = 10.dp, end = 8.dp),
u.profile.localBadge,
Modifier.padding(start = 10.dp, end = 8.dp),
color = if (enabled) MenuTextColor else MaterialTheme.colors.secondary,
fontWeight = if (u.activeUser) FontWeight.Medium else FontWeight.Normal
)
@@ -54,8 +54,9 @@ fun ContactPreviewView(
if (cInfo.contact.verified) {
VerifiedIcon()
}
Text(
NameWithBadge(
cInfo.chatViewName,
cInfo.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
@@ -63,8 +64,9 @@ fun ContactPreviewView(
}
is ChatInfo.ContactRequest ->
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
NameWithBadge(
cInfo.chatViewName,
cInfo.nameBadge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = textColor
@@ -4,6 +4,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.*
@@ -15,10 +16,12 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.*
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.LocalBadge
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
@@ -287,6 +290,7 @@ class AlertManager {
profileName: String,
profileFullName: String,
profileImage: @Composable () -> Unit,
profileBadge: LocalBadge? = null,
subtitle: String? = null,
information: String? = null,
confirmText: String? = generalGetString(MR.strings.connect_plan_open_chat),
@@ -314,8 +318,17 @@ class AlertManager {
) {
profileImage()
Spacer(Modifier.height(DEFAULT_PADDING_HALF))
val nameFontSize = MaterialTheme.typography.h4.fontSize
Text(
profileName,
buildAnnotatedString {
append(profileName)
if (profileBadge != null) {
append(" ")
appendInlineContent(id = "nameBadge")
}
},
inlineContent =
if (profileBadge != null) mapOf("nameBadge" to nameBadgeInline(profileBadge, nameFontSize)) else emptyMap(),
textAlign = TextAlign.Center,
style = MaterialTheme.typography.h4,
lineHeight = 20.sp,
@@ -3,10 +3,14 @@ package chat.simplex.common.views.helpers
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.shape.*
import androidx.compose.material.Icon
import androidx.compose.material.LocalTextStyle
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -14,15 +18,28 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.*
import androidx.compose.ui.graphics.*
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.UriHandler
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.*
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import chat.simplex.common.model.BadgeStatus
import chat.simplex.common.model.BadgeType
import chat.simplex.common.model.ChatInfo
import chat.simplex.common.model.LocalBadge
import chat.simplex.common.model.localDate
import chat.simplex.common.platform.*
import chat.simplex.common.ui.theme.*
import chat.simplex.res.MR
import dev.icerock.moko.resources.ImageResource
import kotlin.math.max
import kotlin.math.roundToInt
@Composable
fun ChatInfoImage(chatInfo: ChatInfo, size: Dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, shadow: Boolean = false) {
@@ -103,6 +120,137 @@ fun ProfileImage(
}
}
// badge height in em: calibrated visually so the badge top matches capital letters and digits
// (Inter's declared cap height is 2048/2816 = 0.727em, but the rendered text is taller than the metrics predict)
private const val fontCapHeightRatio = 0.95f
// fraction of the badge height pushed below the text baseline (like the undershoot of round letters)
private const val badgeBaselineOffsetRatio = 0.05f
// the badge glyph's width / height (the SVGs are cropped to the glyph: 300 x 399)
private const val badgeAspectRatio = 300f / 399f
// A contact/member name with the badge right after it: the badge is baseline-aligned with the name
// and sized to its font (fontSize if given, otherwise style.fontSize), and a truncated name keeps it visible.
@Composable
fun NameWithBadge(
name: String,
badge: LocalBadge?,
modifier: Modifier = Modifier,
color: Color = Color.Unspecified,
fontSize: TextUnit = TextUnit.Unspecified,
fontStyle: FontStyle? = null,
fontWeight: FontWeight? = null,
overflow: TextOverflow = TextOverflow.Clip,
maxLines: Int = Int.MAX_VALUE,
style: TextStyle = LocalTextStyle.current
) {
Row(modifier) {
Text(
name,
Modifier.alignByBaseline().weight(1f, fill = false),
color = color,
fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight,
overflow = overflow,
maxLines = maxLines,
style = style
)
NameBadge(badge, if (fontSize.isSpecified) fontSize else style.fontSize)
}
}
// Badge next to the contact name in a Row: top aligned with capital letters, bottom just below the text baseline.
// Use NameWithBadge unless the row needs special arrangement; then the name Text must use Modifier.alignByBaseline().
@Composable
fun RowScope.NameBadge(badge: LocalBadge?, fontSize: TextUnit = LocalTextStyle.current.fontSize) {
// a badge that expired over a month ago (ExpiredOld) is not shown
if (badge == null || badge.status == BadgeStatus.ExpiredOld) return
val height = with(LocalDensity.current) { (if (fontSize.isSpecified) fontSize else 14.sp).toDp() } * fontCapHeightRatio
BadgeGlyph(
badge,
// the alignment line sits badgeBaselineOffsetRatio above the badge's bottom edge,
// so the Row places the badge that much below the text baseline;
// 6.dp matches the visible gap between the name and the verification shield:
// the shield has 3.dp end padding plus ~17% internal glyph margin, the badge artwork has none
Modifier.alignBy { (it.measuredHeight * (1 - badgeBaselineOffsetRatio)).roundToInt() }.padding(start = 6.dp).height(height).aspectRatio(badgeAspectRatio)
)
}
// badge inside a Text via appendInlineContent(id): bottom on the baseline, cap-height tall.
// precede with append(" ") for the space between the name and the badge.
fun nameBadgeInline(badge: LocalBadge, fontSize: TextUnit, onBadgeClick: (() -> Unit)? = null): InlineTextContent {
val height = fontSize * fontCapHeightRatio
return InlineTextContent(
Placeholder(height * badgeAspectRatio, height, PlaceholderVerticalAlign.AboveBaseline)
) {
// the placeholder bottom sits on the baseline and can't extend below it,
// so the badge is drawn shifted down by badgeBaselineOffsetRatio instead
BadgeGlyph(badge, Modifier.fillMaxSize().graphicsLayer { translationY = size.height * badgeBaselineOffsetRatio }, onBadgeClick)
}
}
@Composable
private fun BadgeGlyph(badge: LocalBadge, modifier: Modifier, onBadgeClick: (() -> Unit)? = null) {
val mod = modifier.let { if (onBadgeClick != null) it.clickable(onClick = onBadgeClick) else it }
if (badge.status == BadgeStatus.Failed || badge.status == BadgeStatus.UnknownKey) {
Icon(painterResource(MR.images.ic_warning_filled), contentDescription = null, tint = WarningOrange, modifier = mod)
} else {
Image(
painterResource(badgeImage(badge.badge.badgeType)),
contentDescription = null,
contentScale = ContentScale.Fit,
alpha = if (badge.status == BadgeStatus.Expired) 0.4f else 1f,
modifier = mod
)
}
}
fun showBadgeInfoAlert(name: String, badge: LocalBadge, uriHandler: UriHandler) {
// a verified badge's type is signed and can't be faked, so the real (possibly unknown) type name is the title
val title = badge.badge.badgeType.text.replaceFirstChar { it.uppercase() }
when {
badge.status == BadgeStatus.Failed ->
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badge_unverified_title),
text = generalGetString(MR.strings.badge_unverified_desc)
)
badge.status == BadgeStatus.UnknownKey ->
AlertManager.shared.showAlertMsg(
title = generalGetString(MR.strings.badge_unknown_key_title),
text = generalGetString(MR.strings.badge_unknown_key_desc)
)
badge.badge.badgeType is BadgeType.Investor ->
AlertManager.shared.showAlertDialog(
title = title,
text = String.format(generalGetString(MR.strings.badge_invested), name),
confirmText = generalGetString(MR.strings.ok),
dismissText = generalGetString(MR.strings.learn_more),
onDismiss = { uriHandler.openUriCatching("https://simplex.chat/crowdfunding") }
)
else -> {
// Supporter, Legend and unknown types use the supporter wording
val expiry = badge.badge.badgeExpiry
val supports =
if (badge.status == BadgeStatus.Expired && expiry != null)
String.format(generalGetString(MR.strings.badge_supported_simplex), name, localDate(expiry))
else
String.format(generalGetString(MR.strings.badge_supports_simplex), name)
AlertManager.shared.showAlertMsg(
title = title,
text = supports + "\n\n" + generalGetString(MR.strings.badge_support_from_v7)
)
}
}
}
private fun badgeImage(t: BadgeType): ImageResource = when (t) {
is BadgeType.Legend -> MR.images.badge_legend
is BadgeType.Investor -> MR.images.badge_investor
else -> MR.images.badge_supporter // Supporter + Unknown
}
@Composable
fun ProfileImage(size: Dp, image: ImageResource) {
Image(
@@ -102,6 +102,28 @@ fun TextEditor(
}
}
@Composable
fun PlainTextEditor(
value: MutableState<String>,
placeholder: String? = null,
singleLine: Boolean = true
) {
BasicTextField(
value = value.value,
onValueChange = { value.value = it },
modifier = Modifier.fillMaxWidth().padding(horizontal = DEFAULT_PADDING, vertical = 12.dp),
textStyle = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.onBackground),
singleLine = singleLine,
cursorBrush = SolidColor(MaterialTheme.colors.secondary),
decorationBox = { innerTextField ->
if (value.value.isEmpty() && placeholder != null) {
Text(placeholder, style = MaterialTheme.typography.body1.copy(color = MaterialTheme.colors.secondary))
}
innerTextField()
}
)
}
@Serializable
data class ParsedFormattedText(
val formattedText: List<FormattedText>? = null
@@ -126,6 +126,10 @@ const val MAX_FILE_SIZE_SMP: Long = 8000000
const val MAX_FILE_SIZE_XFTP: Long = 1_073_741_824 // 1GB
// raised XFTP receive limits for files from a sender with a supporter badge (also investor) or a legend badge
const val MAX_FILE_SIZE_XFTP_SUPPORTER: Long = 2_147_483_648 // 2GB
const val MAX_FILE_SIZE_XFTP_LEGEND: Long = 5_368_709_120 // 5GB
const val MAX_FILE_SIZE_LOCAL: Long = Long.MAX_VALUE
expect fun getAppFileUri(fileName: String): URI
@@ -442,14 +446,25 @@ fun directoryFileCountAndSize(dir: String): Pair<Int, Long> { // count, size in
return fileCount to bytes
}
fun getMaxFileSize(fileProtocol: FileProtocol): Long {
return when (fileProtocol) {
FileProtocol.XFTP -> MAX_FILE_SIZE_XFTP
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL
fun getMaxFileSize(fileProtocol: FileProtocol, senderProfile: LocalProfile? = null): Long = when (fileProtocol) {
FileProtocol.SMP -> MAX_FILE_SIZE_SMP
FileProtocol.LOCAL -> MAX_FILE_SIZE_LOCAL
// a sender's active badge raises the XFTP limit: legend to 5GB, any other (supporter/investor) to 2GB
FileProtocol.XFTP -> {
val badge = senderProfile?.localBadge
if (badge == null || badge.status != BadgeStatus.Active) MAX_FILE_SIZE_XFTP
else if (badge.badge.badgeType == BadgeType.Legend) MAX_FILE_SIZE_XFTP_LEGEND
else MAX_FILE_SIZE_XFTP_SUPPORTER
}
}
// the profile of whoever sent a received chat item - the group member, or the direct chat's contact
fun ciSenderProfile(ci: ChatItem, chatInfo: ChatInfo): LocalProfile? = when (val dir = ci.chatDir) {
is CIDirection.GroupRcv -> dir.groupMember.memberProfile
is CIDirection.DirectRcv -> (chatInfo as? ChatInfo.Direct)?.contact?.profile
else -> null
}
expect suspend fun getBitmapFromVideo(uri: URI, timestamp: Long? = null, random: Boolean = true, withAlertOnException: Boolean = true): VideoPlayerInterface.PreviewAndDuration
fun showWrongUriAlert() {
@@ -413,12 +413,12 @@ private fun MutableState<MigrationFromState>.FinishedView(chatDeletion: Boolean)
}
) {}
}
SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device))
SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption))
if (chatDeletion) {
ProgressView()
}
}
SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_you_must_not_start_database_on_two_device))
SectionTextFooter(annotatedStringResource(MR.strings.migrate_from_device_using_on_two_device_breaks_encryption))
}
@Composable
@@ -474,6 +474,8 @@ private fun showOpenKnownContactAlert(chatModel: ChatModel, rhId: Long?, close:
icon = contact.chatIconName
)
},
// the alert shows the badge inline, so it skips the long-expired (ExpiredOld) badge here too
profileBadge = if (contact.active && contact.profile.localBadge?.status != BadgeStatus.ExpiredOld) contact.profile.localBadge else null,
confirmText = generalGetString(if (contact.nextConnectPrepared) MR.strings.connect_plan_open_new_chat else MR.strings.connect_plan_open_chat),
onConfirm = {
openKnownContact(chatModel, rhId, close, contact)
@@ -633,6 +635,7 @@ fun showPrepareContactAlert(
else MR.images.ic_account_circle_filled
)
},
profileBadge = if (contactShortLinkData.localBadge?.status == BadgeStatus.ExpiredOld) null else contactShortLinkData.localBadge,
information = ownerVerificationMessage(ownerVerification),
confirmText = generalGetString(MR.strings.connect_plan_open_new_chat),
onConfirm = {
@@ -230,7 +230,8 @@ private fun ProfilePickerOption(
disabled: Boolean,
onSelected: () -> Unit,
image: @Composable () -> Unit,
onInfo: (() -> Unit)? = null
onInfo: (() -> Unit)? = null,
badge: LocalBadge? = null
) {
Row(
Modifier
@@ -243,7 +244,7 @@ private fun ProfilePickerOption(
) {
image()
TextIconSpaced(false)
Text(title, modifier = Modifier.align(Alignment.CenterVertically))
NameWithBadge(title, badge, Modifier.align(Alignment.CenterVertically))
if (onInfo != null) {
Spacer(Modifier.padding(6.dp))
Column(Modifier
@@ -365,7 +366,8 @@ fun ActiveProfilePicker(
}
}
},
image = { ProfileImage(size = 42.dp, image = user.image) }
image = { ProfileImage(size = 42.dp, image = user.image) },
badge = user.profile.localBadge
)
}
@@ -326,12 +326,13 @@ fun AppVersionItem(showVersion: () -> Unit) {
Text(appVersionInfo.first + (if (appVersionInfo.second != null) " (" + appVersionInfo.second + ")" else ""))
}
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false) {
@Composable fun ProfilePreview(profileOf: NamedChat, size: Dp = 60.dp, iconColor: Color = MaterialTheme.colors.secondaryVariant, textColor: Color = MaterialTheme.colors.onBackground, stopped: Boolean = false, badge: LocalBadge? = null) {
ProfileImage(size = size, image = profileOf.image, color = iconColor)
Spacer(Modifier.padding(horizontal = 8.dp))
Column(Modifier.height(size), verticalArrangement = Arrangement.Center) {
Text(
NameWithBadge(
profileOf.displayName,
badge,
style = MaterialTheme.typography.caption,
fontWeight = FontWeight.Bold,
color = if (stopped) MaterialTheme.colors.secondary else textColor,
@@ -1850,8 +1850,10 @@
<!-- GroupMemberRole -->
<string name="group_member_role_observer">observer</string>
<string name="group_member_role_observer_channel">subscriber</string>
<string name="group_member_role_author">author</string>
<string name="group_member_role_member">member</string>
<string name="group_member_role_member_channel">contributor</string>
<string name="group_member_role_moderator">moderator</string>
<string name="group_member_role_admin">admin</string>
<string name="group_member_role_owner">owner</string>
@@ -1925,6 +1927,20 @@
<string name="button_welcome_message">Welcome message</string>
<string name="group_link">Group link</string>
<string name="channel_link">Channel link</string>
<string name="channel_webpage">Channel webpage</string>
<string name="group_webpage">Group webpage</string>
<string name="advanced_options">Advanced options</string>
<string name="web_page_url_placeholder">https://</string>
<string name="allow_anyone_to_embed">Allow anyone to embed</string>
<string name="enter_webpage_url">Enter webpage URL</string>
<string name="webpage_url_footer">It will be shown to subscribers and used to allow loading the preview.</string>
<string name="webpage_code">Webpage code</string>
<string name="webpage_code_footer">Add this code to your webpage. It will display the preview of your channel / group.</string>
<string name="copy_code">Copy code</string>
<string name="webpage_info">Create a webpage to show your channel preview to visitors before they subscribe. Host it yourself or use any static hosting.</string>
<string name="relays_no_web_support">Used chat relays do not support webpages.</string>
<string name="embed_any_webpage_can_show">Any webpage can show the preview.</string>
<string name="embed_only_your_page">Only your page above can show the preview.</string>
<string name="create_group_link">Create group link</string>
<string name="button_create_group_link">Create link</string>
<string name="delete_link_question">Delete link?</string>
@@ -2029,9 +2045,10 @@
<string name="change_role">Change role</string>
<string name="change_verb">Change</string>
<string name="switch_verb">Switch</string>
<string name="change_member_role_question">Change group role?</string>
<string name="change_member_role_question">Change role?</string>
<string name="member_role_will_be_changed_with_notification">The role will be changed to "%s". Everyone in the group will be notified.</string>
<string name="member_role_will_be_changed_with_notification_chat">The role will be changed to "%s". Everyone in the chat will be notified.</string>
<string name="member_role_will_be_changed_with_notification_channel">The role will be changed to "%s". Everyone in the channel will be notified.</string>
<string name="member_role_will_be_changed_with_invitation">The role will be changed to "%s". The member will receive a new invitation.</string>
<string name="connect_via_member_address_alert_title">Connect directly?</string>
<string name="connect_via_member_address_alert_desc">Сonnection request will be sent to this group member.</string>
@@ -3008,6 +3025,7 @@
<string name="relay_status_new">new</string>
<string name="relay_status_invited">invited</string>
<string name="relay_status_accepted">accepted</string>
<string name="relay_status_acknowledged_roster">acknowledged roster</string>
<string name="relay_status_active">active</string>
<string name="relay_status_inactive">inactive</string>
<string name="relay_status_rejected">rejected</string>
@@ -3112,4 +3130,12 @@
<string name="tray_tooltip_unread">SimpleX — %d unread</string>
<string name="appearance_minimize_to_tray">Close to tray</string>
<string name="appearance_minimize_to_tray_desc">Runs in background to receive messages</string>
<string name="badge_supports_simplex">%s supports SimpleX Chat.</string>
<string name="badge_supported_simplex">%1$s supported SimpleX Chat. The badge expired on %2$s.</string>
<string name="badge_support_from_v7">You can support SimpleX starting from v7 of the app.</string>
<string name="badge_invested">%s invested in SimpleX Chat crowdfunding.</string>
<string name="badge_unverified_title">Unverified badge</string>
<string name="badge_unverified_desc">This badge could not be verified and may not be genuine.</string>
<string name="badge_unknown_key_title">Badge cannot be verified</string>
<string name="badge_unknown_key_desc">The badge is signed with a key that this version of the app does not recognize. Update the app to verify this badge.</string>
</resources>
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#ffee9b"/>
<stop offset="5%" stop-color="#ffee9b"/>
<stop offset="50%" stop-color="#ffc233"/>
<stop offset="100%" stop-color="#e67700"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#26dee8"/>
<stop offset="50%" stop-color="#3064ea"/>
<stop offset="100%" stop-color="#001064"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" width="300" height="399" viewBox="8.25 8.25 300 399">
<defs>
<linearGradient id="g" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#29f5ff"/>
<stop offset="5%" stop-color="#29f5ff"/>
<stop offset="95%" stop-color="#527eed"/>
<stop offset="100%" stop-color="#3669e9"/>
</linearGradient>
</defs>
<path fill="url(#g)" d="M98.25,8.25h120a90,90,0,0,1,90,90v219a90,90,0,0,1-90,90h-120a90,90,0,0,1-90-90v-219a90,90,0,0,1,90-90Z"/>
<path fill="#ffffff" fill-rule="nonzero" d="M107.25,214.5h-8.62A40.51,40.51,0,0,1,58.12,174V117.37a40.51,40.51,0,0,1,40.51-40.5h8.62a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.12a21.75,21.75,0,0,0-21.76,21.75v49.13A21.75,21.75,0,0,0,102.38,192h1.12a21.75,21.75,0,0,0,21.75-21.75V121.12Z M218.25,214.5h-8.63a40.5,40.5,0,0,1-40.5-40.5V117.37a40.51,40.51,0,0,1,40.5-40.5h8.63a40.5,40.5,0,0,1,40.5,40.5V174a40.5,40.5,0,0,1-40.5,40.5Zm18-93.38a21.75,21.75,0,0,0-21.75-21.75h-1.13a21.75,21.75,0,0,0-21.75,21.75v49.13A21.75,21.75,0,0,0,213.37,192h1.13a21.75,21.75,0,0,0,21.75-21.75V121.12Z M114.37,133.88h88.88a13.13,13.13,0,1,1,0,26.25H114.37a13.13,13.13,0,0,1,0-26.25Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -3,7 +3,7 @@
useWorker = typeof window.Worker !== "undefined";
isDesktop = true;
// Create WebSocket connection.
const socket = new WebSocket(`ws://${location.host}`);
const socket = new WebSocket(`ws://${location.host}${location.search}`);
socket.addEventListener("open", (_event) => {
console.log("Opened socket");
sendMessageToNative = (msg) => {
@@ -192,4 +192,4 @@ function updateCallInfoView(state, description) {
document.getElementById("state").innerText = state;
document.getElementById("description").innerText = description;
}
//# sourceMappingURL=ui.js.map
//# sourceMappingURL=ui.js.map
@@ -18,10 +18,12 @@ import org.nanohttpd.protocols.http.response.Status
import org.nanohttpd.protocols.websockets.*
import java.io.IOException
import java.net.BindException
import java.net.URI
import java.security.SecureRandom
import java.util.Base64
private const val SERVER_HOST = "localhost"
private const val SERVER_PORT = 50395
private const val CALL_SERVER_TOKEN_BYTES = 32
val connections = ArrayList<WebSocket>()
// Spec: spec/services/calls.md#ActiveCallView
@@ -153,14 +155,15 @@ private fun SendStateUpdates() {
@Composable
fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (WVAPIMessage) -> Unit) {
val uriHandler = LocalUriHandler.current
val token = remember { newCallServerToken() }
val endCall = {
val call = chatModel.activeCall.value
if (call != null) withBGApi { chatModel.callManager.endCall(call) }
}
val server = remember {
startServer(onResponse).apply {
startServer(onResponse, token = token).apply {
try {
uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/")
uriHandler.openUri("http://${SERVER_HOST}:${listeningPort}/simplex/call/?token=$token")
} catch (e: Exception) {
Log.e(TAG, "Unable to open browser: ${e.stackTraceToString()}")
AlertManager.shared.showAlertMsg(
@@ -208,7 +211,11 @@ fun WebRTCController(callCommand: SnapshotStateList<WCallCommand>, onResponse: (
}
}
fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): NanoWSD {
fun startServer(
onResponse: (WVAPIMessage) -> Unit,
port: Int = SERVER_PORT,
token: String = newCallServerToken(),
): NanoWSD {
val server = object: NanoWSD(SERVER_HOST, port) {
override fun openWebSocket(session: IHTTPSession): WebSocket = MyWebSocket(onResponse, session)
@@ -227,8 +234,18 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na
override fun handle(session: IHTTPSession): Response {
return when {
session.headers["upgrade"] == "websocket" -> super.handle(session)
session.uri.contains("/simplex/call/") -> resourcesToResponse("/desktop/call.html")
session.headers["upgrade"] == "websocket" ->
if (hasValidCallServerToken(session.parameters, token)) {
super.handle(session)
} else {
unauthorizedResponse()
}
session.uri.contains("/simplex/call/") ->
if (hasValidCallServerToken(session.parameters, token)) {
resourcesToResponse("/desktop/call.html")
} else {
unauthorizedResponse()
}
else -> resourcesToResponse(uriCreateOrNull(session.uri)?.path ?: return newFixedLengthResponse("Error parsing URL"))
}
}
@@ -239,11 +256,23 @@ fun startServer(onResponse: (WVAPIMessage) -> Unit, port: Int = SERVER_PORT): Na
if (port == 0) throw e
Log.w(TAG, "Call server port $port is busy, using a random port: ${e.message}")
server.stop()
return startServer(onResponse, port = 0)
return startServer(onResponse, port = 0, token = token)
}
return server
}
internal fun newCallServerToken(): String {
val bytes = ByteArray(CALL_SERVER_TOKEN_BYTES)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
internal fun hasValidCallServerToken(parameters: Map<String, List<String>>, token: String): Boolean =
token.isNotEmpty() && parameters["token"]?.any { it == token } == true
private fun unauthorizedResponse(): Response =
newFixedLengthResponse(Status.UNAUTHORIZED, "text/plain", "Unauthorized")
class MyWebSocket(val onResponse: (WVAPIMessage) -> Unit, handshakeRequest: IHTTPSession) : WebSocket(handshakeRequest) {
override fun onOpen() {
connections.add(this)
@@ -12,7 +12,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -67,15 +66,17 @@ actual fun UserPickerUsersSection(
}
}
Text(
user.displayName,
fontSize = 12.sp,
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.width(65.dp),
textAlign = TextAlign.Center
)
Row(Modifier.width(65.dp), horizontalArrangement = Arrangement.Center) {
Text(
user.displayName,
fontSize = 12.sp,
fontWeight = if (user.activeUser) FontWeight.Bold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.alignByBaseline().weight(1f, fill = false)
)
NameBadge(user.profile.localBadge, 12.sp)
}
}
}
}
@@ -321,7 +321,11 @@ private suspend fun downloadAsset(asset: GitHubAsset) {
stream.copyTo(output)
}
val newFile = File(file.parentFile, asset.name)
file.renameTo(newFile)
// Moving instead of renameTo: a bare rename can silently fail (returns false, ignored),
// and the enclosing createTmpFileAndDelete then deletes the only copy in its finally block,
// leaving the user with an empty download dir. Files.move performs the same in-place rename
// when possible, falls back to copy when it can't, and throws (handled below) on real failure.
Files.move(file.toPath(), newFile.toPath(), StandardCopyOption.REPLACE_EXISTING)
AlertManager.shared.showAlertDialogButtonsColumn(
generalGetString(MR.strings.app_check_for_updates_download_completed_title),
@@ -0,0 +1,70 @@
package chat.simplex.app
import chat.simplex.common.views.call.startServer
import java.net.Socket
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
// Integration test for the desktop call server's token gate (the handle() enforcement),
// which the unit-level CallServerTokenTest does not exercise.
class CallServerAuthTest {
private val token = "integration-test-token"
// port = 0 binds a random free port, avoiding a clash with a real call server on SERVER_PORT
private val server = startServer(onResponse = {}, port = 0, token = token)
private val port get() = server.listeningPort
@AfterTest
fun tearDown() = server.stop()
@Test
fun testWebSocketUpgradeRejectedWithoutToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/")))
}
@Test
fun testWebSocketUpgradeRejectedWithWrongToken() {
assertEquals(401, requestStatus(webSocketUpgrade(path = "/?token=wrong")))
}
@Test
fun testWebSocketUpgradeAcceptedWithToken() {
assertEquals(101, requestStatus(webSocketUpgrade(path = "/?token=$token")))
}
@Test
fun testCallPageRejectedWithoutToken() {
assertEquals(401, requestStatus(get(path = "/simplex/call/")))
}
@Test
fun testCallPagePassesAuthGateWithToken() {
// Resource serving may differ in the test classpath, so assert only that the auth gate was passed (not 401)
assertNotEquals(401, requestStatus(get(path = "/simplex/call/?token=$token")))
}
private fun get(path: String): List<String> = listOf("GET $path HTTP/1.1", "Host: localhost:$port")
private fun webSocketUpgrade(path: String): List<String> =
listOf(
"GET $path HTTP/1.1",
"Host: localhost:$port",
"Upgrade: websocket",
"Connection: Upgrade",
"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==",
"Sec-WebSocket-Version: 13",
)
// Sends a raw HTTP request and returns the response status code from the status line.
private fun requestStatus(requestLines: List<String>): Int =
Socket("localhost", port).use { socket ->
socket.soTimeout = 5000
socket.getOutputStream().apply {
write((requestLines.joinToString("\r\n") + "\r\n\r\n").toByteArray())
flush()
}
val statusLine = socket.getInputStream().bufferedReader().readLine() ?: error("no response from call server")
statusLine.split(" ")[1].toInt()
}
}
@@ -0,0 +1,29 @@
package chat.simplex.app
import chat.simplex.common.views.call.hasValidCallServerToken
import chat.simplex.common.views.call.newCallServerToken
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class CallServerTokenTest {
@Test
fun testCallServerTokenRequiresExactTokenParameter() {
val token = "secret"
assertTrue(hasValidCallServerToken(mapOf("token" to listOf(token)), token))
assertFalse(hasValidCallServerToken(mapOf("token" to listOf("wrong")), token))
assertFalse(hasValidCallServerToken(mapOf("x-token" to listOf(token)), token))
assertFalse(hasValidCallServerToken(mapOf("token" to listOf(token)), ""))
}
@Test
fun testCallServerTokenIsUrlSafe() {
val token = newCallServerToken()
assertTrue(token.length >= 40)
assertFalse(token.contains("+"))
assertFalse(token.contains("/"))
assertFalse(token.contains("="))
}
}
+4 -4
View File
@@ -24,13 +24,13 @@ android.nonTransitiveRClass=true
kotlin.mpp.androidSourceSetLayoutVersion=2
kotlin.jvm.target=11
android.version_name=6.5.4
android.version_code=353
android.version_name=7.0-beta.0
android.version_code=356
android.bundle=false
desktop.version_name=6.5.4
desktop.version_code=145
desktop.version_name=7.0-beta.0
desktop.version_code=147
kotlin.version=2.1.20
gradle.plugin.version=8.7.0
+7 -1
View File
@@ -1,8 +1,14 @@
module Main where
import Server (simplexChatServer)
import Simplex.Chat.Badges.CLI (runBadgeCommand)
import Simplex.Chat.Terminal (terminalChatConfig)
import Simplex.Chat.Terminal.Main (simplexChatCLI)
import System.Environment (getArgs)
main :: IO ()
main = simplexChatCLI terminalChatConfig (Just simplexChatServer)
main = do
args <- getArgs
case args of
("badge" : _) -> runBadgeCommand args
_ -> simplexChatCLI terminalChatConfig (Just simplexChatServer)
@@ -313,43 +313,48 @@ getGroupReg_ db gId =
getGroupAndReg :: ChatController -> User -> GroupId -> IO (Either String (GroupInfo, GroupReg))
getGroupAndReg cc user@User {userId, userContactId} gId =
withDB "getGroupAndReg" cc $ \db ->
ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show gId ++ " not found") $
DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId)
withDB "getGroupAndReg" cc $ \db -> do
currentTs <- liftIO getCurrentTime
ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show gId ++ " not found") $
DB.query db (groupReqQuery <> " AND g.group_id = ?") (userId, userContactId, gId)
getUserGroupReg :: ChatController -> User -> ContactId -> UserGroupRegId -> IO (Either String (GroupInfo, GroupReg))
getUserGroupReg cc user@User {userId, userContactId} ctId ugrId =
withDB "getUserGroupReg" cc $ \db ->
ExceptT $ firstRow (toGroupInfoReg (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $
withDB "getUserGroupReg" cc $ \db -> do
currentTs <- liftIO getCurrentTime
ExceptT $ firstRow (toGroupInfoReg currentTs (storeCxt cc) user) ("group " ++ show ugrId ++ " not found") $
DB.query db (groupReqQuery <> " AND r.contact_id = ? AND r.user_group_reg_id = ?") (userId, userContactId, ctId, ugrId)
getUserGroupRegs :: ChatController -> User -> ContactId -> IO (Either String [(GroupInfo, GroupReg)])
getUserGroupRegs cc user@User {userId, userContactId} ctId =
withDB' "getUserGroupRegs" cc $ \db ->
map (toGroupInfoReg (storeCxt cc) user)
withDB' "getUserGroupRegs" cc $ \db -> do
currentTs <- getCurrentTime
map (toGroupInfoReg currentTs (storeCxt cc) user)
<$> DB.query db (groupReqQuery <> " AND r.contact_id = ? ORDER BY r.user_group_reg_id") (userId, userContactId, ctId)
getAllListedGroups :: ChatController -> User -> IO (Either String [(GroupInfo, GroupReg, Maybe GroupLink)])
getAllListedGroups cc user = withDB' "getAllListedGroups" cc $ \db -> getAllListedGroups_ db (storeCxt cc) user
getAllListedGroups_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg, Maybe GroupLink)]
getAllListedGroups_ db cxt user@User {userId, userContactId} =
getAllListedGroups_ db cxt user@User {userId, userContactId} = do
currentTs <- getCurrentTime
DB.query db (groupReqQuery <> " AND r.group_reg_status = ?") (userId, userContactId, GRSActive)
>>= mapM (withGroupLink . toGroupInfoReg cxt user)
>>= mapM (withGroupLink . toGroupInfoReg currentTs cxt user)
where
withGroupLink (g, gr) = (g,gr,) . eitherToMaybe <$> runExceptT (getGroupLink db user g)
searchListedGroups :: ChatController -> User -> SearchType -> Maybe GroupId -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int))
searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pageSize =
withDB' "searchListedGroups" cc $ \db ->
withDB' "searchListedGroups" cc $ \db -> do
currentTs <- getCurrentTime
case searchType of
STAll -> case lastGroup_ of
Nothing -> do
gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize)
n <- count $ DB.query db countQuery' (Only GRSActive)
pure (gs, n)
Just gId -> do
gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize)
n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId)
pure (gs, n)
where
@@ -357,11 +362,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa
orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC "
STRecent -> case lastGroup_ of
Nothing -> do
gs <- groups $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, pageSize)
n <- count $ DB.query db countQuery' (Only GRSActive)
pure (gs, n)
Just gId -> do
gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, pageSize)
n <- count $ DB.query db (countQuery' <> " AND r.group_id > ?") (GRSActive, gId)
pure (gs, n)
where
@@ -369,11 +374,11 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa
orderBy = " ORDER BY r.created_at DESC, r.group_reg_id ASC "
STSearch search -> case lastGroup_ of
Nothing -> do
gs <- groups $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, s, s, s, s, pageSize)
n <- count $ DB.query db (countQuery' <> searchCond) (GRSActive, s, s, s, s)
pure (gs, n)
Just gId -> do
gs <- groups $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize)
gs <- groups currentTs $ DB.query db (listedGroupQuery <> " AND r.group_id > ? " <> searchCond <> orderBy <> " LIMIT ?") (userId, userContactId, GRSActive, gId, s, s, s, s, pageSize)
n <- count $ DB.query db (countQuery' <> " AND r.group_id > ? " <> searchCond) (GRSActive, gId, s, s, s, s)
pure (gs, n)
where
@@ -381,7 +386,7 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa
countQuery' = countQuery <> " JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE r.group_reg_status = ? "
orderBy = " ORDER BY g.summary_current_members_count DESC, r.group_reg_id ASC "
where
groups = (map (toGroupInfoReg (storeCxt cc) user) <$>)
groups currentTs = (map (toGroupInfoReg currentTs (storeCxt cc) user) <$>)
count = maybeFirstRow' 0 fromOnly
listedGroupQuery = groupReqQuery <> " AND r.group_reg_status = ? "
countQuery = "SELECT COUNT(1) FROM groups g JOIN sx_directory_group_regs r ON g.group_id = r.group_id "
@@ -395,21 +400,24 @@ searchListedGroups cc user@User {userId, userContactId} searchType lastGroup_ pa
|]
getAllGroupRegs_ :: DB.Connection -> StoreCxt -> User -> IO [(GroupInfo, GroupReg)]
getAllGroupRegs_ db cxt user@User {userId, userContactId} =
map (toGroupInfoReg cxt user)
getAllGroupRegs_ db cxt user@User {userId, userContactId} = do
currentTs <- getCurrentTime
map (toGroupInfoReg currentTs cxt user)
<$> DB.query db groupReqQuery (userId, userContactId)
getDuplicateGroupRegs :: ChatController -> User -> Text -> IO (Either String [(GroupInfo, GroupReg)])
getDuplicateGroupRegs cc user@User {userId, userContactId} displayName =
withDB' "getDuplicateGroupRegs" cc $ \db ->
map (toGroupInfoReg (storeCxt cc) user)
withDB' "getDuplicateGroupRegs" cc $ \db -> do
currentTs <- getCurrentTime
map (toGroupInfoReg currentTs (storeCxt cc) user)
<$> DB.query db (groupReqQuery <> " AND gp.display_name = ?") (userId, userContactId, displayName)
listLastGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int))
listLastGroups cc user@User {userId, userContactId} count =
withDB' "getUserGroupRegs" cc $ \db -> do
currentTs <- getCurrentTime
gs <-
map (toGroupInfoReg (storeCxt cc) user)
map (toGroupInfoReg currentTs (storeCxt cc) user)
<$> DB.query db (groupReqQuery <> " ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count)
n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs"
pure (gs, n)
@@ -417,15 +425,16 @@ listLastGroups cc user@User {userId, userContactId} count =
listPendingGroups :: ChatController -> User -> Int -> IO (Either String ([(GroupInfo, GroupReg)], Int))
listPendingGroups cc user@User {userId, userContactId} count =
withDB' "getUserGroupRegs" cc $ \db -> do
currentTs <- getCurrentTime
gs <-
map (toGroupInfoReg (storeCxt cc) user)
map (toGroupInfoReg currentTs (storeCxt cc) user)
<$> DB.query db (groupReqQuery <> " AND r.group_reg_status LIKE 'pending_approval%' ORDER BY group_reg_id DESC LIMIT ?") (userId, userContactId, count)
n <- maybeFirstRow' 0 fromOnly $ DB.query_ db "SELECT COUNT(1) FROM sx_directory_group_regs WHERE group_reg_status LIKE 'pending_approval%'"
pure (gs, n)
toGroupInfoReg :: StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg)
toGroupInfoReg cxt User {userContactId} (groupRow :. grRow) =
(toGroupInfo cxt userContactId [] groupRow, rowToGroupReg grRow)
toGroupInfoReg :: UTCTime -> StoreCxt -> User -> (GroupInfoRow :. GroupRegRow) -> (GroupInfo, GroupReg)
toGroupInfoReg currentTs cxt User {userContactId} (groupRow :. grRow) =
(toGroupInfo currentTs cxt userContactId [] groupRow, rowToGroupReg grRow)
type GroupRegRow = (GroupId, UserGroupRegId, ContactId, Maybe GroupMemberId, GroupRegStatus, BoolInt, UTCTime)