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 threadId: String
private let userSessionId: SessionId 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? private var inviteByIdValue: String?
// MARK: - Initialization // MARK: - Initialization
@ -200,23 +190,9 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
threadVariant: (isUpdatedGroup ? .group : .legacyGroup), threadVariant: (isUpdatedGroup ? .group : .legacyGroup),
displayPictureFilename: state.group.displayPictureFilename, displayPictureFilename: state.group.displayPictureFilename,
profile: state.profileFront, profile: state.profileFront,
profileIcon: { profileIcon: .none,
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)
}(),
additionalProfile: state.profileBack, additionalProfile: state.profileBack,
additionalProfileIcon: { additionalProfileIcon: .none,
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
return .none
}
// No display picture means the dual-profile so the additionalProfile gets the icon
return .rightPlus
}(),
accessibility: nil accessibility: nil
), ),
styling: SessionCell.StyleInfo( styling: SessionCell.StyleInfo(
@ -226,22 +202,10 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
), ),
accessibility: Accessibility( accessibility: Accessibility(
label: "Profile picture" label: "Profile picture"
), )
onTap: { [weak self, dependencies] in
guard isUpdatedGroup && dependencies[feature: .updatedGroupsAllowDisplayPicture] else {
return
}
self?.updateDisplayPicture(currentFileName: state.group.displayPictureFilename)
}
), ),
SessionCell.Info( SessionCell.Info(
id: .groupName, id: .groupName,
leadingAccessory: .icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .medium,
customTint: .textSecondary
),
title: SessionCell.TextInfo( title: SessionCell.TextInfo(
state.group.name, state.group.name,
font: .titleLarge, font: .titleLarge,
@ -252,23 +216,14 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
alignment: .centerHugging, alignment: .centerHugging,
customPadding: SessionCell.Padding( customPadding: SessionCell.Padding(
top: Values.smallSpacing, top: Values.smallSpacing,
leading: -((IconSize.medium.size + (Values.smallSpacing * 2)) / 2), bottom: Values.smallSpacing
bottom: Values.smallSpacing,
interItem: 0
), ),
backgroundStyle: .noBackground backgroundStyle: .noBackground
), ),
accessibility: Accessibility( accessibility: Accessibility(
identifier: "Group name text field", identifier: "Group name text field",
label: state.group.name 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 : ((state.group.groupDescription ?? "").isEmpty ? nil :
SessionCell.Info( SessionCell.Info(
@ -291,14 +246,7 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
accessibility: Accessibility( accessibility: Accessibility(
identifier: "Group description text field", identifier: "Group description text field",
label: (state.group.groupDescription ?? "") label: (state.group.groupDescription ?? "")
), )
onTap: { [weak self] in
self?.updateGroupNameAndDescription(
isUpdatedGroup: isUpdatedGroup,
currentName: state.group.name,
currentDescription: state.group.groupDescription
)
}
) )
) )
].compactMap { $0 } ].compactMap { $0 }
@ -445,307 +393,6 @@ class EditGroupViewModel: SessionTableViewModel, NavigatableStateHolder, Editabl
// MARK: - Functions // 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( private func inviteContacts(
currentGroupName: String currentGroupName: String
) { ) {

File diff suppressed because it is too large Load Diff

@ -9,47 +9,34 @@ import SessionMessagingKit
import SessionUtilitiesKit import SessionUtilitiesKit
import SignalUtilitiesKit import SignalUtilitiesKit
class SettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, EditableStateHolder, ObservableTableSource { class SettingsViewModel: SessionTableViewModel, NavigationItemSource, NavigatableStateHolder, ObservableTableSource {
public let dependencies: Dependencies public let dependencies: Dependencies
public let navigatableState: NavigatableState = NavigatableState() public let navigatableState: NavigatableState = NavigatableState()
public let editableState: EditableState<TableItem> = EditableState()
public let state: TableDataState<Section, TableItem> = TableDataState() public let state: TableDataState<Section, TableItem> = TableDataState()
public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState() public let observableState: ObservableTableSourceState<Section, TableItem> = ObservableTableSourceState()
private let userSessionId: SessionId private let userSessionId: SessionId
private var updatedName: String?
private var onDisplayPictureSelected: ((ConfirmationModal.ValueUpdate) -> Void)?
private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler( private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(
onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) }, onTransition: { [weak self] in self?.transitionToScreen($0, transitionType: $1) },
onImageDataPicked: { [weak self] resultImageData in onImageDataPicked: { [weak self] resultImageData in
self?.updatedProfilePictureSelected( self?.onDisplayPictureSelected?(.image(resultImageData))
displayPictureUpdate: .currentUserUploadImageData(resultImageData)
)
} }
) )
fileprivate var oldDisplayName: String
private var editedDisplayName: String?
private var editProfilePictureModal: ConfirmationModal?
private var editProfilePictureModalInfo: ConfirmationModal.Info?
// MARK: - Initialization // MARK: - Initialization
init(using dependencies: Dependencies) { init(using dependencies: Dependencies) {
self.dependencies = dependencies self.dependencies = dependencies
self.userSessionId = dependencies[cache: .general].sessionId self.userSessionId = dependencies[cache: .general].sessionId
self.oldDisplayName = Profile.fetchOrCreateCurrentUser(using: dependencies).name
} }
// MARK: - Config // MARK: - Config
enum NavState {
case standard
case editing
}
enum NavItem: Equatable { enum NavItem: Equatable {
case close case close
case qrCode case qrCode
case cancel
case done
} }
public enum Section: SessionTableSection { public enum Section: SessionTableSection {
@ -96,122 +83,35 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
// MARK: - NavigationItemSource // MARK: - NavigationItemSource
lazy var navState: AnyPublisher<NavState, Never> = Publishers lazy var leftNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
.CombineLatest( SessionNavItem(
isEditing, id: .close,
textChanged image: UIImage(named: "X")?
.handleEvents( .withRenderingMode(.alwaysTemplate),
receiveOutput: { [weak self] value, _ in style: .plain,
self?.editedDisplayName = value accessibilityIdentifier: "Close button"
} ) { [weak self] in self?.dismissScreen() }
) ]
.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 rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = navState lazy var rightNavItems: AnyPublisher<[SessionNavItem<NavItem>], Never> = [
.map { [weak self, dependencies] navState -> [SessionNavItem<NavItem>] in SessionNavItem(
switch navState { id: .qrCode,
case .standard: image: UIImage(named: "QRCode")?
return [ .withRenderingMode(.alwaysTemplate),
SessionNavItem( style: .plain,
id: .qrCode, accessibilityIdentifier: "View QR code",
image: UIImage(named: "QRCode")? action: { [weak self, dependencies] in
.withRenderingMode(.alwaysTemplate), let viewController: SessionHostingViewController = SessionHostingViewController(
style: .plain, rootView: QRCodeScreen(using: dependencies)
accessibilityIdentifier: "View QR code", )
action: { [weak self] in viewController.setNavBarTitle("qrCode".localized())
let viewController: SessionHostingViewController = SessionHostingViewController( self?.transitionToScreen(viewController)
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))
}
]
} }
} )
.eraseToAnyPublisher() ]
// MARK: - Content // MARK: - Content
private struct State: Equatable { private struct State: Equatable {
let profile: Profile let profile: Profile
let developerModeEnabled: Bool let developerModeEnabled: Bool
@ -231,6 +131,8 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
.compactMap { [weak self] state -> [SectionModel]? in self?.content(state) } .compactMap { [weak self] state -> [SectionModel]? in self?.content(state) }
private func content(_ state: State) -> [SectionModel] { private func content(_ state: State) -> [SectionModel] {
let editIcon: UIImage? = UIImage(systemName: "pencil")
return [ return [
SectionModel( SectionModel(
model: .profileInfo, model: .profileInfo,
@ -260,6 +162,12 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
), ),
SessionCell.Info( SessionCell.Info(
id: .profileName, id: .profileName,
leadingAccessory: .icon(
editIcon?.withRenderingMode(.alwaysTemplate),
size: .mediumAspectFill,
customTint: .textSecondary,
shouldFill: true
),
title: SessionCell.TextInfo( title: SessionCell.TextInfo(
state.profile.displayName(), state.profile.displayName(),
font: .titleLarge, font: .titleLarge,
@ -268,14 +176,18 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
), ),
styling: SessionCell.StyleInfo( styling: SessionCell.StyleInfo(
alignment: .centerHugging, 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 backgroundStyle: .noBackground
), ),
accessibility: Accessibility( accessibility: Accessibility(
identifier: "Username", identifier: "Username",
label: state.profile.displayName() 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 // 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?) { private func updateProfilePicture(currentFileName: String?) {
let existingImageData: Data? = dependencies[singleton: .storage].read { [userSessionId, dependencies] db in let existingImageData: Data? = dependencies[singleton: .storage].read { [userSessionId, dependencies] db in
DisplayPictureManager.displayPicture(db, id: .user(userSessionId.hexString), using: dependencies) DisplayPictureManager.displayPicture(db, id: .user(userSessionId.hexString), using: dependencies)
} }
let editProfilePictureModalInfo: ConfirmationModal.Info = ConfirmationModal.Info( self.transitionToScreen(
title: "profileDisplayPictureSet".localized(), ConfirmationModal(
body: .image( info: ConfirmationModal.Info(
placeholderData: UIImage(named: "profile_placeholder")?.pngData(), title: "profileDisplayPictureSet".localized(),
valueData: existingImageData, body: .image(
icon: .rightPlus, placeholderData: UIImage(named: "profile_placeholder")?.pngData(),
style: .circular, valueData: existingImageData,
accessibility: Accessibility( icon: .rightPlus,
identifier: "Upload", style: .circular,
label: "Upload" accessibility: Accessibility(
), identifier: "Upload",
onClick: { [weak self] in self?.showPhotoLibraryForAvatar() } label: "Upload"
), ),
confirmTitle: "save".localized(), onClick: { [weak self] onDisplayPictureSelected in
confirmAccessibility: Accessibility( self?.onDisplayPictureSelected = onDisplayPictureSelected
identifier: "Save button" self?.showPhotoLibraryForAvatar()
), }
confirmEnabled: .afterChange { info in ),
switch info.body { confirmTitle: "save".localized(),
case .image(_, let valueData, _, _, _, _): return (valueData != nil) confirmAccessibility: Accessibility(
default: return false identifier: "Save button"
} ),
}, confirmEnabled: .afterChange { info in
cancelTitle: "remove".localized(), switch info.body {
cancelAccessibility: Accessibility( case .image(_, let valueData, _, _, _, _): return (valueData != nil)
identifier: "Remove button" default: return false
), }
cancelEnabled: .bool(existingImageData != nil), },
hasCloseButton: true, cancelTitle: "remove".localized(),
dismissOnConfirm: false, cancelAccessibility: Accessibility(
onConfirm: { [weak self] modal in identifier: "Remove button"
switch modal.info.body { ),
case .image(_, .some(let valueData), _, _, _, _): 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( self?.updateProfile(
displayPictureUpdate: .groupUploadImageData(valueData), displayPictureUpdate: .currentUserRemove,
onComplete: { [weak modal] in modal?.close() } 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( fileprivate func updateProfile(
displayNameUpdate: Profile.DisplayNameUpdate = .none, displayNameUpdate: Profile.DisplayNameUpdate = .none,
displayPictureUpdate: DisplayPictureManager.Update = .none, displayPictureUpdate: DisplayPictureManager.Update = .none,
onComplete: (() -> ())? = nil onComplete: @escaping () -> ()
) { ) {
let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self, dependencies] modalActivityIndicator in
Profile.updateLocal( Profile.updateLocal(
@ -646,7 +590,7 @@ class SettingsViewModel: SessionTableViewModel, NavigationItemSource, Navigatabl
db.afterNextTransactionNested(using: dependencies) { _ in db.afterNextTransactionNested(using: dependencies) { _ in
DispatchQueue.main.async { DispatchQueue.main.async {
modalActivityIndicator.dismiss(completion: { modalActivityIndicator.dismiss(completion: {
onComplete?() onComplete()
}) })
} }
} }

@ -314,6 +314,20 @@ extension SessionCell {
imageViewWidthConstraint.constant = imageView.bounds.width imageViewWidthConstraint.constant = imageView.bounds.width
imageViewHeightConstraint.constant = imageView.bounds.height 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: default:
fixedWidthConstraint.isActive = (accessory.iconSize.size <= fixedWidthConstraint.constant) fixedWidthConstraint.isActive = (accessory.iconSize.size <= fixedWidthConstraint.constant)
imageViewWidthConstraint.constant = accessory.iconSize.size imageViewWidthConstraint.constant = accessory.iconSize.size

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

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

@ -8,7 +8,8 @@ import SessionUtilitiesKit
public extension Network { public extension Network {
enum Destination: Equatable { enum Destination: Equatable {
public struct ServerInfo: 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 server: String
private let queryParameters: [HTTPQueryParam: String] private let queryParameters: [HTTPQueryParam: String]
@ -54,17 +55,24 @@ public extension Network {
fileprivate init( fileprivate init(
method: HTTPMethod, method: HTTPMethod,
url: URL, url: URL,
pathAndParamsString: String, server: String?,
server: String = "", pathAndParamsString: String?,
queryParameters: [HTTPQueryParam: String] = [:], queryParameters: [HTTPQueryParam: String] = [:],
headers: [HTTPHeader: String], headers: [HTTPHeader: String],
x25519PublicKey: String x25519PublicKey: String
) { ) {
self._url = url self._url = url
self._pathAndParamsString = pathAndParamsString self._pathAndParamsString = (pathAndParamsString ?? url.path)
self.method = method 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.queryParameters = queryParameters
self.headers = headers self.headers = headers
self.x25519PublicKey = x25519PublicKey self.x25519PublicKey = x25519PublicKey
@ -76,8 +84,8 @@ public extension Network {
return ServerInfo( return ServerInfo(
method: method, method: method,
url: try (URL(string: "\(server)\(pathAndParamsString)") ?? { throw NetworkError.invalidURL }()), url: try (URL(string: "\(server)\(pathAndParamsString)") ?? { throw NetworkError.invalidURL }()),
pathAndParamsString: pathAndParamsString,
server: server, server: server,
pathAndParamsString: pathAndParamsString,
queryParameters: queryParameters, queryParameters: queryParameters,
headers: headers, headers: headers,
x25519PublicKey: x25519PublicKey x25519PublicKey: x25519PublicKey
@ -88,8 +96,8 @@ public extension Network {
return ServerInfo( return ServerInfo(
method: method, method: method,
url: _url, url: _url,
pathAndParamsString: _pathAndParamsString,
server: server, server: server,
pathAndParamsString: _pathAndParamsString,
queryParameters: queryParameters, queryParameters: queryParameters,
headers: self.headers.updated(with: headers), headers: self.headers.updated(with: headers),
x25519PublicKey: x25519PublicKey x25519PublicKey: x25519PublicKey
@ -189,7 +197,8 @@ public extension Network {
return .serverDownload(info: ServerInfo( return .serverDownload(info: ServerInfo(
method: .get, method: .get,
url: url, url: url,
pathAndParamsString: url.path, server: nil,
pathAndParamsString: nil,
headers: headers, headers: headers,
x25519PublicKey: x25519PublicKey x25519PublicKey: x25519PublicKey
)) ))

@ -73,7 +73,7 @@ public extension Network {
switch self { switch self {
case .file: return "file" case .file: return "file"
case .fileIndividual(let fileId): return "file/\(fileId)" 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" case .sessionVersion: return "session_version"
} }
} }

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

@ -4,12 +4,13 @@ import UIKit
// FIXME: Refactor as part of the Groups Rebuild // FIXME: Refactor as part of the Groups Rebuild
public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate { public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
public static let explanationFont: UIFont = .systemFont(ofSize: Values.smallFontSize)
private static let closeSize: CGFloat = 24 private static let closeSize: CGFloat = 24
public private(set) var info: Info public private(set) var info: Info
private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil
private var internalOnCancel: ((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 private var internalOnTextChanged: ((String, String) -> ())? = nil
// MARK: - Components // MARK: - Components
@ -49,7 +50,7 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
private lazy var explanationLabel: ScrollableLabel = { private lazy var explanationLabel: ScrollableLabel = {
let result: ScrollableLabel = ScrollableLabel() let result: ScrollableLabel = ScrollableLabel()
result.font = .systemFont(ofSize: Values.smallFontSize) result.font = ConfirmationModal.explanationFont
result.themeTextColor = .alert_text result.themeTextColor = .alert_text
result.textAlignment = .center result.textAlignment = .center
result.lineBreakMode = .byWordWrapping result.lineBreakMode = .byWordWrapping
@ -276,7 +277,11 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
textField.text = (inputInfo.initialValue ?? "") textField.text = (inputInfo.initialValue ?? "")
textField.clearButtonMode = (inputInfo.clearButton ? .always : .never) textField.clearButtonMode = (inputInfo.clearButton ? .always : .never)
textFieldContainer.isHidden = false 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): case .dualInput(let explanation, let firstInputInfo, let secondInputInfo, let onTextChanged):
explanationLabel.attributedText = explanation explanationLabel.attributedText = explanation
@ -290,7 +295,11 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
textViewPlaceholder.text = secondInputInfo.placeholder textViewPlaceholder.text = secondInputInfo.placeholder
textViewPlaceholder.isHidden = !textView.text.isEmpty textViewPlaceholder.isHidden = !textView.text.isEmpty
textViewContainer.isHidden = false 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): case .radio(let explanation, let options):
mainStackView.spacing = 0 mainStackView.spacing = 0
@ -405,14 +414,32 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
textView.resignFirstResponder() textView.resignFirstResponder()
} }
internalOnBodyTap?() internalOnBodyTap?({ _ in })
} }
@objc private func imageViewTapped() { @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) internalOnConfirm?(self)
} }
@ -424,16 +451,21 @@ public class ConfirmationModal: Modal, UITextFieldDelegate, UITextViewDelegate {
// MARK: - Types // MARK: - Types
public extension ConfirmationModal { public extension ConfirmationModal {
enum ValueUpdate {
case input(String)
case image(Data?)
}
struct Info: Equatable, Hashable { struct Info: Equatable, Hashable {
let title: String internal let title: String
public let body: Body public let body: Body
let accessibility: Accessibility? let accessibility: Accessibility?
public let showCondition: ShowCondition public let showCondition: ShowCondition
let confirmTitle: String? internal let confirmTitle: String?
let confirmAccessibility: Accessibility? let confirmAccessibility: Accessibility?
let confirmStyle: ThemeValue let confirmStyle: ThemeValue
let confirmEnabled: ButtonValidator let confirmEnabled: ButtonValidator
let cancelTitle: String internal let cancelTitle: String
let cancelAccessibility: Accessibility? let cancelAccessibility: Accessibility?
let cancelStyle: ThemeValue let cancelStyle: ThemeValue
let cancelEnabled: ButtonValidator let cancelEnabled: ButtonValidator
@ -669,7 +701,7 @@ public extension ConfirmationModal.Info {
icon: ProfilePictureView.ProfileIcon = .none, icon: ProfilePictureView.ProfileIcon = .none,
style: ImageStyle, style: ImageStyle,
accessibility: Accessibility?, accessibility: Accessibility?,
onClick: (() -> ()) onClick: ((@escaping (ConfirmationModal.ValueUpdate) -> Void) -> Void)
) )
public static func == (lhs: ConfirmationModal.Info.Body, rhs: ConfirmationModal.Info.Body) -> Bool { 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 } guard let textColor: UIColor = theme.color(for: .textSecondary) else { return }
searchTextField?.attributedPlaceholder = NSAttributedString( searchTextField?.attributedPlaceholder = NSAttributedString(
string: "search".localized(), string: "search".localizedSNUIKit(),
attributes: [ attributes: [
.foregroundColor: textColor .foregroundColor: textColor
]) ])
@ -85,7 +85,7 @@ public extension UISearchBar {
guard let textColor: UIColor = theme.color(for: .textSecondary) else { return } guard let textColor: UIColor = theme.color(for: .textSecondary) else { return }
searchTextField?.attributedPlaceholder = NSAttributedString( searchTextField?.attributedPlaceholder = NSAttributedString(
string: "searchContacts".localized(), string: "searchContacts".localizedSNUIKit(),
attributes: [ attributes: [
.foregroundColor: textColor .foregroundColor: textColor
] ]

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

@ -29,12 +29,12 @@ public enum Format {
case oneMegabyte...Double.greatestFiniteMagnitude: case oneMegabyte...Double.greatestFiniteMagnitude:
return (Format.fileSizeFormatter return (Format.fileSizeFormatter
.string(from: NSNumber(floatLiteral: (fileSizeDouble / oneMegabyte)))? .string(from: NSNumber(floatLiteral: (fileSizeDouble / oneMegabyte)))?
.appending("MB") ?? "attachmentsNa".localized()) .appending("MB") ?? "attachmentsNa".localizedSNUIKit())
default: default:
return (Format.fileSizeFormatter return (Format.fileSizeFormatter
.string(from: NSNumber(floatLiteral: max(0.1, (fileSizeDouble / oneKilobyte))))? .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 { public var title: String {
switch self { switch self {
case .classicDark: return "appearanceThemesClassicDark".localized() case .classicDark: return "appearanceThemesClassicDark".localizedSNUIKit()
case .classicLight: return "appearanceThemesClassicLight".localized() case .classicLight: return "appearanceThemesClassicLight".localizedSNUIKit()
case .oceanDark: return "appearanceThemesOceanDark".localized() case .oceanDark: return "appearanceThemesOceanDark".localizedSNUIKit()
case .oceanLight: return "appearanceThemesOceanLight".localized() case .oceanLight: return "appearanceThemesOceanLight".localizedSNUIKit()
} }
} }

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

Loading…
Cancel
Save