diff --git a/NextcloudTalk.xcodeproj/project.pbxproj b/NextcloudTalk.xcodeproj/project.pbxproj index fa2cad1ea..8eaa18024 100644 --- a/NextcloudTalk.xcodeproj/project.pbxproj +++ b/NextcloudTalk.xcodeproj/project.pbxproj @@ -119,6 +119,11 @@ 1F4DD3EC2571C688007DC98E /* EmojiUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4DD3EA2571C688007DC98E /* EmojiUtils.swift */; }; 1F4DD3ED2571C688007DC98E /* EmojiUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F4DD3EA2571C688007DC98E /* EmojiUtils.swift */; }; 1F53819129195FA4003DA6B7 /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2CA1CCAB1F067F35002FE6A2 /* Images.xcassets */; }; + 1F54991C2D3468BE00E9AA9E /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F54991B2D3468BE00E9AA9E /* DateExtension.swift */; }; + 1F54991E2D346F9700E9AA9E /* UserStatusAbsenceSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F54991D2D346F9700E9AA9E /* UserStatusAbsenceSwiftUIView.swift */; }; + 1F5499202D35B07700E9AA9E /* ButtonContainerSwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F54991F2D35B07700E9AA9E /* ButtonContainerSwiftUI.swift */; }; + 1F549B662D3995C600E9AA9E /* UserSelectionSwiftUIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F549B652D3995C600E9AA9E /* UserSelectionSwiftUIView.swift */; }; + 1F549B692D3A9AA500E9AA9E /* DebouncedOnChange in Frameworks */ = {isa = PBXBuildFile; productRef = 1F549B682D3A9AA500E9AA9E /* DebouncedOnChange */; }; 1F5683CF2BA7980C0023E151 /* FilePreviewImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5683CE2BA7980C0023E151 /* FilePreviewImageView.swift */; }; 1F5813F828EB23EF00318FC3 /* NCSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5813F628EB23EF00318FC3 /* NCSplitViewController.swift */; }; 1F5813F928EB23EF00318FC3 /* NCSplitViewPlaceholderViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F5813F728EB23EF00318FC3 /* NCSplitViewPlaceholderViewController.swift */; }; @@ -259,6 +264,11 @@ 1FB7B99C2BF0DF360093CE98 /* BannedActorCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1FB7B99B2BF0DF360093CE98 /* BannedActorCell.xib */; }; 1FBC3BE52B61ACD5003909E0 /* UnitBaseChatViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBC3BE42B61ACD5003909E0 /* UnitBaseChatViewControllerTest.swift */; }; 1FBC3BE92B61BD09003909E0 /* TestBaseRealm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBC3BE82B61BD09003909E0 /* TestBaseRealm.swift */; }; + 1FBEA1132D31853800C0968C /* CurrentUserAbsence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBEA1122D31853800C0968C /* CurrentUserAbsence.swift */; }; + 1FBEA1142D31858B00C0968C /* CurrentUserAbsence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBEA1122D31853800C0968C /* CurrentUserAbsence.swift */; }; + 1FBEA1152D31858B00C0968C /* CurrentUserAbsence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBEA1122D31853800C0968C /* CurrentUserAbsence.swift */; }; + 1FBEA1162D31858B00C0968C /* CurrentUserAbsence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBEA1122D31853800C0968C /* CurrentUserAbsence.swift */; }; + 1FBEA1182D31B42F00C0968C /* AbsenceLabelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FBEA1172D31B41C00C0968C /* AbsenceLabelView.swift */; }; 1FC18FC02CB7EA300058F621 /* AVRoutePickerViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC18FBF2CB7EA300058F621 /* AVRoutePickerViewExtension.swift */; }; 1FC18FC22CB7EA5C0058F621 /* RPSystemBroadcastPickerViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1FC18FC12CB7EA5C0058F621 /* RPSystemBroadcastPickerViewExtension.swift */; }; 1FC4B3222CC0538B00D28138 /* JDStatusBarNotification in Frameworks */ = {isa = PBXBuildFile; productRef = 1FC4B3212CC0538B00D28138 /* JDStatusBarNotification */; }; @@ -729,6 +739,10 @@ 1F46CE2828E05B3200E7D88E /* ReferenceDefaultView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReferenceDefaultView.swift; sourceTree = ""; }; 1F46CE2A28E05B3C00E7D88E /* ReferenceDefaultView.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = ReferenceDefaultView.xib; sourceTree = ""; }; 1F4DD3EA2571C688007DC98E /* EmojiUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUtils.swift; sourceTree = ""; }; + 1F54991B2D3468BE00E9AA9E /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; + 1F54991D2D346F9700E9AA9E /* UserStatusAbsenceSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserStatusAbsenceSwiftUIView.swift; sourceTree = ""; }; + 1F54991F2D35B07700E9AA9E /* ButtonContainerSwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonContainerSwiftUI.swift; sourceTree = ""; }; + 1F549B652D3995C600E9AA9E /* UserSelectionSwiftUIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSelectionSwiftUIView.swift; sourceTree = ""; }; 1F5683CE2BA7980C0023E151 /* FilePreviewImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreviewImageView.swift; sourceTree = ""; }; 1F5813F628EB23EF00318FC3 /* NCSplitViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCSplitViewController.swift; sourceTree = ""; }; 1F5813F728EB23EF00318FC3 /* NCSplitViewPlaceholderViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCSplitViewPlaceholderViewController.swift; sourceTree = ""; }; @@ -802,6 +816,8 @@ 1FB7B99B2BF0DF360093CE98 /* BannedActorCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = BannedActorCell.xib; sourceTree = ""; }; 1FBC3BE42B61ACD5003909E0 /* UnitBaseChatViewControllerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnitBaseChatViewControllerTest.swift; sourceTree = ""; }; 1FBC3BE82B61BD09003909E0 /* TestBaseRealm.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestBaseRealm.swift; sourceTree = ""; }; + 1FBEA1122D31853800C0968C /* CurrentUserAbsence.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrentUserAbsence.swift; sourceTree = ""; }; + 1FBEA1172D31B41C00C0968C /* AbsenceLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsenceLabelView.swift; sourceTree = ""; }; 1FC18FBF2CB7EA300058F621 /* AVRoutePickerViewExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AVRoutePickerViewExtension.swift; sourceTree = ""; }; 1FC18FC12CB7EA5C0058F621 /* RPSystemBroadcastPickerViewExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RPSystemBroadcastPickerViewExtension.swift; sourceTree = ""; }; 1FC4B3412CCE670400D28138 /* OcsError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OcsError.swift; sourceTree = ""; }; @@ -1322,6 +1338,7 @@ 1F468E7628DCC6C60099597B /* Dynamic in Frameworks */, 1F759C2C2B63CB93000534AB /* Realm in Frameworks */, 1FAB2E882ACD44D0001214EB /* WebRTC in Frameworks */, + 1F549B692D3A9AA500E9AA9E /* DebouncedOnChange in Frameworks */, 1FC4B3332CC057DD00D28138 /* MBProgressHUD in Frameworks */, 1FC4B3222CC0538B00D28138 /* JDStatusBarNotification in Frameworks */, 1F0ECBF52A68274400921E90 /* CDMarkdownKit in Frameworks */, @@ -1568,6 +1585,7 @@ 2CC316692CC26186007CBE16 /* UITableViewExtension.swift */, 1FC18FBF2CB7EA300058F621 /* AVRoutePickerViewExtension.swift */, 1FC18FC12CB7EA5C0058F621 /* RPSystemBroadcastPickerViewExtension.swift */, + 1F54991B2D3468BE00E9AA9E /* DateExtension.swift */, ); name = Extensions; sourceTree = ""; @@ -1873,6 +1891,10 @@ 807E30752A83A90F00089D28 /* UserStatusOptionsSwiftUI.swift */, 80832B752A822E5100195A97 /* UserStatusSwiftUIView.swift */, 80832B772A823D0700195A97 /* UserStatusMessageSwiftUIView.swift */, + 1F54991D2D346F9700E9AA9E /* UserStatusAbsenceSwiftUIView.swift */, + 1F549B652D3995C600E9AA9E /* UserSelectionSwiftUIView.swift */, + 1F54991F2D35B07700E9AA9E /* ButtonContainerSwiftUI.swift */, + 1FBEA1172D31B41C00C0968C /* AbsenceLabelView.swift */, DA8801A127A2DA00009EF248 /* UserProfileTableViewController.swift */, DA66582A27B6992F00B46B11 /* UserProfileTableViewController+AvatarSetup.swift */, DA66582C27B6A73800B46B11 /* UserProfileTableViewController+DelegateMethods.swift */, @@ -1882,6 +1904,7 @@ 2C4446EB265D25BA00DF1DBC /* NCKeyChainController.m */, 2C444701265D641300DF1DBC /* NCUserDefaults.h */, 2C444702265D641300DF1DBC /* NCUserDefaults.m */, + 1FBEA1122D31853800C0968C /* CurrentUserAbsence.swift */, 1F205C4F2CEF903000AAA673 /* UserAbsence.swift */, ); name = Settings; @@ -2303,6 +2326,7 @@ 1FC4B3242CC054BC00D28138 /* UICKeyChainStore */, 1FC4B3322CC057DD00D28138 /* MBProgressHUD */, 1FC4B3352CC0586A00D28138 /* libPhoneNumber */, + 1F549B682D3A9AA500E9AA9E /* DebouncedOnChange */, ); productName = NextcloudTalk; productReference = 2C05747D1EDD9E8E00D9E7F2 /* NextcloudTalk.app */; @@ -2470,6 +2494,7 @@ 1FC4B3232CC054BC00D28138 /* XCRemoteSwiftPackageReference "UICKeyChainStore" */, 1FC4B32F2CC057B700D28138 /* XCRemoteSwiftPackageReference "MBProgressHUD" */, 1FC4B3342CC0586A00D28138 /* XCRemoteSwiftPackageReference "libPhoneNumber-iOS" */, + 1F549B672D3A9AA500E9AA9E /* XCRemoteSwiftPackageReference "DebouncedOnChange" */, ); preferredProjectObjectVersion = 77; productRefGroup = 2C05747E1EDD9E8E00D9E7F2 /* Products */; @@ -2812,6 +2837,7 @@ files = ( F644A2E12CE28CA500E2ED81 /* NCChatFileStatus.swift in Sources */, 1F1B504B2B90CF0C00B0F2F4 /* FederatedCapabilities.m in Sources */, + 1FBEA1162D31858B00C0968C /* CurrentUserAbsence.swift in Sources */, 1F77A5F22AB9A436007B6037 /* EmojiUtils.swift in Sources */, 1F77A5FA2AB9A4DF007B6037 /* NCMessageLocationParameter.m in Sources */, 1F77A6022AB9A532007B6037 /* CCCertificate.m in Sources */, @@ -2935,6 +2961,9 @@ 1F205BA02CEE1B8F00AAA673 /* AiSummaryController.swift in Sources */, 1F4DD3EB2571C688007DC98E /* EmojiUtils.swift in Sources */, 2C4D7D731F309DA500FF4A0D /* RTCSessionDescription+JSON.m in Sources */, + 1F549B662D3995C600E9AA9E /* UserSelectionSwiftUIView.swift in Sources */, + 1FBEA1132D31853800C0968C /* CurrentUserAbsence.swift in Sources */, + 1F54991C2D3468BE00E9AA9E /* DateExtension.swift in Sources */, 2CB3041C2264775E0053078A /* SLKTextView+SLKAdditions.m in Sources */, 2C57CD8428C2255000B22E03 /* PollCreationViewController.swift in Sources */, 1F77A6272ABA0CD9007B6037 /* NCScreensharingController.m in Sources */, @@ -2998,6 +3027,7 @@ 2C78EFA51F86FF4A008AFA74 /* CallParticipantViewCell.m in Sources */, 1F66B72C29FA9414003FB168 /* SLKDefaultTypingIndicatorView.m in Sources */, 1FC18FC22CB7EA5C0058F621 /* RPSystemBroadcastPickerViewExtension.swift in Sources */, + 1F5499202D35B07700E9AA9E /* ButtonContainerSwiftUI.swift in Sources */, 1F46CE2928E05B3200E7D88E /* ReferenceDefaultView.swift in Sources */, 1FCE3D552C9C189D009C68A9 /* NCChatFileControllerWrapper.swift in Sources */, 2C78EF991F80F81E008AFA74 /* NCSignalingController.m in Sources */, @@ -3062,6 +3092,7 @@ DA66582F27B6B19C00B46B11 /* UserProfileTableViewController+Actions.swift in Sources */, 2C6E7449238C1A0800AE396C /* QuotedMessageView.m in Sources */, 2C1ABDCE257E939600AEDFB6 /* NCContact.m in Sources */, + 1FBEA1182D31B42F00C0968C /* AbsenceLabelView.swift in Sources */, 2C7A12422017872600864818 /* AddParticipantsTableViewController.m in Sources */, 2C84BCCC29EEB9C6001BA6DA /* CallReactionView.swift in Sources */, 2C43BA7621309A1000B3068A /* NCMessageParameter.m in Sources */, @@ -3102,6 +3133,7 @@ 2CF338E12CED388B0029CACC /* AvatarView.swift in Sources */, 1FDCC3D429EBF6E700DEB39B /* AvatarImageView.swift in Sources */, 1FB78E262B6AE5A600B0D69D /* FederationInvitation.swift in Sources */, + 1F54991E2D346F9700E9AA9E /* UserStatusAbsenceSwiftUIView.swift in Sources */, 1FDFC94D2BA50B9100670DF4 /* UIFontExtension.swift in Sources */, 1F468E7828DCC7310099597B /* EmojiTextField.swift in Sources */, 80832B762A822E5100195A97 /* UserStatusSwiftUIView.swift in Sources */, @@ -3183,6 +3215,7 @@ 1F1DF8452C64006E00E5EA86 /* SignalingParticipant.swift in Sources */, 2CC1FF4A2818395F009F7288 /* NCDeckCardParameter.m in Sources */, 1FF136112BFB4F8C006A6101 /* NCRoom.swift in Sources */, + 1FBEA1152D31858B00C0968C /* CurrentUserAbsence.swift in Sources */, 2C4446DA265814D100DF1DBC /* ServerCapabilities.m in Sources */, 1FF4DA932C02678000C1B952 /* NCImageSessionManager.swift in Sources */, 2C444705265D641300DF1DBC /* NCUserDefaults.m in Sources */, @@ -3252,6 +3285,7 @@ 2C4446FB265D5C5700DF1DBC /* NCRoomParticipants.m in Sources */, 2CC1FF492818395E009F7288 /* NCDeckCardParameter.m in Sources */, 2CC001C224A37AC500A20167 /* NCPushNotification.m in Sources */, + 1FBEA1142D31858B00C0968C /* CurrentUserAbsence.swift in Sources */, 1FF4DA972C0327FF00C1B952 /* NCWebImageDownloaderOperation.swift in Sources */, 1FF4DAAB2C0A114900C1B952 /* OcsResponse.swift in Sources */, 1F90EFBD25FE39F800F3FA55 /* NCIntentController.m in Sources */, @@ -4272,6 +4306,14 @@ minimumVersion = 1.0.0; }; }; + 1F549B672D3A9AA500E9AA9E /* XCRemoteSwiftPackageReference "DebouncedOnChange" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Tunous/DebouncedOnChange"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; 1F628CB82842BAAF0083A425 /* XCRemoteSwiftPackageReference "QRCodeReader" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/yannickl/QRCodeReader.swift"; @@ -4434,6 +4476,11 @@ package = 1F468E7428DCC6C60099597B /* XCRemoteSwiftPackageReference "Dynamic" */; productName = Dynamic; }; + 1F549B682D3A9AA500E9AA9E /* DebouncedOnChange */ = { + isa = XCSwiftPackageProductDependency; + package = 1F549B672D3A9AA500E9AA9E /* XCRemoteSwiftPackageReference "DebouncedOnChange" */; + productName = DebouncedOnChange; + }; 1F628CB92842BAAF0083A425 /* QRCodeReader */ = { isa = XCSwiftPackageProductDependency; package = 1F628CB82842BAAF0083A425 /* XCRemoteSwiftPackageReference "QRCodeReader" */; diff --git a/NextcloudTalk/AbsenceLabelView.swift b/NextcloudTalk/AbsenceLabelView.swift new file mode 100644 index 000000000..9cd1a0e35 --- /dev/null +++ b/NextcloudTalk/AbsenceLabelView.swift @@ -0,0 +1,43 @@ +// +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI + +struct AbsenceLabelView: View { + @Binding var absenceStatus: UserAbsence? + + var replacement: AttributedString { + var result = AttributedString("Replacement") + result.font = .preferredFont(for: .body, weight: .bold) + return result + } + + var body: some View { + VStack(alignment: .leading) { + if let absenceStatus, absenceStatus.isValid { + if absenceStatus.firstDay == absenceStatus.lastDay { + Text(absenceStatus.firstDay.format(dateStyle: .medium)) + .foregroundColor(.primary) + } else { + Text(absenceStatus.firstDay.format(dateStyle: .medium) + " - " + absenceStatus.lastDay.format(dateStyle: .medium)) + .foregroundColor(.primary) + } + + if absenceStatus.hasReplacementSet { + // Make genstrings happy + let displayedString = NSLocalizedString("Replacement", comment: "Replacement in case of out of office") + ": " + absenceStatus.replacementName + Text(verbatim: displayedString) + .foregroundStyle(.primary) + } + + Text(absenceStatus.messageOrStatus) + .foregroundColor(.secondary) + .padding(.top, 8) + } else { + Text("Configure your next absence period") + } + } + } +} diff --git a/NextcloudTalk/AvatarImageView.swift b/NextcloudTalk/AvatarImageView.swift index 8cd77809b..595d6ff55 100644 --- a/NextcloudTalk/AvatarImageView.swift +++ b/NextcloudTalk/AvatarImageView.swift @@ -5,6 +5,27 @@ import UIKit import SDWebImage +import SwiftUI + +struct AvatarImageViewWrapper: UIViewRepresentable { + @Binding var actorId: String? + @Binding var actorType: String? + + func makeUIView(context: Context) -> AvatarImageView { + let imageView = AvatarImageView(frame: .zero) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + imageView.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + + return imageView + } + + func updateUIView(_ uiView: AvatarImageView, context: Context) { + uiView.cancelCurrentRequest() + + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + uiView.setActorAvatar(forId: actorId, withType: actorType, withDisplayName: nil, withRoomToken: nil, using: activeAccount) + } +} @objcMembers class AvatarImageView: UIImageView, AvatarProtocol { diff --git a/NextcloudTalk/ButtonContainerSwiftUI.swift b/NextcloudTalk/ButtonContainerSwiftUI.swift new file mode 100644 index 000000000..aab6eab2b --- /dev/null +++ b/NextcloudTalk/ButtonContainerSwiftUI.swift @@ -0,0 +1,31 @@ +// +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI +import NextcloudKit + +struct ButtonContainerSwiftUI: View { + var content: () -> Content + + @Environment(\.horizontalSizeClass) var horizontalSizeClass + + init(@ViewBuilder content: @escaping () -> Content) { + self.content = content + } + + var body: some View { + if horizontalSizeClass == .compact { + VStack(spacing: 10, content: content) + .padding(.bottom, 16) + } else { + HStack(spacing: 10) { + Spacer() + content() + Spacer() + } + .padding(.bottom, 16) + } + } +} diff --git a/NextcloudTalk/ChatViewController.swift b/NextcloudTalk/ChatViewController.swift index e5ca41e27..6cfd4089d 100644 --- a/NextcloudTalk/ChatViewController.swift +++ b/NextcloudTalk/ChatViewController.swift @@ -456,12 +456,14 @@ import SwiftyAttributes // Only check once, and only for 1:1 on DND right now guard self.hasCheckedOutOfOfficeStatus == false, self.room.type == .oneToOne, - self.room.status == kUserStatusDND + self.room.status == kUserStatusDND, + let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.room.accountId), + serverCapabilities.absenceSupported else { return } self.hasCheckedOutOfOfficeStatus = true - NCAPIController.sharedInstance().getUserAbsence(forAccountId: self.room.accountId, forUserId: self.room.name) { absenceData in + NCAPIController.sharedInstance().getCurrentUserAbsence(forAccountId: self.room.accountId, forUserId: self.room.name) { absenceData in guard let absenceData else { return } let oooView = OutOfOfficeView() diff --git a/NextcloudTalk/CurrentUserAbsence.swift b/NextcloudTalk/CurrentUserAbsence.swift new file mode 100644 index 000000000..e8de5adf5 --- /dev/null +++ b/NextcloudTalk/CurrentUserAbsence.swift @@ -0,0 +1,32 @@ +// +// SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Foundation + +@objcMembers public class CurrentUserAbsence: NSObject { + + // See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-out-of-office-api.html + public var id: String? + public var userId: String? + public var startDate: Int? + public var endDate: Int? + public var shortMessage: String? + public var message: String? + public var replacementUserId: String? + public var replacementUserDisplayName: String? + + init(dictionary: [String: Any]) { + super.init() + + self.id = dictionary["id"] as? String + self.userId = dictionary["userId"] as? String + self.startDate = dictionary["startDate"] as? Int + self.endDate = dictionary["endDate"] as? Int + self.shortMessage = dictionary["status"] as? String + self.message = dictionary["message"] as? String + self.replacementUserId = dictionary["replacementUserId"] as? String + self.replacementUserDisplayName = dictionary["replacementUserDisplayName"] as? String + } +} diff --git a/NextcloudTalk/DateExtension.swift b/NextcloudTalk/DateExtension.swift new file mode 100644 index 000000000..c06084634 --- /dev/null +++ b/NextcloudTalk/DateExtension.swift @@ -0,0 +1,14 @@ +// +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +extension Date { + func format(dateStyle: DateFormatter.Style = .none, timeStyle: DateFormatter.Style = .none) -> String { + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = dateStyle + dateFormatter.timeStyle = timeStyle + + return dateFormatter.string(from: self) + } +} diff --git a/NextcloudTalk/ExpandedVoiceMessageRecordingView.swift b/NextcloudTalk/ExpandedVoiceMessageRecordingView.swift index ea9ba27ad..760a64bfb 100644 --- a/NextcloudTalk/ExpandedVoiceMessageRecordingView.swift +++ b/NextcloudTalk/ExpandedVoiceMessageRecordingView.swift @@ -25,7 +25,7 @@ struct ExpandedVoiceMessageRecordingView: View { var body: some View { VStack { - Text("\(timeFormatted)") + Text(verbatim: "\(timeFormatted)") .font(.largeTitle) .bold() .padding(.trailing, 10) diff --git a/NextcloudTalk/NCAPIController.h b/NextcloudTalk/NCAPIController.h index 1f48b2a51..4ec8b5f4e 100644 --- a/NextcloudTalk/NCAPIController.h +++ b/NextcloudTalk/NCAPIController.h @@ -19,6 +19,7 @@ typedef void (^GetContactsCompletionBlock)(NSArray *indexes, NSMutableDictionary *contacts, NSMutableArray *contactList, NSError *error); typedef void (^GetContactsWithPhoneNumbersCompletionBlock)(NSDictionary *contacts, NSError *error); +typedef void (^SearchUsersCompletionBlock)(NSArray *indexes, NSMutableDictionary *users, NSMutableArray *userList, NSError *error); typedef void (^GetRoomCompletionBlock)(NSDictionary *roomDict, NSError *error); typedef void (^JoinRoomCompletionBlock)(NSString *sessionId, NCRoom *room, NSError *error, NSInteger statusCode, NSString * _Nullable statusReason); @@ -149,6 +150,7 @@ extern NSInteger const kReceivedChatMessagesLimit; // Contacts Controller - (NSURLSessionDataTask *)searchContactsForAccount:(TalkAccount *)account withPhoneNumbers:(NSDictionary *)phoneNumbers andCompletionBlock:(GetContactsWithPhoneNumbersCompletionBlock)block; - (NSURLSessionDataTask *)getContactsForAccount:(TalkAccount *)account forRoom:(NSString *)room groupRoom:(BOOL)groupRoom withSearchParam:(NSString *)search andCompletionBlock:(GetContactsCompletionBlock)block; +- (NSURLSessionDataTask *)searchUsersForAccount:(TalkAccount *)account withSearchParam:(NSString *)search andCompletionBlock:(SearchUsersCompletionBlock)block; // Rooms Controller - (NSURLSessionDataTask *)joinRoom:(NSString *)token forAccount:(TalkAccount *)account withCompletionBlock:(JoinRoomCompletionBlock)block; diff --git a/NextcloudTalk/NCAPIController.m b/NextcloudTalk/NCAPIController.m index 3d0cb00f8..8295f6627 100644 --- a/NextcloudTalk/NCAPIController.m +++ b/NextcloudTalk/NCAPIController.m @@ -344,6 +344,41 @@ - (NSURLSessionDataTask *)getContactsForAccount:(TalkAccount *)account forRoom:( return task; } +// TODO: Can be combined with `getContactsForAccount` at some point +- (NSURLSessionDataTask *)searchUsersForAccount:(TalkAccount *)account withSearchParam:(NSString *)search andCompletionBlock:(SearchUsersCompletionBlock)block +{ + NSString *URLString = [NSString stringWithFormat:@"%@%@/core/autocomplete/get", account.server, kNCOCSAPIVersion]; + NSDictionary *parameters = @{@"format" : @"json", + @"search" : search ? search : @"", + @"limit" : @"20", + }; + + NCAPISessionManager *apiSessionManager = [_apiSessionManagers objectForKey:account.accountId]; + NSURLSessionDataTask *task = [apiSessionManager GET:URLString parameters:parameters progress:nil success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) { + NSArray *responseContacts = [[responseObject objectForKey:@"ocs"] objectForKey:@"data"]; + NSMutableArray *users = [[NSMutableArray alloc] initWithCapacity:responseContacts.count]; + for (NSDictionary *user in responseContacts) { + NCUser *ncUser = [NCUser userWithDictionary:user]; + if (ncUser && !([ncUser.userId isEqualToString:account.userId] && [ncUser.source isEqualToString:kParticipantTypeUser])) { + [users addObject:ncUser]; + } + } + NSMutableDictionary *indexedContacts = [NCUser indexedUsersFromUsersArray:users]; + NSArray *indexes = [[indexedContacts allKeys] sortedArrayUsingSelector:@selector(localizedCaseInsensitiveCompare:)]; + if (block) { + block(indexes, indexedContacts, users, nil); + } + } failure:^(NSURLSessionDataTask * _Nullable task, NSError * _Nonnull error) { + NSInteger statusCode = [self getResponseStatusCode:task.response]; + [self checkResponseStatusCode:statusCode forAccount:account]; + if (block) { + block(nil, nil, nil, error); + } + }]; + + return task; +} + #pragma mark - Rooms Controller - (NSURLSessionDataTask *)joinRoom:(NSString *)token forAccount:(TalkAccount *)account withCompletionBlock:(JoinRoomCompletionBlock)block diff --git a/NextcloudTalk/NCAPIControllerExtensions.swift b/NextcloudTalk/NCAPIControllerExtensions.swift index bd5be4b07..e0e9569f2 100644 --- a/NextcloudTalk/NCAPIControllerExtensions.swift +++ b/NextcloudTalk/NCAPIControllerExtensions.swift @@ -510,7 +510,7 @@ import Foundation // MARK: - Out-of-office - public func getUserAbsence(forAccountId accountId: String, forUserId userId: String, completionBlock: @escaping (_ absenceData: UserAbsence?) -> Void) { + public func getCurrentUserAbsence(forAccountId accountId: String, forUserId userId: String, completionBlock: @escaping (_ absenceData: CurrentUserAbsence?) -> Void) { guard let account = NCDatabaseManager.sharedInstance().talkAccount(forAccountId: accountId), let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, let encodedUserId = userId.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) @@ -521,6 +521,27 @@ import Foundation let urlString = "\(account.server)/ocs/v2.php/apps/dav/api/v1/outOfOffice/\(encodedUserId)/now" + apiSessionManager.getOcs(urlString, account: account) { ocsResponse, _ in + guard let dataDict = ocsResponse?.dataDict else { + completionBlock(nil) + return + } + completionBlock(CurrentUserAbsence(dictionary: dataDict)) + } + } + + @nonobjc + public func getUserAbsence(forAccountId accountId: String, forUserId userId: String, completionBlock: @escaping (_ absenceData: UserAbsence?) -> Void) { + guard let account = NCDatabaseManager.sharedInstance().talkAccount(forAccountId: accountId), + let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, + let encodedUserId = userId.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { + completionBlock(nil) + return + } + + let urlString = "\(account.server)/ocs/v2.php/apps/dav/api/v1/outOfOffice/\(encodedUserId)" + apiSessionManager.getOcs(urlString, account: account) { ocsResponse, _ in guard let dataDict = ocsResponse?.dataDict else { completionBlock(nil) @@ -530,6 +551,40 @@ import Foundation } } + public func clearUserAbsence(forAccountId accountId: String, forUserId userId: String, completionBlock: @escaping (_ success: Bool) -> Void) { + guard let account = NCDatabaseManager.sharedInstance().talkAccount(forAccountId: accountId), + let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, + let encodedUserId = userId.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { + completionBlock(false) + return + } + + let urlString = "\(account.server)/ocs/v2.php/apps/dav/api/v1/outOfOffice/\(encodedUserId)" + + apiSessionManager.deleteOcs(urlString, account: account) { _, ocsError in + completionBlock(ocsError == nil) + } + } + + @nonobjc + public func setUserAbsence(forAccountId accountId: String, forUserId userId: String, withAbsence absenceData: UserAbsence, completionBlock: @escaping (_ success: Bool) -> Void) { + guard let account = NCDatabaseManager.sharedInstance().talkAccount(forAccountId: accountId), + let apiSessionManager = self.apiSessionManagers.object(forKey: account.accountId) as? NCAPISessionManager, + let encodedUserId = userId.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed), + let absenceDictionary = absenceData.asDictionary() + else { + completionBlock(false) + return + } + + let urlString = "\(account.server)/ocs/v2.php/apps/dav/api/v1/outOfOffice/\(encodedUserId)" + + apiSessionManager.postOcs(urlString, account: account, parameters: absenceDictionary) { _, ocsError in + completionBlock(ocsError == nil) + } + } + // MARK: - Notifications // Needs to be of type Int to be usable from objc diff --git a/NextcloudTalk/NCDatabaseManager.h b/NextcloudTalk/NCDatabaseManager.h index 9f7a1bd69..b527c49c8 100644 --- a/NextcloudTalk/NCDatabaseManager.h +++ b/NextcloudTalk/NCDatabaseManager.h @@ -130,7 +130,7 @@ extern NSString * const NCDatabaseManagerRoomCapabilitiesChangedNotification; - (TalkCapabilities * __nullable)roomTalkCapabilitiesForRoom:(NCRoom *)room; // ServerCapabilities -- (ServerCapabilities *)serverCapabilities; +- (ServerCapabilities * __nullable)serverCapabilities; - (ServerCapabilities * __nullable)serverCapabilitiesForAccountId:(NSString *)accountId; - (void)setServerCapabilities:(NSDictionary *)serverCapabilities forAccountId:(NSString *)accountId; - (BOOL)serverHasTalkCapability:(NSString *)capability; diff --git a/NextcloudTalk/NCDatabaseManager.m b/NextcloudTalk/NCDatabaseManager.m index 219133be2..5047f9d60 100644 --- a/NextcloudTalk/NCDatabaseManager.m +++ b/NextcloudTalk/NCDatabaseManager.m @@ -16,7 +16,7 @@ NSString *const kTalkDatabaseFolder = @"Library/Application Support/Talk"; NSString *const kTalkDatabaseFileName = @"talk.realm"; -uint64_t const kTalkDatabaseSchemaVersion = 73; +uint64_t const kTalkDatabaseSchemaVersion = 74; NSString * const kCapabilitySystemMessages = @"system-messages"; NSString * const kCapabilityNotificationLevels = @"notification-levels"; @@ -614,6 +614,7 @@ - (void)setServerCapabilities:(NSDictionary *)serverCapabilities forAccountId:(N NSDictionary *provisioningAPICaps = [serverCaps objectForKey:@"provisioning_api"]; NSDictionary *guestsCaps = [serverCaps objectForKey:@"guests"]; NSDictionary *notificationsCaps = [serverCaps objectForKey:@"notifications"]; + NSDictionary *davCaps = [serverCaps objectForKey:@"dav"]; ServerCapabilities *capabilities = [[ServerCapabilities alloc] init]; capabilities.accountId = accountId; @@ -643,6 +644,8 @@ - (void)setServerCapabilities:(NSDictionary *)serverCapabilities forAccountId:(N capabilities.guestsAppEnabled = [[guestsCaps objectForKey:@"enabled"] boolValue]; capabilities.referenceApiSupported = [[coreCaps objectForKey:@"reference-api"] boolValue]; capabilities.modRewriteWorking = [[coreCaps objectForKey:@"mod-rewrite-working"] boolValue]; + capabilities.absenceSupported = [[davCaps objectForKey:@"absence-supported"] boolValue]; + capabilities.absenceReplacementSupported = [[davCaps objectForKey:@"absence-replacement"] boolValue]; capabilities.notificationsCapabilities = [notificationsCaps objectForKey:@"ocs-endpoints"]; [self setTalkCapabilities:talkCaps onTalkCapabilitiesObject:capabilities]; diff --git a/NextcloudTalk/NCUtils.swift b/NextcloudTalk/NCUtils.swift index 4998e14b3..29ca35c52 100644 --- a/NextcloudTalk/NCUtils.swift +++ b/NextcloudTalk/NCUtils.swift @@ -202,14 +202,6 @@ import AVFoundation return dateFormatter.string(from: date) } - public static func getDate(fromDate date: Date) -> String { - let dateFormatter = DateFormatter() - dateFormatter.dateStyle = .short - dateFormatter.timeStyle = .none - - return dateFormatter.string(from: date) - } - public static func relativeTimeFromDate(date: Date) -> String { let todayDate = Date() var ti = date.timeIntervalSince(todayDate) diff --git a/NextcloudTalk/NextcloudTalk-Bridging-Header.h b/NextcloudTalk/NextcloudTalk-Bridging-Header.h index 798152218..bae4f5923 100644 --- a/NextcloudTalk/NextcloudTalk-Bridging-Header.h +++ b/NextcloudTalk/NextcloudTalk-Bridging-Header.h @@ -83,5 +83,6 @@ #import "NCCallController.h" #import "CallConstants.h" #import "AvatarBackgroundImageView.h" +#import "NCUser.h" #endif /* NextcloudTalk_Bridging_Header_h */ diff --git a/NextcloudTalk/OutOfOfficeView.swift b/NextcloudTalk/OutOfOfficeView.swift index a05478eff..87dcdbeda 100644 --- a/NextcloudTalk/OutOfOfficeView.swift +++ b/NextcloudTalk/OutOfOfficeView.swift @@ -72,7 +72,7 @@ import SwiftyAttributes return true } - public func setupAbsence(withData absenceData: UserAbsence, inRoom room: NCRoom) { + public func setupAbsence(withData absenceData: CurrentUserAbsence, inRoom room: NCRoom) { translatesAutoresizingMaskIntoConstraints = false title.text = String.localizedStringWithFormat(NSLocalizedString("%@ is out of office", comment: "'%@' is the name of a user"), room.displayName) @@ -105,7 +105,9 @@ import SwiftyAttributes dates.isHidden = true } - if let replacementUserId = absenceData.replacementUserId, let replacementUserDisplayname = absenceData.replacementUserDisplayName { + if let replacementUserId = absenceData.replacementUserId, let replacementUserDisplayname = absenceData.replacementUserDisplayName, + !replacementUserId.isEmpty, !replacementUserDisplayname.isEmpty { + let replacementString = NSLocalizedString("Replacement", comment: "Replacement in case of out of office").withFont(.preferredFont(forTextStyle: .body)) let separatorString = ": ".withFont(.preferredFont(forTextStyle: .body)) let usernameString = replacementUserDisplayname.withFont(.preferredFont(for: .body, weight: .bold)) diff --git a/NextcloudTalk/ServerCapabilities.h b/NextcloudTalk/ServerCapabilities.h index 5ccd61558..d8104ae37 100644 --- a/NextcloudTalk/ServerCapabilities.h +++ b/NextcloudTalk/ServerCapabilities.h @@ -38,6 +38,8 @@ NS_ASSUME_NONNULL_BEGIN @property BOOL guestsAppEnabled; @property BOOL referenceApiSupported; @property BOOL modRewriteWorking; +@property BOOL absenceSupported; +@property BOOL absenceReplacementSupported; @property RLMArray *notificationsCapabilities; @end diff --git a/NextcloudTalk/UserAbsence.swift b/NextcloudTalk/UserAbsence.swift index 79c545abe..7ab9ab08c 100644 --- a/NextcloudTalk/UserAbsence.swift +++ b/NextcloudTalk/UserAbsence.swift @@ -5,28 +5,72 @@ import Foundation -@objcMembers public class UserAbsence: NSObject { +public struct UserAbsence { // See: https://docs.nextcloud.com/server/latest/developer_manual/client_apis/OCS/ocs-out-of-office-api.html - public var id: String? + public var id: Int = 0 public var userId: String? - public var startDate: Int? - public var endDate: Int? - public var shortMessage: String? - public var message: String? + public var firstDay = Date() + public var lastDay = Date() + public var status: String + public var message: String public var replacementUserId: String? public var replacementUserDisplayName: String? - init(dictionary: [String: Any]) { - super.init() + public var messageOrStatus: String { + !message.isEmpty ? message : status + } - self.id = dictionary["id"] as? String + public var isValid: Bool { + !message.isEmpty && !status.isEmpty + } + + public var hasReplacementSet: Bool { + guard let replacementUserId else { return false } + return !replacementUserId.isEmpty + } + + public var replacementName: String { + replacementUserDisplayName ?? replacementUserId ?? "" + } + + init(dictionary: [String: Any]) { + self.id = dictionary["id"] as? Int ?? 0 self.userId = dictionary["userId"] as? String - self.startDate = dictionary["startDate"] as? Int - self.endDate = dictionary["endDate"] as? Int - self.shortMessage = dictionary["status"] as? String - self.message = dictionary["message"] as? String + self.status = dictionary["status"] as? String ?? "" + self.message = dictionary["message"] as? String ?? "" self.replacementUserId = dictionary["replacementUserId"] as? String self.replacementUserDisplayName = dictionary["replacementUserDisplayName"] as? String + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + if let firstDayString = dictionary["firstDay"] as? String, let date = dateFormatter.date(from: firstDayString) { + self.firstDay = date + } + + if let lastDayString = dictionary["lastDay"] as? String, let date = dateFormatter.date(from: lastDayString) { + self.lastDay = date + } + + } + + public func asDictionary() -> [String: Any]? { + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd" + + var result: [String: Any] = [ + "firstDay": dateFormatter.string(from: firstDay), + "lastDay": dateFormatter.string(from: lastDay), + "status": status, + "message": message + ] + + if let replacementUserId, let replacementUserDisplayName { + result["replacementUserId"] = replacementUserId + result["replacementUserDisplayName"] = replacementUserDisplayName + } + + return result } } diff --git a/NextcloudTalk/UserSelectionSwiftUIView.swift b/NextcloudTalk/UserSelectionSwiftUIView.swift new file mode 100644 index 000000000..e35ab337b --- /dev/null +++ b/NextcloudTalk/UserSelectionSwiftUIView.swift @@ -0,0 +1,103 @@ +// +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI +import NextcloudKit +import DebouncedOnChange + +struct UserSelectionSwiftUIView: View { + + @Environment(\.dismiss) var dismiss + + @Binding var selectedUserId: String? + @Binding var selectedUserDisplayName: String? + + @State private var searchQuery = "" + @State private var searchTask: URLSessionDataTask? + @State private var isSearching: Bool = false + @State private var userData: [NCUser] = [] + + @FocusState private var textFieldIsFocused: Bool + + var searchInput: some View { + TextField("Search for a user", text: $searchQuery) + .autocorrectionDisabled() + .focused($textFieldIsFocused) + .onChange(of: searchQuery, debounceTime: 0.5) { _ in + self.searchUsers() + } + } + + var userList: some View { + List { + Section { + searchInput + } + + ForEach(userData, id: \.self) { user in + Button(action: { + self.selectedUserId = user.userId + self.selectedUserDisplayName = user.name + self.dismiss() + }, + label: { + HStack { + AvatarImageViewWrapper(actorId: Binding.constant(user.userId), actorType: Binding.constant("users")) + .frame(width: 28, height: 28) + .clipShape(Capsule()) + + Text(user.name) + .foregroundColor(.primary) + } + }) + } + } + .overlay { + Group { + if userData.isEmpty { + if isSearching { + ProgressView() + } else { + Text("No user found") + } + } + } + .foregroundStyle(.secondary) + } + } + + var body: some View { + VStack { + if #available(iOS 16.0, *) { + userList + .scrollContentBackground(.hidden) + } else { + userList + } + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationBarTitle(Text("Absence"), displayMode: .inline) + .navigationBarHidden(false) + .onAppear { + self.textFieldIsFocused = true + } + } + + func searchUsers() { + self.userData = [] + self.searchTask?.cancel() + + guard !self.searchQuery.isEmpty else { return } + + self.isSearching = true + + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + self.searchTask = NCAPIController.sharedInstance().searchUsers(for: activeAccount, withSearchParam: searchQuery) { _, _, userList, _ in + userData = userList as? [NCUser] ?? [] + self.isSearching = false + } + } + +} diff --git a/NextcloudTalk/UserStatusAbsenceSwiftUIView.swift b/NextcloudTalk/UserStatusAbsenceSwiftUIView.swift new file mode 100644 index 000000000..668a6d95b --- /dev/null +++ b/NextcloudTalk/UserStatusAbsenceSwiftUIView.swift @@ -0,0 +1,117 @@ +// +// SPDX-FileCopyrightText: 2025 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import SwiftUI +import NextcloudKit + +struct UserStatusAbsenceSwiftUIView: View { + + @Environment(\.dismiss) var dismiss + @Binding var changed: Bool + @State var absenceStatus: UserAbsence + + let replacementSupported = NCDatabaseManager.sharedInstance().serverCapabilities()?.absenceReplacementSupported ?? false + + var body: some View { + VStack(alignment: .center) { + List { + Section(header: Text("Details")) { + DatePicker(selection: $absenceStatus.firstDay, displayedComponents: .date) { + Text("First day") + } + + DatePicker(selection: $absenceStatus.lastDay, in: absenceStatus.firstDay..., displayedComponents: .date) { + Text("Last day (inclusive)") + } + } + .tint(Color(NCAppBranding.themeColor())) + + if replacementSupported { + Section(header: Text("Replacement (optional)", comment: "Replacement in case of out of office")) { + NavigationLink(destination: { + UserSelectionSwiftUIView(selectedUserId: $absenceStatus.replacementUserId, selectedUserDisplayName: $absenceStatus.replacementUserDisplayName) + }, label: { + HStack { + if absenceStatus.hasReplacementSet { + AvatarImageViewWrapper(actorId: $absenceStatus.replacementUserId, actorType: Binding.constant("users")) + .frame(width: 28, height: 28) + .clipShape(Capsule()) + + Text(absenceStatus.replacementName) + } else { + Text("Select a replacement", comment: "Replacement in case of out of office") + .foregroundStyle(.primary) + } + } + }) + + if absenceStatus.hasReplacementSet { + Button("Reset replacement") { + absenceStatus.replacementUserId = nil + absenceStatus.replacementUserDisplayName = nil + } + .tint(.primary) + } + } + } + + Section(header: Text("Short absence status")) { + TextField("Status", text: $absenceStatus.status) + } + + Section(header: Text("Long absence message")) { + if #available(iOS 16.0, *) { + TextField("Message", text: $absenceStatus.message, axis: .vertical) + } else { + // Work around for auto-expanding TextField in iOS < 16 + ZStack { + TextEditor(text: $absenceStatus.message) + Text(absenceStatus.message).opacity(0).padding(.all, 8) + .multilineTextAlignment(.leading) + } + } + } + } + + ButtonContainerSwiftUI { + NCButtonSwiftUI(title: NSLocalizedString("Disable absence", comment: ""), + action: disableAbsence, + style: .tertiary, + disabled: Binding.constant(!absenceStatus.isValid)) + NCButtonSwiftUI(title: NSLocalizedString("Save", comment: ""), + action: setActiveUserStatus, + style: .primary, + disabled: Binding.constant(!absenceStatus.isValid)) + } + } + .background(Color(uiColor: .systemGroupedBackground)) + .navigationBarTitle(Text("Absence"), displayMode: .inline) + .navigationBarHidden(false) + } + + func setActiveUserStatus() { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + NCAPIController.sharedInstance().setUserAbsence(forAccountId: activeAccount.accountId, forUserId: activeAccount.user, withAbsence: self.absenceStatus) { success in + if success { + dismiss() + changed.toggle() + } else { + NCUserInterfaceController.sharedInstance().presentAlert(withTitle: NSLocalizedString("Could not set absence", comment: ""), withMessage: NSLocalizedString("An error occurred while setting absence", comment: "")) + } + } + } + + func disableAbsence() { + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + NCAPIController.sharedInstance().clearUserAbsence(forAccountId: activeAccount.accountId, forUserId: activeAccount.userId) { success in + if success { + dismiss() + changed.toggle() + } else { + NCUserInterfaceController.sharedInstance().presentAlert(withTitle: NSLocalizedString("Could not disable absence", comment: ""), withMessage: NSLocalizedString("An error occurred while disabling absence", comment: "")) + } + } + } +} diff --git a/NextcloudTalk/UserStatusMessageSwiftUIView.swift b/NextcloudTalk/UserStatusMessageSwiftUIView.swift index 6a74df384..81ac0254c 100644 --- a/NextcloudTalk/UserStatusMessageSwiftUIView.swift +++ b/NextcloudTalk/UserStatusMessageSwiftUIView.swift @@ -28,15 +28,6 @@ struct UserStatusMessageSwiftUIView: View { @State private var errorAlertTitle = "" @State private var errorAlertMessage = "" - let clearAtOptions = [ - NSLocalizedString("Don't clear", comment: ""), - NSLocalizedString("30 minutes", comment: ""), - NSLocalizedString("1 hour", comment: ""), - NSLocalizedString("4 hours", comment: ""), - NSLocalizedString("Today", comment: ""), - NSLocalizedString("This week", comment: "") - ] - var body: some View { VStack(alignment: .center) { if isLoading { @@ -74,11 +65,13 @@ struct UserStatusMessageSwiftUIView: View { setClearAt(clearAt: selectedClearAtString) }) { HStack(spacing: 20) { - Text(status.icon ?? " ") + Text(verbatim: status.icon ?? " ") VStack(alignment: .leading) { - Text(status.message ?? "") + Text(verbatim: status.message ?? "") .foregroundColor(.primary) - Text(getPredefinedClearStatusText(clearAt: status.clearAt, clearAtTime: status.clearAtTime, clearAtType: status.clearAtType)) + + let displayedString = getPredefinedClearStatusText(clearAt: status.clearAt, clearAtTime: status.clearAtTime, clearAtType: status.clearAtType) + Text(verbatim: displayedString) .font(.subheadline) .foregroundColor(.secondary) .lineLimit(1) @@ -103,33 +96,16 @@ struct UserStatusMessageSwiftUIView: View { } } } - if !UIDevice.current.orientation.isLandscape { - VStack(spacing: 10) { - NCButtonSwiftUI(title: NSLocalizedString("Clear status message", comment: ""), - action: clearActiveUserStatus, - style: .tertiary, - disabled: Binding.constant(!userHasStatusSet)) - NCButtonSwiftUI(title: NSLocalizedString("Set status message", comment: ""), - action: setActiveUserStatus, - style: .primary, - disabled: Binding.constant(selectedMessage.isEmpty && selectedIcon.isEmpty)) - .padding(.bottom, 16) - } - } else { - HStack(spacing: 10) { - Spacer() - NCButtonSwiftUI(title: NSLocalizedString("Clear status message", comment: ""), - action: clearActiveUserStatus, - style: .tertiary, - disabled: Binding.constant(!userHasStatusSet)) - .padding(.bottom, 16) - NCButtonSwiftUI(title: NSLocalizedString("Set status message", comment: ""), - action: setActiveUserStatus, - style: .primary, - disabled: Binding.constant(selectedMessage.isEmpty && selectedIcon.isEmpty)) - .padding(.bottom, 16) - Spacer() - } + + ButtonContainerSwiftUI { + NCButtonSwiftUI(title: NSLocalizedString("Clear status message", comment: ""), + action: clearActiveUserStatus, + style: .tertiary, + disabled: Binding.constant(!userHasStatusSet)) + NCButtonSwiftUI(title: NSLocalizedString("Set status message", comment: ""), + action: setActiveUserStatus, + style: .primary, + disabled: Binding.constant(selectedMessage.isEmpty && selectedIcon.isEmpty)) } } } diff --git a/NextcloudTalk/UserStatusSwiftUIView.swift b/NextcloudTalk/UserStatusSwiftUIView.swift index 6d46d9392..933ec8ddf 100644 --- a/NextcloudTalk/UserStatusSwiftUIView.swift +++ b/NextcloudTalk/UserStatusSwiftUIView.swift @@ -16,18 +16,18 @@ struct UserStatusSwiftUIView: View { @Environment(\.dismiss) var dismiss @State var userStatus: NCUserStatus + @State var absenceStatus: UserAbsence? @State var changed: Bool = false - init(userStatus: NCUserStatus) { - _userStatus = State(initialValue: userStatus) - } - weak var delegate: UserStatusViewDelegate? + let absenceSupported = NCDatabaseManager.sharedInstance().serverCapabilities()?.absenceSupported ?? false + var body: some View { NavigationView { VStack { Form { + Section(header: Text("Online status")) { NavigationLink(destination: { UserStatusOptionsSwiftUI(changed: $changed, userStatus: $userStatus) @@ -38,6 +38,7 @@ struct UserStatusSwiftUIView: View { } }) } + Section(header: Text("Status message")) { NavigationLink(destination: { UserStatusMessageSwiftUIView(changed: $changed) @@ -45,6 +46,20 @@ struct UserStatusSwiftUIView: View { Text(userStatus.readableUserStatusMessage().isEmpty ? NSLocalizedString("What is your status?", comment: "") : userStatus.readableUserStatusMessage() ) }) } + + if absenceSupported { + Section(header: Text("Absence")) { + if let absenceStatus { + NavigationLink(destination: { + UserStatusAbsenceSwiftUIView(changed: $changed, absenceStatus: absenceStatus) + }, label: { + AbsenceLabelView(absenceStatus: $absenceStatus) + }) + } else { + ProgressView().tint(.secondary) + } + } + } } } .navigationBarTitle(Text(NSLocalizedString("Status", comment: "")), displayMode: .inline) @@ -74,17 +89,18 @@ struct UserStatusSwiftUIView: View { .tint(Color(NCAppBranding.themeTextColor())) .onAppear { getUserStatus() + getAbsenceStatus() } .onChange(of: changed) { newValue in if newValue == true { getUserStatus() + getAbsenceStatus() changed = false } } .onDisappear { delegate?.userStatusViewDidDisappear() } - } func getUserStatus() { @@ -95,7 +111,49 @@ struct UserStatusSwiftUIView: View { } } } + + func getAbsenceStatus() { + guard absenceSupported else { return } + + let activeAccount = NCDatabaseManager.sharedInstance().activeAccount() + NCAPIController.sharedInstance().getUserAbsence(forAccountId: activeAccount.accountId, forUserId: activeAccount.userId) { absenceData in + guard let absenceData else { + absenceStatus = UserAbsence(dictionary: [:]) + return + } + + absenceStatus = absenceData + } + } +} + +extension UserStatusSwiftUIView { + // Move init to extension to keep the memberwise initializer of structs + init(userStatus: NCUserStatus) { + _userStatus = State(initialValue: userStatus) + } +} + +/* +struct UserStatusSwiftUIViewPreview: PreviewProvider { + static var previews: some View { + let absenceData: [String: Any] = [ + "id": 1, + "firstDay": "2025-01-01", + "lastDay": "2025-01-10", + "status": "I'm away", + "message": "I'm really away" + ] + + @State var userStatus = NCUserStatus() + @State var absence = UserAbsence(dictionary: absenceData) + + userStatus.status = "online" + + return UserStatusSwiftUIView(userStatus: userStatus, absenceStatus: absence) + } } +*/ @objc class UserStatusSwiftUIViewFactory: NSObject { diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index cfdfab809..6654d8e4d 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -64,6 +64,9 @@ /* No comment provided by engineer. */ "About" = "About"; +/* No comment provided by engineer. */ +"Absence" = "Absence"; + /* No comment provided by engineer. */ "Accept" = "Accept"; @@ -259,6 +262,9 @@ /* No comment provided by engineer. */ "An error occurred while deleting the message" = "An error occurred while deleting the message"; +/* No comment provided by engineer. */ +"An error occurred while disabling absence" = "An error occurred while disabling absence"; + /* No comment provided by engineer. */ "An error occurred while opening the file %@" = "An error occurred while opening the file %@"; @@ -274,6 +280,9 @@ /* No comment provided by engineer. */ "An error occurred while setting \(formattedPhoneNumber) as phone number" = "An error occurred while setting \(formattedPhoneNumber) as phone number"; +/* No comment provided by engineer. */ +"An error occurred while setting absence" = "An error occurred while setting absence"; + /* No comment provided by engineer. */ "An error occurred while setting description" = "An error occurred while setting description"; @@ -481,6 +490,9 @@ /* No comment provided by engineer. */ "Configuration" = "Configuration"; +/* No comment provided by engineer. */ +"Configure your next absence period" = "Configure your next absence period"; + /* No comment provided by engineer. */ "Confirm and hide warning" = "Confirm and hide warning"; @@ -589,6 +601,9 @@ /* No comment provided by engineer. */ "Could not delete conversation" = "Could not delete conversation"; +/* No comment provided by engineer. */ +"Could not disable absence" = "Could not disable absence"; + /* No comment provided by engineer. */ "Could not get available languages" = "Could not get available languages"; @@ -619,6 +634,9 @@ /* No comment provided by engineer. */ "Could not send the message" = "Could not send the message"; +/* No comment provided by engineer. */ +"Could not set absence" = "Could not set absence"; + /* No comment provided by engineer. */ "Could not set conversation name" = "Could not set conversation name"; @@ -700,6 +718,9 @@ /* No comment provided by engineer. */ "Description cannot be longer than 500 characters" = "Description cannot be longer than 500 characters"; +/* No comment provided by engineer. */ +"Details" = "Details"; + /* No comment provided by engineer. */ "detected" = "detected"; @@ -709,6 +730,9 @@ /* No comment provided by engineer. */ "Diagnostics" = "Diagnostics"; +/* No comment provided by engineer. */ +"Disable absence" = "Disable absence"; + /* No comment provided by engineer. */ "Disable blur" = "Disable blur"; @@ -925,6 +949,9 @@ /* No comment provided by engineer. */ "Files" = "Files"; +/* No comment provided by engineer. */ +"First day" = "First day"; + /* No comment provided by engineer. */ "For password reset and notifications" = "For password reset and notifications"; @@ -1048,6 +1075,9 @@ /* No comment provided by engineer. */ "Join open conversations" = "Join open conversations"; +/* No comment provided by engineer. */ +"Last day (inclusive)" = "Last day (inclusive)"; + /* Last subscription to the push notification server */ "Last subscription" = "Last subscription"; @@ -1114,6 +1144,9 @@ /* No comment provided by engineer. */ "Logged out" = "Logged out"; +/* No comment provided by engineer. */ +"Long absence message" = "Long absence message"; + /* No comment provided by engineer. */ "Lower hand" = "Lower hand"; @@ -1282,6 +1315,9 @@ /* No comment provided by engineer. */ "No shared items" = "No shared items"; +/* No comment provided by engineer. */ +"No user found" = "No user found"; + /* '{Microphone, Camera, ...} access was not requested' */ "Not requested" = "Not requested"; @@ -1528,6 +1564,9 @@ /* Replacement in case of out of office */ "Replacement" = "Replacement"; +/* Replacement in case of out of office */ +"Replacement (optional)" = "Replacement (optional)"; + /* No comment provided by engineer. */ "Reply" = "Reply"; @@ -1576,6 +1615,9 @@ /* No comment provided by engineer. */ "Search for places" = "Search for places"; +/* Replacement in case of out of office */ +"Select a replacement" = "Select a replacement"; + /* No comment provided by engineer. */ "Select language" = "Select language"; @@ -1666,6 +1708,9 @@ /* No comment provided by engineer. */ "Sharing to a federated conversation is not supported." = "Sharing to a federated conversation is not supported."; +/* No comment provided by engineer. */ +"Short absence status" = "Short absence status"; + /* No comment provided by engineer. */ "Show more…" = "Show more…"; diff --git a/generate-localizable-strings-file.sh b/generate-localizable-strings-file.sh index 79750ab38..8bf412fbb 100755 --- a/generate-localizable-strings-file.sh +++ b/generate-localizable-strings-file.sh @@ -7,7 +7,7 @@ echo 'Generating Localizable.strings file...' cd NextcloudTalk -genstrings -o en.lproj *.m *.swift ../ShareExtension/*.m ../ShareExtension/*.swift ../NotificationServiceExtension/*.m ../BroadcastUploadExtension/*.swift ../ThirdParty/SlackTextViewController/Source/*.m +genstrings -o en.lproj -SwiftUI *.m *.swift ../ShareExtension/*.m ../ShareExtension/*.swift ../NotificationServiceExtension/*.m ../BroadcastUploadExtension/*.swift ../ThirdParty/SlackTextViewController/Source/*.m iconv -f UTF-16 -t UTF-8 en.lproj/Localizable.strings > en.lproj/Localizable-utf8.strings mv en.lproj/Localizable-utf8.strings en.lproj/Localizable.strings echo 'Localizable.strings file generated!'