Conversation settings tweaks and bug fixes (tests WIP)

• Updated the app and conversation settings to use modals to set names/descriptions
• Updated the ThreadSettingsViewModel to allow non-admins to view a larger version of the display picture in groups and communities
• Removing the logic to set display pic, name & description from the EditGroupViewModel
• Fixed a couple of unlocalised strings
• Fixed an issue where downloading files was failing due to the URL reconstruction being invalid
pull/894/head
Morgan Pretty 6 months ago
parent 41b3331305
commit fc612c1d0f

@ -30,16 +30,6 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
private let threadId: String
private let userSessionId: SessionId
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImageDataPicked: { [weak self] resultImageData in
self?.updatedDisplayPictureSelected(update: .groupUploadImageData(resultImageData))
}
)
fileprivate var newDisplayName: String?
fileprivate var newGroupDescription: String?
private var editDisplayPictureModal: ConfirmationModal?
private var editDisplayPictureModalInfo: ConfirmationModal.Info?
private var inviteByIdValue: String?
// MARK: - Initialization
@ -200,23 +190,9 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
threadVariant: (isUpdatedGroup ? .group : .legacyGroup),
displayPictureFilename: state.group.displayPictureFilename,
profile: state.profileFront,
profileIcon: {
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
return .none
}
// If we already have a display picture then the main profile gets the icon
return (state.group.displayPictureFilename != nil ? .rightPlus : .none)
}(),
profileIcon: .none,
additionalProfile: state.profileBack,
additionalProfileIcon: {
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
return .none
}
// No display picture means the dual-profile so the additionalProfile gets the icon
return .rightPlus
}(),
additionalProfileIcon: .none,
accessibility: nil
),
styling: SessionCell.StyleInfo(
@ -226,22 +202,10 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
),
accessibility: Accessibility(
label: "Profile picture"
),
onTap: { [weak self, dependencies] in
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
return
}
self?.updateDisplayPicture(currentFileName: state.group.displayPictureFilename)
}
)
),
SessionCell.Info(
id: .groupName,
leadingAccessory: .icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .medium,
customTint: .textSecondary
),
title: SessionCell.TextInfo(
state.group.name,
font: .titleLarge,
@ -252,23 +216,14 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
alignment: .centerHugging,
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
leading: -((IconSize.medium.size + (Values.smallSpacing * 2)) / 2),
bottom: Values.smallSpacing,
interItem: 0
bottom: Values.smallSpacing
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Group name text field",
label: state.group.name
),
onTap: { [weak self] in
self?.updateGroupNameAndDescription(
isUpdatedGroup: isUpdatedGroup,
currentName: state.group.name,
currentDescription: state.group.groupDescription
)
}
)
),
((state.group.groupDescription ?? "").isEmpty ? nil :
SessionCell.Info(
@ -291,14 +246,7 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
accessibility: Accessibility(
identifier: "Group description text field",
label: (state.group.groupDescription ?? "")
),
onTap: { [weak self] in
self?.updateGroupNameAndDescription(
isUpdatedGroup: isUpdatedGroup,
currentName: state.group.name,
currentDescription: state.group.groupDescription
)
}
)
)
)
].compactMap { $0 }
@ -445,307 +393,6 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
// MARK: - Functions
private func updateDisplayPicture(currentFileName: String?) {
guard dependencies[feature: .updatedGroupsAllowDisplayPicture] else { return }
let existingImageData: Data? = dependencies[singleton: .storage].read { [threadId, dependencies] db in
DisplayPictureManager.displayPicture(db, id: .group(threadId), using: dependencies)
}
let editDisplayPictureModalInfo: ConfirmationModal.Info = ConfirmationModal.Info(
title: "groupSetDisplayPicture".localized(),
body: .image(
placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
valueData: existingImageData,
icon: .rightPlus,
style: .circular,
accessibility: Accessibility(
identifier: "Image picker",
label: "Image picker"
),
onClick: { [weak self] in self?.showPhotoLibraryForAvatar() }
),
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { info in
switch info.body {
case .image(_, let valueData, _, _, _, _): return (valueData != nil)
default: return false
}
},
cancelTitle: "remove".localized(),
cancelEnabled: .bool(existingImageData != nil),
hasCloseButton: true,
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
switch modal.info.body {
case .image(_, .some(let valueData), _, _, _, _):
self?.updateDisplayPicture(
displayPictureUpdate: .groupUploadImageData(valueData),
onComplete: { [weak modal] in modal?.close() }
)
default: modal.close()
}
},
onCancel: { [weak self] modal in
self?.updateDisplayPicture(
displayPictureUpdate: .groupRemove,
onComplete: { [weak modal] in modal?.close() }
)
},
afterClosed: { [weak self] in
self?.editDisplayPictureModal = nil
self?.editDisplayPictureModalInfo = nil
}
)
let modal: ConfirmationModal = ConfirmationModal(info: editDisplayPictureModalInfo)
self.editDisplayPictureModalInfo = editDisplayPictureModalInfo
self.editDisplayPictureModal = modal
self.transitionToScreen(modal, transitionType: .present)
}
private func updatedDisplayPictureSelected(update: DisplayPictureManager.Update) {
guard let info: ConfirmationModal.Info = self.editDisplayPictureModalInfo else { return }
self.editDisplayPictureModal?.updateContent(
with: info.with(
body: .image(
placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
valueData: {
switch update {
case .groupUploadImageData(let imageData): return imageData
default: return nil
}
}(),
icon: .rightPlus,
style: .circular,
accessibility: Accessibility(
identifier: "Image picker",
label: "Image picker"
),
onClick: { [weak self] in self?.showPhotoLibraryForAvatar() }
)
)
)
}
private func showPhotoLibraryForAvatar() {
Permissions.requestLibraryPermissionIfNeeded(isSavingMedia: false, using: dependencies) { [weak self] in
DispatchQueue.main.async {
let picker: UIImagePickerController = UIImagePickerController()
picker.sourceType = .photoLibrary
picker.mediaTypes = [ "public.image" ] // stringlint:disable
picker.delegate = self?.imagePickerHandler
self?.transitionToScreen(picker, transitionType: .present)
}
}
}
private func updateDisplayPicture(
displayPictureUpdate: DisplayPictureManager.Update,
onComplete: (() -> ())? = nil
) {
switch displayPictureUpdate {
case .none: onComplete?()
default: break
}
func performChanges(_ viewController: ModalActivityIndicatorViewController, _ displayPictureUpdate: DisplayPictureManager.Update) {
let existingFileName: String? = dependencies[singleton: .storage].read { [threadId] db in
try? ClosedGroup
.filter(id: threadId)
.select(.displayPictureFilename)
.asRequest(of: String.self)
.fetchOne(db)
}
MessageSender
.updateGroup(
groupSessionId: threadId,
displayPictureUpdate: displayPictureUpdate,
using: dependencies
)
.sinkUntilComplete(
receiveCompletion: { [dependencies] result in
// Remove any cached avatar image value
if let existingFileName: String = existingFileName {
dependencies.mutate(cache: .displayPicture) { $0.imageData[existingFileName] = nil }
}
DispatchQueue.main.async {
viewController.dismiss(completion: {
onComplete?()
})
}
}
)
}
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] viewController in
switch displayPictureUpdate {
case .none, .currentUserRemove, .currentUserUploadImageData, .currentUserUpdateTo,
.contactRemove, .contactUpdateTo:
viewController.dismiss(animated: true) // Shouldn't get called
case .groupRemove, .groupUpdateTo: performChanges(viewController, displayPictureUpdate)
case .groupUploadImageData(let data):
DisplayPictureManager.prepareAndUploadDisplayPicture(
queue: DispatchQueue.global(qos: .background),
imageData: data,
success: { url, fileName, key in
performChanges(viewController, .groupUpdateTo(url: url, key: key, fileName: fileName))
},
failure: { error in
DispatchQueue.main.async {
viewController.dismiss {
let message: String = {
switch (displayPictureUpdate, error) {
case (.groupRemove, _): return "profileDisplayPictureRemoveError".localized()
case (_, .uploadMaxFileSizeExceeded):
return "profileDisplayPictureSizeError".localized()
default: return "profileErrorUpdate".localized()
}
}()
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized(),
body: .text(message),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text,
dismissType: .single
)
),
transitionType: .present
)
}
}
},
using: dependencies
)
}
}
self.transitionToScreen(viewController, transitionType: .present)
}
private func updateGroupNameAndDescription(
isUpdatedGroup: Bool,
currentName: String,
currentDescription: String?
) {
/// Set `newDisplayName` to `currentName` so we can disable the "save" button when there are no changes
self.newDisplayName = currentName
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "groupInformationSet".localized(),
body: { [weak self, dependencies] in
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDescriptionEditing] else {
return .input(
explanation: NSAttributedString(string: "EDIT_LEGACY_GROUP_INFO_MESSAGE"),//.localized()),
info: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupNameEnter".localized(),
initialValue: currentName
),
onChange: { updatedName in
self?.newDisplayName = updatedName
}
)
}
return .dualInput(
explanation: NSAttributedString(string: "EDIT_GROUP_INFO_MESSAGE"),//.localized()),
firstInfo: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupNameEnter".localized(),
initialValue: currentName
),
secondInfo: ConfirmationModal.Info.Body.InputInfo(
placeholder: "groupDescriptionEnter".localized(),
initialValue: currentDescription
),
onChange: { updatedName, updatedDescription in
self?.newDisplayName = updatedName
self?.newGroupDescription = updatedDescription
}
)
}(),
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { [weak self] _ in
self?.newDisplayName != currentName &&
self?.newDisplayName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false
},
cancelStyle: .danger,
dismissOnConfirm: false,
onConfirm: { [weak self, dependencies, threadId] modal in
guard
let finalName: String = (self?.newDisplayName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.nullIfEmpty
else { return }
let finalDescription: String? = self?.newGroupDescription
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
/// Check if the data violates any of the size constraints
let maybeErrorString: String? = {
guard !Profile.isTooLong(profileName: finalName) else { return "groupNameEnterShorter".localized() }
return nil // No error has occurred
}()
if let errorString: String = maybeErrorString {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "theError".localized(),
body: .text(errorString),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text,
dismissType: .single
)
),
transitionType: .present
)
return
}
/// Update the group appropriately
MessageSender
.updateGroup(
groupSessionId: threadId,
name: finalName,
groupDescription: finalDescription,
using: dependencies
)
.sinkUntilComplete(
receiveCompletion: { [weak self] result in
switch result {
case .finished: modal.dismiss(animated: true)
case .failure:
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "theError".localized(),
body: .text("deleteAfterLegacyGroupsGroupUpdateErrorTitle".localized()),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
}
}
)
}
)
),
transitionType: .present
)
}
private func inviteContacts(
currentGroupName: String
) {

File diff suppressed because it is too large Load Diff

@ -9,47 +9,34 @@ import SessionMessagingKit
import SessionUtilitiesKit
import SignalUtilitiesKit
class SettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, EditableStateHolder, ObservableTableSource {
class SettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState()
public let editableState: EditableState<TableItem> = EditableState()
public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let userSessionId: SessionId
private var updatedName: String?
private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)?
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImageDataPicked: { [weak self] resultImageData in
self?.updatedProfilePictureSelected(
displayPictureUpdate: .currentUserUploadImageData(resultImageData)
)
self?.onDisplayPictureSelected?(.image(resultImageData))
}
)
fileprivate var oldDisplayName: String
private var editedDisplayName: String?
private var editProfilePictureModal: ConfirmationModal?
private var editProfilePictureModalInfo: ConfirmationModal.Info?
// MARK: - Initialization
init(using dependencies: Dependencies) {
self.dependencies = dependencies
self.userSessionId = dependencies[cache: .general].sessionId
self.oldDisplayName = Profile.fetchOrCreateCurrentUser(using: dependencies).name
}
// MARK: - Config
enum NavState {
case standard
case editing
}
enum NavItem: Equatable {
case close
case qrCode
case cancel
case done
}
public enum Section: SessionTableSection {
@ -96,122 +83,35 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
// MARK: - NavigationItemSource
lazy var navState: AnyPublisher<NavState, Never> = Publishers
.CombineLatest(
isEditing,
textChanged
.handleEvents(
receiveOutput: { [weak self] value, _ in
self?.editedDisplayName = value
}
)
.filter { _ in false }
.prepend((nil, .profileName))
)
.map { isEditing, _ -> NavState in (isEditing ? .editing : .standard) }
.removeDuplicates()
.prepend(.standard) // Initial value
.shareReplay(1)
.eraseToAnyPublisher()
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { navState -> [SessionNavItem<NavItem>] in
switch navState {
case .standard:
return [
SessionNavItem(
id: .close,
image: UIImage(named: "X")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Close button"
) { [weak self] in self?.dismissScreen() }
]
case .editing:
return [
SessionNavItem(
id: .cancel,
systemItem: .cancel,
accessibilityIdentifier: "Cancel button"
) { [weak self] in
self?.setIsEditing(false)
self?.editedDisplayName = self?.oldDisplayName
}
]
}
}
.eraseToAnyPublisher()
lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .close,
image: UIImage(named: "X")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "Close button"
) { [weak self] in self?.dismissScreen() }
]
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState
.map { [weak self, dependencies] navState -> [SessionNavItem<NavItem>] in
switch navState {
case .standard:
return [
SessionNavItem(
id: .qrCode,
image: UIImage(named: "QRCode")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "View QR code",
action: { [weak self] in
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: QRCodeScreen(using: dependencies)
)
viewController.setNavBarTitle("qrCode".localized())
self?.transitionToScreen(viewController)
}
)
]
case .editing:
return [
SessionNavItem(
id: .done,
systemItem: .done,
accessibilityIdentifier: "Done"
) { [weak self] in
let updatedNickname: String = (self?.editedDisplayName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !updatedNickname.isEmpty else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "displayNameErrorDescription".localized(),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
return
}
guard !Profile.isTooLong(profileName: updatedNickname) else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "displayNameErrorDescriptionShorter".localized(),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text
)
),
transitionType: .present
)
return
}
self?.setIsEditing(false)
self?.oldDisplayName = updatedNickname
self?.updateProfile(displayNameUpdate: .currentUserUpdate(updatedNickname))
}
]
lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
SessionNavItem(
id: .qrCode,
image: UIImage(named: "QRCode")?
.withRenderingMode(.alwaysTemplate),
style: .plain,
accessibilityIdentifier: "View QR code",
action: { [weak self, dependencies] in
let viewController: SessionHostingViewController = SessionHostingViewController(
rootView: QRCodeScreen(using: dependencies)
)
viewController.setNavBarTitle("qrCode".localized())
self?.transitionToScreen(viewController)
}
}
.eraseToAnyPublisher()
)
]
// MARK: - Content
private struct State: Equatable {
let profile: Profile
let developerModeEnabled: Bool
@ -231,6 +131,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
.compactMap { [weak self] state -> [SectionModel]? in self?.content(state) }
private func content(_ state: State) -> [SectionModel] {
let editIcon: UIImage? = UIImage(systemName: "pencil")
return [
SectionModel(
model: .profileInfo,
@ -260,6 +162,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
),
SessionCell.Info(
id: .profileName,
leadingAccessory: .icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .mediumAspectFill,
customTint: .textSecondary,
shouldFill: true
),
title: SessionCell.TextInfo(
state.profile.displayName(),
font: .titleLarge,
@ -268,14 +176,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
),
styling: SessionCell.StyleInfo(
alignment: .centerHugging,
customPadding: SessionCell.Padding(top: Values.smallSpacing),
customPadding: SessionCell.Padding(
top: Values.smallSpacing,
leading: -((IconSize.medium.size + (Values.smallSpacing * 2)) / 2),
bottom: Values.mediumSpacing
),
backgroundStyle: .noBackground
),
accessibility: Accessibility(
identifier: "Username",
label: state.profile.displayName()
),
onTap: { [weak self] in self?.setIsEditing(true) }
onTap: { [weak self] in self?.updateDisplayName(current: state.profile.displayName()) }
)
]
),
@ -530,91 +442,123 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
// MARK: - Functions
private func updateDisplayName(current: String) {
/// Set `updatedName` to `current` so we can disable the "save" button when there are no changes and don't need to worry
/// about retrieving them in the confirmation closure
self.updatedName = current
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "displayNameSet".localized(),
body: .input(
explanation: nil,
info: ConfirmationModal.Info.Body.InputInfo(
placeholder: "displayNameEnter".localized(),
initialValue: current
),
onChange: { [weak self] updatedName in self?.updatedName = updatedName }
),
confirmTitle: "save".localized(),
confirmEnabled: .afterChange { [weak self] _ in
self?.updatedName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false &&
self?.updatedName != current
},
cancelStyle: .alert_text,
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
guard
let finalDisplayName: String = (self?.updatedName ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
.nullIfEmpty
else { return }
/// Check if the data violates the size constraints
guard !Profile.isTooLong(profileName: finalDisplayName) else {
self?.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "theError".localized(),
body: .text("displayNameErrorDescriptionShorter".localized()),
cancelTitle: "okay".localized(),
cancelStyle: .alert_text,
dismissType: .single
)
),
transitionType: .present
)
return
}
/// Update the nickname
self?.updateProfile(displayNameUpdate: .currentUserUpdate(finalDisplayName)) {
modal.dismiss(animated: true)
}
}
)
),
transitionType: .present
)
}
private func updateProfilePicture(currentFileName: String?) {
let existingImageData: Data? = dependencies[singleton: .storage].read { [userSessionId, dependencies] db in
DisplayPictureManager.displayPicture(db, id: .user(userSessionId.hexString), using: dependencies)
}
let editProfilePictureModalInfo: ConfirmationModal.Info = ConfirmationModal.Info(
title: "profileDisplayPictureSet".localized(),
body: .image(
placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
valueData: existingImageData,
icon: .rightPlus,
style: .circular,
accessibility: Accessibility(
identifier: "Upload",
label: "Upload"
),
onClick: { [weak self] in self?.showPhotoLibraryForAvatar() }
),
confirmTitle: "save".localized(),
confirmAccessibility: Accessibility(
identifier: "Save button"
),
confirmEnabled: .afterChange { info in
switch info.body {
case .image(_, let valueData, _, _, _, _): return (valueData != nil)
default: return false
}
},
cancelTitle: "remove".localized(),
cancelAccessibility: Accessibility(
identifier: "Remove button"
),
cancelEnabled: .bool(existingImageData != nil),
hasCloseButton: true,
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
switch modal.info.body {
case .image(_, .some(let valueData), _, _, _, _):
self.transitionToScreen(
ConfirmationModal(
info: ConfirmationModal.Info(
title: "profileDisplayPictureSet".localized(),
body: .image(
placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
valueData: existingImageData,
icon: .rightPlus,
style: .circular,
accessibility: Accessibility(
identifier: "Upload",
label: "Upload"
),
onClick: { [weak self] onDisplayPictureSelected in
self?.onDisplayPictureSelected = onDisplayPictureSelected
self?.showPhotoLibraryForAvatar()
}
),
confirmTitle: "save".localized(),
confirmAccessibility: Accessibility(
identifier: "Save button"
),
confirmEnabled: .afterChange { info in
switch info.body {
case .image(_, let valueData, _, _, _, _): return (valueData != nil)
default: return false
}
},
cancelTitle: "remove".localized(),
cancelAccessibility: Accessibility(
identifier: "Remove button"
),
cancelEnabled: .bool(existingImageData != nil),
hasCloseButton: true,
dismissOnConfirm: false,
onConfirm: { [weak self] modal in
switch modal.info.body {
case .image(_, .some(let valueData), _, _, _, _):
self?.updateProfile(
displayPictureUpdate: .currentUserUploadImageData(valueData),
onComplete: { [weak modal] in modal?.close() }
)
default: modal.close()
}
},
onCancel: { [weak self] modal in
self?.updateProfile(
displayPictureUpdate: .groupUploadImageData(valueData),
displayPictureUpdate: .currentUserRemove,
onComplete: { [weak modal] in modal?.close() }
)
default: modal.close()
}
},
onCancel: { [weak self] modal in
self?.updateProfile(
displayPictureUpdate: .currentUserRemove,
onComplete: { [weak modal] in modal?.close() }
)
},
afterClosed: { [weak self] in
self?.editProfilePictureModal = nil
self?.editProfilePictureModalInfo = nil
}
)
let modal: ConfirmationModal = ConfirmationModal(info: editProfilePictureModalInfo)
self.editProfilePictureModalInfo = editProfilePictureModalInfo
self.editProfilePictureModal = modal
self.transitionToScreen(modal, transitionType: .present)
}
fileprivate func updatedProfilePictureSelected(displayPictureUpdate: DisplayPictureManager.Update) {
guard let info: ConfirmationModal.Info = self.editProfilePictureModalInfo else { return }
self.editProfilePictureModal?.updateContent(
with: info.with(
body: .image(
placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
valueData: {
switch displayPictureUpdate {
case .currentUserUploadImageData(let imageData): return imageData
default: return nil
}
}(),
icon: .rightPlus,
style: .circular,
accessibility: Accessibility(
identifier: "Image picker",
label: "Image picker"
),
onClick: { [weak self] in self?.showPhotoLibraryForAvatar() }
}
)
)
),
transitionType: .present
)
}
@ -634,7 +578,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
fileprivate func updateProfile(
displayNameUpdate: Profile.DisplayNameUpdate = .none,
displayPictureUpdate: DisplayPictureManager.Update = .none,
onComplete: (() -> ())? = nil
onComplete: @escaping () -> ()
) {
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in
Profile.updateLocal(
@ -646,7 +590,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
db.afterNextTransactionNested(using: dependencies) { _ in
DispatchQueue.main.async {
modalActivityIndicator.dismiss(completion: {
onComplete?()
onComplete()
})
}
}

@ -314,6 +314,20 @@ extension SessionCell {
imageViewWidthConstraint.constant = imageView.bounds.width
imageViewHeightConstraint.constant = imageView.bounds.height
case .mediumAspectFill:
imageView.sizeToFit()
imageViewWidthConstraint.constant = (imageView.bounds.width > imageView.bounds.height ?
(accessory.iconSize.size * (imageView.bounds.width / imageView.bounds.height)) :
accessory.iconSize.size
)
imageViewHeightConstraint.constant = (imageView.bounds.width > imageView.bounds.height ?
accessory.iconSize.size :
(accessory.iconSize.size * (imageView.bounds.height / imageView.bounds.width))
)
fixedWidthConstraint.constant = imageViewWidthConstraint.constant
fixedWidthConstraint.isActive = true
default:
fixedWidthConstraint.isActive = (accessory.iconSize.size <= fixedWidthConstraint.constant)
imageViewWidthConstraint.constant = accessory.iconSize.size

@ -365,8 +365,17 @@ public extension Profile {
}
/// The name to display in the UI for a given thread variant
func displayName(for threadVariant: SessionThread.Variant = .contact) -> String {
return Profile.displayName(for: threadVariant, id: id, name: name, nickname: nickname, suppressId: false)
func displayName(
for threadVariant: SessionThread.Variant = .contact,
ignoringNickname: Bool = false
) -> String {
return Profile.displayName(
for: threadVariant,
id: id,
name: name,
nickname: (ignoringNickname ? nil : nickname),
suppressId: false
)
}
static func displayName(

@ -534,8 +534,8 @@ public extension SessionThread {
profile: Profile? = nil
) -> String {
switch variant {
case .legacyGroup, .group: return (closedGroupName ?? "Unknown Group")
case .community: return (openGroupName ?? "Unknown Community")
case .legacyGroup, .group: return (closedGroupName ?? "groupUnknown".localized())
case .community: return (openGroupName ?? "communityUnknown".localized())
case .contact:
guard !isNoteToSelf else { return "noteToSelf".localized() }
guard let profile: Profile = profile else {

@ -8,7 +8,8 @@ import SessionUtilitiesKit
public extension Network {
enum Destination: Equatable {
public struct ServerInfo: Equatable {
private static let invalidUrl: URL = URL(fileURLWithPath: "")
private static let invalidServer: String = "INVALID_SERVER"
private static let invalidUrl: URL = URL(fileURLWithPath: "INVALID_URL")
private let server: String
private let queryParameters: [HTTPQueryParam: String]
@ -54,17 +55,24 @@ public extension Network {
fileprivate init(
method: HTTPMethod,
url: URL,
pathAndParamsString: String,
server: String = "",
server: String?,
pathAndParamsString: String?,
queryParameters: [HTTPQueryParam: String] = [:],
headers: [HTTPHeader: String],
x25519PublicKey: String
) {
self._url = url
self._pathAndParamsString = pathAndParamsString
self._pathAndParamsString = (pathAndParamsString ?? url.path)
self.method = method
self.server = server
self.server = {
if let explicitServer: String = server { return explicitServer }
if let urlHost: String = url.host {
return "\(url.scheme.map { "\($0)://" } ?? "")\(urlHost)"
}
return ServerInfo.invalidServer
}()
self.queryParameters = queryParameters
self.headers = headers
self.x25519PublicKey = x25519PublicKey
@ -76,8 +84,8 @@ public extension Network {
return ServerInfo(
method: method,
url: try (URL(string: "\(server)\(pathAndParamsString)") ?? { throw NetworkError.invalidURL }()),
pathAndParamsString: pathAndParamsString,
server: server,
pathAndParamsString: pathAndParamsString,
queryParameters: queryParameters,
headers: headers,
x25519PublicKey: x25519PublicKey
@ -88,8 +96,8 @@ public extension Network {
return ServerInfo(
method: method,
url: _url,
pathAndParamsString: _pathAndParamsString,
server: server,
pathAndParamsString: _pathAndParamsString,
queryParameters: queryParameters,
headers: self.headers.updated(with: headers),
x25519PublicKey: x25519PublicKey
@ -189,7 +197,8 @@ public extension Network {
return .serverDownload(info: ServerInfo(
method: .get,
url: url,
pathAndParamsString: url.path,
server: nil,
pathAndParamsString: nil,
headers: headers,
x25519PublicKey: x25519PublicKey
))

@ -73,7 +73,7 @@ public extension Network {
switch self {
case .file: return "file"
case .fileIndividual(let fileId): return "file/\(fileId)"
case .directUrl(let url): return url.path
case .directUrl(let url): return url.path.removingPrefix("/")
case .sessionVersion: return "session_version"
}
}

@ -372,7 +372,7 @@ private class MigrationTest {
try db.execute(
sql: "INSERT INTO \(name) (\(columnNames)) VALUES (\(columnArgs))",
arguments: StatementArguments(columnInfo.map { column in
arguments: StatementArguments(columnInfo.map { (column: Row) in
// If we want to allow setting nulls (and the column is nullable but not a primary
// key) then use null for it's value
guard !nullsWherePossible || column["notnull"] != 0 || column["pk"] == 1 else {

@ -4,12 +4,13 @@ import UIKit
// FIXME: Refactor as part of the Groups Rebuild
public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
public static let explanationFont: UIFont = .systemFont(ofSize: Values.smallFontSize)
private static let closeSize: CGFloat = 24
public private(set) var info: Info
private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil
private var internalOnCancel: ((ConfirmationModal) -> ())? = nil
private var internalOnBodyTap: (() -> ())? = nil
private var internalOnBodyTap: ((@escaping (ValueUpdate) -> Void) -> Void)? = nil
private var internalOnTextChanged: ((String, String) -> ())? = nil
// MARK: - Components
@ -49,7 +50,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
private lazy var explanationLabel: ScrollableLabel = {
let result: ScrollableLabel = ScrollableLabel()
result.font = .systemFont(ofSize: Values.smallFontSize)
result.font = ConfirmationModal.explanationFont
result.themeTextColor = .alert_text
result.textAlignment = .center
result.lineBreakMode = .byWordWrapping
@ -276,7 +277,11 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
textField.text = (inputInfo.initialValue ?? "")
textField.clearButtonMode = (inputInfo.clearButton ? .always : .never)
textFieldContainer.isHidden = false
internalOnTextChanged = { text, _ in onTextChanged(text) }
internalOnTextChanged = { [weak confirmButton, weak cancelButton] text, _ in
onTextChanged(text)
confirmButton?.isEnabled = info.confirmEnabled.isValid(with: info)
cancelButton?.isEnabled = info.cancelEnabled.isValid(with: info)
}
case .dualInput(let explanation, let firstInputInfo, let secondInputInfo, let onTextChanged):
explanationLabel.attributedText = explanation
@ -290,7 +295,11 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
textViewPlaceholder.text = secondInputInfo.placeholder
textViewPlaceholder.isHidden = !textView.text.isEmpty
textViewContainer.isHidden = false
internalOnTextChanged = onTextChanged
internalOnTextChanged = { [weak confirmButton, weak cancelButton] firstText, secondText in
onTextChanged(firstText, secondText)
confirmButton?.isEnabled = info.confirmEnabled.isValid(with: info)
cancelButton?.isEnabled = info.cancelEnabled.isValid(with: info)
}
case .radio(let explanation, let options):
mainStackView.spacing = 0
@ -405,14 +414,32 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
textView.resignFirstResponder()
}
internalOnBodyTap?()
internalOnBodyTap?({ _ in })
}
@objc private func imageViewTapped() {
internalOnBodyTap?()
internalOnBodyTap?({ [weak self, info = self.info] valueUpdate in
switch (valueUpdate, info.body) {
case (.image(let updatedValueData), .image(let placeholderData, _, let icon, let style, let accessibility, let onClick)):
self?.updateContent(
with: info.with(
body: .image(
placeholderData: placeholderData,
valueData: updatedValueData,
icon: icon,
style: style,
accessibility: accessibility,
onClick: onClick
)
)
)
default: break
}
})
}
@objc private func confirmationPressed() {
@objc internal func confirmationPressed() {
internalOnConfirm?(self)
}
@ -424,16 +451,21 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
// MARK: - Types
public extension ConfirmationModal {
enum ValueUpdate {
case input(String)
case image(Data?)
}
struct Info: Equatable, Hashable {
let title: String
internal let title: String
public let body: Body
let accessibility: Accessibility?
public let showCondition: ShowCondition
let confirmTitle: String?
internal let confirmTitle: String?
let confirmAccessibility: Accessibility?
let confirmStyle: ThemeValue
let confirmEnabled: ButtonValidator
let cancelTitle: String
internal let cancelTitle: String
let cancelAccessibility: Accessibility?
let cancelStyle: ThemeValue
let cancelEnabled: ButtonValidator
@ -669,7 +701,7 @@ public extension ConfirmationModal.Info {
icon: ProfilePictureView.ProfileIcon = .none,
style: ImageStyle,
accessibility: Accessibility?,
onClick: (() -> ())
onClick: ((@escaping (ConfirmationModal.ValueUpdate) -> Void) -> Void)
)
public static func == (lhs: ConfirmationModal.Info.Body, rhs: ConfirmationModal.Info.Body) -> Bool {

@ -53,7 +53,7 @@ public extension UISearchBar {
guard let textColor: UIColor = theme.color(for: .textSecondary) else { return }
searchTextField?.attributedPlaceholder = NSAttributedString(
string: "search".localized(),
string: "search".localizedSNUIKit(),
attributes: [
.foregroundColor: textColor
])
@ -85,7 +85,7 @@ public extension UISearchBar {
guard let textColor: UIColor = theme.color(for: .textSecondary) else { return }
searchTextField?.attributedPlaceholder = NSAttributedString(
string: "searchContacts".localized(),
string: "searchContacts".localizedSNUIKit(),
attributes: [
.foregroundColor: textColor
]

@ -31,7 +31,7 @@ public struct SessionSearchBar: View {
ZStack(alignment: .leading) {
if searchText.isEmpty {
Text("search".localized())
Text("search".localizedSNUIKit())
.font(.system(size: Values.smallFontSize))
.foregroundColor(themeColor: .textSecondary)
}
@ -58,7 +58,7 @@ public struct SessionSearchBar: View {
Button {
cancelAction()
} label: {
Text("cancel".localized())
Text("cancel".localizedSNUIKit())
.font(.system(size: Values.smallFontSize))
.foregroundColor(themeColor: .textSecondary)
.padding(.leading, Values.mediumSpacing)

@ -29,12 +29,12 @@ public enum Format {
case oneMegabyte...Double.greatestFiniteMagnitude:
return (Format.fileSizeFormatter
.string(from: NSNumber(floatLiteral: (fileSizeDouble / oneMegabyte)))?
.appending("MB") ?? "attachmentsNa".localized())
.appending("MB") ?? "attachmentsNa".localizedSNUIKit())
default:
return (Format.fileSizeFormatter
.string(from: NSNumber(floatLiteral: max(0.1, (fileSizeDouble / oneKilobyte))))?
.appending("KB") ?? "attachmentsNa".localized())
.appending("KB") ?? "attachmentsNa".localizedSNUIKit())
}
}

@ -15,10 +15,10 @@ public enum Theme: String, CaseIterable, Codable {
public var title: String {
switch self {
case .classicDark: return "appearanceThemesClassicDark".localized()
case .classicLight: return "appearanceThemesClassicLight".localized()
case .oceanDark: return "appearanceThemesOceanDark".localized()
case .oceanLight: return "appearanceThemesOceanLight".localized()
case .classicDark: return "appearanceThemesClassicDark".localizedSNUIKit()
case .classicLight: return "appearanceThemesClassicLight".localizedSNUIKit()
case .oceanDark: return "appearanceThemesOceanDark".localizedSNUIKit()
case .oceanLight: return "appearanceThemesOceanLight".localizedSNUIKit()
}
}

@ -11,13 +11,15 @@ public enum IconSize: Differentiable {
case veryLarge
case extraLarge
case mediumAspectFill
case fit
public var size: CGFloat {
switch self {
case .verySmall: return 12
case .small: return 20
case .medium: return 24
case .medium, .mediumAspectFill: return 24
case .large: return 32
case .veryLarge: return 40
case .extraLarge: return 80

Loading…
Cancel
Save