mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-05 20:57:44 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+108
-13
@@ -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 {
|
||||
|
||||
+1
-1
@@ -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 -> {
|
||||
|
||||
+20
-7
@@ -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))
|
||||
|
||||
+10
-8
@@ -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
|
||||
)
|
||||
|
||||
+22
-9
@@ -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,
|
||||
|
||||
+3
-2
@@ -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
|
||||
)
|
||||
|
||||
|
||||
+1
-2
@@ -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(
|
||||
|
||||
+31
-10
@@ -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) {
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+7
-6
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
-6
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+186
@@ -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>"""
|
||||
}
|
||||
+30
-6
@@ -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) }
|
||||
)
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+37
-25
@@ -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,
|
||||
)
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+5
-5
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+7
-4
@@ -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)
|
||||
|
||||
+3
-9
@@ -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 ->
|
||||
|
||||
+6
-12
@@ -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)))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
+3
-3
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+12
-4
@@ -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) }
|
||||
}
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+2
-2
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
+5
-4
@@ -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
|
||||
)
|
||||
|
||||
+4
-2
@@ -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
|
||||
|
||||
+14
-1
@@ -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,
|
||||
|
||||
+148
@@ -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(
|
||||
|
||||
+22
@@ -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
|
||||
|
||||
+20
-5
@@ -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() {
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+3
@@ -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 = {
|
||||
|
||||
+5
-3
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+36
-7
@@ -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)
|
||||
|
||||
+11
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-1
@@ -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),
|
||||
|
||||
+70
@@ -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()
|
||||
}
|
||||
}
|
||||
+29
@@ -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("="))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user