Merge branch 'master' into master-ghc8107

This commit is contained in:
Evgeny Poberezkin 2023-10-29 18:24:13 +00:00
commit 6881600e06
29 changed files with 693 additions and 321 deletions

View File

@ -1017,9 +1017,9 @@ private func sendCommandOkResp(_ cmd: ChatCommand) async throws {
throw r
}
func apiNewGroup(_ p: GroupProfile) throws -> GroupInfo {
func apiNewGroup(incognito: Bool, groupProfile: GroupProfile) throws -> GroupInfo {
let userId = try currentUserId("apiNewGroup")
let r = chatSendCmdSync(.apiNewGroup(userId: userId, groupProfile: p))
let r = chatSendCmdSync(.apiNewGroup(userId: userId, incognito: incognito, groupProfile: groupProfile))
if case let .groupCreated(_, groupInfo) = r { return groupInfo }
throw r
}

View File

@ -17,10 +17,13 @@ struct CIGroupInvitationView: View {
var memberRole: GroupMemberRole
var chatIncognito: Bool = false
@State private var frameWidth: CGFloat = 0
@State private var inProgress = false
@State private var progressByTimeout = false
var body: some View {
let action = !chatItem.chatDir.sent && groupInvitation.status == .pending
let v = ZStack(alignment: .bottomTrailing) {
ZStack {
VStack(alignment: .leading) {
groupInfoView(action)
.padding(.horizontal, 2)
@ -31,13 +34,15 @@ struct CIGroupInvitationView: View {
Divider().frame(width: frameWidth)
if action {
VStack(alignment: .leading, spacing: 2) {
groupInvitationText()
.overlay(DetermineWidth())
Text(chatIncognito ? "Tap to join incognito" : "Tap to join")
.foregroundColor(chatIncognito ? .indigo : .accentColor)
.foregroundColor(inProgress ? .secondary : chatIncognito ? .indigo : .accentColor)
.font(.callout)
.padding(.trailing, 60)
.overlay(DetermineWidth())
}
} else {
groupInvitationText()
.padding(.trailing, 60)
@ -45,6 +50,12 @@ struct CIGroupInvitationView: View {
}
}
.padding(.bottom, 2)
if progressByTimeout {
ProgressView().scaleEffect(2)
}
}
chatItem.timestampText
.font(.caption)
.foregroundColor(.secondary)
@ -55,11 +66,24 @@ struct CIGroupInvitationView: View {
.cornerRadius(18)
.textSelection(.disabled)
.onPreferenceChange(DetermineWidth.Key.self) { frameWidth = $0 }
.onChange(of: inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
}
if action {
v.onTapGesture {
joinGroup(groupInvitation.groupId)
inProgress = true
joinGroup(groupInvitation.groupId) {
await MainActor.run { inProgress = false }
}
}
.disabled(inProgress)
} else {
v
}
@ -67,7 +91,7 @@ struct CIGroupInvitationView: View {
private func groupInfoView(_ action: Bool) -> some View {
var color: Color
if action {
if action && !inProgress {
color = chatIncognito ? .indigo : .accentColor
} else {
color = Color(uiColor: .tertiaryLabel)

View File

@ -37,6 +37,10 @@ struct ChatView: View {
@FocusState private var searchFocussed
// opening GroupMemberInfoView on member icon
@State private var selectedMember: GroupMember? = nil
// opening GroupLinkView on link button (incognito)
@State private var showGroupLinkSheet: Bool = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
var body: some View {
if #available(iOS 16.0, *) {
@ -173,9 +177,16 @@ struct ChatView: View {
HStack {
if groupInfo.canAddMembers {
if (chat.chatInfo.incognito) {
Image(systemName: "person.crop.circle.badge.plus")
.foregroundColor(Color(uiColor: .tertiaryLabel))
.onTapGesture { AlertManager.shared.showAlert(cantInviteIncognitoAlert()) }
groupLinkButton()
.appSheet(isPresented: $showGroupLinkSheet) {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: false
)
}
} else {
addMembersButton()
.appSheet(isPresented: $showAddMembersSheet) {
@ -418,6 +429,25 @@ struct ChatView: View {
}
}
private func groupLinkButton() -> some View {
Button {
if case let .group(gInfo) = chat.chatInfo {
Task {
do {
if let link = try apiGetGroupLink(gInfo.groupId) {
(groupLink, groupLinkMemberRole) = link
}
} catch let error {
logger.error("ChatView apiGetGroupLink: \(responseError(error))")
}
showGroupLinkSheet = true
}
}
} label: {
Image(systemName: "link.badge.plus")
}
}
private func loadChatItems(_ cInfo: ChatInfo, _ ci: ChatItem, _ proxy: ScrollViewProxy) {
if let firstItem = chatModel.reversedChatItems.last, firstItem.id == ci.id {
if loadingItems || firstPage { return }

View File

@ -14,20 +14,28 @@ import PhotosUI
struct NativeTextEditor: UIViewRepresentable {
@Binding var text: String
@Binding var disableEditing: Bool
let height: CGFloat
let font: UIFont
@Binding var height: CGFloat
@Binding var focused: Bool
let alignment: TextAlignment
let onImagesAdded: ([UploadContent]) -> Void
private let minHeight: CGFloat = 37
private let defaultHeight: CGFloat = {
let field = CustomUITextField(height: Binding.constant(0))
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
return min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, 37), 360).rounded(.down)
}()
func makeUIView(context: Context) -> UITextView {
let field = CustomUITextField()
let field = CustomUITextField(height: _height)
field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right
field.autocapitalizationType = .sentences
field.setOnTextChangedListener { newText, images in
if !disableEditing {
// Speed up the process of updating layout, reduce jumping content on screen
if !isShortEmoji(newText) { updateHeight(field) }
text = newText
} else {
field.text = text
@ -39,20 +47,68 @@ struct NativeTextEditor: UIViewRepresentable {
field.setOnFocusChangedListener { focused = $0 }
field.delegate = field
field.textContainerInset = UIEdgeInsets(top: 8, left: 5, bottom: 6, right: 4)
updateFont(field)
updateHeight(field)
return field
}
func updateUIView(_ field: UITextView, context: Context) {
field.text = text
field.font = font
field.textAlignment = alignment == .leading ? .left : .right
updateFont(field)
updateHeight(field)
}
private func updateHeight(_ field: UITextView) {
let maxHeight = min(360, field.font!.lineHeight * 12)
// When having emoji in text view and then removing it, sizeThatFits shows previous size (too big for empty text view), so using work around with default size
let newHeight = field.text == ""
? defaultHeight
: min(max(field.sizeThatFits(CGSizeMake(field.frame.size.width, CGFloat.greatestFiniteMagnitude)).height, minHeight), maxHeight).rounded(.down)
if field.frame.size.height != newHeight {
field.frame.size = CGSizeMake(field.frame.size.width, newHeight)
(field as! CustomUITextField).invalidateIntrinsicContentHeight(newHeight)
}
}
private func updateFont(_ field: UITextView) {
field.font = isShortEmoji(field.text)
? (field.text.count < 4 ? largeEmojiUIFont : mediumEmojiUIFont)
: UIFont.preferredFont(forTextStyle: .body)
}
}
private class CustomUITextField: UITextView, UITextViewDelegate {
var height: Binding<CGFloat>
var newHeight: CGFloat = 0
var onTextChanged: (String, [UploadContent]) -> Void = { newText, image in }
var onFocusChanged: (Bool) -> Void = { focused in }
init(height: Binding<CGFloat>) {
self.height = height
super.init(frame: .zero, textContainer: nil)
}
required init?(coder: NSCoder) {
fatalError("Not implemented")
}
// This func here needed because using frame.size.height in intrinsicContentSize while loading a screen with text (for example. when you have a draft),
// produces incorrect height because at that point intrinsicContentSize has old value of frame.size.height even if it was set to new value right before the call
// (who knows why...)
func invalidateIntrinsicContentHeight(_ newHeight: CGFloat) {
self.newHeight = newHeight
invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: CGSize {
if height.wrappedValue != newHeight {
DispatchQueue.main.asyncAfter(deadline: .now(), execute: { self.height.wrappedValue = self.newHeight })
}
return CGSizeMake(0, newHeight)
}
func setOnTextChangedListener(onTextChanged: @escaping (String, [UploadContent]) -> Void) {
self.onTextChanged = onTextChanged
}
@ -144,14 +200,14 @@ private class CustomUITextField: UITextView, UITextViewDelegate {
struct NativeTextEditor_Previews: PreviewProvider{
static var previews: some View {
return NativeTextEditor(
NativeTextEditor(
text: Binding.constant("Hello, world!"),
disableEditing: Binding.constant(false),
height: 100,
font: UIFont.preferredFont(forTextStyle: .body),
height: Binding.constant(100),
focused: Binding.constant(false),
alignment: TextAlignment.leading,
onImagesAdded: { _ in }
)
.fixedSize(horizontal: false, vertical: true)
}
}

View File

@ -32,15 +32,12 @@ struct SendMessageView: View {
var sendButtonColor = Color.accentColor
@State private var teHeight: CGFloat = 42
@State private var teFont: Font = .body
@State private var teUiFont: UIFont = UIFont.preferredFont(forTextStyle: .body)
@State private var sendButtonSize: CGFloat = 29
@State private var sendButtonOpacity: CGFloat = 1
@State private var showCustomDisappearingMessageDialogue = false
@State private var showCustomTimePicker = false
@State private var selectedDisappearingMessageTime: Int? = customDisappearingMessageTimeDefault.get()
@State private var progressByTimeout = false
var maxHeight: CGFloat = 360
var minHeight: CGFloat = 37
@AppStorage(DEFAULT_LIVE_MESSAGE_ALERT_SHOWN) private var liveMessageAlertShown = false
var body: some View {
@ -57,30 +54,16 @@ struct SendMessageView: View {
.frame(maxWidth: .infinity)
} else {
let alignment: TextAlignment = isRightToLeft(composeState.message) ? .trailing : .leading
Text(composeState.message)
.lineLimit(10)
.font(teFont)
.multilineTextAlignment(alignment)
// put text on top (after NativeTextEditor) and set color to precisely align it on changes
// .foregroundColor(.red)
.foregroundColor(.clear)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 6)
.matchedGeometryEffect(id: "te", in: namespace)
.background(GeometryReader(content: updateHeight))
NativeTextEditor(
text: $composeState.message,
disableEditing: $composeState.inProgress,
height: teHeight,
font: teUiFont,
height: $teHeight,
focused: $keyboardVisible,
alignment: alignment,
onImagesAdded: onMediaAdded
)
.allowsTightening(false)
.frame(height: teHeight)
.fixedSize(horizontal: false, vertical: true)
}
}
@ -100,11 +83,13 @@ struct SendMessageView: View {
.frame(height: teHeight, alignment: .bottom)
}
}
.padding(.vertical, 1)
.overlay(
RoundedRectangle(cornerSize: CGSize(width: 20, height: 20))
.strokeBorder(.secondary, lineWidth: 0.3, antialiased: true)
.frame(height: teHeight)
)
}
.onChange(of: composeState.message, perform: { text in updateFont(text) })
.onChange(of: composeState.inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 3) {
@ -415,16 +400,12 @@ struct SendMessageView: View {
.padding([.bottom, .trailing], 4)
}
private func updateHeight(_ g: GeometryProxy) -> Color {
private func updateFont(_ text: String) {
DispatchQueue.main.async {
teHeight = min(max(g.frame(in: .local).size.height, minHeight), maxHeight)
(teFont, teUiFont) = isShortEmoji(composeState.message)
? composeState.message.count < 4
? (largeEmojiFont, largeEmojiUIFont)
: (mediumEmojiFont, mediumEmojiUIFont)
: (.body, UIFont.preferredFont(forTextStyle: .body))
teFont = isShortEmoji(text)
? (text.count < 4 ? largeEmojiFont : mediumEmojiFont)
: .body
}
return Color.clear
}
}

View File

@ -225,7 +225,13 @@ struct GroupChatInfoView: View {
private func groupLinkButton() -> some View {
NavigationLink {
GroupLinkView(groupId: groupInfo.groupId, groupLink: $groupLink, groupLinkMemberRole: $groupLinkMemberRole)
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: false,
creatingGroup: false
)
.navigationBarTitle("Group link")
.navigationBarTitleDisplayMode(.large)
} label: {

View File

@ -13,6 +13,9 @@ struct GroupLinkView: View {
var groupId: Int64
@Binding var groupLink: String?
@Binding var groupLinkMemberRole: GroupMemberRole
var showTitle: Bool = false
var creatingGroup: Bool = false
var linkCreatedCb: (() -> Void)? = nil
@State private var creatingLink = false
@State private var alert: GroupLinkAlert?
@ -29,10 +32,35 @@ struct GroupLinkView: View {
}
var body: some View {
if creatingGroup {
NavigationView {
groupLinkView()
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button ("Continue") { linkCreatedCb?() }
}
}
}
} else {
groupLinkView()
}
}
private func groupLinkView() -> some View {
List {
Group {
if showTitle {
Text("Group link")
.font(.largeTitle)
.bold()
.fixedSize(horizontal: false, vertical: true)
}
Text("You can share a link or a QR code - anybody will be able to join the group. You won't lose members of the group if you later delete it.")
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
Section {
if let groupLink = groupLink {
Picker("Initial role", selection: $groupLinkMemberRole) {
@ -48,9 +76,11 @@ struct GroupLinkView: View {
Label("Share link", systemImage: "square.and.arrow.up")
}
if !creatingGroup {
Button(role: .destructive) { alert = .deleteLink } label: {
Label("Delete link", systemImage: "trash")
}
}
} else {
Button(action: createGroupLink) {
Label("Create link", systemImage: "link.badge.plus")

View File

@ -33,8 +33,11 @@ struct ChatListNavLink: View {
@State private var showContactConnectionInfo = false
@State private var showInvalidJSON = false
@State private var showDeleteContactActionSheet = false
@State private var inProgress = false
@State private var progressByTimeout = false
var body: some View {
Group {
switch chat.chatInfo {
case let .direct(contact):
contactNavLink(contact)
@ -48,12 +51,22 @@ struct ChatListNavLink: View {
invalidJSONPreview(json)
}
}
.onChange(of: inProgress) { inProgress in
if inProgress {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
progressByTimeout = inProgress
}
} else {
progressByTimeout = false
}
}
}
@ViewBuilder private func contactNavLink(_ contact: Contact) -> some View {
NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat) }
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) }
)
.swipeActions(edge: .leading, allowsFullSwipe: true) {
markReadButton()
@ -101,7 +114,7 @@ struct ChatListNavLink: View {
@ViewBuilder private func groupNavLink(_ groupInfo: GroupInfo) -> some View {
switch (groupInfo.membership.memberStatus) {
case .memInvited:
ChatPreviewView(chat: chat)
ChatPreviewView(chat: chat, progressByTimeout: $progressByTimeout)
.frame(height: rowHeights[dynamicTypeSize])
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
joinGroupButton()
@ -112,12 +125,16 @@ struct ChatListNavLink: View {
.onTapGesture { showJoinGroupDialog = true }
.confirmationDialog("Group invitation", isPresented: $showJoinGroupDialog, titleVisibility: .visible) {
Button(chat.chatInfo.incognito ? "Join incognito" : "Join group") {
joinGroup(groupInfo.groupId)
inProgress = true
joinGroup(groupInfo.groupId) {
await MainActor.run { inProgress = false }
}
}
Button("Delete invitation", role: .destructive) { Task { await deleteChat(chat) } }
}
.disabled(inProgress)
case .memAccepted:
ChatPreviewView(chat: chat)
ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false))
.frame(height: rowHeights[dynamicTypeSize])
.onTapGesture {
AlertManager.shared.showAlert(groupInvitationAcceptedAlert())
@ -134,7 +151,7 @@ struct ChatListNavLink: View {
NavLinkPlain(
tag: chat.chatInfo.id,
selection: $chatModel.chatId,
label: { ChatPreviewView(chat: chat) },
label: { ChatPreviewView(chat: chat, progressByTimeout: Binding.constant(false)) },
disabled: !groupInfo.ready
)
.frame(height: rowHeights[dynamicTypeSize])
@ -159,7 +176,10 @@ struct ChatListNavLink: View {
private func joinGroupButton() -> some View {
Button {
joinGroup(chat.chatInfo.apiId)
inProgress = true
joinGroup(chat.chatInfo.apiId) {
await MainActor.run { inProgress = false }
}
} label: {
Label("Join", systemImage: chat.chatInfo.incognito ? "theatermasks" : "ipad.and.arrow.forward")
}
@ -419,7 +439,7 @@ func deleteContactConnectionAlert(_ contactConnection: PendingContactConnection,
)
}
func joinGroup(_ groupId: Int64) {
func joinGroup(_ groupId: Int64, _ onComplete: @escaping () async -> Void) {
Task {
logger.debug("joinGroup")
do {
@ -434,7 +454,9 @@ func joinGroup(_ groupId: Int64) {
AlertManager.shared.showAlertMsg(title: "No group!", message: "This group no longer exists.")
await deleteGroup()
}
await onComplete()
} catch let error {
await onComplete()
let a = getErrorAlert(error, "Error joining group")
AlertManager.shared.showAlertMsg(title: a.title, message: a.message)
}

View File

@ -12,6 +12,7 @@ import SimpleXChat
struct ChatPreviewView: View {
@EnvironmentObject var chatModel: ChatModel
@ObservedObject var chat: Chat
@Binding var progressByTimeout: Bool
@Environment(\.colorScheme) var colorScheme
var darkGreen = Color(red: 0, green: 0.5, blue: 0)
@ -252,6 +253,12 @@ struct ChatPreviewView: View {
} else {
incognitoIcon(chat.chatInfo.incognito)
}
case .group:
if progressByTimeout {
ProgressView()
} else {
incognitoIcon(chat.chatInfo.incognito)
}
default:
incognitoIcon(chat.chatInfo.incognito)
}
@ -280,30 +287,30 @@ struct ChatPreviewView_Previews: PreviewProvider {
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: []
))
), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))]
))
), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
))
), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete), itemDeleted: .deleted(deletedTs: .now))]
))
), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.direct,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "hello", .sndSent(sndProgress: .complete))],
chatStats: ChatStats(unreadCount: 3, minUnreadItemId: 0)
))
), progressByTimeout: Binding.constant(false))
ChatPreviewView(chat: Chat(
chatInfo: ChatInfo.sampleData.group,
chatItems: [ChatItem.getSample(1, .directSnd, .now, "Lorem ipsum dolor sit amet, d. consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.")],
chatStats: ChatStats(unreadCount: 11, minUnreadItemId: 0)
))
), progressByTimeout: Binding.constant(false))
}
.previewLayout(.fixed(width: 360, height: 78))
}

View File

@ -12,6 +12,7 @@ import SimpleXChat
struct AddGroupView: View {
@EnvironmentObject var m: ChatModel
@Environment(\.dismiss) var dismiss: DismissAction
@AppStorage(GROUP_DEFAULT_INCOGNITO, store: groupDefaults) private var incognitoDefault = false
@State private var chat: Chat?
@State private var groupInfo: GroupInfo?
@State private var profile = GroupProfile(displayName: "", fullName: "")
@ -21,9 +22,12 @@ struct AddGroupView: View {
@State private var showTakePhoto = false
@State private var chosenImage: UIImage? = nil
@State private var showInvalidNameAlert = false
@State private var groupLink: String?
@State private var groupLinkMemberRole: GroupMemberRole = .member
var body: some View {
if let chat = chat, let groupInfo = groupInfo {
if !groupInfo.membership.memberIncognito {
AddGroupMembersViewCommon(
chat: chat,
groupInfo: groupInfo,
@ -35,29 +39,40 @@ struct AddGroupView: View {
m.chatId = groupInfo.id
}
}
} else {
GroupLinkView(
groupId: groupInfo.groupId,
groupLink: $groupLink,
groupLinkMemberRole: $groupLinkMemberRole,
showTitle: true,
creatingGroup: true
) {
dismiss()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
m.chatId = groupInfo.id
}
}
}
} else {
createGroupView().keyboardPadding()
}
}
func createGroupView() -> some View {
VStack(alignment: .leading) {
List {
Group {
Text("Create secret group")
.font(.largeTitle)
.padding(.vertical, 4)
Text("The group is fully decentralized it is visible only to the members.")
.padding(.bottom, 4)
HStack {
Image(systemName: "info.circle").foregroundColor(.secondary).font(.footnote)
Spacer().frame(width: 8)
Text("Your chat profile will be sent to group members").font(.footnote)
}
.padding(.bottom)
.bold()
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom, 24)
.onTapGesture(perform: hideKeyboard)
ZStack(alignment: .center) {
ZStack(alignment: .topTrailing) {
profileImageView(profile.image)
ProfileImage(imageStr: profile.image, color: Color(uiColor: .secondarySystemGroupedBackground))
.aspectRatio(1, contentMode: .fit)
.frame(maxWidth: 128, maxHeight: 128)
if profile.image != nil {
Button {
profile.image = nil
@ -71,47 +86,35 @@ struct AddGroupView: View {
}
editImageButton { showChooseSource = true }
.buttonStyle(BorderlessButtonStyle()) // otherwise whole "list row" is clickable
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.bottom, 4)
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
.listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0))
ZStack(alignment: .topLeading) {
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
if name != mkValidName(name) {
Button {
showInvalidNameAlert = true
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "exclamationmark.circle").foregroundColor(.clear)
}
textField("Enter group name…", text: $profile.displayName)
.focused($focusDisplayName)
.submitLabel(.go)
.onSubmit {
if canCreateProfile() { createGroup() }
}
}
.padding(.bottom)
Spacer()
Button {
createGroup()
} label: {
Text("Create")
Image(systemName: "greaterthan")
Section {
groupNameTextField()
Button(action: createGroup) {
settingsRow("checkmark", color: .accentColor) { Text("Create group") }
}
.disabled(!canCreateProfile())
.frame(maxWidth: .infinity, alignment: .trailing)
IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: {
VStack(alignment: .leading, spacing: 4) {
sharedGroupProfileInfo(incognitoDefault)
Text("Fully decentralized visible only to members.")
}
.frame(maxWidth: .infinity, alignment: .leading)
.onTapGesture(perform: hideKeyboard)
}
}
.onAppear() {
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
focusDisplayName = true
}
}
.padding()
.confirmationDialog("Group image", isPresented: $showChooseSource, titleVisibility: .visible) {
Button("Take picture") {
showTakePhoto = true
@ -141,20 +144,48 @@ struct AddGroupView: View {
profile.image = nil
}
}
.contentShape(Rectangle())
.onTapGesture { hideKeyboard() }
}
func groupNameTextField() -> some View {
ZStack(alignment: .leading) {
let name = profile.displayName.trimmingCharacters(in: .whitespaces)
if name != mkValidName(name) {
Button {
showInvalidNameAlert = true
} label: {
Image(systemName: "exclamationmark.circle").foregroundColor(.red)
}
} else {
Image(systemName: "pencil").foregroundColor(.secondary)
}
textField("Enter group name…", text: $profile.displayName)
.focused($focusDisplayName)
.submitLabel(.continue)
.onSubmit {
if canCreateProfile() { createGroup() }
}
}
}
func textField(_ placeholder: LocalizedStringKey, text: Binding<String>) -> some View {
TextField(placeholder, text: text)
.padding(.leading, 32)
.padding(.leading, 36)
}
func sharedGroupProfileInfo(_ incognito: Bool) -> Text {
let name = ChatModel.shared.currentUser?.displayName ?? ""
return Text(
incognito
? "A new random profile will be shared."
: "Your profile **\(name)** will be shared."
)
}
func createGroup() {
hideKeyboard()
do {
profile.displayName = profile.displayName.trimmingCharacters(in: .whitespaces)
let gInfo = try apiNewGroup(profile)
let gInfo = try apiNewGroup(incognito: incognitoDefault, groupProfile: profile)
Task {
let groupMembers = await apiListMembers(gInfo.groupId)
await MainActor.run {

View File

@ -54,9 +54,11 @@ struct PasteToConnectView: View {
IncognitoToggle(incognitoEnabled: $incognitoDefault)
} footer: {
VStack(alignment: .leading, spacing: 4) {
sharedProfileInfo(incognitoDefault)
+ Text(String("\n\n"))
+ Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
Text("You can also connect by clicking the link. If it opens in the browser, click **Open in mobile app** button.")
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.alert(item: $alert) { a in planAndConnectAlert(a, dismiss: true) }

View File

@ -38,11 +38,11 @@ struct ScanToConnectView: View {
)
.padding(.top)
Group {
VStack(alignment: .leading, spacing: 4) {
sharedProfileInfo(incognitoDefault)
+ Text(String("\n\n"))
+ Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
Text("If you cannot meet in person, you can **scan QR code in the video call**, or your contact can share an invitation link.")
}
.frame(maxWidth: .infinity, alignment: .leading)
.font(.footnote)
.foregroundColor(.secondary)
.padding(.horizontal)

View File

@ -50,7 +50,7 @@ public enum ChatCommand {
case apiVerifyToken(token: DeviceToken, nonce: String, code: String)
case apiDeleteToken(token: DeviceToken)
case apiGetNtfMessage(nonce: String, encNtfInfo: String)
case apiNewGroup(userId: Int64, groupProfile: GroupProfile)
case apiNewGroup(userId: Int64, incognito: Bool, groupProfile: GroupProfile)
case apiAddMember(groupId: Int64, contactId: Int64, memberRole: GroupMemberRole)
case apiJoinGroup(groupId: Int64)
case apiMemberRole(groupId: Int64, memberId: Int64, memberRole: GroupMemberRole)
@ -175,7 +175,7 @@ public enum ChatCommand {
case let .apiVerifyToken(token, nonce, code): return "/_ntf verify \(token.cmdString) \(nonce) \(code)"
case let .apiDeleteToken(token): return "/_ntf delete \(token.cmdString)"
case let .apiGetNtfMessage(nonce, encNtfInfo): return "/_ntf message \(nonce) \(encNtfInfo)"
case let .apiNewGroup(userId, groupProfile): return "/_group \(userId) \(encodeJSON(groupProfile))"
case let .apiNewGroup(userId, incognito, groupProfile): return "/_group \(userId) incognito=\(onOff(incognito)) \(encodeJSON(groupProfile))"
case let .apiAddMember(groupId, contactId, memberRole): return "/_add #\(groupId) \(contactId) \(memberRole)"
case let .apiJoinGroup(groupId): return "/_join #\(groupId)"
case let .apiMemberRole(groupId, memberId, memberRole): return "/_member role #\(groupId) \(memberId) \(memberRole.rawValue)"

View File

@ -1166,9 +1166,9 @@ object ChatController {
}
}
suspend fun apiNewGroup(p: GroupProfile): GroupInfo? {
suspend fun apiNewGroup(incognito: Boolean, groupProfile: GroupProfile): GroupInfo? {
val userId = kotlin.runCatching { currentUserId("apiNewGroup") }.getOrElse { return null }
val r = sendCmd(CC.ApiNewGroup(userId, p))
val r = sendCmd(CC.ApiNewGroup(userId, incognito, groupProfile))
if (r is CR.GroupCreated) return r.groupInfo
Log.e(TAG, "apiNewGroup bad response: ${r.responseType} ${r.details}")
return null
@ -1889,7 +1889,7 @@ sealed class CC {
class ApiDeleteChatItem(val type: ChatType, val id: Long, val itemId: Long, val mode: CIDeleteMode): CC()
class ApiDeleteMemberChatItem(val groupId: Long, val groupMemberId: Long, val itemId: Long): CC()
class ApiChatItemReaction(val type: ChatType, val id: Long, val itemId: Long, val add: Boolean, val reaction: MsgReaction): CC()
class ApiNewGroup(val userId: Long, val groupProfile: GroupProfile): CC()
class ApiNewGroup(val userId: Long, val incognito: Boolean, val groupProfile: GroupProfile): CC()
class ApiAddMember(val groupId: Long, val contactId: Long, val memberRole: GroupMemberRole): CC()
class ApiJoinGroup(val groupId: Long): CC()
class ApiMemberRole(val groupId: Long, val memberId: Long, val memberRole: GroupMemberRole): CC()
@ -1999,7 +1999,7 @@ sealed class CC {
is ApiDeleteChatItem -> "/_delete item ${chatRef(type, id)} $itemId ${mode.deleteMode}"
is ApiDeleteMemberChatItem -> "/_delete member item #$groupId $groupMemberId $itemId"
is ApiChatItemReaction -> "/_reaction ${chatRef(type, id)} $itemId ${onOff(add)} ${json.encodeToString(reaction)}"
is ApiNewGroup -> "/_group $userId ${json.encodeToString(groupProfile)}"
is ApiNewGroup -> "/_group $userId incognito=${onOff(incognito)} ${json.encodeToString(groupProfile)}"
is ApiAddMember -> "/_add #$groupId $contactId ${memberRole.memberRole}"
is ApiJoinGroup -> "/_join #$groupId"
is ApiMemberRole -> "/_member role #$groupId $memberId ${memberRole.memberRole}"

View File

@ -269,8 +269,11 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
cancelFile = { fileId ->
withApi { chatModel.controller.cancelFile(user, fileId) }
},
joinGroup = { groupId ->
withApi { chatModel.controller.apiJoinGroup(groupId) }
joinGroup = { groupId, onComplete ->
withApi {
chatModel.controller.apiJoinGroup(groupId)
onComplete.invoke()
}
},
startCall = out@ { media ->
withBGApi {
@ -386,6 +389,16 @@ fun ChatView(chatId: String, chatModel: ChatModel, onComposed: suspend (chatId:
}
}
},
openGroupLink = { groupInfo ->
hideKeyboard(view)
withApi {
val link = chatModel.controller.apiGetGroupLink(groupInfo.groupId)
ModalManager.end.closeModals()
ModalManager.end.showModalCloseable(true) {
GroupLinkView(chatModel, groupInfo, link?.first, link?.second, onGroupLinkUpdated = null)
}
}
},
markRead = { range, unreadCountAfter ->
chatModel.markChatItemsRead(chat.chatInfo, range, unreadCountAfter)
ntfManager.cancelNotificationsForChat(chat.id)
@ -431,7 +444,7 @@ fun ChatLayout(
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
startCall: (CallMediaType) -> Unit,
endCall: () -> Unit,
acceptCall: (Contact) -> Unit,
@ -446,6 +459,7 @@ fun ChatLayout(
setReaction: (ChatInfo, ChatItem, Boolean, MsgReaction) -> Unit,
showItemDetails: (ChatInfo, ChatItem) -> Unit,
addMembers: (GroupInfo) -> Unit,
openGroupLink: (GroupInfo) -> Unit,
markRead: (CC.ItemRange, unreadCountAfter: Int?) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
@ -492,7 +506,7 @@ fun ChatLayout(
}
Scaffold(
topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, changeNtfsState, onSearchValueChanged) },
topBar = { ChatInfoToolbar(chat, back, info, startCall, endCall, addMembers, openGroupLink, changeNtfsState, onSearchValueChanged) },
bottomBar = composeView,
modifier = Modifier.navigationBarsWithImePadding(),
floatingActionButton = { floatingButton.value() },
@ -523,6 +537,7 @@ fun ChatInfoToolbar(
startCall: (CallMediaType) -> Unit,
endCall: () -> Unit,
addMembers: (GroupInfo) -> Unit,
openGroupLink: (GroupInfo) -> Unit,
changeNtfsState: (Boolean, currentValue: MutableState<Boolean>) -> Unit,
onSearchValueChanged: (String) -> Unit,
) {
@ -604,7 +619,8 @@ fun ChatInfoToolbar(
})
}
}
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers && !chat.chatInfo.incognito) {
} else if (chat.chatInfo is ChatInfo.Group && chat.chatInfo.groupInfo.canAddMembers) {
if (!chat.chatInfo.incognito) {
barButtons.add {
IconButton({
showMenu.value = false
@ -613,6 +629,16 @@ fun ChatInfoToolbar(
Icon(painterResource(MR.images.ic_person_add_500), stringResource(MR.strings.icon_descr_add_members), tint = MaterialTheme.colors.primary)
}
}
} else {
barButtons.add {
IconButton({
showMenu.value = false
openGroupLink(chat.chatInfo.groupInfo)
}) {
Icon(painterResource(MR.images.ic_add_link), stringResource(MR.strings.group_link), tint = MaterialTheme.colors.primary)
}
}
}
}
if ((chat.chatInfo is ChatInfo.Direct && chat.chatInfo.contact.ready && chat.chatInfo.contact.active) || chat.chatInfo is ChatInfo.Group) {
val ntfsEnabled = remember { mutableStateOf(chat.chatInfo.ntfsEnabled) }
@ -720,7 +746,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
acceptCall: (Contact) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
openDirectChat: (Long) -> Unit,
@ -872,7 +898,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
) {
MemberImage(member)
}
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
}
}
} else {
@ -881,7 +907,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
.padding(start = 8.dp + MEMBER_IMAGE_SIZE + 4.dp, end = if (voiceWithTransparentBack) 12.dp else 66.dp)
.then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, getConnectedMemberNames = ::getConnectedMemberNames, developerTools = developerTools)
}
}
}
@ -891,7 +917,7 @@ fun BoxWithConstraintsScope.ChatItemsList(
.padding(start = if (voiceWithTransparentBack) 12.dp else 104.dp, end = 12.dp)
.then(swipeableModifier)
) {
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = {}, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
ChatItemView(chat.chatInfo, cItem, composeState, provider, useLinkPreviews = useLinkPreviews, linkMode = linkMode, deleteMessage = deleteMessage, receiveFile = receiveFile, cancelFile = cancelFile, joinGroup = { _, _ -> }, acceptCall = acceptCall, acceptFeature = acceptFeature, openDirectChat = openDirectChat, updateContactStats = updateContactStats, updateMemberStats = updateMemberStats, syncContactConnection = syncContactConnection, syncMemberConnection = syncMemberConnection, findModelChat = findModelChat, findModelMember = findModelMember, scrollToItem = scrollToItem, setReaction = setReaction, showItemDetails = showItemDetails, developerTools = developerTools)
}
}
} else { // direct message
@ -1323,7 +1349,7 @@ fun PreviewChatLayout() {
deleteMessage = { _, _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
joinGroup = { _, _ -> },
startCall = {},
endCall = {},
acceptCall = { _ -> },
@ -1338,6 +1364,7 @@ fun PreviewChatLayout() {
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
addMembers = { _ -> },
openGroupLink = {},
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},
@ -1393,7 +1420,7 @@ fun PreviewGroupChatLayout() {
deleteMessage = { _, _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
joinGroup = { _, _ -> },
startCall = {},
endCall = {},
acceptCall = { _ -> },
@ -1408,6 +1435,7 @@ fun PreviewGroupChatLayout() {
setReaction = { _, _, _, _ -> },
showItemDetails = { _, _ -> },
addMembers = { _ -> },
openGroupLink = {},
markRead = { _, _ -> },
changeNtfsState = { _, _ -> },
onSearchValueChanged = {},

View File

@ -23,7 +23,15 @@ import chat.simplex.common.views.newchat.*
import chat.simplex.res.MR
@Composable
fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: String?, memberRole: GroupMemberRole?, onGroupLinkUpdated: (Pair<String, GroupMemberRole>?) -> Unit) {
fun GroupLinkView(
chatModel: ChatModel,
groupInfo: GroupInfo,
connReqContact: String?,
memberRole: GroupMemberRole?,
onGroupLinkUpdated: ((Pair<String, GroupMemberRole>?) -> Unit)?,
creatingGroup: Boolean = false,
close: (() -> Unit)? = null
) {
var groupLink by rememberSaveable { mutableStateOf(connReqContact) }
val groupLinkMemberRole = rememberSaveable { mutableStateOf(memberRole) }
var creatingLink by rememberSaveable { mutableStateOf(false) }
@ -34,7 +42,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(link)
onGroupLinkUpdated?.invoke(link)
}
creatingLink = false
}
@ -58,7 +66,7 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
if (link != null) {
groupLink = link.first
groupLinkMemberRole.value = link.second
onGroupLinkUpdated(link)
onGroupLinkUpdated?.invoke(link)
}
}
}
@ -73,13 +81,15 @@ fun GroupLinkView(chatModel: ChatModel, groupInfo: GroupInfo, connReqContact: St
val r = chatModel.controller.apiDeleteGroupLink(groupInfo.groupId)
if (r) {
groupLink = null
onGroupLinkUpdated(null)
onGroupLinkUpdated?.invoke(null)
}
}
},
destructive = true,
)
}
},
creatingGroup = creatingGroup,
close = close
)
if (creatingLink) {
ProgressIndicator()
@ -94,8 +104,19 @@ fun GroupLinkLayout(
creatingLink: Boolean,
createLink: () -> Unit,
updateLink: () -> Unit,
deleteLink: () -> Unit
deleteLink: () -> Unit,
creatingGroup: Boolean = false,
close: (() -> Unit)? = null
) {
@Composable
fun ContinueButton(close: () -> Unit) {
SimpleButton(
stringResource(MR.strings.continue_to_next_step),
icon = painterResource(MR.images.ic_check),
click = close
)
}
Column(
Modifier
.verticalScroll(rememberScrollState()),
@ -112,7 +133,16 @@ fun GroupLinkLayout(
verticalArrangement = Arrangement.SpaceEvenly
) {
if (groupLink == null) {
Row(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = DEFAULT_PADDING, vertical = 10.dp)
) {
SimpleButton(stringResource(MR.strings.button_create_group_link), icon = painterResource(MR.images.ic_add_link), disabled = creatingLink, click = createLink)
if (creatingGroup && close != null) {
ContinueButton(close)
}
}
} else {
RoleSelectionRow(groupInfo, groupLinkMemberRole)
var initialLaunch by remember { mutableStateOf(true) }
@ -134,6 +164,9 @@ fun GroupLinkLayout(
icon = painterResource(MR.images.ic_share),
click = { clipboard.shareText(simplexChatLink(groupLink)) }
)
if (creatingGroup && close != null) {
ContinueButton(close)
} else {
SimpleButton(
stringResource(MR.strings.delete_link),
icon = painterResource(MR.images.ic_delete),
@ -143,6 +176,7 @@ fun GroupLinkLayout(
}
}
}
}
SectionBottomSpacer()
}
}

View File

@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.stringResource
@ -17,6 +18,7 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.helpers.*
import chat.simplex.common.model.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
@Composable
fun CIGroupInvitationView(
@ -24,16 +26,26 @@ fun CIGroupInvitationView(
groupInvitation: CIGroupInvitation,
memberRole: GroupMemberRole,
chatIncognito: Boolean = false,
joinGroup: (Long) -> Unit
joinGroup: (Long, () -> Unit) -> Unit
) {
val sent = ci.chatDir.sent
val action = !sent && groupInvitation.status == CIGroupInvitationStatus.Pending
val inProgress = remember { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
inProgress.value
} else {
false
}
}
@Composable
fun groupInfoView() {
val p = groupInvitation.groupProfile
val iconColor =
if (action) if (chatIncognito) Indigo else MaterialTheme.colors.primary
if (action && !inProgress.value) if (chatIncognito) Indigo else MaterialTheme.colors.primary
else if (isInDarkTheme()) FileDark else FileLight
Row(
@ -70,8 +82,9 @@ fun CIGroupInvitationView(
val sentColor = CurrentColors.collectAsState().value.appColors.sentMessage
val receivedColor = CurrentColors.collectAsState().value.appColors.receivedMessage
Surface(
modifier = if (action) Modifier.clickable(onClick = {
joinGroup(groupInvitation.groupId)
modifier = if (action && !inProgress.value) Modifier.clickable(onClick = {
inProgress.value = true
joinGroup(groupInvitation.groupId) { inProgress.value = false }
}) else Modifier,
shape = RoundedCornerShape(18.dp),
color = if (sent) sentColor else receivedColor,
@ -82,6 +95,9 @@ fun CIGroupInvitationView(
.padding(vertical = 3.dp)
.padding(start = 8.dp, end = 12.dp),
contentAlignment = Alignment.BottomEnd
) {
Box(
contentAlignment = Alignment.Center
) {
Column(
Modifier
@ -93,9 +109,15 @@ fun CIGroupInvitationView(
Divider(Modifier.fillMaxWidth().padding(bottom = 4.dp))
if (action) {
groupInvitationText()
Text(stringResource(
if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join),
color = if (chatIncognito) Indigo else MaterialTheme.colors.primary)
Text(
stringResource(
if (chatIncognito) MR.strings.group_invitation_tap_to_join_incognito else MR.strings.group_invitation_tap_to_join
),
color = if (inProgress.value)
MaterialTheme.colors.secondary
else
if (chatIncognito) Indigo else MaterialTheme.colors.primary
)
} else {
Box(Modifier.padding(end = 48.dp)) {
groupInvitationText()
@ -103,6 +125,16 @@ fun CIGroupInvitationView(
}
}
}
if (progressByTimeout) {
CircularProgressIndicator(
Modifier.size(32.dp),
color = if (isInDarkTheme()) FileDark else FileLight,
strokeWidth = 3.dp
)
}
}
Text(
ci.timestampText,
color = MaterialTheme.colors.secondary,
@ -124,7 +156,7 @@ fun PendingCIGroupInvitationViewPreview() {
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
joinGroup = { _, _ -> }
)
}
}
@ -140,7 +172,7 @@ fun CIGroupInvitationViewAcceptedPreview() {
ci = ChatItem.getGroupInvitationSample(),
groupInvitation = CIGroupInvitation.getSample(status = CIGroupInvitationStatus.Accepted),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
joinGroup = { _, _ -> }
)
}
}
@ -156,7 +188,7 @@ fun CIGroupInvitationViewLongNamePreview() {
status = CIGroupInvitationStatus.Accepted
),
memberRole = GroupMemberRole.Admin,
joinGroup = {}
joinGroup = { _, _ -> }
)
}
}

View File

@ -50,7 +50,7 @@ fun ChatItemView(
deleteMessage: (Long, CIDeleteMode) -> Unit,
receiveFile: (Long, Boolean) -> Unit,
cancelFile: (Long) -> Unit,
joinGroup: (Long) -> Unit,
joinGroup: (Long, () -> Unit) -> Unit,
acceptCall: (Contact) -> Unit,
scrollToItem: (Long) -> Unit,
acceptFeature: (Contact, ChatFeature, Int?) -> Unit,
@ -578,7 +578,7 @@ fun PreviewChatItemView() {
deleteMessage = { _, _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
joinGroup = { _, _ -> },
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> },
@ -609,7 +609,7 @@ fun PreviewChatItemViewDeletedContent() {
deleteMessage = { _, _ -> },
receiveFile = { _, _ -> },
cancelFile = {},
joinGroup = {},
joinGroup = { _, _ -> },
acceptCall = { _ -> },
scrollToItem = {},
acceptFeature = { _, _, _ -> },

View File

@ -12,6 +12,7 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
@ -44,11 +45,22 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}
val selectedChat = remember(chat.id) { derivedStateOf { chat.id == ChatModel.chatId.value } }
val showChatPreviews = chatModel.showChatPreviews.value
val inProgress = remember { mutableStateOf(false) }
var progressByTimeout by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(inProgress.value) {
progressByTimeout = if (inProgress.value) {
delay(1000)
inProgress.value
} else {
false
}
}
when (chat.chatInfo) {
is ChatInfo.Direct -> {
val contactNetworkStatus = chatModel.contactNetworkStatus(chat.chatInfo.contact)
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode) },
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, contactNetworkStatus, stopped, linkMode, inProgress = false, progressByTimeout = false) },
click = { directChatAction(chat.chatInfo, chatModel) },
dropdownMenuItems = { ContactMenuItems(chat, chatModel, showMenu, showMarkRead) },
showMenu,
@ -58,9 +70,9 @@ fun ChatListNavLinkView(chat: Chat, chatModel: ChatModel) {
}
is ChatInfo.Group ->
ChatListNavLinkLayout(
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode) },
click = { groupChatAction(chat.chatInfo.groupInfo, chatModel) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, showMarkRead) },
chatLinkPreview = { ChatPreviewView(chat, showChatPreviews, chatModel.draft.value, chatModel.draftChatId.value, chatModel.currentUser.value?.profile?.displayName, null, stopped, linkMode, inProgress.value, progressByTimeout) },
click = { if (!inProgress.value) groupChatAction(chat.chatInfo.groupInfo, chatModel, inProgress) },
dropdownMenuItems = { GroupMenuItems(chat, chat.chatInfo.groupInfo, chatModel, showMenu, inProgress, showMarkRead) },
showMenu,
stopped,
selectedChat
@ -110,9 +122,9 @@ fun directChatAction(chatInfo: ChatInfo, chatModel: ChatModel) {
withBGApi { openChat(chatInfo, chatModel) }
}
fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel) {
fun groupChatAction(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState<Boolean>? = null) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel)
GroupMemberStatus.MemInvited -> acceptGroupInvitationAlertDialog(groupInfo, chatModel, inProgress)
GroupMemberStatus.MemAccepted -> groupInvitationAcceptedAlert()
else -> withBGApi { openChat(ChatInfo.Group(groupInfo), chatModel) }
}
@ -193,10 +205,19 @@ fun ContactMenuItems(chat: Chat, chatModel: ChatModel, showMenu: MutableState<Bo
}
@Composable
fun GroupMenuItems(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
fun GroupMenuItems(
chat: Chat,
groupInfo: GroupInfo,
chatModel: ChatModel,
showMenu: MutableState<Boolean>,
inProgress: MutableState<Boolean>,
showMarkRead: Boolean
) {
when (groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> {
JoinGroupAction(chat, groupInfo, chatModel, showMenu)
if (!inProgress.value) {
JoinGroupAction(chat, groupInfo, chatModel, showMenu, inProgress)
}
if (groupInfo.canDelete) {
DeleteGroupAction(chat, groupInfo, chatModel, showMenu)
}
@ -317,8 +338,20 @@ fun DeleteGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, sh
}
@Composable
fun JoinGroupAction(chat: Chat, groupInfo: GroupInfo, chatModel: ChatModel, showMenu: MutableState<Boolean>) {
val joinGroup: () -> Unit = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } }
fun JoinGroupAction(
chat: Chat,
groupInfo: GroupInfo,
chatModel: ChatModel,
showMenu: MutableState<Boolean>,
inProgress: MutableState<Boolean>
) {
val joinGroup: () -> Unit = {
withApi {
inProgress.value = true
chatModel.controller.apiJoinGroup(groupInfo.groupId)
inProgress.value = false
}
}
ItemAction(
if (chat.chatInfo.incognito) stringResource(MR.strings.join_group_incognito_button) else stringResource(MR.strings.join_group_button),
if (chat.chatInfo.incognito) painterResource(MR.images.ic_theater_comedy_filled) else painterResource(MR.images.ic_login),
@ -558,12 +591,18 @@ fun pendingContactAlertDialog(chatInfo: ChatInfo, chatModel: ChatModel) {
)
}
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel) {
fun acceptGroupInvitationAlertDialog(groupInfo: GroupInfo, chatModel: ChatModel, inProgress: MutableState<Boolean>? = null) {
AlertManager.shared.showAlertDialog(
title = generalGetString(MR.strings.join_group_question),
text = generalGetString(MR.strings.you_are_invited_to_group_join_to_connect_with_group_members),
confirmText = if (groupInfo.membership.memberIncognito) generalGetString(MR.strings.join_group_incognito_button) else generalGetString(MR.strings.join_group_button),
onConfirm = { withApi { chatModel.controller.apiJoinGroup(groupInfo.groupId) } },
onConfirm = {
withApi {
inProgress?.value = true
chatModel.controller.apiJoinGroup(groupInfo.groupId)
inProgress?.value = false
}
},
dismissText = generalGetString(MR.strings.delete_verb),
onDismiss = { deleteGroup(groupInfo, chatModel) }
)
@ -680,7 +719,9 @@ fun PreviewChatListNavLinkDirect() {
null,
null,
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
linkMode = SimplexLinkMode.DESCRIPTION,
inProgress = false,
progressByTimeout = false
)
},
click = {},
@ -721,7 +762,9 @@ fun PreviewChatListNavLinkGroup() {
null,
null,
stopped = false,
linkMode = SimplexLinkMode.DESCRIPTION
linkMode = SimplexLinkMode.DESCRIPTION,
inProgress = false,
progressByTimeout = false
)
},
click = {},

View File

@ -37,7 +37,9 @@ fun ChatPreviewView(
currentUserProfileDisplayName: String?,
contactNetworkStatus: NetworkStatus?,
stopped: Boolean,
linkMode: SimplexLinkMode
linkMode: SimplexLinkMode,
inProgress: Boolean,
progressByTimeout: Boolean
) {
val cInfo = chat.chatInfo
@ -135,7 +137,12 @@ fun ChatPreviewView(
}
is ChatInfo.Group ->
when (cInfo.groupInfo.membership.memberStatus) {
GroupMemberStatus.MemInvited -> chatPreviewTitleText(if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary)
GroupMemberStatus.MemInvited -> chatPreviewTitleText(
if (inProgress)
MaterialTheme.colors.secondary
else
if (chat.chatInfo.incognito) Indigo else MaterialTheme.colors.primary
)
GroupMemberStatus.MemAccepted -> chatPreviewTitleText(MaterialTheme.colors.secondary)
else -> chatPreviewTitleText()
}
@ -194,6 +201,17 @@ fun ChatPreviewView(
}
}
@Composable
fun progressView() {
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 1.5.dp
)
}
@Composable
fun chatStatusImage() {
if (cInfo is ChatInfo.Direct) {
@ -213,17 +231,17 @@ fun ChatPreviewView(
)
else ->
CircularProgressIndicator(
Modifier
.padding(horizontal = 2.dp)
.size(15.dp),
color = MaterialTheme.colors.secondary,
strokeWidth = 1.5.dp
)
progressView()
}
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
} else if (cInfo is ChatInfo.Group) {
if (progressByTimeout) {
progressView()
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
} else {
IncognitoIcon(chat.chatInfo.incognito)
}
@ -351,6 +369,6 @@ fun unreadCountStr(n: Int): String {
@Composable
fun PreviewChatPreviewView() {
SimpleXTheme {
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION)
ChatPreviewView(Chat.sampleData, true, null, null, "", contactNetworkStatus = NetworkStatus.Connected(), stopped = false, linkMode = SimplexLinkMode.DESCRIPTION, inProgress = false, progressByTimeout = false)
}
}

View File

@ -1,5 +1,6 @@
package chat.simplex.common.views.newchat
import SectionTextFooter
import androidx.compose.desktop.ui.tooling.preview.Preview
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
@ -11,10 +12,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.buildAnnotatedString
import dev.icerock.moko.resources.compose.painterResource
import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import chat.simplex.common.model.*
@ -22,11 +22,10 @@ import chat.simplex.common.ui.theme.*
import chat.simplex.common.views.chat.group.AddGroupMembersView
import chat.simplex.common.views.chatlist.setGroupMembers
import chat.simplex.common.views.helpers.*
import chat.simplex.common.views.onboarding.ReadableText
import chat.simplex.common.views.usersettings.DeleteImageButton
import chat.simplex.common.views.usersettings.EditImageButton
import chat.simplex.common.platform.*
import chat.simplex.common.views.*
import chat.simplex.common.views.chat.group.GroupLinkView
import chat.simplex.common.views.usersettings.*
import chat.simplex.res.MR
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@ -35,9 +34,9 @@ import java.net.URI
@Composable
fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
AddGroupLayout(
createGroup = { groupProfile ->
createGroup = { incognito, groupProfile ->
withApi {
val groupInfo = chatModel.controller.apiNewGroup(groupProfile)
val groupInfo = chatModel.controller.apiNewGroup(incognito, groupProfile)
if (groupInfo != null) {
chatModel.addChat(Chat(chatInfo = ChatInfo.Group(groupInfo), chatItems = listOf()))
chatModel.chatItems.clear()
@ -45,24 +44,36 @@ fun AddGroupView(chatModel: ChatModel, close: () -> Unit) {
chatModel.chatId.value = groupInfo.id
setGroupMembers(groupInfo, chatModel)
close.invoke()
if (!groupInfo.incognito) {
ModalManager.end.showModalCloseable(true) { close ->
AddGroupMembersView(groupInfo, true, chatModel, close)
AddGroupMembersView(groupInfo, creatingGroup = true, chatModel, close)
}
} else {
ModalManager.end.showModalCloseable(true) { close ->
GroupLinkView(chatModel, groupInfo, connReqContact = null, memberRole = null, onGroupLinkUpdated = null, creatingGroup = true, close)
}
}
}
}
},
incognitoPref = chatModel.controller.appPrefs.incognito,
close
)
}
@Composable
fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
fun AddGroupLayout(
createGroup: (Boolean, GroupProfile) -> Unit,
incognitoPref: SharedPreference<Boolean>,
close: () -> Unit
) {
val bottomSheetModalState = rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val scope = rememberCoroutineScope()
val displayName = rememberSaveable { mutableStateOf("") }
val chosenImage = rememberSaveable { mutableStateOf<URI?>(null) }
val profileImage = rememberSaveable { mutableStateOf<String?>(null) }
val focusRequester = remember { FocusRequester() }
val incognito = remember { mutableStateOf(incognitoPref.get()) }
ProvideWindowInsets(windowInsetsAnimationsEnabled = true) {
ModalBottomSheetLayout(
@ -87,7 +98,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
.padding(horizontal = DEFAULT_PADDING)
) {
AppBarTitle(stringResource(MR.strings.create_secret_group_title))
ReadableText(MR.strings.group_is_decentralized, TextAlign.Center)
Box(
Modifier
.fillMaxWidth()
@ -118,20 +128,32 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
}
ProfileNameField(displayName, "", { isValidDisplayName(it.trim()) }, focusRequester)
Spacer(Modifier.height(8.dp))
val enabled = canCreateProfile(displayName.value)
if (enabled) {
CreateGroupButton(MaterialTheme.colors.primary, Modifier
.clickable {
createGroup(GroupProfile(
SettingsActionItem(
painterResource(MR.images.ic_check),
stringResource(MR.strings.create_group_button),
click = {
createGroup(incognito.value, GroupProfile(
displayName = displayName.value.trim(),
fullName = "",
image = profileImage.value
))
},
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = !canCreateProfile(displayName.value)
)
IncognitoToggle(incognitoPref, incognito) { ModalManager.start.showModal { IncognitoView() } }
SectionTextFooter(
buildAnnotatedString {
append(sharedProfileInfo(chatModel, incognito.value))
append("\n")
append(annotatedStringResource(MR.strings.group_is_decentralized))
}
.padding(8.dp))
} else {
CreateGroupButton(MaterialTheme.colors.secondary, Modifier.padding(8.dp))
}
)
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
@ -142,21 +164,6 @@ fun AddGroupLayout(createGroup: (GroupProfile) -> Unit, close: () -> Unit) {
}
}
@Composable
fun CreateGroupButton(color: Color, modifier: Modifier) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Surface(shape = RoundedCornerShape(20.dp), color = Color.Transparent) {
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(MR.strings.create_profile_button), style = MaterialTheme.typography.caption, color = color, fontWeight = FontWeight.Bold)
Icon(painterResource(MR.images.ic_arrow_forward_ios), stringResource(MR.strings.create_profile_button), tint = color)
}
}
}
}
fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmpty() && isValidDisplayName(displayName.trim())
@Preview
@ -164,7 +171,8 @@ fun canCreateProfile(displayName: String): Boolean = displayName.trim().isNotEmp
fun PreviewAddGroupLayout() {
SimpleXTheme {
AddGroupLayout(
createGroup = {},
createGroup = { _, _ -> },
incognitoPref = SharedPreference({ false }, {}),
close = {}
)
}

View File

@ -3,10 +3,10 @@ package chat.simplex.common.views.newchat
import SectionBottomSpacer
import SectionTextFooter
import androidx.compose.desktop.ui.tooling.preview.Preview
import chat.simplex.common.platform.Log
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.MaterialTheme
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import dev.icerock.moko.resources.compose.painterResource
@ -14,7 +14,6 @@ import dev.icerock.moko.resources.compose.stringResource
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.unit.dp
import chat.simplex.common.platform.TAG
import chat.simplex.common.model.ChatModel
import chat.simplex.common.model.SharedPreference
import chat.simplex.common.ui.theme.*
@ -23,7 +22,6 @@ import chat.simplex.common.views.usersettings.IncognitoView
import chat.simplex.common.views.usersettings.SettingsActionItem
import chat.simplex.res.MR
import java.net.URI
import java.net.URISyntaxException
@Composable
fun PasteToConnectView(chatModel: ChatModel, close: () -> Unit) {
@ -97,6 +95,8 @@ fun PasteToConnectLayout(
painterResource(MR.images.ic_link),
stringResource(MR.strings.connect_button),
click = { connectViaLink(connectionLink.value) },
textColor = MaterialTheme.colors.primary,
iconColor = MaterialTheme.colors.primary,
disabled = connectionLink.value.isEmpty() || connectionLink.value.trim().contains(" ")
)

View File

@ -1294,11 +1294,11 @@
<!-- AddGroupView.kt -->
<string name="create_secret_group_title">Create secret group</string>
<string name="group_is_decentralized">The group is fully decentralized it is visible only to the members.</string>
<string name="group_is_decentralized">Fully decentralized visible only to members.</string>
<string name="group_display_name_field">Enter group name:</string>
<string name="group_full_name_field">Group full name:</string>
<string name="group_main_profile_sent">Your chat profile will be sent to group members</string>
<string name="create_group_button">Create group</string>
<!-- GroupProfileView.kt -->
<string name="group_profile_is_stored_on_members_devices">Group profile is stored on members\' devices, not on the servers.</string>

View File

@ -9,7 +9,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: bba7ad349459dc212782517bce68a60299bbb827
tag: 3a2969c44ad2f7244188fd98ad0df504ead89d14
source-repository-package
type: git

View File

@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."bba7ad349459dc212782517bce68a60299bbb827" = "0qsiv83zvz2q4g9rhjys57yaj14hvjl0wp71dmckvqayyz7mfqqa";
"https://github.com/simplex-chat/simplexmq.git"."3a2969c44ad2f7244188fd98ad0df504ead89d14" = "12kdb016bcfgsjpypyr3x0rnf2w4jgyawwygjl5vjsdsa8rm1mqv";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/kazu-yamamoto/http2.git"."804fa283f067bd3fd89b8c5f8d25b3047813a517" = "1j67wp7rfybfx3ryx08z6gqmzj85j51hmzhgx47ihgmgr47sl895";
"https://github.com/simplex-chat/direct-sqlcipher.git"."34309410eb2069b029b8fc1872deb1e0db123294" = "0kwkmhyfsn2lixdlgl15smgr1h5gjk7fky6abzh8rng2h5ymnffd";

View File

@ -5832,7 +5832,7 @@ chatCommandP =
mcTextP = MCText . safeDecodeUtf8 <$> A.takeByteString
msgContentP = "text " *> mcTextP <|> "json " *> jsonP
ciDeleteMode = "broadcast" $> CIDMBroadcast <|> "internal" $> CIDMInternal
displayName = safeDecodeUtf8 <$> (quoted "'\"" <|> takeNameTill isSpace)
displayName = safeDecodeUtf8 <$> (quoted "'" <|> takeNameTill isSpace)
where
takeNameTill p =
A.peekChar' >>= \c ->
@ -5947,14 +5947,20 @@ timeItToView s action = do
pure a
mkValidName :: String -> String
mkValidName = reverse . dropWhile isSpace . fst . foldl' addChar ("", '\NUL')
mkValidName = reverse . dropWhile isSpace . fst3 . foldl' addChar ("", '\NUL', 0 :: Int)
where
addChar (r, prev) c = if notProhibited && validChar then (c' : r, c') else (r, prev)
fst3 (x, _, _) = x
addChar (r, prev, punct) c = if validChar then (c' : r, c', punct') else (r, prev, punct)
where
c' = if isSpace c then ' ' else c
punct'
| isPunctuation c = punct + 1
| isSpace c = punct
| otherwise = 0
validChar
| prev == '\NUL' || isSpace prev = validFirstChar
| isPunctuation prev = validFirstChar || isSpace c
| c == '\'' = False
| prev == '\NUL' = c > ' ' && c /= '#' && c /= '@' && validFirstChar
| isSpace prev = validFirstChar || (punct == 0 && isPunctuation c)
| isPunctuation prev = validFirstChar || isSpace c || (punct < 3 && isPunctuation c)
| otherwise = validFirstChar || isSpace c || isMark c || isPunctuation c
validFirstChar = isLetter c || isNumber c || isSymbol c
notProhibited = c `notElem` ("@#'\"`" :: String)

View File

@ -9,12 +9,14 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TypeApplications #-}
module Simplex.Chat.Messages.CIContent where
import Data.Aeson (FromJSON, ToJSON)
import qualified Data.Aeson as J
import qualified Data.Aeson.TH as JQ
import Data.Int (Int64)
import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1, encodeUtf8)
@ -314,11 +316,11 @@ instance ToJSON DBRcvDirectEvent where
newtype DBMsgErrorType = DBME MsgErrorType
instance FromJSON DBMsgErrorType where
parseJSON v = DBME <$> J.genericParseJSON (singleFieldJSON fstToLower) v
parseJSON v = DBME <$> $(JQ.mkParseJSON (singleFieldJSON fstToLower) ''MsgErrorType) v
instance ToJSON DBMsgErrorType where
toJSON (DBME v) = J.genericToJSON (singleFieldJSON fstToLower) v
toEncoding (DBME v) = J.genericToEncoding (singleFieldJSON fstToLower) v
toJSON (DBME v) = $(JQ.mkToJSON (singleFieldJSON fstToLower) ''MsgErrorType) v
toEncoding (DBME v) = $(JQ.mkToEncoding (singleFieldJSON fstToLower) ''MsgErrorType) v
data CIGroupInvitation = CIGroupInvitation
{ groupId :: GroupId,

View File

@ -49,7 +49,7 @@ extra-deps:
# - simplexmq-1.0.0@sha256:34b2004728ae396e3ae449cd090ba7410781e2b3cefc59259915f4ca5daa9ea8,8561
# - ../simplexmq
- github: simplex-chat/simplexmq
commit: bba7ad349459dc212782517bce68a60299bbb827
commit: 3a2969c44ad2f7244188fd98ad0df504ead89d14
- github: kazu-yamamoto/http2
commit: 804fa283f067bd3fd89b8c5f8d25b3047813a517
# - ../direct-sqlcipher

View File

@ -14,14 +14,26 @@ testMkValidName = do
mkValidName "John Doe" `shouldBe` "John Doe"
mkValidName "J.Doe" `shouldBe` "J.Doe"
mkValidName "J. Doe" `shouldBe` "J. Doe"
mkValidName "J..Doe" `shouldBe` "J.Doe"
mkValidName "J ..Doe" `shouldBe` "J Doe"
mkValidName "J . . Doe" `shouldBe` "J Doe"
mkValidName "J..Doe" `shouldBe` "J..Doe"
mkValidName "J ..Doe" `shouldBe` "J ..Doe"
mkValidName "J ... Doe" `shouldBe` "J ... Doe"
mkValidName "J .... Doe" `shouldBe` "J ... Doe"
mkValidName "J . . Doe" `shouldBe` "J . Doe"
mkValidName "@alice" `shouldBe` "alice"
mkValidName "#alice" `shouldBe` "alice"
mkValidName " alice" `shouldBe` "alice"
mkValidName "alice " `shouldBe` "alice"
mkValidName "John Doe" `shouldBe` "John Doe"
mkValidName "'John Doe'" `shouldBe` "John Doe"
mkValidName "\"John Doe\"" `shouldBe` "John Doe"
mkValidName "`John Doe`" `shouldBe` "John Doe"
mkValidName "\"John Doe\"" `shouldBe` "John Doe\""
mkValidName "`John Doe`" `shouldBe` "`John Doe`"
mkValidName "John \"Doe\"" `shouldBe` "John \"Doe\""
mkValidName "John `Doe`" `shouldBe` "John `Doe`"
mkValidName "alice/bob" `shouldBe` "alice/bob"
mkValidName "alice / bob" `shouldBe` "alice / bob"
mkValidName "alice /// bob" `shouldBe` "alice /// bob"
mkValidName "alice //// bob" `shouldBe` "alice /// bob"
mkValidName "alice >>= bob" `shouldBe` "alice >>= bob"
mkValidName "alice@example.com" `shouldBe` "alice@example.com"
mkValidName "alice <> bob" `shouldBe` "alice <> bob"
mkValidName "alice -> bob" `shouldBe` "alice -> bob"