diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index 274592165..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,40 +0,0 @@ - - -- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-ios/blob/master/CODE_OF_CONDUCT.md). -- [ ] I have searched open and closed issues for duplicates -- [ ] I am submitting a bug report for existing functionality that does not work as intended -- [ ] This isn't a feature request or a discussion topic - ----------------------------------------- - -### Bug description -Describe here the issue that you are experiencing. - -### Steps to reproduce -- using hyphens as bullet points -- list the steps -- that reproduce the bug - -#### Actual result: -Describe here what happens after you run the steps above (i.e. the buggy behaviour). - -#### Expected result: -Describe here what should happen after you run the steps above (i.e. what would be the correct behaviour). - -### Screenshots - - -### Device info - -**Device**: iDevice X - -**iOS version**: X.Y.Z - -**Session version:** X.Y.Z diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index cb0bf8ed5..000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - ---- - -**Code of conduct** - -- [ ] I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-ios/blob/master/CODE_OF_CONDUCT.md). - -**Describe the bug** - -A clear and concise description of what the bug is. - -**To reproduce** - -Steps to reproduce the behavior. - -**Screenshots or logs** - -If applicable, add screenshots or logs to help explain your problem. - -**Smartphone (please complete the following information):** - - - Device: [e.g. iPhone 6] - - OS: [e.g. iOS 8.1] - - Version of Session or latest commit hash - -**Additional context** - -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 000000000..08818a28b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,74 @@ +name: 🐞 Bug Report +description: Create a report to help us improve +title: "[BUG] " +labels: [bug] +body: +- type: checkboxes + attributes: + label: Code of conduct + description: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-ios/blob/master/CODE_OF_CONDUCT.md). + options: + - label: I have read and agree to adhere to the [Code of Conduct](https://github.com/oxen-io/session-ios/blob/master/CODE_OF_CONDUCT.md) + required: true + +- type: checkboxes + attributes: + label: Self-training on how to write a bug report + description: High quality bug report can help the team save time and improve the chance of getting fixed. Please read [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report) before submitting your issue. + options: + - label: I have learned [how to write a bug report](https://www.browserstack.com/guide/how-to-write-a-bug-report) + required: true + +- type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please search to see if an issue already exists for the bug you encountered. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: Current Behavior + description: A concise description of what you're experiencing. + validations: + required: false +- type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expected to happen. + validations: + required: false +- type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior. + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run '...' + 4. See error... + validations: + required: false +- type: input + attributes: + label: iOS Version + description: What version of iOS are you running? + placeholder: ex. iOS 16.0 + validations: + required: false +- type: input + attributes: + label: Session Version + description: What version of Session are you running? (This can be found at the bottom of the app settings) + placeholder: ex. 2.0.0 (375) + validations: + required: false +- type: textarea + attributes: + label: Anything else? + description: | + Add any other context about the problem here. + + Tip: You can attach screenshots or log files to help explain your problem by clicking this area to highlight it and then dragging files in. + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 000000000..3c9712e52 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: 🚀 Feature request +description: Suggest an idea for Session +title: '[Feature] <title>' +labels: [feature-request] +body: +- type: checkboxes + attributes: + label: Is there an existing request for feature? + description: Please search to see if an issue already exists for the feature you are requesting. + options: + - label: I have searched the existing issues + required: true +- type: textarea + attributes: + label: What feature would you like? + description: | + A clear and concise description of the feature you would like added to Session + validations: + required: true +- type: textarea + attributes: + label: Anything else? + description: | + Add any other context or screenshots about the feature request here + validations: + required: false diff --git a/Podfile b/Podfile index fe69ab1cf..d69db9d91 100644 --- a/Podfile +++ b/Podfile @@ -105,6 +105,9 @@ post_install do |installer| enable_whole_module_optimization_for_crypto_swift(installer) set_minimum_deployment_target(installer) enable_fts5_support(installer) + + #FIXME: Remove this workaround once an official fix is released (hopefully Cocoapods 1.12.1) + xcode_14_3_workaround(installer) end def enable_whole_module_optimization_for_crypto_swift(installer) @@ -135,3 +138,12 @@ def enable_fts5_support(installer) end end end + +# Workaround for Xcode 14.3: +# Sourced from https://github.com/flutter/flutter/issues/123852#issuecomment-1493232105 +def xcode_14_3_workaround(installer) + system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionMessagingKit-SessionMessagingKitTests-frameworks.sh\'') + system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests/Pods-GlobalDependencies-FrameworkAndExtensionDependencies-ExtendedDependencies-SessionUtilitiesKit-SessionUtilitiesKitTests-frameworks.sh\'') + system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session/Pods-GlobalDependencies-Session-frameworks.sh\'') + system('sed -i \'\' \'44s/readlink/readlink -f/\' \'Pods/Target Support Files/Pods-GlobalDependencies-Session-SessionTests/Pods-GlobalDependencies-Session-SessionTests-frameworks.sh\'') +end diff --git a/Podfile.lock b/Podfile.lock index 9cd0a2706..9f60bdddf 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -27,7 +27,7 @@ PODS: - DifferenceKit/Core (1.3.0) - DifferenceKit/UIKitExtension (1.3.0): - DifferenceKit/Core - - GRDB.swift/SQLCipher (6.10.1): + - GRDB.swift/SQLCipher (6.13.0): - SQLCipher (>= 3.4.2) - libwebp (1.2.1): - libwebp/demux (= 1.2.1) @@ -222,7 +222,7 @@ SPEC CHECKSUMS: CryptoSwift: a532e74ed010f8c95f611d00b8bbae42e9fe7c17 Curve25519Kit: e63f9859ede02438ae3defc5e1a87e09d1ec7ee6 DifferenceKit: ab185c4d7f9cef8af3fcf593e5b387fb81e999ca - GRDB.swift: 1cc67278f1a9878d6eb1b849485518112b79cab7 + GRDB.swift: fe420b1af49ec519c7e96e07887ee44f5dfa2b78 libwebp: 98a37e597e40bfdb4c911fc98f2c53d0b12d05fc Nimble: 5316ef81a170ce87baf72dd961f22f89a602ff84 NVActivityIndicatorView: 1f6c5687f1171810aa27a3296814dc2d7dec3667 @@ -242,6 +242,6 @@ SPEC CHECKSUMS: YYImage: f1ddd15ac032a58b78bbed1e012b50302d318331 ZXingObjC: fdbb269f25dd2032da343e06f10224d62f537bdb -PODFILE CHECKSUM: 97324ae5888b01db2f2adc4dcc239e2e7d6867f7 +PODFILE CHECKSUM: e9443a8235dbff1fc342aa9bf08bbc66923adf68 COCOAPODS: 1.11.3 diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index 5a083dfc1..2947ac3a0 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -109,7 +109,13 @@ 7B1D74AA27BCC16E0030B423 /* NSENotificationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */; }; 7B1D74AC27BDE7510030B423 /* Promise+Timeout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */; }; 7B1D74B027C365960030B423 /* Timer+MainThread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */; }; + 7B2561C22978B307005C086C /* MediaInfoVC+MediaInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2561C12978B307005C086C /* MediaInfoVC+MediaInfoView.swift */; }; + 7B2561C429874851005C086C /* SessionCarouselView+Info.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2561C329874851005C086C /* SessionCarouselView+Info.swift */; }; 7B2E985829AC227C001792D7 /* UIContextualAction+Theming.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B2E985729AC227C001792D7 /* UIContextualAction+Theming.swift */; }; + 7B3A392E2977791E002FE4AC /* MediaInfoVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3A392D2977791E002FE4AC /* MediaInfoVC.swift */; }; + 7B3A3930297A3919002FE4AC /* MediaInfoVC+MediaPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3A392F297A3919002FE4AC /* MediaInfoVC+MediaPreviewView.swift */; }; + 7B3A39322980D02B002FE4AC /* SessionCarouselView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3A39312980D02B002FE4AC /* SessionCarouselView.swift */; }; + 7B3A3934298882D6002FE4AC /* SessionCarouselViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B3A3933298882D6002FE4AC /* SessionCarouselViewDelegate.swift */; }; 7B46AAAF28766DF4001AF2DC /* AllMediaViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */; }; 7B4C75CB26B37E0F0000AC89 /* UnsendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */; }; 7B4C75CD26BB92060000AC89 /* DeletedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */; }; @@ -182,9 +188,6 @@ B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B80A579E23DFF1F300876683 /* NewClosedGroupVC.swift */; }; B817AD9A26436593009DF825 /* SimplifiedConversationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B817AD9926436593009DF825 /* SimplifiedConversationCell.swift */; }; B817AD9C26436F73009DF825 /* ThreadPickerVC.swift in Sources */ = {isa = PBXBuildFile; fileRef = B817AD9B26436F73009DF825 /* ThreadPickerVC.swift */; }; - B81D25C426157F40004D1FE1 /* storage-seed-3.crt in Resources */ = {isa = PBXBuildFile; fileRef = B81D25B926157F20004D1FE1 /* storage-seed-3.crt */; }; - B81D25C526157F40004D1FE1 /* storage-seed-1.crt in Resources */ = {isa = PBXBuildFile; fileRef = B81D25B726157F20004D1FE1 /* storage-seed-1.crt */; }; - B81D25C626157F40004D1FE1 /* public-loki-foundation.crt in Resources */ = {isa = PBXBuildFile; fileRef = B81D25B826157F20004D1FE1 /* public-loki-foundation.crt */; }; B82149C125D605C6009C0F2A /* InfoBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = B82149C025D605C6009C0F2A /* InfoBanner.swift */; }; B8269D2925C7A4B400488AB4 /* InputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D2825C7A4B400488AB4 /* InputView.swift */; }; B8269D3325C7A8C600488AB4 /* InputViewButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8269D3225C7A8C600488AB4 /* InputViewButton.swift */; }; @@ -428,9 +431,6 @@ C38EF407255B6DF7007E1867 /* Toast.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3E9255B6DF6007E1867 /* Toast.swift */; }; C38EF40B255B6DF7007E1867 /* TappableStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF3ED255B6DF6007E1867 /* TappableStackView.swift */; }; C38EF48A255B7E3F007E1867 /* SessionUIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C331FF1B2558F9D300070591 /* SessionUIKit.framework */; }; - C3A01E05261D24C400290BEB /* public-loki-foundation.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E02261D24C400290BEB /* public-loki-foundation.der */; }; - C3A01E06261D24C400290BEB /* storage-seed-1.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E03261D24C400290BEB /* storage-seed-1.der */; }; - C3A01E07261D24C400290BEB /* storage-seed-3.der in Resources */ = {isa = PBXBuildFile; fileRef = C3A01E04261D24C400290BEB /* storage-seed-3.der */; }; C3A3A171256E1D25004D228D /* SSKReachabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */; }; C3A71D0B2558989C0043A11F /* MessageWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D0A2558989C0043A11F /* MessageWrapper.swift */; }; C3A71D1E25589AC30043A11F /* WebSocketProto.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A71D1C25589AC30043A11F /* WebSocketProto.swift */; }; @@ -832,6 +832,12 @@ FDD250702837199200198BDA /* GarbageCollectionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */; }; FDD250722837234B00198BDA /* MediaGalleryNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */; }; FDDF074A29DAB36900E5E8B5 /* JobRunnerSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */; }; + FDDCBDA829E776BF00303C38 /* seed2-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */; }; + FDDCBDA929E776BF00303C38 /* seed1-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */; }; + FDDCBDAA29E776BF00303C38 /* seed1-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA429E776BF00303C38 /* seed1-2023-2y.der */; }; + FDDCBDAB29E776BF00303C38 /* seed2-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */; }; + FDDCBDAC29E776BF00303C38 /* seed3-2023-2y.crt in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */; }; + FDDCBDAD29E776BF00303C38 /* seed3-2023-2y.der in Resources */ = {isa = PBXBuildFile; fileRef = FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */; }; FDE77F6B280FEB28002CFC5D /* ControlMessageProcessRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDE77F6A280FEB28002CFC5D /* ControlMessageProcessRecord.swift */; }; FDED2E3C282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDED2E3B282E1B5D00B2CD2A /* UICollectionView+ReusableView.swift */; }; FDF0B73C27FFD3D6004C14C5 /* LinkPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDF0B73B27FFD3D6004C14C5 /* LinkPreview.swift */; }; @@ -1184,8 +1190,14 @@ 7B1D74A927BCC16E0030B423 /* NSENotificationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSENotificationPresenter.swift; sourceTree = "<group>"; }; 7B1D74AB27BDE7510030B423 /* Promise+Timeout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Promise+Timeout.swift"; sourceTree = "<group>"; }; 7B1D74AF27C365960030B423 /* Timer+MainThread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Timer+MainThread.swift"; sourceTree = "<group>"; }; + 7B2561C12978B307005C086C /* MediaInfoVC+MediaInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaInfoVC+MediaInfoView.swift"; sourceTree = "<group>"; }; + 7B2561C329874851005C086C /* SessionCarouselView+Info.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SessionCarouselView+Info.swift"; sourceTree = "<group>"; }; 7B2DB2AD26F1B0FF0035B509 /* si */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = si; path = si.lproj/Localizable.strings; sourceTree = "<group>"; }; 7B2E985729AC227C001792D7 /* UIContextualAction+Theming.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIContextualAction+Theming.swift"; sourceTree = "<group>"; }; + 7B3A392D2977791E002FE4AC /* MediaInfoVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaInfoVC.swift; sourceTree = "<group>"; }; + 7B3A392F297A3919002FE4AC /* MediaInfoVC+MediaPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MediaInfoVC+MediaPreviewView.swift"; sourceTree = "<group>"; }; + 7B3A39312980D02B002FE4AC /* SessionCarouselView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCarouselView.swift; sourceTree = "<group>"; }; + 7B3A3933298882D6002FE4AC /* SessionCarouselViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionCarouselViewDelegate.swift; sourceTree = "<group>"; }; 7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllMediaViewController.swift; sourceTree = "<group>"; }; 7B4C75CA26B37E0F0000AC89 /* UnsendRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsendRequest.swift; sourceTree = "<group>"; }; 7B4C75CC26BB92060000AC89 /* DeletedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeletedMessageView.swift; sourceTree = "<group>"; }; @@ -1271,9 +1283,6 @@ B80A579E23DFF1F300876683 /* NewClosedGroupVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewClosedGroupVC.swift; sourceTree = "<group>"; }; B817AD9926436593009DF825 /* SimplifiedConversationCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SimplifiedConversationCell.swift; sourceTree = "<group>"; }; B817AD9B26436F73009DF825 /* ThreadPickerVC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadPickerVC.swift; sourceTree = "<group>"; }; - B81D25B726157F20004D1FE1 /* storage-seed-1.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "storage-seed-1.crt"; sourceTree = "<group>"; }; - B81D25B826157F20004D1FE1 /* public-loki-foundation.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "public-loki-foundation.crt"; sourceTree = "<group>"; }; - B81D25B926157F20004D1FE1 /* storage-seed-3.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "storage-seed-3.crt"; sourceTree = "<group>"; }; B82149C025D605C6009C0F2A /* InfoBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBanner.swift; sourceTree = "<group>"; }; B8269D2825C7A4B400488AB4 /* InputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputView.swift; sourceTree = "<group>"; }; B8269D3225C7A8C600488AB4 /* InputViewButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputViewButton.swift; sourceTree = "<group>"; }; @@ -1547,9 +1556,6 @@ C396469D2509D3F400B0B9F5 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = "<group>"; }; C396469E2509D40400B0B9F5 /* vi-VN */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "vi-VN"; path = "vi-VN.lproj/Localizable.strings"; sourceTree = "<group>"; }; C396469F2509D41100B0B9F5 /* id-ID */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "id-ID"; path = "id-ID.lproj/Localizable.strings"; sourceTree = "<group>"; }; - C3A01E02261D24C400290BEB /* public-loki-foundation.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "public-loki-foundation.der"; sourceTree = "<group>"; }; - C3A01E03261D24C400290BEB /* storage-seed-1.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "storage-seed-1.der"; sourceTree = "<group>"; }; - C3A01E04261D24C400290BEB /* storage-seed-3.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "storage-seed-3.der"; sourceTree = "<group>"; }; C3A3A170256E1D25004D228D /* SSKReachabilityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSKReachabilityManager.swift; sourceTree = "<group>"; }; C3A71D0A2558989C0043A11F /* MessageWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageWrapper.swift; sourceTree = "<group>"; }; C3A71D1C25589AC30043A11F /* WebSocketProto.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebSocketProto.swift; sourceTree = "<group>"; }; @@ -1914,6 +1920,12 @@ FDD2506F2837199200198BDA /* GarbageCollectionJob.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GarbageCollectionJob.swift; sourceTree = "<group>"; }; FDD250712837234B00198BDA /* MediaGalleryNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaGalleryNavigationController.swift; sourceTree = "<group>"; }; FDDF074929DAB36900E5E8B5 /* JobRunnerSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerSpec.swift; sourceTree = "<group>"; }; + FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed2-2023-2y.crt"; sourceTree = "<group>"; }; + FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed1-2023-2y.crt"; sourceTree = "<group>"; }; + FDDCBDA429E776BF00303C38 /* seed1-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed1-2023-2y.der"; sourceTree = "<group>"; }; + FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed2-2023-2y.der"; sourceTree = "<group>"; }; + FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "seed3-2023-2y.crt"; sourceTree = "<group>"; }; + FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */ = {isa = PBXFileReference; lastKnownFileType = file; path = "seed3-2023-2y.der"; sourceTree = "<group>"; }; FDE7214F287E50D50093DF33 /* ProtoWrappers.py */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.script.python; path = ProtoWrappers.py; sourceTree = "<group>"; }; FDE72150287E50D50093DF33 /* LintLocalizableStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LintLocalizableStrings.swift; sourceTree = "<group>"; }; FDE77F68280F9EDA002CFC5D /* JobRunnerError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JobRunnerError.swift; sourceTree = "<group>"; }; @@ -2374,12 +2386,12 @@ B81D260326158DF5004D1FE1 /* Certificates */ = { isa = PBXGroup; children = ( - B81D25B826157F20004D1FE1 /* public-loki-foundation.crt */, - C3A01E02261D24C400290BEB /* public-loki-foundation.der */, - B81D25B726157F20004D1FE1 /* storage-seed-1.crt */, - C3A01E03261D24C400290BEB /* storage-seed-1.der */, - B81D25B926157F20004D1FE1 /* storage-seed-3.crt */, - C3A01E04261D24C400290BEB /* storage-seed-3.der */, + FDDCBDA329E776BF00303C38 /* seed1-2023-2y.crt */, + FDDCBDA429E776BF00303C38 /* seed1-2023-2y.der */, + FDDCBDA229E776BF00303C38 /* seed2-2023-2y.crt */, + FDDCBDA529E776BF00303C38 /* seed2-2023-2y.der */, + FDDCBDA629E776BF00303C38 /* seed3-2023-2y.crt */, + FDDCBDA729E776BF00303C38 /* seed3-2023-2y.der */, ); path = Certificates; sourceTree = "<group>"; @@ -2596,6 +2608,9 @@ FD52090828B59411006098F6 /* ScreenLockUI.swift */, FD37EA0828AA2D27003AE748 /* SessionTableViewModel.swift */, FD37EA0628AA2CCA003AE748 /* SessionTableViewController.swift */, + 7B3A39312980D02B002FE4AC /* SessionCarouselView.swift */, + 7B2561C329874851005C086C /* SessionCarouselView+Info.swift */, + 7B3A3933298882D6002FE4AC /* SessionCarouselViewDelegate.swift */, ); path = Shared; sourceTree = "<group>"; @@ -2999,6 +3014,9 @@ 4C1885D1218F8E1C00B67051 /* PhotoGridViewCell.swift */, 4C4AE69F224AF21900D4AF6F /* SendMediaNavigationController.swift */, 7B46AAAE28766DF4001AF2DC /* AllMediaViewController.swift */, + 7B3A392D2977791E002FE4AC /* MediaInfoVC.swift */, + 7B3A392F297A3919002FE4AC /* MediaInfoVC+MediaPreviewView.swift */, + 7B2561C12978B307005C086C /* MediaInfoVC+MediaInfoView.swift */, ); path = "Media Viewing & Editing"; sourceTree = "<group>"; @@ -4727,19 +4745,16 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - B81D25C526157F40004D1FE1 /* storage-seed-1.crt in Resources */, - B81D25C426157F40004D1FE1 /* storage-seed-3.crt in Resources */, - B81D25C626157F40004D1FE1 /* public-loki-foundation.crt in Resources */, 4C63CC00210A620B003AE45C /* SignalTSan.supp in Resources */, 4C6F527C20FFE8400097DEEE /* SignalUBSan.supp in Resources */, 34CF078A203E6B78005C4D61 /* end_call_tone_cept.caf in Resources */, C3CA3AA2255CDADA00F4C6D4 /* english.txt in Resources */, B6F509971AA53F760068F56A /* Localizable.strings in Resources */, - C3A01E05261D24C400290BEB /* public-loki-foundation.der in Resources */, B66DBF4A19D5BBC8006EA940 /* Images.xcassets in Resources */, 34CF0788203E6B78005C4D61 /* ringback_tone_ansi.caf in Resources */, 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */, 34C3C78F2040A4F70000134C /* sonarping.mp3 in Resources */, + FDDCBDA929E776BF00303C38 /* seed1-2023-2y.crt in Resources */, 34661FB820C1C0D60056EDD6 /* message_sent.aiff in Resources */, 45CB2FA81CB7146C00E1B343 /* Launch Screen.storyboard in Resources */, 34C3C78D20409F320000134C /* Opening.m4r in Resources */, @@ -4751,12 +4766,12 @@ 45B74A742044AAB600CD42F8 /* aurora-quiet.aifc in Resources */, 7B0EFDF4275490EA00FFAAE7 /* ringing.mp3 in Resources */, 45B74A852044AAB600CD42F8 /* bamboo.aifc in Resources */, - C3A01E06261D24C400290BEB /* storage-seed-1.der in Resources */, 45B74A782044AAB600CD42F8 /* bamboo-quiet.aifc in Resources */, 45B74A7B2044AAB600CD42F8 /* chord.aifc in Resources */, 45B74A812044AAB600CD42F8 /* chord-quiet.aifc in Resources */, 45B74A832044AAB600CD42F8 /* circles.aifc in Resources */, 45B74A892044AAB600CD42F8 /* circles-quiet.aifc in Resources */, + FDDCBDAA29E776BF00303C38 /* seed1-2023-2y.der in Resources */, C34C8F7423A7830B00D82669 /* SpaceMono-Bold.ttf in Resources */, 4503F1BF20470A5B00CEE724 /* classic.aifc in Resources */, B8D07405265C683300F77E07 /* ElegantIcons.ttf in Resources */, @@ -4769,8 +4784,11 @@ 45B74A7C2044AAB600CD42F8 /* hello-quiet.aifc in Resources */, 7B50D64D28AC7CF80086CCEC /* silence.aiff in Resources */, 45B74A792044AAB600CD42F8 /* input.aifc in Resources */, + FDDCBDAB29E776BF00303C38 /* seed2-2023-2y.der in Resources */, C3CA3ABE255CDB0D00F4C6D4 /* portuguese.txt in Resources */, 45B74A8C2044AAB600CD42F8 /* input-quiet.aifc in Resources */, + FDDCBDAC29E776BF00303C38 /* seed3-2023-2y.crt in Resources */, + FDDCBDA829E776BF00303C38 /* seed2-2023-2y.crt in Resources */, 45B74A7A2044AAB600CD42F8 /* keys.aifc in Resources */, 45B74A762044AAB600CD42F8 /* keys-quiet.aifc in Resources */, 45B74A862044AAB600CD42F8 /* note.aifc in Resources */, @@ -4780,7 +4798,7 @@ 45B74A822044AAB600CD42F8 /* pulse.aifc in Resources */, C3CA3AC8255CDB2900F4C6D4 /* spanish.txt in Resources */, B8FF8E6225C10DA5004D1F22 /* GeoLite2-Country-Blocks-IPv4 in Resources */, - C3A01E07261D24C400290BEB /* storage-seed-3.der in Resources */, + FDDCBDAD29E776BF00303C38 /* seed3-2023-2y.der in Resources */, 45B74A802044AAB600CD42F8 /* pulse-quiet.aifc in Resources */, 45B74A8B2044AAB600CD42F8 /* synth.aifc in Resources */, 45B74A752044AAB600CD42F8 /* synth-quiet.aifc in Resources */, @@ -5623,6 +5641,7 @@ buildActionMask = 2147483647; files = ( FD52090928B59411006098F6 /* ScreenLockUI.swift in Sources */, + 7B2561C429874851005C086C /* SessionCarouselView+Info.swift in Sources */, FDF2220B2818F38D000A4995 /* SessionApp.swift in Sources */, FD7115EB28C5D78E00B47552 /* ThreadSettingsViewModel.swift in Sources */, B8041AA725C90927003C2166 /* TypingIndicatorCell.swift in Sources */, @@ -5637,6 +5656,7 @@ 7BAF54D027ACCEEC003D12F8 /* EmptySearchResultCell.swift in Sources */, B8783E9E23EB948D00404FB8 /* UILabel+Interaction.swift in Sources */, FD37E9D928A230F2003AE748 /* TraitObservingWindow.swift in Sources */, + 7B3A3930297A3919002FE4AC /* MediaInfoVC+MediaPreviewView.swift in Sources */, B893063F2383961A005EAA8E /* ScanQRCodeWrapperVC.swift in Sources */, FD848B8F283EF2A8000E298B /* UIScrollView+Utilities.swift in Sources */, B879D449247E1BE300DB3608 /* PathVC.swift in Sources */, @@ -5680,6 +5700,7 @@ FD71164828E2CE8700B47552 /* SessionCell+AccessoryView.swift in Sources */, B80A579F23DFF1F300876683 /* NewClosedGroupVC.swift in Sources */, FD71163A28E2C53700B47552 /* SessionAvatarCell.swift in Sources */, + 7B3A392E2977791E002FE4AC /* MediaInfoVC.swift in Sources */, 7BA68909272A27BE00EFC32F /* SessionCall.swift in Sources */, B835247925C38D880089A44F /* MessageCell.swift in Sources */, B86BD08623399CEF000F5AE3 /* SeedModal.swift in Sources */, @@ -5739,6 +5760,7 @@ FD71164228E2C85A00B47552 /* TransitionType.swift in Sources */, FD848B9828422F1A000E298B /* Date+Utilities.swift in Sources */, FD37E9DB28A244E9003AE748 /* ThemePreviewView.swift in Sources */, + 7B3A3934298882D6002FE4AC /* SessionCarouselViewDelegate.swift in Sources */, B85357C323A1BD1200AAF6CD /* SeedVC.swift in Sources */, 45B5360E206DD8BB00D61655 /* UIResponder+OWS.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, @@ -5787,6 +5809,7 @@ FD39352C28F382920084DADA /* VersionFooterView.swift in Sources */, 7B9F71D22852EEE2006DFE7B /* Emoji+SkinTones.swift in Sources */, 7B7CB18E270D066F0079FF93 /* IncomingCallBanner.swift in Sources */, + 7B2561C22978B307005C086C /* MediaInfoVC+MediaInfoView.swift in Sources */, B8569AE325CBB19A00DBA3DB /* DocumentView.swift in Sources */, 7BFD1A8A2745C4F000FB91B9 /* Permissions.swift in Sources */, B85357BF23A1AE0800AAF6CD /* SeedReminderView.swift in Sources */, @@ -5808,6 +5831,7 @@ FD71163828E2C50700B47552 /* SessionTableViewModel.swift in Sources */, FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */, C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, + 7B3A39322980D02B002FE4AC /* SessionCarouselView.swift in Sources */, FD37E9CC28A1E578003AE748 /* AppearanceViewController.swift in Sources */, B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */, C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, @@ -6092,7 +6116,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 398; + CURRENT_PROJECT_VERSION = 406; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6117,7 +6141,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.9; + MARKETING_VERSION = 2.2.14; MTL_ENABLE_DEBUG_INFO = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6165,7 +6189,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 398; + CURRENT_PROJECT_VERSION = 406; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6195,7 +6219,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.9; + MARKETING_VERSION = 2.2.14; MTL_ENABLE_DEBUG_INFO = NO; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.ShareExtension"; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -6231,7 +6255,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 398; + CURRENT_PROJECT_VERSION = 406; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = "$(inherited)"; @@ -6254,7 +6278,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.9; + MARKETING_VERSION = 2.2.14; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -6305,7 +6329,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 398; + CURRENT_PROJECT_VERSION = 406; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = SUQ8J2PCT7; ENABLE_NS_ASSERTIONS = NO; @@ -6333,7 +6357,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 2.2.9; + MARKETING_VERSION = 2.2.14; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger.NotificationServiceExtension"; @@ -7233,7 +7257,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 398; + CURRENT_PROJECT_VERSION = 406; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7272,7 +7296,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.2.9; + MARKETING_VERSION = 2.2.14; OTHER_LDFLAGS = "$(inherited)"; OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\""; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; @@ -7305,7 +7329,7 @@ CODE_SIGN_ENTITLEMENTS = Session/Meta/Signal.entitlements; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; - CURRENT_PROJECT_VERSION = 398; + CURRENT_PROJECT_VERSION = 406; DEVELOPMENT_TEAM = SUQ8J2PCT7; FRAMEWORK_SEARCH_PATHS = ( "$(inherited)", @@ -7344,7 +7368,7 @@ "$(SRCROOT)", ); LLVM_LTO = NO; - MARKETING_VERSION = 2.2.9; + MARKETING_VERSION = 2.2.14; OTHER_LDFLAGS = "$(inherited)"; PRODUCT_BUNDLE_IDENTIFIER = "com.loki-project.loki-messenger"; PRODUCT_NAME = Session; diff --git a/Session/Calls/Call Management/SessionCall.swift b/Session/Calls/Call Management/SessionCall.swift index 4c3250429..de3f64f48 100644 --- a/Session/Calls/Call Management/SessionCall.swift +++ b/Session/Calls/Call Management/SessionCall.swift @@ -356,6 +356,10 @@ public final class SessionCall: CurrentCallProtocol, WebRTCSessionDelegate { webRTCSession.attachLocalRenderer(renderer) } + func removeLocalVideoRenderer(_ renderer: RTCVideoRenderer) { + webRTCSession.removeLocalRenderer(renderer) + } + // MARK: - Delegate public func webRTCIsConnected() { diff --git a/Session/Calls/CallVC.swift b/Session/Calls/CallVC.swift index 49152e981..44dab72c8 100644 --- a/Session/Calls/CallVC.swift +++ b/Session/Calls/CallVC.swift @@ -8,6 +8,9 @@ import SessionMessagingKit import SessionUtilitiesKit final class CallVC: UIViewController, VideoPreviewDelegate { + static let floatingVideoViewWidth: CGFloat = UIDevice.current.isIPad ? 160 : 80 + static let floatingVideoViewHeight: CGFloat = UIDevice.current.isIPad ? 346: 173 + let call: SessionCall var latestKnownAudioOutputDeviceName: String? var durationTimer: Timer? @@ -21,27 +24,90 @@ final class CallVC: UIViewController, VideoPreviewDelegate { return result }() + enum FloatingViewVideoSource { + case local + case remote + } + + var floatingViewVideoSource: FloatingViewVideoSource = .local + // MARK: - UI Components - private lazy var localVideoView: LocalVideoView = { + private lazy var floatingLocalVideoView: LocalVideoView = { let result = LocalVideoView() - result.clipsToBounds = true + result.alpha = 0 result.themeBackgroundColor = .backgroundSecondary - result.isHidden = !call.isVideoEnabled - result.layer.cornerRadius = UIDevice.current.isIPad ? 20 : 10 - result.layer.masksToBounds = true - result.set(.width, to: LocalVideoView.width) - result.set(.height, to: LocalVideoView.height) - result.makeViewDraggable() + result.set(.width, to: Self.floatingVideoViewWidth) + result.set(.height, to: Self.floatingVideoViewHeight) return result }() - private lazy var remoteVideoView: RemoteVideoView = { + private lazy var floatingRemoteVideoView: RemoteVideoView = { + let result = RemoteVideoView() + result.alpha = 0 + result.themeBackgroundColor = .backgroundSecondary + result.set(.width, to: Self.floatingVideoViewWidth) + result.set(.height, to: Self.floatingVideoViewHeight) + + return result + }() + + private lazy var fullScreenLocalVideoView: LocalVideoView = { + let result = LocalVideoView() + result.alpha = 0 + result.themeBackgroundColor = .backgroundPrimary + result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleFullScreenVideoViewTapped))) + + return result + }() + + private lazy var fullScreenRemoteVideoView: RemoteVideoView = { let result = RemoteVideoView() result.alpha = 0 result.themeBackgroundColor = .backgroundPrimary - result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleRemoteVieioViewTapped))) + result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(handleFullScreenVideoViewTapped))) + + return result + }() + + private lazy var floatingViewContainer: UIView = { + let result = UIView() + result.isHidden = true + result.clipsToBounds = true + result.layer.cornerRadius = UIDevice.current.isIPad ? 20 : 10 + result.layer.masksToBounds = true + result.themeBackgroundColor = .backgroundSecondary + result.makeViewDraggable() + + let noVideoIcon: UIImageView = UIImageView( + image: UIImage(systemName: "video.slash")? + .withRenderingMode(.alwaysTemplate) + ) + noVideoIcon.themeTintColor = .textPrimary + noVideoIcon.set(.width, to: 34) + noVideoIcon.set(.height, to: 28) + result.addSubview(noVideoIcon) + noVideoIcon.center(in: result) + + result.addSubview(floatingLocalVideoView) + floatingLocalVideoView.pin(to: result) + + result.addSubview(floatingRemoteVideoView) + floatingRemoteVideoView.pin(to: result) + + let swappingVideoIcon: UIImageView = UIImageView( + image: UIImage(systemName: "arrow.2.squarepath")? + .withRenderingMode(.alwaysTemplate) + ) + swappingVideoIcon.themeTintColor = .textPrimary + swappingVideoIcon.set(.width, to: 16) + swappingVideoIcon.set(.height, to: 12) + result.addSubview(swappingVideoIcon) + swappingVideoIcon.pin(.top, to: .top, of: result, withInset: Values.smallSpacing) + swappingVideoIcon.pin(.trailing, to: .trailing, of: result, withInset: -Values.smallSpacing) + + result.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(switchVideo))) return result }() @@ -265,7 +331,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate { self.call.remoteVideoStateDidChange = { isEnabled in DispatchQueue.main.async { UIView.animate(withDuration: 0.25) { - self.remoteVideoView.alpha = isEnabled ? 1 : 0 + let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView + remoteVideoView.alpha = isEnabled ? 1 : 0 } if self.callInfoLabel.alpha < 0.5 { @@ -346,7 +413,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate { if shouldRestartCamera { cameraManager.prepare() } - touch(call.videoCapturer) + _ = call.videoCapturer // Force the lazy var to instantiate titleLabel.text = self.call.contactName AppEnvironment.shared.callManager.startCall(call) { [weak self] error in DispatchQueue.main.async { @@ -375,13 +442,16 @@ final class CallVC: UIViewController, VideoPreviewDelegate { view.addSubview(profilePictureContainer) // Remote video view - call.attachRemoteVideoRenderer(remoteVideoView) - view.addSubview(remoteVideoView) - remoteVideoView.translatesAutoresizingMaskIntoConstraints = false - remoteVideoView.pin(to: view) + call.attachRemoteVideoRenderer(fullScreenRemoteVideoView) + view.addSubview(fullScreenRemoteVideoView) + fullScreenRemoteVideoView.translatesAutoresizingMaskIntoConstraints = false + fullScreenRemoteVideoView.pin(to: view) // Local video view - call.attachLocalVideoRenderer(localVideoView) + call.attachLocalVideoRenderer(floatingLocalVideoView) + view.addSubview(fullScreenLocalVideoView) + fullScreenLocalVideoView.translatesAutoresizingMaskIntoConstraints = false + fullScreenLocalVideoView.pin(to: view) // Fade view view.addSubview(fadeView) @@ -398,7 +468,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate { view.addSubview(titleLabel) titleLabel.translatesAutoresizingMaskIntoConstraints = false titleLabel.center(.vertical, in: minimizeButton) - titleLabel.center(.horizontal, in: view) + titleLabel.pin(.leading, to: .leading, of: view, withInset: Values.largeSpacing) + titleLabel.pin(.trailing, to: .trailing, of: view, withInset: -Values.largeSpacing) // Response Panel view.addSubview(responsePanel) @@ -431,12 +502,12 @@ final class CallVC: UIViewController, VideoPreviewDelegate { callDurationLabel.center(in: callInfoLabelContainer) } - private func addLocalVideoView() { + private func addFloatingVideoView() { let safeAreaInsets = UIApplication.shared.keyWindow?.safeAreaInsets - CurrentAppContext().mainWindow?.addSubview(localVideoView) - localVideoView.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing) + CurrentAppContext().mainWindow?.addSubview(floatingViewContainer) + floatingViewContainer.autoPinEdge(toSuperviewEdge: .right, withInset: Values.smallSpacing) let topMargin = (safeAreaInsets?.top ?? 0) + Values.veryLargeSpacing - localVideoView.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin) + floatingViewContainer.autoPinEdge(toSuperviewEdge: .top, withInset: topMargin) } override func viewDidAppear(_ animated: Bool) { @@ -445,7 +516,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate { if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.start() } shouldRestartCamera = true - addLocalVideoView() + addFloatingVideoView() + let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView remoteVideoView.alpha = (call.isRemoteVideoEnabled ? 1 : 0) } @@ -454,7 +526,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate { if (call.isVideoEnabled && shouldRestartCamera) { cameraManager.stop() } - localVideoView.removeFromSuperview() + floatingViewContainer.removeFromSuperview() } // MARK: - Orientation @@ -501,7 +573,8 @@ final class CallVC: UIViewController, VideoPreviewDelegate { self.callInfoLabel.text = "Call Ended" UIView.animate(withDuration: 0.25) { - self.remoteVideoView.alpha = 0 + let remoteVideoView: RemoteVideoView = self.floatingViewVideoSource == .remote ? self.floatingRemoteVideoView : self.fullScreenRemoteVideoView + remoteVideoView.alpha = 0 self.operationPanel.alpha = 1 self.responsePanel.alpha = 1 self.callInfoLabel.alpha = 1 @@ -559,7 +632,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate { @objc private func operateCamera() { if (call.isVideoEnabled) { - localVideoView.isHidden = true + floatingViewContainer.isHidden = true cameraManager.stop() videoButton.themeTintColor = .textPrimary videoButton.themeBackgroundColor = .backgroundSecondary @@ -575,7 +648,9 @@ final class CallVC: UIViewController, VideoPreviewDelegate { } func cameraDidConfirmTurningOn() { - localVideoView.isHidden = false + floatingViewContainer.isHidden = false + let localVideoView: LocalVideoView = self.floatingViewVideoSource == .local ? self.floatingLocalVideoView : self.fullScreenLocalVideoView + localVideoView.alpha = 1 cameraManager.prepare() cameraManager.start() videoButton.themeTintColor = .backgroundSecondary @@ -584,6 +659,34 @@ final class CallVC: UIViewController, VideoPreviewDelegate { call.isVideoEnabled = true } + @objc private func switchVideo() { + if self.floatingViewVideoSource == .remote { + call.removeRemoteVideoRenderer(self.floatingRemoteVideoView) + call.removeLocalVideoRenderer(self.fullScreenLocalVideoView) + + self.floatingRemoteVideoView.alpha = 0 + self.floatingLocalVideoView.alpha = call.isVideoEnabled ? 1 : 0 + self.fullScreenRemoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0 + self.fullScreenLocalVideoView.alpha = 0 + + self.floatingViewVideoSource = .local + call.attachRemoteVideoRenderer(self.fullScreenRemoteVideoView) + call.attachLocalVideoRenderer(self.floatingLocalVideoView) + } else { + call.removeRemoteVideoRenderer(self.fullScreenRemoteVideoView) + call.removeLocalVideoRenderer(self.floatingLocalVideoView) + + self.floatingRemoteVideoView.alpha = call.isRemoteVideoEnabled ? 1 : 0 + self.floatingLocalVideoView.alpha = 0 + self.fullScreenRemoteVideoView.alpha = 0 + self.fullScreenLocalVideoView.alpha = call.isVideoEnabled ? 1 : 0 + + self.floatingViewVideoSource = .remote + call.attachRemoteVideoRenderer(self.floatingRemoteVideoView) + call.attachLocalVideoRenderer(self.fullScreenLocalVideoView) + } + } + @objc private func switchCamera() { cameraManager.switchCamera() } @@ -645,7 +748,7 @@ final class CallVC: UIViewController, VideoPreviewDelegate { } } - @objc private func handleRemoteVieioViewTapped(gesture: UITapGestureRecognizer) { + @objc private func handleFullScreenVideoViewTapped(gesture: UITapGestureRecognizer) { let isHidden = callDurationLabel.alpha < 0.5 UIView.animate(withDuration: 0.5) { diff --git a/Session/Calls/Views & Modals/CallVideoView.swift b/Session/Calls/Views & Modals/CallVideoView.swift index 899732f66..d2e8894c0 100644 --- a/Session/Calls/Views & Modals/CallVideoView.swift +++ b/Session/Calls/Views & Modals/CallVideoView.swift @@ -87,10 +87,7 @@ class RemoteVideoView: TargetView { // MARK: LocalVideoView class LocalVideoView: TargetView { - - static let width: CGFloat = UIDevice.current.isIPad ? 160 : 80 - static let height: CGFloat = UIDevice.current.isIPad ? 346: 173 - + override func renderFrame(_ frame: RTCVideoFrame?) { super.renderFrame(frame) DispatchMainThreadSafe { diff --git a/Session/Closed Groups/EditClosedGroupVC.swift b/Session/Closed Groups/EditClosedGroupVC.swift index 2a04c703f..bc7f80f8e 100644 --- a/Session/Closed Groups/EditClosedGroupVC.swift +++ b/Session/Closed Groups/EditClosedGroupVC.swift @@ -485,7 +485,7 @@ final class EditClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegat targetView: self.view, info: ConfirmationModal.Info( title: title, - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Closed Groups/NewClosedGroupVC.swift b/Session/Closed Groups/NewClosedGroupVC.swift index 1d9f66159..d5cc50a83 100644 --- a/Session/Closed Groups/NewClosedGroupVC.swift +++ b/Session/Closed Groups/NewClosedGroupVC.swift @@ -305,7 +305,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: title, - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text @@ -350,7 +350,7 @@ final class NewClosedGroupVC: BaseVC, UITableViewDataSource, UITableViewDelegate targetView: self?.view, info: ConfirmationModal.Info( title: "GROUP_CREATION_ERROR_TITLE".localized(), - explanation: "GROUP_CREATION_ERROR_MESSAGE".localized(), + body: .text("GROUP_CREATION_ERROR_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift index a66dcfef3..a21818707 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC+Action.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC+Action.swift @@ -35,6 +35,14 @@ extension ContextMenuVC { // MARK: - Actions + static func info(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { + return Action( + icon: UIImage(named: "ic_info"), + title: "context_menu_info".localized(), + accessibilityLabel: "Message info" + ) { delegate?.info(cellViewModel) } + } + static func retry(_ cellViewModel: MessageViewModel, _ delegate: ContextMenuActionDelegate?) -> Action { return Action( icon: UIImage(systemName: "arrow.triangle.2.circlepath"), @@ -207,6 +215,8 @@ extension ContextMenuVC { return !currentThreadIsMessageRequest }() + let shouldShowInfo: Bool = (cellViewModel.attachments?.isEmpty == false) + let generatedActions: [Action] = [ (canRetry ? Action.retry(cellViewModel, delegate) : nil), (canReply ? Action.reply(cellViewModel, delegate) : nil), @@ -216,6 +226,7 @@ extension ContextMenuVC { (canDelete ? Action.delete(cellViewModel, delegate) : nil), (canBan ? Action.ban(cellViewModel, delegate) : nil), (canBan ? Action.banAndDeleteAllMessages(cellViewModel, delegate) : nil), + (shouldShowInfo ? Action.info(cellViewModel, delegate) : nil), ] .appending(contentsOf: (shouldShowEmojiActions ? recentEmojis : []).map { Action.react(cellViewModel, $0, delegate) }) .appending(Action.emojiPlusButton(cellViewModel, delegate)) @@ -230,6 +241,7 @@ extension ContextMenuVC { // MARK: - Delegate protocol ContextMenuActionDelegate { + func info(_ cellViewModel: MessageViewModel) func retry(_ cellViewModel: MessageViewModel) func reply(_ cellViewModel: MessageViewModel) func copy(_ cellViewModel: MessageViewModel) diff --git a/Session/Conversations/Context Menu/ContextMenuVC.swift b/Session/Conversations/Context Menu/ContextMenuVC.swift index d68851588..3c9d9aab0 100644 --- a/Session/Conversations/Context Menu/ContextMenuVC.swift +++ b/Session/Conversations/Context Menu/ContextMenuVC.swift @@ -164,7 +164,9 @@ final class ContextMenuVC: UIViewController { let menuStackView = UIStackView( arrangedSubviews: actions .filter { !$0.isEmojiAction && !$0.isEmojiPlus && !$0.isDismissAction } - .map { action -> ActionView in ActionView(for: action, dismiss: snDismiss) } + .map { action -> ActionView in + ActionView(for: action, dismiss: snDismiss) + } ) menuStackView.axis = .vertical menuBackgroundView.addSubview(menuStackView) diff --git a/Session/Conversations/ConversationVC+Interaction.swift b/Session/Conversations/ConversationVC+Interaction.swift index a266e5b83..6f8cfb32b 100644 --- a/Session/Conversations/ConversationVC+Interaction.swift +++ b/Session/Conversations/ConversationVC+Interaction.swift @@ -68,7 +68,7 @@ extension ConversationVC: let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modal_call_permission_request_title".localized(), - explanation: "modal_call_permission_request_explanation".localized(), + body: .text("modal_call_permission_request_explanation".localized()), confirmTitle: "vc_settings_title".localized(), confirmAccessibilityLabel: "Settings", cancelAccessibilityLabel: "Cancel", @@ -132,11 +132,13 @@ extension ConversationVC: format: "modal_blocked_title".localized(), self.viewModel.threadData.displayName ), - attributedExplanation: NSAttributedString(string: message) - .adding( - attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], - range: (message as NSString).range(of: self.viewModel.threadData.displayName) - ), + body: .attributedText( + NSAttributedString(string: message) + .adding( + attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], + range: (message as NSString).range(of: self.viewModel.threadData.displayName) + ) + ), confirmTitle: "modal_blocked_button_title".localized(), confirmAccessibilityLabel: "Confirm block", cancelAccessibilityLabel: "Cancel block", @@ -205,7 +207,7 @@ extension ConversationVC: let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "GIPHY_PERMISSION_TITLE".localized(), - explanation: "GIPHY_PERMISSION_MESSAGE".localized(), + body: .text("GIPHY_PERMISSION_MESSAGE".localized()), confirmTitle: "continue_2".localized() ) { [weak self] _ in Storage.shared.writeAsync( @@ -295,7 +297,7 @@ extension ConversationVC: targetView: self?.view, info: ConfirmationModal.Info( title: "Session", - explanation: "An error occurred.", + body: .text("An error occurred."), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) @@ -312,7 +314,7 @@ extension ConversationVC: targetView: self?.view, info: ConfirmationModal.Info( title: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE".localized(), - explanation: "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY".localized(), + body: .text("ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) @@ -410,7 +412,7 @@ extension ConversationVC: let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modal_send_seed_title".localized(), - explanation: "modal_send_seed_explanation".localized(), + body: .text("modal_send_seed_explanation".localized()), confirmTitle: "modal_send_seed_send_button_title".localized(), confirmStyle: .danger, cancelStyle: .alert_text, @@ -540,7 +542,7 @@ extension ConversationVC: let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modal_send_seed_title".localized(), - explanation: "modal_send_seed_explanation".localized(), + body: .text("modal_send_seed_explanation".localized()), confirmTitle: "modal_send_seed_send_button_title".localized(), confirmStyle: .danger, cancelStyle: .alert_text, @@ -646,7 +648,7 @@ extension ConversationVC: let linkPreviewModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modal_link_previews_title".localized(), - explanation: "modal_link_previews_explanation".localized(), + body: .text("modal_link_previews_explanation".localized()), confirmTitle: "modal_link_previews_button_title".localized() ) { [weak self] _ in Storage.shared.writeAsync { db in @@ -661,6 +663,10 @@ extension ConversationVC: } func inputTextViewDidChangeContent(_ inputTextView: InputTextView) { + // Note: If there is a 'draft' message then we don't want it to trigger the typing indicator to + // appear (as that is not expected/correct behaviour) + guard !viewIsAppearing else { return } + let newText: String = (inputTextView.text ?? "") if !newText.isEmpty { @@ -886,11 +892,13 @@ extension ConversationVC: format: "modal_download_attachment_title".localized(), cellViewModel.authorName ), - attributedExplanation: NSAttributedString(string: message) - .adding( - attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], - range: (message as NSString).range(of: cellViewModel.authorName) - ), + body: .attributedText( + NSAttributedString(string: message) + .adding( + attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], + range: (message as NSString).range(of: cellViewModel.authorName) + ) + ), confirmTitle: "modal_download_button_title".localized(), confirmAccessibilityLabel: "Download media", cancelAccessibilityLabel: "Don't download media", @@ -1537,11 +1545,13 @@ extension ConversationVC: let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "Join \(finalName)?", - attributedExplanation: NSMutableAttributedString(string: message) - .adding( - attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], - range: (message as NSString).range(of: finalName) - ), + body: .attributedText( + NSMutableAttributedString(string: message) + .adding( + attributes: [ .font: UIFont.boldSystemFont(ofSize: Values.smallFontSize) ], + range: (message as NSString).range(of: finalName) + ) + ), confirmTitle: "JOIN_COMMUNITY_BUTTON_TITLE".localized(), onConfirm: { modal in guard let presentingViewController: UIViewController = modal.presentingViewController else { @@ -1578,7 +1588,7 @@ extension ConversationVC: let errorModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "COMMUNITY_ERROR_GENERIC".localized(), - explanation: error.localizedDescription, + body: .text(error.localizedDescription), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) @@ -1596,6 +1606,17 @@ extension ConversationVC: // MARK: - ContextMenuActionDelegate + func info(_ cellViewModel: MessageViewModel) { + let mediaInfoVC = MediaInfoVC( + attachments: (cellViewModel.attachments ?? []), + isOutgoing: (cellViewModel.variant == .standardOutgoing), + threadId: self.viewModel.threadData.threadId, + threadVariant: self.viewModel.threadData.threadVariant, + interactionId: cellViewModel.id + ) + navigationController?.pushViewController(mediaInfoVC, animated: true) + } + func retry(_ cellViewModel: MessageViewModel) { Storage.shared.writeAsync { [weak self] db in guard @@ -2033,7 +2054,7 @@ extension ConversationVC: targetView: self.view, info: ConfirmationModal.Info( title: "Session", - explanation: "This will ban the selected user from this room. It won't ban them from other rooms.", + body: .text("This will ban the selected user from this room. It won't ban them from other rooms."), confirmTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, onConfirm: { [weak self] _ in @@ -2057,7 +2078,7 @@ extension ConversationVC: targetView: self?.view, info: ConfirmationModal.Info( title: CommonStrings.errorAlertTitle, - explanation: "context_menu_ban_user_error_alert_message".localized(), + body: .text("context_menu_ban_user_error_alert_message".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) @@ -2082,7 +2103,7 @@ extension ConversationVC: targetView: self.view, info: ConfirmationModal.Info( title: "Session", - explanation: "This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there.", + body: .text("This will ban the selected user from this room and delete all messages sent by them. It won't ban them from other rooms or delete the messages they sent there."), confirmTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, onConfirm: { [weak self] _ in @@ -2106,7 +2127,7 @@ extension ConversationVC: targetView: self?.view, info: ConfirmationModal.Info( title: CommonStrings.errorAlertTitle, - explanation: "context_menu_ban_user_error_alert_message".localized(), + body: .text("context_menu_ban_user_error_alert_message".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) @@ -2216,7 +2237,7 @@ extension ConversationVC: targetView: self.view, info: ConfirmationModal.Info( title: "VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE".localized(), - explanation: "VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized(), + body: .text("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) @@ -2287,7 +2308,7 @@ extension ConversationVC: targetView: self.view, info: ConfirmationModal.Info( title: "ATTACHMENT_ERROR_ALERT_TITLE".localized(), - explanation: (attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), + body: .text(attachment.localizedErrorDescription ?? SignalAttachment.missingDataErrorMessage), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, afterClosed: onDismiss diff --git a/Session/Conversations/ConversationVC.swift b/Session/Conversations/ConversationVC.swift index f4e2adf2a..17ab87f7d 100644 --- a/Session/Conversations/ConversationVC.swift +++ b/Session/Conversations/ConversationVC.swift @@ -169,7 +169,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl }() lazy var snInputView: InputView = InputView( - threadVariant: self.viewModel.threadData.threadVariant, + threadVariant: self.viewModel.initialThreadVariant, delegate: self ) @@ -180,6 +180,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl result.layer.cornerRadius = (ConversationVC.unreadCountViewSize / 2) result.set(.width, greaterThanOrEqualTo: ConversationVC.unreadCountViewSize) result.set(.height, to: ConversationVC.unreadCountViewSize) + result.isHidden = true return result }() @@ -361,12 +362,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl scrollButton.pin(.right, to: .right, of: view, withInset: -20) messageRequestView.pin(.left, to: .left, of: view) messageRequestView.pin(.right, to: .right, of: view) - self.messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16) - self.scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) - self.scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint - self.scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) - self.scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16) - + messageRequestsViewBotomConstraint = messageRequestView.pin(.bottom, to: .bottom, of: view, withInset: -16) + scrollButtonBottomConstraint = scrollButton.pin(.bottom, to: .bottom, of: view, withInset: -16) + scrollButtonBottomConstraint?.isActive = false // Note: Need to disable this to avoid a conflict with the other bottom constraint + scrollButtonMessageRequestsBottomConstraint = scrollButton.pin(.bottom, to: .top, of: messageRequestView, withInset: -16) + scrollButtonPendingMessageRequestInfoBottomConstraint = scrollButton.pin(.bottom, to: .top, of: pendingMessageRequestExplanationLabel, withInset: -16) + messageRequestBlockButton.pin(.top, to: .top, of: messageRequestView, withInset: 10) messageRequestBlockButton.center(.horizontal, in: messageRequestView) @@ -441,11 +442,6 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - // Flag that the initial layout has been completed (the flag blocks and unblocks a number - // of different behaviours) - didFinishInitialLayout = true - viewIsAppearing = false - if delayFirstResponder || isShowingSearchUI { delayFirstResponder = false @@ -457,7 +453,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } } - recoverInputView() + recoverInputView { [weak self] in + // Flag that the initial layout has been completed (the flag blocks and unblocks a number + // of different behaviours) + self?.didFinishInitialLayout = true + self?.viewIsAppearing = false + } } override func viewWillDisappear(_ animated: Bool) { @@ -483,7 +484,11 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } + recoverInputView() if !isShowingSearchUI { @@ -1261,11 +1266,12 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl self.blockedBanner.pin([ UIView.HorizontalEdge.left, UIView.VerticalEdge.top, UIView.HorizontalEdge.right ], to: self.view) } - func recoverInputView() { + func recoverInputView(completion: (() -> ())? = nil) { // This is a workaround for an issue where the textview is not scrollable // after the app goes into background and goes back in foreground. DispatchQueue.main.async { self.snInputView.text = self.snInputView.text + completion?() } } @@ -1298,7 +1304,7 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl targetView: self?.view, info: ConfirmationModal.Info( title: CommonStrings.errorAlertTitle, - explanation: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized(), + body: .text("INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Conversations/ConversationViewModel.swift b/Session/Conversations/ConversationViewModel.swift index a5c30bed7..65f4ecec4 100644 --- a/Session/Conversations/ConversationViewModel.swift +++ b/Session/Conversations/ConversationViewModel.swift @@ -53,27 +53,65 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Initialization init(threadId: String, threadVariant: SessionThread.Variant, focusedInteractionId: Int64?) { - // If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest - // unread interaction and start focused around that one - let targetInteractionId: Int64? = { - if let focusedInteractionId: Int64 = focusedInteractionId { return focusedInteractionId } + typealias InitialData = ( + targetInteractionId: Int64?, + currentUserIsClosedGroupMember: Bool?, + openGroupPermissions: OpenGroup.Permissions?, + blindedKey: String? + ) + + let initialData: InitialData? = Storage.shared.read { db -> InitialData in + let interaction: TypedTableAlias<Interaction> = TypedTableAlias() + let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias() - return Storage.shared.read { db in - let interaction: TypedTableAlias<Interaction> = TypedTableAlias() - - return try Interaction + // If we have a specified 'focusedInteractionId' then use that, otherwise retrieve the oldest + // unread interaction and start focused around that one + let targetInteractionId: Int64? = (focusedInteractionId != nil ? focusedInteractionId : + try Interaction .select(.id) .filter(interaction[.wasRead] == false) .filter(interaction[.threadId] == threadId) .order(interaction[.timestampMs].asc) .asRequest(of: Int64.self) .fetchOne(db) - } - }() + ) + let currentUserIsClosedGroupMember: Bool? = (threadVariant != .closedGroup ? nil : + try GroupMember + .filter(groupMember[.groupId] == threadId) + .filter(groupMember[.profileId] == getUserHexEncodedPublicKey(db)) + .filter(groupMember[.role] == GroupMember.Role.standard) + .isNotEmpty(db) + ) + let openGroupPermissions: OpenGroup.Permissions? = (threadVariant != .openGroup ? nil : + try OpenGroup + .filter(id: threadId) + .select(.permissions) + .asRequest(of: OpenGroup.Permissions.self) + .fetchOne(db) + ) + let blindedKey: String? = SessionThread.getUserHexEncodedBlindedKey( + db, + threadId: threadId, + threadVariant: threadVariant + ) + + return ( + targetInteractionId, + currentUserIsClosedGroupMember, + openGroupPermissions, + blindedKey + ) + } self.threadId = threadId self.initialThreadVariant = threadVariant - self.focusedInteractionId = targetInteractionId + self.focusedInteractionId = initialData?.targetInteractionId + self.threadData = SessionThreadViewModel( + threadId: threadId, + threadVariant: threadVariant, + currentUserIsClosedGroupMember: initialData?.currentUserIsClosedGroupMember, + openGroupPermissions: initialData?.openGroupPermissions + ).populatingCurrentUserBlindedKey(currentUserBlindedPublicKeyForThisThread: initialData?.blindedKey) self.pagedDataObserver = nil // Note: Since this references self we need to finish initializing before setting it, we @@ -93,7 +131,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { DispatchQueue.global(qos: .userInitiated).async { [weak self] in // If we don't have a `initialFocusedId` then default to `.pageBefore` (it'll query // from a `0` offset) - guard let initialFocusedId: Int64 = targetInteractionId else { + guard let initialFocusedId: Int64 = initialData?.targetInteractionId else { self?.pagedDataObserver?.load(.pageBefore) return } @@ -105,21 +143,7 @@ public class ConversationViewModel: OWSAudioPlayerDelegate { // MARK: - Thread Data /// This value is the current state of the view - public private(set) lazy var threadData: SessionThreadViewModel = SessionThreadViewModel( - threadId: self.threadId, - threadVariant: self.initialThreadVariant, - currentUserIsClosedGroupMember: (self.initialThreadVariant != .closedGroup ? - nil : - Storage.shared.read { db in - try GroupMember - .filter(GroupMember.Columns.groupId == self.threadId) - .filter(GroupMember.Columns.profileId == getUserHexEncodedPublicKey(db)) - .filter(GroupMember.Columns.role == GroupMember.Role.standard) - .isNotEmpty(db) - } - ) - ) - .populatingCurrentUserBlindedKey() + public private(set) var threadData: SessionThreadViewModel /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise /// performance https://github.com/groue/GRDB.swift#valueobservation-performance diff --git a/Session/Conversations/Input View/InputView.swift b/Session/Conversations/Input View/InputView.swift index 8101060d1..4727b53fa 100644 --- a/Session/Conversations/Input View/InputView.swift +++ b/Session/Conversations/Input View/InputView.swift @@ -37,8 +37,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M set { inputTextView.selectedRange = newValue } } - var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder } - var enabledMessageTypes: MessageInputTypes = .all { didSet { setEnabledMessageTypes(enabledMessageTypes, message: nil) @@ -440,10 +438,6 @@ final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, M override func resignFirstResponder() -> Bool { inputTextView.resignFirstResponder() } - - func inputTextViewBecomeFirstResponder() { - inputTextView.becomeFirstResponder() - } func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) { // Not relevant in this case diff --git a/Session/Conversations/Message Cells/Content Views/DocumentView.swift b/Session/Conversations/Message Cells/Content Views/DocumentView.swift index cdcfe5fed..88d2dc07c 100644 --- a/Session/Conversations/Message Cells/Content Views/DocumentView.swift +++ b/Session/Conversations/Message Cells/Content Views/DocumentView.swift @@ -46,7 +46,7 @@ final class DocumentView: UIView { // Size label let sizeLabel = UILabel() sizeLabel.font = .systemFont(ofSize: Values.verySmallFontSize) - sizeLabel.text = OWSFormat.formatFileSize(UInt(attachment.byteCount)) + sizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount) sizeLabel.themeTextColor = textColor sizeLabel.lineBreakMode = .byTruncatingTail diff --git a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift index 846e0aa72..a4cdf7e67 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaAlbumView.swift @@ -29,7 +29,7 @@ public class MediaAlbumView: UIStackView { mediaCache: mediaCache, attachment: $0, isOutgoing: isOutgoing, - maxMessageWidth: maxMessageWidth + cornerRadius: VisibleMessageCell.largeCornerRadius ) } diff --git a/Session/Conversations/Message Cells/Content Views/MediaView.swift b/Session/Conversations/Message Cells/Content Views/MediaView.swift index 301818a8b..507b72917 100644 --- a/Session/Conversations/Message Cells/Content Views/MediaView.swift +++ b/Session/Conversations/Message Cells/Content Views/MediaView.swift @@ -16,10 +16,9 @@ public class MediaView: UIView { // MARK: - - private let mediaCache: NSCache<NSString, AnyObject> + private let mediaCache: NSCache<NSString, AnyObject>? public let attachment: Attachment private let isOutgoing: Bool - private let maxMessageWidth: CGFloat private var loadBlock: (() -> Void)? private var unloadBlock: (() -> Void)? @@ -46,22 +45,21 @@ public class MediaView: UIView { // MARK: - Initializers public required init( - mediaCache: NSCache<NSString, AnyObject>, + mediaCache: NSCache<NSString, AnyObject>? = nil, attachment: Attachment, isOutgoing: Bool, - maxMessageWidth: CGFloat + cornerRadius: CGFloat ) { self.mediaCache = mediaCache self.attachment = attachment self.isOutgoing = isOutgoing - self.maxMessageWidth = maxMessageWidth super.init(frame: .zero) themeBackgroundColor = .backgroundSecondary clipsToBounds = true layer.masksToBounds = true - layer.cornerRadius = VisibleMessageCell.largeCornerRadius + layer.cornerRadius = cornerRadius createContents() } @@ -396,7 +394,7 @@ public class MediaView: UIView { applyMediaBlock(media) - self?.mediaCache.setObject(media, forKey: cacheKey as NSString) + self?.mediaCache?.setObject(media, forKey: cacheKey as NSString) self?.loadState.mutate { $0 = .loaded } } @@ -405,7 +403,7 @@ public class MediaView: UIView { return } - if let media: AnyObject = self.mediaCache.object(forKey: cacheKey as NSString) { + if let media: AnyObject = self.mediaCache?.object(forKey: cacheKey as NSString) { Logger.verbose("media cache hit") guard Thread.isMainThread else { diff --git a/Session/Conversations/Message Cells/Content Views/QuoteView.swift b/Session/Conversations/Message Cells/Content Views/QuoteView.swift index ebd726a1c..69e90a357 100644 --- a/Session/Conversations/Message Cells/Content Views/QuoteView.swift +++ b/Session/Conversations/Message Cells/Content Views/QuoteView.swift @@ -3,6 +3,7 @@ import UIKit import SessionUIKit import SessionMessagingKit +import SessionUtilitiesKit final class QuoteView: UIView { static let thumbnailSize: CGFloat = 48 @@ -237,17 +238,27 @@ final class QuoteView: UIView { .compactMap { $0 } .asSet() .contains(authorId) + let authorLabel = UILabel() authorLabel.font = .boldSystemFont(ofSize: Values.smallFontSize) - authorLabel.text = (isCurrentUser ? - "MEDIA_GALLERY_SENDER_NAME_YOU".localized() : - Profile.displayName( + authorLabel.text = { + guard !isCurrentUser else { return "MEDIA_GALLERY_SENDER_NAME_YOU".localized() } + guard body != nil else { + // When we can't find the quoted message we want to hide the author label + return Profile.displayNameNoFallback( + id: authorId, + threadVariant: threadVariant + ) + } + + return Profile.displayName( id: authorId, threadVariant: threadVariant ) - ) + }() authorLabel.themeTextColor = targetThemeColor authorLabel.lineBreakMode = .byTruncatingTail + authorLabel.isHidden = (authorLabel.text == nil) let authorLabelSize = authorLabel.systemLayoutSizeFitting(availableSpace) authorLabel.set(.height, to: authorLabelSize.height) diff --git a/Session/Conversations/Settings/OWSMessageTimerView.m b/Session/Conversations/Settings/OWSMessageTimerView.m index bfe57d7e3..ad2a924cf 100644 --- a/Session/Conversations/Settings/OWSMessageTimerView.m +++ b/Session/Conversations/Settings/OWSMessageTimerView.m @@ -6,6 +6,7 @@ #import "OWSMath.h" #import "UIView+OWS.h" #import <QuartzCore/QuartzCore.h> +#import <PureLayout/PureLayout.h> #import <SignalCoreKit/NSDate+OWS.h> #import <SessionUtilitiesKit/NSTimer+Proxying.h> #import <SessionSnodeKit/SessionSnodeKit.h> diff --git a/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift b/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift index 886cc326a..a562ee556 100644 --- a/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift +++ b/Session/Conversations/Settings/ThreadDisappearingMessagesViewModel.swift @@ -98,7 +98,12 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this private lazy var _observableSettingsData: ObservableData = ValueObservation - .trackingConstantRegion { [weak self, config] db -> [SectionModel] in + .trackingConstantRegion { [weak self, config, dependencies, threadId = self.threadId] db -> [SectionModel] in + let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies) + let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel + .conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey) + .fetchOne(db) + return [ SectionModel( model: .content, @@ -109,6 +114,10 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear rightAccessory: .radio( isSelected: { (self?.currentSelection.value == 0) } ), + isEnabled: ( + maybeThreadViewModel?.threadVariant != .closedGroup || + maybeThreadViewModel?.currentUserIsClosedGroupMember == true + ), onTap: { self?.currentSelection.send(0) } ) ].appending( @@ -122,6 +131,10 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear rightAccessory: .radio( isSelected: { (self?.currentSelection.value == duration) } ), + isEnabled: ( + maybeThreadViewModel?.threadVariant != .closedGroup || + maybeThreadViewModel?.currentUserIsClosedGroupMember == true + ), onTap: { self?.currentSelection.send(duration) } ) } diff --git a/Session/Conversations/Settings/ThreadSettingsViewModel.swift b/Session/Conversations/Settings/ThreadSettingsViewModel.swift index 447732eeb..fe5722e7d 100644 --- a/Session/Conversations/Settings/ThreadSettingsViewModel.swift +++ b/Session/Conversations/Settings/ThreadSettingsViewModel.swift @@ -3,6 +3,7 @@ import Foundation import Combine import GRDB +import YYImage import DifferenceKit import SessionUIKit import SessionMessagingKit @@ -395,7 +396,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav accessibilityLabel: "Leave group", confirmationInfo: ConfirmationModal.Info( title: "leave_group_confirmation_alert_title".localized(), - attributedExplanation: { + body: .attributedText({ if currentUserIsClosedGroupAdmin { return NSAttributedString(string: "admin_group_leave_warning".localized()) } @@ -412,7 +413,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav range: (mutableAttributedString.string as NSString).range(of: threadViewModel.displayName) ) return mutableAttributedString - }(), + }()), confirmTitle: "LEAVE_BUTTON_TITLE".localized(), confirmStyle: .danger, cancelStyle: .alert_text @@ -548,9 +549,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav threadViewModel.displayName ) }(), - explanation: (threadViewModel.threadIsBlocked == true ? - nil : - "BLOCK_USER_BEHAVIOR_EXPLANATION".localized() + body: (threadViewModel.threadIsBlocked == true ? .none : + .text("BLOCK_USER_BEHAVIOR_EXPLANATION".localized()) ), confirmTitle: (threadViewModel.threadIsBlocked == true ? "BLOCK_LIST_UNBLOCK_BUTTON".localized() : @@ -688,13 +688,12 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav displayName ) ), - explanation: (oldBlockedState == false ? + body: (oldBlockedState == true ? .none : .text( String( format: "BLOCK_LIST_VIEW_BLOCKED_ALERT_MESSAGE_FORMAT".localized(), displayName - ) : - nil - ), + ) + )), accessibilityLabel: oldBlockedState == false ? "User blocked" : "Confirm unblock", accessibilityId: "Test_name", cancelTitle: "BUTTON_OK".localized(), diff --git a/Session/Home/GlobalSearch/GlobalSearchViewController.swift b/Session/Home/GlobalSearch/GlobalSearchViewController.swift index 139b5c383..d6e07e269 100644 --- a/Session/Home/GlobalSearch/GlobalSearchViewController.swift +++ b/Session/Home/GlobalSearch/GlobalSearchViewController.swift @@ -91,14 +91,17 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo setupNavigationBar() } - public override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) searchBar.becomeFirstResponder() } public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) - searchBar.resignFirstResponder() + + UIView.performWithoutAnimation { + searchBar.resignFirstResponder() + } } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { @@ -138,10 +141,6 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo } } - private func reloadTableData() { - tableView.reloadData() - } - // MARK: - Update Search Results private func refreshSearchResults() { @@ -155,9 +154,11 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo let searchText = rawSearchText.stripped guard searchText.count > 0 else { + guard searchText != (lastSearchText ?? "") else { return } + searchResultSet = defaultSearchResults lastSearchText = nil - reloadTableData() + tableView.reloadData() return } guard lastSearchText != searchText else { return } @@ -212,7 +213,7 @@ class GlobalSearchViewController: BaseVC, UITableViewDelegate, UITableViewDataSo .compactMap { $0 } .flatMap { $0 } self?.isLoading = false - self?.reloadTableData() + self?.tableView.reloadData() self?.refreshTimer = nil default: break @@ -283,18 +284,12 @@ extension GlobalSearchViewController { return } - if let presentedVC = self.presentedViewController { - presentedVC.dismiss(animated: false, completion: nil) - } - - let viewControllers: [UIViewController] = (self.navigationController? - .viewControllers) - .defaulting(to: []) - .appending( - ConversationVC(threadId: threadId, threadVariant: threadVariant, focusedInteractionId: focusedInteractionId) - ) - - self.navigationController?.setViewControllers(viewControllers, animated: true) + let viewController: ConversationVC = ConversationVC( + threadId: threadId, + threadVariant: threadVariant, + focusedInteractionId: focusedInteractionId + ) + self.navigationController?.pushViewController(viewController, animated: true) } // MARK: - UITableViewDataSource diff --git a/Session/Home/HomeVC.swift b/Session/Home/HomeVC.swift index 1b15da4bb..3475539da 100644 --- a/Session/Home/HomeVC.swift +++ b/Session/Home/HomeVC.swift @@ -308,7 +308,10 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { @@ -393,8 +396,18 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi // in from a frame of CGRect.zero) guard hasLoadedInitialThreadData else { hasLoadedInitialThreadData = true - UIView.performWithoutAnimation { - handleThreadUpdates(updatedData, changeset: changeset, initialLoad: true) + + UIView.performWithoutAnimation { [weak self] in + // Hide the 'loading conversations' label (now that we have received conversation data) + self?.loadingConversationsLabel.isHidden = true + + // Show the empty state if there is no data + self?.emptyStateView.isHidden = ( + !updatedData.isEmpty && + updatedData.contains(where: { !$0.elements.isEmpty }) + ) + + self?.viewModel.updateThreadData(updatedData) } return } @@ -739,7 +752,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "delete_conversation_confirmation_alert_title".localized(), - attributedExplanation: confirmationModalExplanation, + body: .attributedText(confirmationModalExplanation), confirmTitle: "TXT_DELETE_TITLE".localized(), confirmStyle: .danger, cancelStyle: .alert_text, @@ -824,7 +837,7 @@ final class HomeVC: BaseVC, UITableViewDataSource, UITableViewDelegate, SeedRemi let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: confirmationModalTitle, - attributedExplanation: confirmationModalExplanation, + body: .attributedText(confirmationModalExplanation), confirmTitle: "LEAVE_BUTTON_TITLE".localized(), confirmStyle: .danger, cancelStyle: .alert_text, diff --git a/Session/Home/Message Requests/MessageRequestsViewController.swift b/Session/Home/Message Requests/MessageRequestsViewController.swift index 2dfbbf89f..180b47761 100644 --- a/Session/Home/Message Requests/MessageRequestsViewController.swift +++ b/Session/Home/Message Requests/MessageRequestsViewController.swift @@ -162,7 +162,10 @@ class MessageRequestsViewController: BaseVC, UITableViewDelegate, UITableViewDat } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Home/New Conversation/NewDMVC.swift b/Session/Home/New Conversation/NewDMVC.swift index ca5673833..bb82d716a 100644 --- a/Session/Home/New Conversation/NewDMVC.swift +++ b/Session/Home/New Conversation/NewDMVC.swift @@ -214,7 +214,7 @@ final class NewDMVC: BaseVC, UIPageViewControllerDataSource, UIPageViewControlle targetView: self?.view, info: ConfirmationModal.Info( title: "ALERT_ERROR_TITLE".localized(), - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Media Viewing & Editing/DocumentTitleViewController.swift b/Session/Media Viewing & Editing/DocumentTitleViewController.swift index 7a1e34733..bff7c7597 100644 --- a/Session/Media Viewing & Editing/DocumentTitleViewController.swift +++ b/Session/Media Viewing & Editing/DocumentTitleViewController.swift @@ -119,7 +119,10 @@ public class DocumentTileViewController: UIViewController, UITableViewDelegate, } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges() + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift index d56724cdf..69b8a016f 100644 --- a/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift +++ b/Session/Media Viewing & Editing/GIFs/GifPickerViewController.swift @@ -389,7 +389,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect targetView: self?.view, info: ConfirmationModal.Info( title: "GIF_PICKER_FAILURE_ALERT_TITLE".localized(), - explanation: error.localizedDescription, + body: .text(error.localizedDescription), confirmTitle: CommonStrings.retryButton, cancelTitle: CommonStrings.dismissButton, cancelStyle: .alert_text, @@ -458,7 +458,7 @@ class GifPickerViewController: OWSViewController, UISearchBarDelegate, UICollect targetView: self.view, info: ConfirmationModal.Info( title: CommonStrings.errorAlertTitle, - explanation: "GIF_PICKER_VIEW_MISSING_QUERY".localized(), + body: .text("GIF_PICKER_VIEW_MISSING_QUERY".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift b/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift new file mode 100644 index 000000000..b1c5483e1 --- /dev/null +++ b/Session/Media Viewing & Editing/MediaInfoVC+MediaInfoView.swift @@ -0,0 +1,193 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit +import SessionMessagingKit +import SignalUtilitiesKit + +extension MediaInfoVC { + final class MediaInfoView: UIView { + private static let cornerRadius: CGFloat = 12 + + private var attachment: Attachment? + private let width: CGFloat = MediaInfoVC.mediaSize - 2 * MediaInfoVC.arrowSize.width + + // MARK: - UI + + private lazy var fileIdLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.themeTextColor = .textPrimary + + return result + }() + + private lazy var fileTypeLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.themeTextColor = .textPrimary + + return result + }() + + private lazy var fileSizeLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.themeTextColor = .textPrimary + + return result + }() + + private lazy var resolutionLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.themeTextColor = .textPrimary + + return result + }() + + private lazy var durationLabel: UILabel = { + let result: UILabel = UILabel() + result.font = .systemFont(ofSize: Values.mediumFontSize) + result.themeTextColor = .textPrimary + + return result + }() + + // MARK: - Lifecycle + + init(attachment: Attachment?) { + self.attachment = attachment + + super.init(frame: CGRect.zero) + self.accessibilityLabel = "Media info" + setUpViewHierarchy() + update(attachment: attachment) + } + + override init(frame: CGRect) { + preconditionFailure("Use init(attachment:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(attachment:) instead.") + } + + private func setUpViewHierarchy() { + let backgroundView: UIView = UIView() + backgroundView.clipsToBounds = true + backgroundView.themeBackgroundColor = .contextMenu_background + backgroundView.layer.cornerRadius = Self.cornerRadius + addSubview(backgroundView) + backgroundView.pin(to: self) + + let container: UIView = UIView() + container.set(.width, to: self.width) + + // File ID + let fileIdTitleLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.text = "ATTACHMENT_INFO_FILE_ID".localized() + ":" + result.themeTextColor = .textPrimary + + return result + }() + let fileIdContainerStackView: UIStackView = UIStackView(arrangedSubviews: [ fileIdTitleLabel, fileIdLabel ]) + fileIdContainerStackView.axis = .vertical + fileIdContainerStackView.spacing = 6 + container.addSubview(fileIdContainerStackView) + fileIdContainerStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing, UIView.VerticalEdge.top ], to: container) + + // File Type + let fileTypeTitleLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.text = "ATTACHMENT_INFO_FILE_TYPE".localized() + ":" + result.themeTextColor = .textPrimary + + return result + }() + let fileTypeContainerStackView: UIStackView = UIStackView(arrangedSubviews: [ fileTypeTitleLabel, fileTypeLabel ]) + fileTypeContainerStackView.axis = .vertical + fileTypeContainerStackView.spacing = 6 + container.addSubview(fileTypeContainerStackView) + fileTypeContainerStackView.pin(.leading, to: .leading, of: container) + fileTypeContainerStackView.pin(.top, to: .bottom, of: fileIdContainerStackView, withInset: Values.largeSpacing) + + // File Size + let fileSizeTitleLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.text = "ATTACHMENT_INFO_FILE_SIZE".localized() + ":" + result.themeTextColor = .textPrimary + + return result + }() + let fileSizeContainerStackView: UIStackView = UIStackView(arrangedSubviews: [ fileSizeTitleLabel, fileSizeLabel ]) + fileSizeContainerStackView.axis = .vertical + fileSizeContainerStackView.spacing = 6 + container.addSubview(fileSizeContainerStackView) + fileSizeContainerStackView.pin(.trailing, to: .trailing, of: container) + fileSizeContainerStackView.pin(.top, to: .bottom, of: fileIdContainerStackView, withInset: Values.largeSpacing) + fileSizeContainerStackView.set(.width, to: 90) + + // Resolution + let resolutionTitleLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.text = "ATTACHMENT_INFO_RESOLUTION".localized() + ":" + result.themeTextColor = .textPrimary + + return result + }() + let resolutionContainerStackView: UIStackView = UIStackView(arrangedSubviews: [ resolutionTitleLabel, resolutionLabel ]) + resolutionContainerStackView.axis = .vertical + resolutionContainerStackView.spacing = 6 + container.addSubview(resolutionContainerStackView) + resolutionContainerStackView.pin(.leading, to: .leading, of: container) + resolutionContainerStackView.pin(.top, to: .bottom, of: fileTypeContainerStackView, withInset: Values.largeSpacing) + + // Duration + let durationTitleLabel: UILabel = { + let result = UILabel() + result.font = .boldSystemFont(ofSize: Values.mediumFontSize) + result.text = "ATTACHMENT_INFO_DURATION".localized() + ":" + result.themeTextColor = .textPrimary + + return result + }() + let durationContainerStackView: UIStackView = UIStackView(arrangedSubviews: [ durationTitleLabel, durationLabel ]) + durationContainerStackView.axis = .vertical + durationContainerStackView.spacing = 6 + container.addSubview(durationContainerStackView) + durationContainerStackView.pin(.trailing, to: .trailing, of: container) + durationContainerStackView.pin(.top, to: .bottom, of: fileSizeContainerStackView, withInset: Values.largeSpacing) + durationContainerStackView.set(.width, to: 90) + container.pin(.bottom, to: .bottom, of: durationContainerStackView) + + backgroundView.addSubview(container) + container.pin(to: backgroundView, withInset: Values.largeSpacing) + } + + // MARK: - Interaction + public func update(attachment: Attachment?) { + guard let attachment: Attachment = attachment else { return } + + self.attachment = attachment + + fileIdLabel.text = attachment.serverId + fileTypeLabel.text = attachment.contentType + fileSizeLabel.text = OWSFormat.formatFileSize(attachment.byteCount) + resolutionLabel.text = { + guard let width = attachment.width, let height = attachment.height else { return "N/A" } + return "\(width)×\(height)" + }() + durationLabel.text = { + guard let duration = attachment.duration else { return "N/A" } + return floor(duration).formatted(format: .videoDuration) + }() + } + } +} diff --git a/Session/Media Viewing & Editing/MediaInfoVC+MediaPreviewView.swift b/Session/Media Viewing & Editing/MediaInfoVC+MediaPreviewView.swift new file mode 100644 index 000000000..462e143ff --- /dev/null +++ b/Session/Media Viewing & Editing/MediaInfoVC+MediaPreviewView.swift @@ -0,0 +1,63 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit +import SessionMessagingKit + +extension MediaInfoVC { + final class MediaPreviewView: UIView { + private static let cornerRadius: CGFloat = 8 + + private let attachment: Attachment + private let isOutgoing: Bool + + // MARK: - UI + + private lazy var mediaView: MediaView = { + let result: MediaView = MediaView.init( + attachment: attachment, + isOutgoing: isOutgoing, + cornerRadius: 0 + ) + + return result + }() + + // MARK: - Lifecycle + + init(attachment: Attachment, isOutgoing: Bool) { + self.attachment = attachment + self.isOutgoing = isOutgoing + + super.init(frame: CGRect.zero) + self.accessibilityLabel = "Media info" + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(attachment:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(attachment:) instead.") + } + + private func setUpViewHierarchy() { + set(.width, to: MediaInfoVC.mediaSize) + set(.height, to: MediaInfoVC.mediaSize) + + addSubview(mediaView) + mediaView.pin(to: self) + + mediaView.loadMedia() + } + + // MARK: - Copy + + /// This function is used to make sure the carousel view contains this class can loop infinitely + func copyView() -> MediaPreviewView { + return MediaPreviewView(attachment: self.attachment, isOutgoing: self.isOutgoing) + } + } +} diff --git a/Session/Media Viewing & Editing/MediaInfoVC.swift b/Session/Media Viewing & Editing/MediaInfoVC.swift new file mode 100644 index 000000000..1d8991d4c --- /dev/null +++ b/Session/Media Viewing & Editing/MediaInfoVC.swift @@ -0,0 +1,151 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit +import SignalUtilitiesKit + +final class MediaInfoVC: BaseVC, SessionCarouselViewDelegate { + internal static let mediaSize: CGFloat = UIScreen.main.bounds.width - 2 * Values.veryLargeSpacing + internal static let arrowSize: CGSize = CGSize(width: 20, height: 30) + + private let attachments: [Attachment] + private let isOutgoing: Bool + private let threadId: String + private let threadVariant: SessionThread.Variant + private let interactionId: Int64 + + private var currentPage: Int = 0 + + // MARK: - UI + private lazy var mediaInfoView: MediaInfoView = MediaInfoView(attachment: nil) + private lazy var mediaCarouselView: SessionCarouselView = { + let slices: [MediaPreviewView] = self.attachments.map { + MediaPreviewView( + attachment: $0, + isOutgoing: self.isOutgoing + ) + } + let result: SessionCarouselView = SessionCarouselView( + info: SessionCarouselView.Info( + slices: slices, + copyOfFirstSlice: slices.first?.copyView(), + copyOfLastSlice: slices.last?.copyView(), + sliceSize: CGSize( + width: Self.mediaSize, + height: Self.mediaSize + ), + shouldShowPageControl: true, + pageControlStyle: SessionCarouselView.PageControlStyle( + size: .medium, + backgroundColor: .init(white: 0, alpha: 0.4), + bottomInset: Values.mediumSpacing + ), + shouldShowArrows: true, + arrowsSize: Self.arrowSize, + cornerRadius: 8 + ) + ) + result.set(.height, to: Self.mediaSize) + result.delegate = self + + return result + }() + + private lazy var fullScreenButton: UIButton = { + let result: UIButton = UIButton(type: .custom) + result.setImage( + UIImage(systemName: "arrow.up.left.and.arrow.down.right")? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) + result.themeTintColor = .textPrimary + result.backgroundColor = .init(white: 0, alpha: 0.4) + result.layer.cornerRadius = 14 + result.set(.width, to: 28) + result.set(.height, to: 28) + result.addTarget(self, action: #selector(showMediaFullScreen), for: .touchUpInside) + + return result + }() + + // MARK: - Initialization + + init( + attachments: [Attachment], + isOutgoing: Bool, + threadId: String, + threadVariant: SessionThread.Variant, + interactionId: Int64 + ) { + self.threadId = threadId + self.threadVariant = threadVariant + self.interactionId = interactionId + self.isOutgoing = isOutgoing + self.attachments = attachments + super.init(nibName: nil, bundle: nil) + } + + override init(nibName: String?, bundle: Bundle?) { + preconditionFailure("Use init(attachments:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(attachments:) instead.") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + ViewControllerUtilities.setUpDefaultSessionStyle( + for: self, + title: "message_info_title".localized(), + hasCustomBackButton: false + ) + + let mediaStackView: UIStackView = UIStackView() + mediaStackView.axis = .horizontal + + mediaInfoView.update(attachment: attachments[0]) + + mediaCarouselView.addSubview(fullScreenButton) + fullScreenButton.pin(.trailing, to: .trailing, of: mediaCarouselView, withInset: -(Values.smallSpacing + Values.veryLargeSpacing)) + fullScreenButton.pin(.bottom, to: .bottom, of: mediaCarouselView, withInset: -Values.smallSpacing) + + let stackView: UIStackView = UIStackView(arrangedSubviews: [ mediaCarouselView, mediaInfoView ]) + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = Values.largeSpacing + + self.view.addSubview(stackView) + stackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self.view) + stackView.pin(.top, to: .top, of: self.view, withInset: Values.veryLargeSpacing) + } + + // MARK: - Interaction + + @objc func showMediaFullScreen() { + let attachment = self.attachments[self.currentPage] + let viewController: UIViewController? = MediaGalleryViewModel.createDetailViewController( + for: self.threadId, + threadVariant: self.threadVariant, + interactionId: self.interactionId, + selectedAttachmentId: attachment.id, + options: [ .sliderEnabled ] + ) + if let viewController: UIViewController = viewController { + viewController.transitioningDelegate = nil + self.present(viewController, animated: true) + } + } + + // MARK: - SessionCarouselViewDelegate + + func carouselViewDidScrollToNewSlice(currentPage: Int) { + self.currentPage = currentPage + mediaInfoView.update(attachment: attachments[currentPage]) + } +} diff --git a/Session/Media Viewing & Editing/MediaPageViewController.swift b/Session/Media Viewing & Editing/MediaPageViewController.swift index 0ffc3cd42..7da69bfb9 100644 --- a/Session/Media Viewing & Editing/MediaPageViewController.swift +++ b/Session/Media Viewing & Editing/MediaPageViewController.swift @@ -245,7 +245,10 @@ class MediaPageViewController: UIPageViewController, UIPageViewControllerDataSou } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges() + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Media Viewing & Editing/MediaTileViewController.swift b/Session/Media Viewing & Editing/MediaTileViewController.swift index bbd5506c4..db7915b95 100644 --- a/Session/Media Viewing & Editing/MediaTileViewController.swift +++ b/Session/Media Viewing & Editing/MediaTileViewController.swift @@ -175,7 +175,10 @@ public class MediaTileViewController: UIViewController, UICollectionViewDataSour } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift index 5314a0f5c..f690ed88a 100644 --- a/Session/Media Viewing & Editing/PhotoCaptureViewController.swift +++ b/Session/Media Viewing & Editing/PhotoCaptureViewController.swift @@ -315,7 +315,7 @@ class PhotoCaptureViewController: OWSViewController { let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: CommonStrings.errorAlertTitle, - explanation: error.localizedDescription, + body: .text(error.localizedDescription), cancelTitle: CommonStrings.dismissButton, cancelStyle: .alert_text, afterClosed: { [weak self] in self?.dismiss(animated: true) } diff --git a/Session/Meta/AppDelegate.swift b/Session/Meta/AppDelegate.swift index dc7d58d64..4982d7679 100644 --- a/Session/Meta/AppDelegate.swift +++ b/Session/Meta/AppDelegate.swift @@ -446,19 +446,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD guard CurrentAppContext().isMainApp else { return } - CurrentAppContext().setMainAppBadgeNumber( - Storage.shared + /// On application startup the `Storage.read` can be slightly slow while GRDB spins up it's database + /// read pools (up to a few seconds), since this read is blocking we want to dispatch it to run async to ensure + /// we don't block user interaction while it's running + DispatchQueue.global(qos: .default).async { + let unreadCount: Int = Storage.shared .read { db in let userPublicKey: String = getUserHexEncodedPublicKey(db) let thread: TypedTableAlias<SessionThread> = TypedTableAlias() return try Interaction .filter(Interaction.Columns.wasRead == false) - .filter( - // Exclude outgoing and deleted messages from the count - Interaction.Columns.variant != Interaction.Variant.standardOutgoing && - Interaction.Columns.variant != Interaction.Variant.standardIncomingDeleted - ) + .filter(Interaction.Variant.variantsToIncrementUnreadCount.contains(Interaction.Columns.variant)) .filter( // Only count mentions if 'onlyNotifyForMentions' is set thread[.onlyNotifyForMentions] == false || @@ -482,7 +481,11 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD .fetchCount(db) } .defaulting(to: 0) - ) + + DispatchQueue.main.async { + CurrentAppContext().setMainAppBadgeNumber(unreadCount) + } + } } } diff --git a/Session/Meta/Certificates/public-loki-foundation.crt b/Session/Meta/Certificates/public-loki-foundation.crt deleted file mode 100644 index 344a05543..000000000 --- a/Session/Meta/Certificates/public-loki-foundation.crt +++ /dev/null @@ -1,24 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEEzCCAvugAwIBAgIUY9RQqbjhsQEkdeSgV9L0os9xZ7AwDQYJKoZIhvcNAQEL -BQAwfDELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN -ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x -HzAdBgNVBAMMFnB1YmxpYy5sb2tpLmZvdW5kYXRpb24wHhcNMjEwNDA3MDExMDMx -WhcNMjMwNDA3MDExMDMxWjB8MQswCQYDVQQGEwJBVTERMA8GA1UECAwIVmljdG9y -aWExEjAQBgNVBAcMCU1lbGJvdXJuZTElMCMGA1UECgwcT3hlbiBQcml2YWN5IFRl -Y2ggRm91bmRhdGlvbjEfMB0GA1UEAwwWcHVibGljLmxva2kuZm91bmRhdGlvbjCC -ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAM5dBJSIR5+VNNUxUOo6FG0e -RmZteRqBt50KXGbOi2A23a6sa57pLFh9Yw3hmlWV+QCL7ipG1X4IC55OStgoesf+ -K65VwEMP6Mtq0sSJS3R5TiuV2ZSRdSZTVjUyRXVe5T4Aw6wXVTAbc/HsyS780tDh -GclfDHhonPhZpmTAnSbfMOS+BfOnBNvDxdto0kVh6k5nrGlkT4ECloulHTQF2lwJ -0D6IOtv9AJplPdg6s2c4dY7durOdvr3NNVfvn5PTeRvbEPqzZur4WUUKIPNGu6mY -PxImqd4eUsL0Vod4aAsTIx4YMmCTi0m9W6zJI6nXcK/6a+iiA3+NTNMzEA9gQhEC -AwEAAaOBjDCBiTAdBgNVHQ4EFgQU/zahokxLvvFUpbnM6z/pwS1KsvwwHwYDVR0j -BBgwFoAU/zahokxLvvFUpbnM6z/pwS1KsvwwDwYDVR0TAQH/BAUwAwEB/zAhBgNV -HREEGjAYghZwdWJsaWMubG9raS5mb3VuZGF0aW9uMBMGA1UdJQQMMAoGCCsGAQUF -BwMBMA0GCSqGSIb3DQEBCwUAA4IBAQBql+JvoqpaYrFFTOuDn08U+pdcd3GM7tbI -zRH5LU+YnIpp9aRheek+2COW8DXsIy/kUngETCMLmX6ZaUj/WdHnTDkB0KTgxSHv -ad3ZznKPKZ26qJOklr+0ZWj4J3jHbisSzql6mqq7R2Kp4ESwzwqxvkbykM5RUnmz -Go/3Ol7bpN/ZVwwEkGfD/5rRHf57E/gZn2pBO+zotlQgr7HKRsIXQ2hIXVQqWmPQ -lvfIwrwAZlfES7BARFnHOpyVQxV8uNcV5K5eXzuVFjHBqvq+BtyGhWkP9yKJCHS9 -OUXxch0rzRsH2C/kRVVhEk0pI3qlFiRC8pCJs98SNE9l69EQtG7I ------END CERTIFICATE----- diff --git a/Session/Meta/Certificates/public-loki-foundation.der b/Session/Meta/Certificates/public-loki-foundation.der deleted file mode 100644 index 698980d78..000000000 Binary files a/Session/Meta/Certificates/public-loki-foundation.der and /dev/null differ diff --git a/Session/Meta/Certificates/seed1-2023-2y.crt b/Session/Meta/Certificates/seed1-2023-2y.crt new file mode 100644 index 000000000..658e0eb41 --- /dev/null +++ b/Session/Meta/Certificates/seed1-2023-2y.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEDTCCAvWgAwIBAgIUPwyEuBgX6kfxt+G2tQ4GNTZErMMwDQYJKoZIhvcNAQEL +BQAwejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN +ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x +HTAbBgNVBAMMFHNlZWQxLmdldHNlc3Npb24ub3JnMB4XDTIzMDQxMjEyNTYyMloX +DTI1MDQxMTEyNTYyMlowejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3Rvcmlh +MRIwEAYDVQQHDAlNZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNo +IEZvdW5kYXRpb24xHTAbBgNVBAMMFHNlZWQxLmdldHNlc3Npb24ub3JnMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwxkbApgfFA1upIFj47y+7k+qrM0l +MLDvtX3U95icVgb7HGhxKzkzbCOscKZnVsq1N90drYVh7to0H69b2t6y7l+9q6Zd +Ytzi9U0NoL/OabmR6F+w/XpokRM7CMz9zeg84VLnyu2yRdR26keG4/AZRXk+j8Dy +6xp09+hTF7kfdfzL3HdYyUsyx+/CqoyzU01yn4aVgJ9aufYu38QKnnjfROiVahJf +Xm1MvHLmDCe+WbDFgsp2Y0NjNbpASUgrOEPNnIJeY3Lw4kzwNVGsbSBHgvLgSfaD +p5L6k89TUUKA0onlGFAN/MDXL4DNfjSpmfzHyhM8XwKJ9COSXsvvpX5hHQIDAQAB +o4GKMIGHMB0GA1UdDgQWBBRypjuvZ+5vWDB4kcKE9MkFrVp0tzAfBgNVHSMEGDAW +gBRypjuvZ+5vWDB4kcKE9MkFrVp0tzAPBgNVHRMBAf8EBTADAQH/MB8GA1UdEQQY +MBaCFHNlZWQxLmdldHNlc3Npb24ub3JnMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0G +CSqGSIb3DQEBCwUAA4IBAQBW8q3DzJWVXZew9pJ1MqjqsMuNt2OlnptwIZUme/Lh +krhqBj5o87218542ao1Hkgph4IuuwEQPwJvUoUbh7dT/k+4D6Ua3oUxhmdeyFUv+ +mjQKZ1mfcfrwW+6rCWJRa2mAVYfOhdfBQZgLP7NqYdskVQF5LWXSs1IF3XLTyROy +gCeapTexTvKlr/TMW4spE4ewaQ4AfB2c24iVLcpAWT+12GaJ0AYO+gY2o7LQqywN +qIxt2mbvXyf2wuhr489tmGz53mKa3Xu7JC1uU6g9zqJ4FGMYsI8pa0Ec2ODRBb8s +8W54r5LN472aTYn+UGgV8wadzPFd0FZtQABkDTuWSZY7 +-----END CERTIFICATE----- diff --git a/Session/Meta/Certificates/seed1-2023-2y.der b/Session/Meta/Certificates/seed1-2023-2y.der new file mode 100644 index 000000000..d3064e94d Binary files /dev/null and b/Session/Meta/Certificates/seed1-2023-2y.der differ diff --git a/Session/Meta/Certificates/seed2-2023-2y.crt b/Session/Meta/Certificates/seed2-2023-2y.crt new file mode 100644 index 000000000..fea4fd4f5 --- /dev/null +++ b/Session/Meta/Certificates/seed2-2023-2y.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEDTCCAvWgAwIBAgIUaPiMYcZh7cZZfacCni2NwT5DKh4wDQYJKoZIhvcNAQEL +BQAwejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN +ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x +HTAbBgNVBAMMFHNlZWQyLmdldHNlc3Npb24ub3JnMB4XDTIzMDQxMjEyNTY0NVoX +DTI1MDQxMTEyNTY0NVowejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3Rvcmlh +MRIwEAYDVQQHDAlNZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNo +IEZvdW5kYXRpb24xHTAbBgNVBAMMFHNlZWQyLmdldHNlc3Npb24ub3JnMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAh2UcfW0I+1QWRa3cj7RnMGelYkGK +7l4V6q7je1IkudXBNretkvVF1NCpfZ8dz72JmdGPJ5/uIEW15HDD2L63OmSDVPhA +2JCb/NqmXfeO91lyxgb0sDnN1UH0wzuS75aBjaQ0nXQV3ffmqKnNNv0HK+LTMFD+ +Dv2yGDtZTWH6H3VzPLCvHHYXVdyuQHwchAcNQar5k4dbdEIcYIV+ANccPg7iQ81a +ITZ9bCeACdMqbB9gILq21KWdkxCu1fwSXs/B6n+U4UpJyv87fprvAyU3HqQhqlU7 +dHnzA1dPn8D4a/3CMYZogVm8USNjv4HmWIwKbYDX+VahvuZwEi6+pwEurQIDAQAB +o4GKMIGHMB0GA1UdDgQWBBRxVM4+gFFipZFAg+Fs4x580js+2TAfBgNVHSMEGDAW +gBRxVM4+gFFipZFAg+Fs4x580js+2TAPBgNVHRMBAf8EBTADAQH/MB8GA1UdEQQY +MBaCFHNlZWQyLmdldHNlc3Npb24ub3JnMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0G +CSqGSIb3DQEBCwUAA4IBAQBIFj6hsOgNVr2kZufimTxoT1TE8uvycIWyt04q6/nP +8h33u/sHuNPdnr2UewqRyDRFefxrGlqBUQAQJVyzJGIlju/HTZaBnVB0H2smCRtK +ZRHAJ/cwcnAp+STjqgPqt1ZZ6JcfFwJZID4pPmrW8WaQNAtQPi2Ly2JLQ+Ym5wus +aGxGjbDRQSWGmUpg5TE+XdDsHeJtCl6HAEjvtXfq1uzKedRzmqYfIa8Rd7b2tmuy +dN27swR4DRJOK4rAxHnI8jt7GKVtPXnYfRuk2+0dVZ4CD6qHw+CO5mcdCabnflgT +XS8BYlOvkAyVbtmZNAacoUZvPRx3o186BMJoK2coQyFN +-----END CERTIFICATE----- diff --git a/Session/Meta/Certificates/seed2-2023-2y.der b/Session/Meta/Certificates/seed2-2023-2y.der new file mode 100644 index 000000000..acc374d57 Binary files /dev/null and b/Session/Meta/Certificates/seed2-2023-2y.der differ diff --git a/Session/Meta/Certificates/seed3-2023-2y.crt b/Session/Meta/Certificates/seed3-2023-2y.crt new file mode 100644 index 000000000..1a45bf9fd --- /dev/null +++ b/Session/Meta/Certificates/seed3-2023-2y.crt @@ -0,0 +1,24 @@ +-----BEGIN CERTIFICATE----- +MIIEDTCCAvWgAwIBAgIUEZkKsCM3Leodz+JB0ADefbWoRbswDQYJKoZIhvcNAQEL +BQAwejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3RvcmlhMRIwEAYDVQQHDAlN +ZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNoIEZvdW5kYXRpb24x +HTAbBgNVBAMMFHNlZWQzLmdldHNlc3Npb24ub3JnMB4XDTIzMDUxNzAyNDAwOFoX +DTI1MDQxMjAyNDAwOFowejELMAkGA1UEBhMCQVUxETAPBgNVBAgMCFZpY3Rvcmlh +MRIwEAYDVQQHDAlNZWxib3VybmUxJTAjBgNVBAoMHE94ZW4gUHJpdmFjeSBUZWNo +IEZvdW5kYXRpb24xHTAbBgNVBAMMFHNlZWQzLmdldHNlc3Npb24ub3JnMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAx4Yz/kIXn5t+VMATXsortcyK3DFF +hjNICxAt8qdLwyCCJDnedBdfeQb7zrn2A3btzfKrBD0x3JrbVHabUrtI+wFqfDLS +id2WOIIM/8RP2V/e4zanpKsk9yB/euKga+M+fybfTn1WTqQU5nEuU6eZyyEEZBk6 +1rzWJstxWhcfN4rfl+ciSWLcmFLC2LuNZqwm6To77oLPj+DGrUHyRKFZ4Tw9ilcU +TpMKFaMmNzrHEzS5lPJIRa+2LD5vDYR/sv+lPiKMXTb64OTOJjTfucdsyZqWrI0R +mV2pBcrYBoDbxO+7pnr8GrJIcFqTLDI6MbjH6eseZqRHJSYKrNCyGlDeSQIDAQAB +o4GKMIGHMB0GA1UdDgQWBBRUYnrMlCbDZo6YXpnivhBui51XhDAfBgNVHSMEGDAW +gBRUYnrMlCbDZo6YXpnivhBui51XhDAPBgNVHRMBAf8EBTADAQH/MB8GA1UdEQQY +MBaCFHNlZWQzLmdldHNlc3Npb24ub3JnMBMGA1UdJQQMMAoGCCsGAQUFBwMBMA0G +CSqGSIb3DQEBCwUAA4IBAQBFYRlRODyQTIhNQC+pTapKtHdS9GJqKvyJX6NVFF6w ++oBzZGNYsDTmzaelraAuUz+uS7d0vngu5cV+3jG0DgksELT6hbpuHcad1rxAhuDv +wv/f02qJyB1F2luXma2n+NHgRFhvIYulWjV/DSSmwea2XD4DH+ZKcYeEXyT71b2T +VZfGnxLPVMz99iA6sQxsNfccFMvDxKofha7teRkUJ+SVzyutrneYySqrjGie6+Nb +oOw4CnpiqiUKIf47B6ZKlsJ8MAS8zAo6O9UqfmNdVoXFrZDjaQGPAjSH1oxL7iP5 +pED6BUMytm8spiTEVBYIer/gcXaA4zWSKZ/Fd24OK0GL +-----END CERTIFICATE----- diff --git a/Session/Meta/Certificates/seed3-2023-2y.der b/Session/Meta/Certificates/seed3-2023-2y.der new file mode 100644 index 000000000..b197d674d Binary files /dev/null and b/Session/Meta/Certificates/seed3-2023-2y.der differ diff --git a/Session/Meta/Certificates/storage-seed-1.crt b/Session/Meta/Certificates/storage-seed-1.crt deleted file mode 100644 index 7360d6fca..000000000 --- a/Session/Meta/Certificates/storage-seed-1.crt +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEITCCAwmgAwIBAgIUJsox1ZQPK/6iDsCC+MUJfNAlFuYwDQYJKoZIhvcNAQEL -BQAwgYAxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0b3JpYTESMBAGA1UEBwwJ -TWVsYm91cm5lMSUwIwYDVQQKDBxPeGVuIFByaXZhY3kgVGVjaCBGb3VuZGF0aW9u -MSMwIQYDVQQDDBpzdG9yYWdlLnNlZWQxLmxva2kubmV0d29yazAeFw0yMTA0MDcw -MTE5MjZaFw0yMzA0MDcwMTE5MjZaMIGAMQswCQYDVQQGEwJBVTERMA8GA1UECAwI -VmljdG9yaWExEjAQBgNVBAcMCU1lbGJvdXJuZTElMCMGA1UECgwcT3hlbiBQcml2 -YWN5IFRlY2ggRm91bmRhdGlvbjEjMCEGA1UEAwwac3RvcmFnZS5zZWVkMS5sb2tp -Lm5ldHdvcmswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtWH3Rz8Dd -kEmM7tcBWHrJ/G8drr/+qidboEVYzxpyRjszaDxKXVhx4eBBsAD5RuCWuTuZmM8k -TKEDLtf8xfb5SQ7YNX+346s9NXS5Poy4CIPASiW/QWXgIHFbVdv2hC+cKOP61OLM -OGnOxfig6tQyd6EaCkedpY1DvSa2lPnQSOwC/jXCx6Vboc0zTY5R2bHtNc9hjIFP -F4VClLAQSh2F4R1V9MH5KZMW+CCP6oaJY658W9JYXYRwlLrL2EFOVxHgcxq/6+fw -+axXK9OXJrGZjuA+hiz+L/uAOtE4WuxrSeuNMHSrMtM9QqVn4bBuMJ21mAzfNoMP -OIwgMT9DwUjVAgMBAAGjgZAwgY0wHQYDVR0OBBYEFOubJp9SoXIw+ONiWgkOaW8K -zI/TMB8GA1UdIwQYMBaAFOubJp9SoXIw+ONiWgkOaW8KzI/TMA8GA1UdEwEB/wQF -MAMBAf8wJQYDVR0RBB4wHIIac3RvcmFnZS5zZWVkMS5sb2tpLm5ldHdvcmswEwYD -VR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggEBAIiHNhNrjYvwXVWs -gacx8T/dpqpu9GE3L17LotgQr4R+IYHpNtcmwOTdtWWFfUTr75OCs+c3DqgRKEoj -lnULOsVcalpAGIvW15/fmZWOf66Dpa4+ljDmAc3SOQiD0gGNtqblgI5zG1HF38QP -hjYRhCZ5CVeGOLucvQ8tVVwQvArPFIkBr0jH9jHVgRWEI2MeI3FsU2H93D4TfGln -N4SmmCfYBqygaaZBWkJEt0bYhn8uGHdU9UY9L2FPtfHVKkmFgO7cASGlvXS7B/TT -/8IgbtM3O8mZc2asmdQhGwoAKz93ryyCd8X2UZJg/IwCSCayOlYZWY2fR4OPQmmV -gxJsm+g= ------END CERTIFICATE----- diff --git a/Session/Meta/Certificates/storage-seed-1.der b/Session/Meta/Certificates/storage-seed-1.der deleted file mode 100644 index fac2672f9..000000000 Binary files a/Session/Meta/Certificates/storage-seed-1.der and /dev/null differ diff --git a/Session/Meta/Certificates/storage-seed-3.crt b/Session/Meta/Certificates/storage-seed-3.crt deleted file mode 100644 index 92574b769..000000000 --- a/Session/Meta/Certificates/storage-seed-3.crt +++ /dev/null @@ -1,25 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIEITCCAwmgAwIBAgIUc486Dy9Y00bUFfDeYmJIgSS5xREwDQYJKoZIhvcNAQEL -BQAwgYAxCzAJBgNVBAYTAkFVMREwDwYDVQQIDAhWaWN0b3JpYTESMBAGA1UEBwwJ -TWVsYm91cm5lMSUwIwYDVQQKDBxPeGVuIFByaXZhY3kgVGVjaCBGb3VuZGF0aW9u -MSMwIQYDVQQDDBpzdG9yYWdlLnNlZWQzLmxva2kubmV0d29yazAeFw0yMTA0MDcw -MTIwNTJaFw0yMzA0MDcwMTIwNTJaMIGAMQswCQYDVQQGEwJBVTERMA8GA1UECAwI -VmljdG9yaWExEjAQBgNVBAcMCU1lbGJvdXJuZTElMCMGA1UECgwcT3hlbiBQcml2 -YWN5IFRlY2ggRm91bmRhdGlvbjEjMCEGA1UEAwwac3RvcmFnZS5zZWVkMy5sb2tp -Lm5ldHdvcmswggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCtokMlsFzf -piYeD0EVNikMyvjltpF6fUEde9NOVrTtNTQT6kkDk+/0HF5LYgPaatv6v7fpUQHi -kIwd6F0LTRGeWDFdsaWMdtlR1n/GxLPrOROsE8dcLt6GLavPf9rDabgva93m/JD6 -XW+Ne+MPEwqS8dAmFGhZd0gju6AtKFoSHnIf5pSQN6fSZUF/JQtHLVprAKKWKDiS -ZwmWbmrZR2aofLD/VRpetabajnZlv9EeWloQwvUsw1C1hkAmmtFeeXtg7ePwrOzo -6CnmcUJwOmi+LWqQV4A+58RZPFKaZoC5pzaKd0OYB8eZ8HB1F41UjGJgheX5Cyl4 -+amfF3l8dSq1AgMBAAGjgZAwgY0wHQYDVR0OBBYEFM9VSq4pGydjtX92Beul4+ml -jBKtMB8GA1UdIwQYMBaAFM9VSq4pGydjtX92Beul4+mljBKtMA8GA1UdEwEB/wQF -MAMBAf8wJQYDVR0RBB4wHIIac3RvcmFnZS5zZWVkMy5sb2tpLm5ldHdvcmswEwYD -VR0lBAwwCgYIKwYBBQUHAwEwDQYJKoZIhvcNAQELBQADggEBAAYxmhhkcKE1n6g1 -JqOa3UCBo4EfbqY5+FDZ0FVqv/cwemwVpKLbe6luRIS8poomdPCyMOS45V7wN3H9 -cFpfJ1TW19ydPVKmCXrl29ngmnY1q7YDwE/4qi3VK/UiqDkTHMKWjVPkenOyi8u6 -VVQANXSnKrn6GtigNFjGyD38O+j7AUSXBtXOJczaoF6r6BWgwQZ2WmgjuwvKTWSN -4r8uObERoAQYVaeXfgdr4e9X/JdskBDaLFfoW/rrSozHB4FqVNFW96k+aIUgRa5p -9kv115QcBPCSh9qOyTHij4tswS6SyOFaiKrNC4hgHQXP4QgioKmtsR/2Y+qJ6ddH -6oo+4QU= ------END CERTIFICATE----- diff --git a/Session/Meta/Certificates/storage-seed-3.der b/Session/Meta/Certificates/storage-seed-3.der deleted file mode 100644 index 13239eb1a..000000000 Binary files a/Session/Meta/Certificates/storage-seed-3.der and /dev/null differ diff --git a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png index 53c019a76..03d20502b 100644 Binary files a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png and b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@1x.png differ diff --git a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png index 4625ebffa..5633c5ced 100644 Binary files a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png and b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@2x.png differ diff --git a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png index 4adf408bd..db52359aa 100644 Binary files a/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png and b/Session/Meta/Images.xcassets/attachment_audio.imageset/attachment_audio@3x.png differ diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json b/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json new file mode 100644 index 000000000..6364b3c6d --- /dev/null +++ b/Session/Meta/Images.xcassets/profile_placeholder.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "profile_placeholder.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "profile_placeholder@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "profile_placeholder@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png new file mode 100644 index 000000000..cf0843dcf Binary files /dev/null and b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder.png differ diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png new file mode 100644 index 000000000..02649edd9 Binary files /dev/null and b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@2x.png differ diff --git a/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png new file mode 100644 index 000000000..3a6241122 Binary files /dev/null and b/Session/Meta/Images.xcassets/profile_placeholder.imageset/profile_placeholder@3x.png differ diff --git a/Session/Meta/Session-Info.plist b/Session/Meta/Session-Info.plist index e186b98c9..be0439a2f 100644 --- a/Session/Meta/Session-Info.plist +++ b/Session/Meta/Session-Info.plist @@ -66,17 +66,17 @@ <dict> <key>NSExceptionDomains</key> <dict> - <key>public.loki.foundation</key> + <key>seed1.getsession.org</key> <dict> <key>NSExceptionRequiresForwardSecrecy</key> <false/> </dict> - <key>storage.seed1.loki.network</key> + <key>seed2.getsession.org</key> <dict> <key>NSExceptionRequiresForwardSecrecy</key> <false/> </dict> - <key>storage.seed3.loki.network</key> + <key>seed3.getsession.org</key> <dict> <key>NSExceptionRequiresForwardSecrecy</key> <false/> @@ -84,21 +84,19 @@ </dict> </dict> <key>NSAppleMusicUsageDescription</key> - <string>Signal needs to use Apple Music to play media attachments.</string> + <string>Session needs to use Apple Music to play media attachments.</string> <key>NSCameraUsageDescription</key> <string>Session needs camera access to take pictures and scan QR codes.</string> - <key>NSContactsUsageDescription</key> - <string>Signal uses your contacts to find users you know. We do not store your contacts on the server.</string> <key>NSFaceIDUsageDescription</key> <string>Session's Screen Lock feature uses Face ID.</string> <key>NSHumanReadableCopyright</key> <string>com.loki-project.loki-messenger</string> <key>NSMicrophoneUsageDescription</key> - <string>Session needs access to your microphone to record media.</string> + <string>Session needs access to your microphone for calls and to send to audio messages.</string> <key>NSPhotoLibraryAddUsageDescription</key> <string>Session needs access to your library to save photos.</string> <key>NSPhotoLibraryUsageDescription</key> - <string>Session needs access to your library to send photos.</string> + <string>Session needs access to your library to update your avatar and send photos.</string> <key>PHPhotoLibraryPreventAutomaticLimitedAccessAlert</key> <true/> <key>UIAppFonts</key> diff --git a/Session/Meta/Signal.entitlements b/Session/Meta/Signal.entitlements index 7be77767f..84526edd9 100644 --- a/Session/Meta/Signal.entitlements +++ b/Session/Meta/Signal.entitlements @@ -4,19 +4,6 @@ <dict> <key>aps-environment</key> <string>production</string> - <key>com.apple.developer.icloud-container-identifiers</key> - <array> - <string>iCloud.$(CFBundleIdentifier)</string> - </array> - <key>com.apple.developer.icloud-services</key> - <array> - <string>CloudDocuments</string> - <string>CloudKit</string> - </array> - <key>com.apple.developer.ubiquity-container-identifiers</key> - <array> - <string>iCloud.$(CFBundleIdentifier)</string> - </array> <key>com.apple.security.application-groups</key> <array> <string>group.com.loki-project.loki-messenger</string> diff --git a/Session/Meta/Translations/de.lproj/Localizable.strings b/Session/Meta/Translations/de.lproj/Localizable.strings index f5007fbe3..1d590e0d3 100644 --- a/Session/Meta/Translations/de.lproj/Localizable.strings +++ b/Session/Meta/Translations/de.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Nur für mich löschen"; "delete_message_for_everyone" = "Für jeden löschen"; "delete_message_for_me_and_recipient" = "Für mich und %@ löschen"; +"context_menu_info" = "Info"; "context_menu_reply" = "Antworten"; "context_menu_save" = "Speichern"; "context_menu_ban_user" = "Nutzer sperren"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/en.lproj/Localizable.strings b/Session/Meta/Translations/en.lproj/Localizable.strings index da74d3933..cf97bdb51 100644 --- a/Session/Meta/Translations/en.lproj/Localizable.strings +++ b/Session/Meta/Translations/en.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Delete just for me"; "delete_message_for_everyone" = "Delete for everyone"; "delete_message_for_me_and_recipient" = "Delete for me and %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Reply"; "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/es.lproj/Localizable.strings b/Session/Meta/Translations/es.lproj/Localizable.strings index 46107d2bc..ee4801866 100644 --- a/Session/Meta/Translations/es.lproj/Localizable.strings +++ b/Session/Meta/Translations/es.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Eliminar solo para mí"; "delete_message_for_everyone" = "Eliminar para todos"; "delete_message_for_me_and_recipient" = "Eliminar para mí y para %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Responder"; "context_menu_save" = "Guardar"; "context_menu_ban_user" = "Banear Usuario"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/fa.lproj/Localizable.strings b/Session/Meta/Translations/fa.lproj/Localizable.strings index b5cc4f0f8..4b1b87715 100644 --- a/Session/Meta/Translations/fa.lproj/Localizable.strings +++ b/Session/Meta/Translations/fa.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "حذف برای من"; "delete_message_for_everyone" = "حذف برای همه"; "delete_message_for_me_and_recipient" = "حذف برای من و %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "پاسخ"; "context_menu_save" = "ذخیره"; "context_menu_ban_user" = "مسدود کردن کاربر"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/fi.lproj/Localizable.strings b/Session/Meta/Translations/fi.lproj/Localizable.strings index 666585405..a75e1070c 100644 --- a/Session/Meta/Translations/fi.lproj/Localizable.strings +++ b/Session/Meta/Translations/fi.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Poista vain minun nähtäväksi"; "delete_message_for_everyone" = "Poista kaikkien näkyviltä"; "delete_message_for_me_and_recipient" = "Poista minulta ja vastaanottajalta"; +"context_menu_info" = "Info"; "context_menu_reply" = "Vastaa"; "context_menu_save" = "Tallenna"; "context_menu_ban_user" = "Estä Käyttäjä"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/fr.lproj/Localizable.strings b/Session/Meta/Translations/fr.lproj/Localizable.strings index 6625f503b..f23971351 100644 --- a/Session/Meta/Translations/fr.lproj/Localizable.strings +++ b/Session/Meta/Translations/fr.lproj/Localizable.strings @@ -61,7 +61,7 @@ /* Button text to enable batch selection mode */ "BUTTON_SELECT" = "Sélectionner"; /* keyboard toolbar label when starting to search with no current results */ -"CONVERSATION_SEARCH_SEARCHING" = "Searching..."; +"CONVERSATION_SEARCH_SEARCHING" = "Recherche..."; /* keyboard toolbar label when no messages match the search string */ "CONVERSATION_SEARCH_NO_RESULTS" = "Aucune correspondance"; /* keyboard toolbar label when exactly 1 message matches the search string */ @@ -289,7 +289,7 @@ "vc_create_private_chat_title" = "Nouvelle Session"; "vc_create_private_chat_enter_session_id_tab_title" = "Saisir un Session ID"; "vc_create_private_chat_scan_qr_code_tab_title" = "Scanner un Code QR"; -"vc_enter_public_key_explanation" = "Start a new conversation by entering someone's Session ID or share your Session ID with them."; +"vc_enter_public_key_explanation" = "Démarrez une nouvelle conversation en saisissant l'ID Session de quelqu'un ou en partageant votre ID Session avec eux."; "vc_scan_qr_code_camera_access_explanation" = "Session a besoin d'accéder à l'appareil photo pour scanner les codes QR"; "vc_scan_qr_code_grant_camera_access_button_title" = "Autoriser l'accès"; "vc_create_closed_group_title" = "Nouveau groupe privé"; @@ -304,10 +304,10 @@ "vc_join_public_chat_scan_qr_code_tab_title" = "Scannez le code QR"; "vc_enter_chat_url_text_field_hint" = "Saisissez une URL de groupe public"; "vc_settings_title" = "Paramètres"; -"vc_group_settings_title" = "Group Settings"; +"vc_group_settings_title" = "Paramètres de Groupe"; "vc_settings_display_name_missing_error" = "Veuillez choisir un nom d'utilisateur"; "vc_settings_display_name_too_long_error" = "Veuillez choisir un nom d'utilisateur plus court"; -"vc_settings_privacy_button_title" = "Confidientalité "; +"vc_settings_privacy_button_title" = "Confidentialité "; "vc_settings_notifications_button_title" = "Notifications"; "vc_settings_recovery_phrase_button_title" = "Phrase de récupération"; "vc_settings_clear_all_data_button_title" = "Effacer les données"; @@ -319,13 +319,13 @@ // MARK: - Not Yet Translated "fast_mode_explanation" = "Vous serez notifiés de nouveaux messages de manière certaine et immédiate en utilisant les serveurs de notification d’Apple."; "fast_mode" = "Mode rapide"; -"slow_mode_explanation" = "Session vérifiera occasionnellement la présence de nouveaux message en tâche de fond."; +"slow_mode_explanation" = "Session vérifiera occasionnellement la présence de nouveaux messages en tâche de fond."; "slow_mode" = "Mode lent"; "vc_pn_mode_title" = "Notifications de message"; "vc_link_device_recovery_phrase_tab_title" = "Phrase de récupération"; "vc_link_device_scan_qr_code_explanation" = "Allez dans paramètre → Phrase de récupération sur votre autre appareil pour afficher votre QR Code."; "vc_enter_recovery_phrase_title" = "Phrase de récupération"; -"vc_enter_recovery_phrase_explanation" = "Pour lier votre appareil, entrez la phrase de récupération qui vous a été donné lors de la création du compte."; +"vc_enter_recovery_phrase_explanation" = "Pour lier votre appareil, entrez la phrase de récupération qui vous a été donnée lors de la création du compte."; "vc_enter_public_key_text_field_hint" = "Entrez un ID Session ou un nom ONS"; "admin_group_leave_warning" = "Puisque vous êtes le créateur de ce groupe, il sera supprimé pour tout le monde. Ceci ne peut pas être annulé."; "vc_join_open_group_suggestions_title" = "Ou rejoignez un de ceux-ci..."; @@ -360,17 +360,18 @@ "modal_send_seed_explanation" = "Voici votre phrase de récupération. Si vous l'envoyez à quelqu'un, cette personne aura un accès complet à votre compte."; "modal_send_seed_send_button_title" = "Envoyer"; "vc_conversation_settings_notify_for_mentions_only_title" = "Activer les notifications que sur mention"; -"vc_conversation_settings_notify_for_mentions_only_explanation" = "Quand activer, vous recevrez les notifications d’uniquement les messages vous notifiant."; +"vc_conversation_settings_notify_for_mentions_only_explanation" = "Quand activé, vous recevrez uniquement les notifications des messages vous mentionnant."; "view_conversation_title_notify_for_mentions_only" = "Me notifier que si je suis mentionné(e)"; "message_deleted" = "Ce message a été supprimé"; "delete_message_for_me" = "Supprimer pour moi uniquement"; "delete_message_for_everyone" = "Supprimer pour tout le monde"; "delete_message_for_me_and_recipient" = "Supprimer pour moi et %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Répondre"; "context_menu_save" = "Enregistrer"; "context_menu_ban_user" = "Bannir l'utilisateur"; "context_menu_ban_and_delete_all" = "Bannir et supprimer tout"; -"context_menu_ban_user_error_alert_message" = "Unable to ban user"; +"context_menu_ban_user_error_alert_message" = "Impossible de bannir l'utilisateur"; "accessibility_expanding_attachments_button" = "Ajouter une pièce jointe"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Document"; @@ -400,28 +401,28 @@ "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_TITLE" = "Êtes-vous sûr de vouloir supprimer toutes les demandes de messages ?"; "MESSAGE_REQUESTS_CLEAR_ALL_CONFIRMATION_ACTON" = "Effacer"; "MESSAGE_REQUESTS_DELETE_CONFIRMATION_ACTON" = "Êtes-vous sûr de vouloir supprimer cette demande de message ?"; -"MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON" = "Are you sure you want to block this contact?"; +"MESSAGE_REQUESTS_BLOCK_CONFIRMATION_ACTON" = "Êtes-vous sûr de vouloir bloquer ce contact ?"; "MESSAGE_REQUESTS_INFO" = "Envoyer un message à cet utilisateur acceptera automatiquement sa demande de message."; "MESSAGE_REQUESTS_ACCEPTED" = "Votre demande de message a été réceptionnée."; "MESSAGE_REQUESTS_NOTIFICATION" = "Vous avez une nouvelle demande de message"; "TXT_HIDE_TITLE" = "Masquer"; "TXT_DELETE_ACCEPT" = "Accepter"; -"TXT_DECLINE_TITLE" = "Decline"; -"TXT_BLOCK_USER_TITLE" = "Block User"; +"TXT_DECLINE_TITLE" = "Refuser"; +"TXT_BLOCK_USER_TITLE" = "Bloquer Utilisateur"; "ALERT_ERROR_TITLE" = "Erreur"; -"modal_call_permission_request_title" = "Autorisations d'appel requises"; +"modal_call_permission_request_title" = "Autorisation d'appel requise"; "modal_call_permission_request_explanation" = "Vous pouvez activer la permission \"Appels vocaux et vidéo\" dans les paramètres de confidentialité."; -"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oops, an error occurred"; -"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Please try again later"; -"LOADING_CONVERSATIONS" = "Loading Conversations..."; -"DATABASE_MIGRATION_FAILED" = "An error occurred when optimising the database\n\nYou can export your application logs to be able to share for troubleshooting or you can restore your device\n\nWarning: Restoring your device will result in loss of any data older than two weeks"; -"RECOVERY_PHASE_ERROR_GENERIC" = "Something went wrong. Please check your recovery phrase and try again."; -"RECOVERY_PHASE_ERROR_LENGTH" = "Looks like you didn't enter enough words. Please check your recovery phrase and try again."; -"RECOVERY_PHASE_ERROR_LAST_WORD" = "You seem to be missing the last word of your recovery phrase. Please check what you entered and try again."; -"RECOVERY_PHASE_ERROR_INVALID_WORD" = "There appears to be an invalid word in your recovery phrase. Please check what you entered and try again."; -"RECOVERY_PHASE_ERROR_FAILED" = "Your recovery phrase couldn't be verified. Please check what you entered and try again."; +"DEFAULT_OPEN_GROUP_LOAD_ERROR_TITLE" = "Oups, une erreur est survenue"; +"DEFAULT_OPEN_GROUP_LOAD_ERROR_SUBTITLE" = "Veuillez réessayer plus tard"; +"LOADING_CONVERSATIONS" = "Chargement des conversations..."; +"DATABASE_MIGRATION_FAILED" = "Une erreur est survenue pendant l'optimisation de la base de données\n\nVous pouvez exporter votre journal d'application pour le partager et aider à régler le problème ou vous pouvez restaurer votre appareil\n\nAttention : restaurer votre appareil résultera en une perte des données des deux dernières semaines"; +"RECOVERY_PHASE_ERROR_GENERIC" = "Quelque chose s'est mal passé. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; +"RECOVERY_PHASE_ERROR_LENGTH" = "Il semble que vous n'avez pas saisi tous les mots. Vérifiez votre phrase de récupération et réessayez s'il vous plaît."; +"RECOVERY_PHASE_ERROR_LAST_WORD" = "Il semble qu'il vous manque le dernier mot de votre phrase de récupération. Vérifiez votre saisie et réessayez s'il vous plaît."; +"RECOVERY_PHASE_ERROR_INVALID_WORD" = "Il semble qu'il y a un mot invalide dans votre phrase de récupération. Vérifiez votre saisie et réessayez s'il vous plaît."; +"RECOVERY_PHASE_ERROR_FAILED" = "Votre phrase de récupération n'a pas pu être validée. Vérifiez votre saisie et réessayez s'il vous plaît."; /* Indicates that an unknown error occurred while using Touch ID/Face ID/Phone Passcode. */ -"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "Authentication could not be accessed."; +"SCREEN_LOCK_ENABLE_UNKNOWN_ERROR" = "L'authentification a échoué."; /* Indicates that Touch ID/Face ID/Phone Passcode authentication failed. */ "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_FAILED" = "Échec d’authentification"; /* Indicates that Touch ID/Face ID/Phone Passcode is 'locked out' on this device due to authentication failures. */ @@ -433,183 +434,195 @@ /* Indicates that Touch ID/Face ID/Phone Passcode passcode is not set. */ "SCREEN_LOCK_ERROR_LOCAL_AUTHENTICATION_PASSCODE_NOT_SET" = "Vous devez activer un code dans vos réglages iOS pour utiliser le verrou d’écran."; /* Label for the button to send a message */ -"SEND_BUTTON_TITLE" = "Send"; +"SEND_BUTTON_TITLE" = "Envoyer"; /* Generic text for button that retries whatever the last action was. */ -"RETRY_BUTTON_TEXT" = "Retry"; +"RETRY_BUTTON_TEXT" = "Réessayer"; /* notification action */ -"SHOW_THREAD_BUTTON_TITLE" = "Show Chat"; +"SHOW_THREAD_BUTTON_TITLE" = "Montrer Discussion"; /* notification body */ -"SEND_FAILED_NOTIFICATION_BODY" = "Your message failed to send."; -"INVALID_SESSION_ID_MESSAGE" = "Please check the Session ID and try again."; -"INVALID_RECOVERY_PHRASE_MESSAGE" = "Please check the Recovery Phrase and try again."; -"QUOTED_MESSAGE_NOT_FOUND" = "Original message not found."; -"MEDIA_TAB_TITLE" = "Media"; +"SEND_FAILED_NOTIFICATION_BODY" = "Échec d'envoi de votre message."; +"INVALID_SESSION_ID_MESSAGE" = "Veuillez vérifier l'ID Session et réessayez."; +"INVALID_RECOVERY_PHRASE_MESSAGE" = "Veuillez vérifier la phrase de récupération et réessayez."; +"QUOTED_MESSAGE_NOT_FOUND" = "Message original non trouvé."; +"MEDIA_TAB_TITLE" = "Média"; "DOCUMENT_TAB_TITLE" = "Documents"; -"DOCUMENT_TILES_EMPTY_DOCUMENT" = "You don't have any document in this conversation."; -"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Loading Newer Document…"; -"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Loading Older Document…"; +"DOCUMENT_TILES_EMPTY_DOCUMENT" = "Vous n'avez aucun document dans cette conversation."; +"DOCUMENT_TILES_LOADING_MORE_RECENT_LABEL" = "Chargement des documents les plus récents…"; +"DOCUMENT_TILES_LOADING_OLDER_LABEL" = "Chargement des documents les plus anciens…"; /* The name for the emoji category 'Activities' */ -"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activities"; +"EMOJI_CATEGORY_ACTIVITIES_NAME" = "Activités"; /* The name for the emoji category 'Animals & Nature' */ -"EMOJI_CATEGORY_ANIMALS_NAME" = "Animals & Nature"; +"EMOJI_CATEGORY_ANIMALS_NAME" = "Animaux & Nature"; /* The name for the emoji category 'Flags' */ -"EMOJI_CATEGORY_FLAGS_NAME" = "Flags"; +"EMOJI_CATEGORY_FLAGS_NAME" = "Drapeaux"; /* The name for the emoji category 'Food & Drink' */ -"EMOJI_CATEGORY_FOOD_NAME" = "Food & Drink"; +"EMOJI_CATEGORY_FOOD_NAME" = "Nourriture & Boissons"; /* The name for the emoji category 'Objects' */ -"EMOJI_CATEGORY_OBJECTS_NAME" = "Objects"; +"EMOJI_CATEGORY_OBJECTS_NAME" = "Objets"; /* The name for the emoji category 'Recents' */ -"EMOJI_CATEGORY_RECENTS_NAME" = "Recently Used"; +"EMOJI_CATEGORY_RECENTS_NAME" = "Récents"; /* The name for the emoji category 'Smileys & People' */ -"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & People"; +"EMOJI_CATEGORY_SMILEYSANDPEOPLE_NAME" = "Smileys & Personnes"; /* The name for the emoji category 'Symbols' */ -"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symbols"; +"EMOJI_CATEGORY_SYMBOLS_NAME" = "Symboles"; /* The name for the emoji category 'Travel & Places' */ -"EMOJI_CATEGORY_TRAVEL_NAME" = "Travel & Places"; -"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@."; -"EMOJI_REACTS_MORE_REACTORS_ONE" = "And 1 other has reacted %@ to this message."; -"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message."; -"EMOJI_REACTS_SHOW_LESS" = "Show less"; -"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon."; +"EMOJI_CATEGORY_TRAVEL_NAME" = "Voyages et Lieux"; +"EMOJI_REACTS_NOTIFICATION" = "%@ a réagi au message de %@."; +"EMOJI_REACTS_MORE_REACTORS_ONE" = "et 1 autre personne a réagi %@ à ce message."; +"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "et %@ autres personnes ont réagi %@ à ce message."; +"EMOJI_REACTS_SHOW_LESS" = "Moins"; +"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Ralentissez ! Vous avez envoyé trop de réactions Emojis. Réessayez un peu plus tard."; /* New conversation screen*/ -"vc_new_conversation_title" = "New Conversation"; -"CREATE_GROUP_BUTTON_TITLE" = "Create"; -"JOIN_COMMUNITY_BUTTON_TITLE" = "Join"; -"PRIVACY_TITLE" = "Confidientalité"; +"vc_new_conversation_title" = "Nouvelle Conversation"; +"CREATE_GROUP_BUTTON_TITLE" = "Créer"; +"JOIN_COMMUNITY_BUTTON_TITLE" = "Rejoindre"; +"PRIVACY_TITLE" = "Confidentialité"; "PRIVACY_SECTION_SCREEN_SECURITY" = "Sécurité de l’écran"; -"PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Lock Session"; -"PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Require Touch ID, Face ID or your passcode to unlock Session."; +"PRIVACY_SCREEN_SECURITY_LOCK_SESSION_TITLE" = "Verrouiller Session"; +"PRIVACY_SCREEN_SECURITY_LOCK_SESSION_DESCRIPTION" = "Requiert Touch ID, Face ID ou votre code pour déverrouiller Session."; "PRIVACY_SECTION_READ_RECEIPTS" = "Accusés de lecture"; "PRIVACY_READ_RECEIPTS_TITLE" = "Accusés de lecture"; -"PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Send read receipts in one-to-one chats."; +"PRIVACY_READ_RECEIPTS_DESCRIPTION" = "Envoyer un accusé réception dans les conversations 1 à 1."; "PRIVACY_SECTION_TYPING_INDICATORS" = "Indicateurs de saisie"; "PRIVACY_TYPING_INDICATORS_TITLE" = "Indicateurs de saisie"; -"PRIVACY_TYPING_INDICATORS_DESCRIPTION" = "See and share typing indicators in one-to-one conversations."; -"PRIVACY_SECTION_LINK_PREVIEWS" = "Link Previews"; +"PRIVACY_TYPING_INDICATORS_DESCRIPTION" = "Voir et partager l'indicateur de saisie dans les conversions 1 à 1."; +"PRIVACY_SECTION_LINK_PREVIEWS" = "Aperçus des liens"; "PRIVACY_LINK_PREVIEWS_TITLE" = "Envoyer des aperçus de liens."; -"PRIVACY_LINK_PREVIEWS_DESCRIPTION" = "Generate link previews for supported URLs."; -"PRIVACY_SECTION_CALLS" = "Calls (Beta)"; +"PRIVACY_LINK_PREVIEWS_DESCRIPTION" = "Générer un lien d'aperçu pour les URL supportées."; +"PRIVACY_SECTION_CALLS" = "Appels (Béta)"; "PRIVACY_CALLS_TITLE" = "Appels audio et vidéo"; -"PRIVACY_CALLS_DESCRIPTION" = "Enables voice and video calls to and from other users."; -"PRIVACY_CALLS_WARNING_TITLE" = "Voice and Video Calls (Beta)"; -"PRIVACY_CALLS_WARNING_DESCRIPTION" = "Your IP address is visible to your call partner and an Oxen Foundation server while using beta calls. Are you sure you want to enable Voice and Video Calls?"; +"PRIVACY_CALLS_DESCRIPTION" = "Active les appels voix et vidéos de et vers d'autres utilisateurs."; +"PRIVACY_CALLS_WARNING_TITLE" = "Appels voix et vidéo (Béta)"; +"PRIVACY_CALLS_WARNING_DESCRIPTION" = "Votre adresse IP est visible de votre partenaire d'appel et d'un serveur de Oxen Foundation pendant l'utilisation d'un appel. Êtes-vous certain de vouloir activer les appels voix et vidéo ?"; "NOTIFICATIONS_TITLE" = "Notifications"; "NOTIFICATIONS_SECTION_STRATEGY" = "Stratégie de notification"; "NOTIFICATIONS_STRATEGY_FAST_MODE_TITLE" = "Utiliser le mode rapide"; -"NOTIFICATIONS_STRATEGY_FAST_MODE_DESCRIPTION" = "You'll be notified of new message reliably and immediately using Apple's notification servers."; -"NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION" = "Go to device notification settings"; -"NOTIFICATIONS_SECTION_STYLE" = "Notification Style"; -"NOTIFICATIONS_STYLE_SOUND_TITLE" = "Sound"; -"NOTIFICATIONS_STYLE_SOUND_WHEN_OPEN_TITLE" = "Sound When App is Open"; +"NOTIFICATIONS_STRATEGY_FAST_MODE_DESCRIPTION" = "Vous serez notifiés des nouveaux messages de manière fiable et rapide en utilisant les serveurs de notifications d'Apple."; +"NOTIFICATIONS_STRATEGY_FAST_MODE_ACTION" = "Aller aux paramètres de notification"; +"NOTIFICATIONS_SECTION_STYLE" = "Style de notification"; +"NOTIFICATIONS_STYLE_SOUND_TITLE" = "Son"; +"NOTIFICATIONS_STYLE_SOUND_WHEN_OPEN_TITLE" = "Son quand l'application est ouverte"; "NOTIFICATIONS_STYLE_CONTENT_TITLE" = "Contenu des notifications"; -"NOTIFICATIONS_STYLE_CONTENT_DESCRIPTION" = "The information shown in notifications."; -"NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_AND_CONTENT" = "Name & Content"; +"NOTIFICATIONS_STYLE_CONTENT_DESCRIPTION" = "L'information qui apparaît dans les notifications."; +"NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_AND_CONTENT" = "Nom et contenu"; "NOTIFICATIONS_STYLE_CONTENT_OPTION_NAME_ONLY" = "Nom seulement"; "NOTIFICATIONS_STYLE_CONTENT_OPTION_NO_NAME_OR_CONTENT" = "Ni nom ni contenu"; "CONVERSATION_SETTINGS_TITLE" = "Conversations"; -"CONVERSATION_SETTINGS_SECTION_MESSAGE_TRIMMING" = "Message Trimming"; -"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_TITLE" = "Trim Communities"; -"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_DESCRIPTION" = "Delete messages older than 6 months from Communities that have over 2,000 messages."; -"CONVERSATION_SETTINGS_SECTION_AUDIO_MESSAGES" = "Audio Messages"; -"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_TITLE" = "Autoplay Audio Messages"; -"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_DESCRIPTION" = "Autoplay consecutive audio messages."; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE" = "Blocked Contacts"; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE" = "You have no blocked contacts."; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK" = "Unblock"; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE" = "Are you sure you want to unblock %@?"; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK" = "this contact"; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1" = "Are you sure you want to unblock %@"; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE" = "and %@?"; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3" = "and %d others?"; -"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON" = "Unblock"; -"APPEARANCE_TITLE" = "Appearance"; -"APPEARANCE_THEMES_TITLE" = "Themes"; -"APPEARANCE_PRIMARY_COLOR_TITLE" = "Primary colour"; -"APPEARANCE_PRIMARY_COLOR_PREVIEW_INC_QUOTE" = "How are you?"; -"APPEARANCE_PRIMARY_COLOR_PREVIEW_INC_MESSAGE" = "I'm good thanks, you?"; -"APPEARANCE_PRIMARY_COLOR_PREVIEW_OUT_MESSAGE" = "I'm doing great, thanks."; -"APPEARANCE_NIGHT_MODE_TITLE" = "Auto night-mode"; -"APPEARANCE_NIGHT_MODE_TOGGLE" = "Match system settings"; -"HELP_TITLE" = "Help"; -"HELP_REPORT_BUG_TITLE" = "Report a Bug"; -"HELP_REPORT_BUG_DESCRIPTION" = "Export your logs, then upload the file though Session's Help Desk."; -"HELP_REPORT_BUG_ACTION_TITLE" = "Export Logs"; -"HELP_TRANSLATE_TITLE" = "Translate Session"; -"HELP_FEEDBACK_TITLE" = "We'd love your Feedback"; +"CONVERSATION_SETTINGS_SECTION_MESSAGE_TRIMMING" = "Épuration des messages"; +"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_TITLE" = "Épuration des communautés"; +"CONVERSATION_SETTINGS_MESSAGE_TRIMMING_DESCRIPTION" = "Supprimer les messages datant de plus de 6 mois dans les communautés ayant plus de 2000 messages."; +"CONVERSATION_SETTINGS_SECTION_AUDIO_MESSAGES" = "Messages audio"; +"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_TITLE" = "Jouer automatiquement les messages audio"; +"CONVERSATION_SETTINGS_AUDIO_MESSAGES_AUTOPLAY_DESCRIPTION" = "Jouer automatiquement les messages audio de manière consécutive."; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_TITLE" = "Contacts bloqués"; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_EMPTY_STATE" = "Vous n'avez aucun contact bloqué."; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK" = "Débloquer"; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_SINGLE" = "Êtes-vous sûr de vouloir débloquer %@?"; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_FALLBACK" = "Ce contact"; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_1" = "Êtes-vous sûr de vouloir débloquer %@"; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_2_SINGLE" = "et %@?"; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_TITLE_MULTIPLE_3" = "et %d autres?"; +"CONVERSATION_SETTINGS_BLOCKED_CONTACTS_UNBLOCK_CONFIRMATION_ACTON" = "Débloquer"; +"APPEARANCE_TITLE" = "Apparence"; +"APPEARANCE_THEMES_TITLE" = "Thèmes"; +"APPEARANCE_PRIMARY_COLOR_TITLE" = "Couleur primaire"; +"APPEARANCE_PRIMARY_COLOR_PREVIEW_INC_QUOTE" = "Comment allez-vous ?"; +"APPEARANCE_PRIMARY_COLOR_PREVIEW_INC_MESSAGE" = "Je vais bien, et vous ?"; +"APPEARANCE_PRIMARY_COLOR_PREVIEW_OUT_MESSAGE" = "Je vais très bien. Merci."; +"APPEARANCE_NIGHT_MODE_TITLE" = "Mode nuit automatique"; +"APPEARANCE_NIGHT_MODE_TOGGLE" = "Se conformer aux paramètres système"; +"HELP_TITLE" = "Aide"; +"HELP_REPORT_BUG_TITLE" = "Rapporter un bogue"; +"HELP_REPORT_BUG_DESCRIPTION" = "Exporter votre journal d'application et téléverser le fichier via le support Session."; +"HELP_REPORT_BUG_ACTION_TITLE" = "Exporter Journal"; +"HELP_TRANSLATE_TITLE" = "Traduire Session"; +"HELP_FEEDBACK_TITLE" = "Nous aimerions votre retour d'expérience"; "HELP_FAQ_TITLE" = "FAQ"; "HELP_SUPPORT_TITLE" = "Support"; "modal_clear_all_data_title" = "Effacer toutes les données"; -"modal_clear_all_data_explanation" = "This will permanently delete your messages and contacts. Would you like to clear this device only, or delete your data from the network as well?"; -"modal_clear_all_data_explanation_2" = "Are you sure you want to delete your data from the network? If you continue, you will not be able to restore your messages or contacts."; -"modal_clear_all_data_device_only_button_title" = "Clear Device Only"; -"modal_clear_all_data_entire_account_button_title" = "Clear Device and Network"; +"modal_clear_all_data_explanation" = "Ceci supprimera de manière permanente vos messages et contacts. Voulez-vous effacer vos données sur cet appareil seulement ou sur le réseau aussi ?"; +"modal_clear_all_data_explanation_2" = "Êtes-vous sûr de vouloir effacer les données sur le réseau ? Si vous continuez, vous ne pourrez restaurer ni vos messages ni vos contacts."; +"modal_clear_all_data_device_only_button_title" = "Effacer sur l'appareil seulement"; +"modal_clear_all_data_entire_account_button_title" = "Effacer sur l'appareil et le réseau"; "dialog_clear_all_data_deletion_failed_1" = "Les données n’ont pas été supprimées sur un nœud de service. ID du nœud de service : %@."; "dialog_clear_all_data_deletion_failed_2" = "Les données n’ont pas été supprimées sur %@ nœuds de service. ID des nœuds de service : %@."; -"modal_clear_all_data_confirm" = "Clear"; +"modal_clear_all_data_confirm" = "Effacer"; "modal_seed_title" = "Votre phrase de récupération"; -"modal_seed_explanation" = "You can use your recovery phrase to restore your account or link a device."; -"modal_permission_explanation" = "Session needs %@ access to continue. You can enable access in the iOS settings."; -"modal_permission_settings_title" = "Settings"; -"modal_permission_camera" = "camera"; +"modal_seed_explanation" = "Vous pouvez utiliser votre phrase de récupération pour restaurer votre compte ou pour lier un autre appareil."; +"modal_permission_explanation" = "Session a besoin de l'accès %@ pour pouvoir continuer. Vous pouvez donner cet accès depuis les paramètres iOS."; +"modal_permission_settings_title" = "Paramètres"; +"modal_permission_camera" = "caméra"; "modal_permission_microphone" = "microphone"; -"modal_permission_library" = "library"; -"DISAPPEARING_MESSAGES_OFF" = "Off"; -"DISAPPEARING_MESSAGES_SUBTITLE_OFF" = "Off"; -"DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER" = "Disappear After: %@"; -"COPY_GROUP_URL" = "Copy Group URL"; +"modal_permission_library" = "disque"; +"DISAPPEARING_MESSAGES_OFF" = "Éteint"; +"DISAPPEARING_MESSAGES_SUBTITLE_OFF" = "Éteint"; +"DISAPPEARING_MESSAGES_SUBTITLE_DISAPPEAR_AFTER" = "Disparaît après : %@"; +"COPY_GROUP_URL" = "Copier l'URL de Groupe"; "NEW_CONVERSATION_CONTACTS_SECTION_TITLE" = "Contacts"; -"GROUP_ERROR_NO_MEMBER_SELECTION" = "Please pick at least 1 group member"; -"GROUP_CREATION_PLEASE_WAIT" = "Please wait while the group is created..."; -"GROUP_CREATION_ERROR_TITLE" = "Couldn't Create Group"; -"GROUP_CREATION_ERROR_MESSAGE" = "Please check your internet connection and try again."; -"GROUP_UPDATE_ERROR_TITLE" = "Couldn't Update Group"; -"GROUP_UPDATE_ERROR_MESSAGE" = "Can't leave while adding or removing other members."; -"GROUP_ACTION_REMOVE" = "Remove"; -"GROUP_TITLE_MEMBERS" = "Members"; -"GROUP_TITLE_FALLBACK" = "Group"; -"DM_ERROR_DIRECT_BLINDED_ID" = "You can only send messages to Blinded IDs from within a Community"; -"DM_ERROR_INVALID" = "Please check the Session ID or ONS name and try again"; -"COMMUNITY_ERROR_INVALID_URL" = "Please check the URL you entered and try again."; -"COMMUNITY_ERROR_GENERIC" = "Couldn't Join"; -"DISAPPERING_MESSAGES_TITLE" = "Disappearing Messages"; -"DISAPPERING_MESSAGES_TYPE_TITLE" = "Delete Type"; -"DISAPPERING_MESSAGES_TYPE_AFTER_READ_TITLE" = "Disappear After Read"; -"DISAPPERING_MESSAGES_TYPE_AFTER_READ_DESCRIPTION" = "Messages delete after they have been read."; -"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_TITLE" = "Disappear After Send"; -"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_DESCRIPTION" = "Messages delete after they have been sent."; -"DISAPPERING_MESSAGES_TIMER_TITLE" = "Timer"; -"DISAPPERING_MESSAGES_SAVE_TITLE" = "Set"; -"DISAPPERING_MESSAGES_GROUP_WARNING" = "This setting applies to everyone in this conversation."; -"DISAPPERING_MESSAGES_GROUP_WARNING_ADMIN_ONLY" = "This setting applies to everyone in this conversation. Only group admins can change this setting."; -"DISAPPERING_MESSAGES_SUMMARY" = "Disappear After %@ - %@"; -"DISAPPERING_MESSAGES_INFO_ENABLE" = "%@ has set messages to disappear %@ after they have been %@"; -"DISAPPERING_MESSAGES_INFO_UPDATE" = "%@ has changed messages to disappear %@ after they have been %@"; -"DISAPPERING_MESSAGES_INFO_DISABLE" = "%@ has turned off disappearing messages"; -"MESSAGE_STATE_READ" = "Read"; -"MESSAGE_STATE_SENT" = "Sent"; -"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "You will be able to send voice messages and attachments once the recipient has approved this message request"; -"MESSAGE_DELIVERY_STATUS_SENDING" = "Sending"; -"MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; -"MESSAGE_DELIVERY_STATUS_READ" = "Read"; -"MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; -"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; -"MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; -"MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; -"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Failed to sync message to your other devices"; -"delete_message_for_me_and_my_devices" = "Delete from all of my devices"; -"context_menu_resend" = "Resend"; -"context_menu_resync" = "Resync"; -"GIPHY_PERMISSION_TITLE" = "Search GIFs?"; -"GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; -"mute_button_text" = "Mute"; -"unmute_button_text" = "Unmute"; -"mark_read_button_text" = "Mark read"; -"mark_unread_button_text" = "Mark unread"; -"leave_group_confirmation_alert_title" = "Leave Group"; -"leave_community_confirmation_alert_title" = "Leave Community"; -"leave_community_confirmation_alert_message" = "Are you sure you want to leave %@?"; -"group_you_leaving" = "Leaving..."; -"group_leave_error" = "Failed to leave Group!"; -"group_unable_to_leave" = "Unable to leave the Group, please try again"; -"delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; -"delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"GROUP_ERROR_NO_MEMBER_SELECTION" = "Veuillez choisir au moins 1 membre de groupe"; +"GROUP_CREATION_PLEASE_WAIT" = "Veuillez patienter pendant la création du groupe..."; +"GROUP_CREATION_ERROR_TITLE" = "Impossible de créer le groupe"; +"GROUP_CREATION_ERROR_MESSAGE" = "Veuillez vérifier votre connexion internet et réessayez."; +"GROUP_UPDATE_ERROR_TITLE" = "Impossible de mettre à jour le groupe"; +"GROUP_UPDATE_ERROR_MESSAGE" = "Impossible de quitter pendant l'ajout ou la suppression d'un autre membre."; +"GROUP_ACTION_REMOVE" = "Retirer"; +"GROUP_TITLE_MEMBERS" = "Membres"; +"GROUP_TITLE_FALLBACK" = "Groupe"; +"DM_ERROR_DIRECT_BLINDED_ID" = "Vous pouvez seulement envoyer des messages à des IDs anonymes depuis une communauté"; +"DM_ERROR_INVALID" = "Veuillez vérifier l'ID Session ou l'ONS et réessayez"; +"COMMUNITY_ERROR_INVALID_URL" = "Veuillez vérifier l'URL et réessayez"; +"COMMUNITY_ERROR_GENERIC" = "Impossible de rejoindre"; +"DISAPPERING_MESSAGES_TITLE" = "Messages éphémères"; +"DISAPPERING_MESSAGES_TYPE_TITLE" = "Type de suppression"; +"DISAPPERING_MESSAGES_TYPE_AFTER_READ_TITLE" = "Disparaît après lecture"; +"DISAPPERING_MESSAGES_TYPE_AFTER_READ_DESCRIPTION" = "Les messages disparaissent une fois lus."; +"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_TITLE" = "Disparaît après envoi"; +"DISAPPERING_MESSAGES_TYPE_AFTER_SEND_DESCRIPTION" = "Les messages disparaissent une fois envoyés."; +"DISAPPERING_MESSAGES_TIMER_TITLE" = "Compteur"; +"DISAPPERING_MESSAGES_SAVE_TITLE" = "Sauver"; +"DISAPPERING_MESSAGES_GROUP_WARNING" = "Ce paramètre s'applique à toutes les personnes de cette conversation."; +"DISAPPERING_MESSAGES_GROUP_WARNING_ADMIN_ONLY" = "Ce paramètre s'applique à toutes les personnes de cette conversation. Seuls les administrateurs du groupe peuvent changer ce paramètre."; +"DISAPPERING_MESSAGES_SUMMARY" = "Disparaît après %@ - %@"; +"DISAPPERING_MESSAGES_INFO_ENABLE" = "%@ a paramétré les messages pour qu'ils disparaissent %@ après avoir été %@"; +"DISAPPERING_MESSAGES_INFO_UPDATE" = "%@ a paramétré les messages pour qu'ils disparaissent %@ après avoir été %@"; +"DISAPPERING_MESSAGES_INFO_DISABLE" = "%@ a désactivé les messages éphémères."; +"MESSAGE_STATE_READ" = "Lu"; +"MESSAGE_STATE_SENT" = "Envoyé"; +"MESSAGE_REQUEST_PENDING_APPROVAL_INFO" = "Vous pourrez envoyer des messages et des pièces jointes une fois que le destinataire aura approuvé votre demande."; +"MESSAGE_DELIVERY_STATUS_SENDING" = "Envoi"; +"MESSAGE_DELIVERY_STATUS_SENT" = "Envoyé"; +"MESSAGE_DELIVERY_STATUS_READ" = "Lu"; +"MESSAGE_DELIVERY_STATUS_FAILED" = "Échec de l'envoi"; +"MESSAGE_INFO_SENT" = "Envoyé"; +"MESSAGE_INFO_RECEIVED" = "Reçu"; +"MESSAGE_INFO_FROM" = "De"; +"ATTACHMENT_INFO_FILE_ID" = "ID Fichier"; +"ATTACHMENT_INFO_FILE_TYPE" = "Type Fichier"; +"ATTACHMENT_INFO_FILE_SIZE" = "Taille Fichier"; +"ATTACHMENT_INFO_RESOLUTION" = "Résolution"; +"ATTACHMENT_INFO_DURATION" = "Durée"; +"MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Échec de synchronisation"; +"MESSAGE_DELIVERY_STATUS_SYNCING" = "Synchronisation"; +"MESSAGE_DELIVERY_FAILED_TITLE" = "Échec d'envoi du message"; +"MESSAGE_DELIVERY_FAILED_SYNC_TITLE" = "Échec de synchronisation vers vos autres appareils"; +"delete_message_for_me_and_my_devices" = "Effacer sur mes autres appareils"; +"context_menu_resend" = "Réenvoyer"; +"context_menu_resync" = "Resynchroniser"; +"GIPHY_PERMISSION_TITLE" = "Rechercher GIFs?"; +"GIPHY_PERMISSION_MESSAGE" = "Session va se connecter à Giphy. Envoyer des GIFs empêchera la protection de vos métadonnées."; +"message_info_title" = "Info Message"; +"mute_button_text" = "Silence"; +"unmute_button_text" = "Actif"; +"mark_read_button_text" = "Marquer comme lu"; +"mark_unread_button_text" = "Marquer comme non lu"; +"leave_group_confirmation_alert_title" = "Quitter le groupe"; +"leave_community_confirmation_alert_title" = "Quitter la communauté"; +"leave_community_confirmation_alert_message" = "Êtes-vous sûr de vouloir quitter %@?"; +"group_you_leaving" = "Quitter..."; +"group_leave_error" = "Impossible de quitter le groupe!"; +"group_unable_to_leave" = "Impossible de quitter le groupe, veuillez réessayer"; +"delete_conversation_confirmation_alert_message" = "Êtes-vous sûr de vouloir supprimer votre conversation avec %@ ?"; +"delete_conversation_confirmation_alert_title" = "Supprimer conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/hi.lproj/Localizable.strings b/Session/Meta/Translations/hi.lproj/Localizable.strings index e1cdaac55..ca058e22a 100644 --- a/Session/Meta/Translations/hi.lproj/Localizable.strings +++ b/Session/Meta/Translations/hi.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Delete just for me"; "delete_message_for_everyone" = "Delete for everyone"; "delete_message_for_me_and_recipient" = "Delete for me and %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Reply"; "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/hr.lproj/Localizable.strings b/Session/Meta/Translations/hr.lproj/Localizable.strings index 77cafa902..e6cd0d2a3 100644 --- a/Session/Meta/Translations/hr.lproj/Localizable.strings +++ b/Session/Meta/Translations/hr.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Izbriši samo za mene"; "delete_message_for_everyone" = "Izbriši za sve"; "delete_message_for_me_and_recipient" = "Izbriši za mene i %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Odgovori"; "context_menu_save" = "Spremi"; "context_menu_ban_user" = "Zabrani korisnik"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/id-ID.lproj/Localizable.strings b/Session/Meta/Translations/id-ID.lproj/Localizable.strings index 1f23902a3..c620b6ae6 100644 --- a/Session/Meta/Translations/id-ID.lproj/Localizable.strings +++ b/Session/Meta/Translations/id-ID.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Delete just for me"; "delete_message_for_everyone" = "Delete for everyone"; "delete_message_for_me_and_recipient" = "Delete for me and %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Reply"; "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/it.lproj/Localizable.strings b/Session/Meta/Translations/it.lproj/Localizable.strings index c824b802c..2c1b9996f 100644 --- a/Session/Meta/Translations/it.lproj/Localizable.strings +++ b/Session/Meta/Translations/it.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Elimina solo per me"; "delete_message_for_everyone" = "Elimina per tutti"; "delete_message_for_me_and_recipient" = "Elimina per me e %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Rispondi"; "context_menu_save" = "Salva"; "context_menu_ban_user" = "Banna utente"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/ja.lproj/Localizable.strings b/Session/Meta/Translations/ja.lproj/Localizable.strings index 75cb1e6bf..cc28d1898 100644 --- a/Session/Meta/Translations/ja.lproj/Localizable.strings +++ b/Session/Meta/Translations/ja.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "自分の端末から削除"; "delete_message_for_everyone" = "全員の端末から削除"; "delete_message_for_me_and_recipient" = "自分と %@ の端末から削除する"; +"context_menu_info" = "Info"; "context_menu_reply" = "返信"; "context_menu_save" = "保存"; "context_menu_ban_user" = "ユーザーをBAN"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/nl.lproj/Localizable.strings b/Session/Meta/Translations/nl.lproj/Localizable.strings index 4c9b31f6e..477156edf 100644 --- a/Session/Meta/Translations/nl.lproj/Localizable.strings +++ b/Session/Meta/Translations/nl.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Verwijder alleen voor mij"; "delete_message_for_everyone" = "Verwijder voor iedereen"; "delete_message_for_me_and_recipient" = "Verwijderen voor mij en %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Antwoord"; "context_menu_save" = "Opslaan"; "context_menu_ban_user" = "Gebruiker verbannen"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/pl.lproj/Localizable.strings b/Session/Meta/Translations/pl.lproj/Localizable.strings index 35d1f7a04..69e08221d 100644 --- a/Session/Meta/Translations/pl.lproj/Localizable.strings +++ b/Session/Meta/Translations/pl.lproj/Localizable.strings @@ -366,11 +366,12 @@ "delete_message_for_me" = "Usuń tylko dla mnie"; "delete_message_for_everyone" = "Usuń dla wszystkich"; "delete_message_for_me_and_recipient" = "Usuń dla mnie i %@"; -"context_menu_ban_user_error_alert_message" = "Unable to ban user"; +"context_menu_info" = "Info"; "context_menu_reply" = "Odpowiedz"; "context_menu_save" = "Zapisz"; "context_menu_ban_user" = "Zbanuj użytkownika"; "context_menu_ban_and_delete_all" = "Zbanuj i usuń wszystko"; +"context_menu_ban_user_error_alert_message" = "Unable to ban user"; "accessibility_expanding_attachments_button" = "Dodaj załączniki"; "accessibility_gif_button" = "Gif"; "accessibility_document_button" = "Dokument"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings index c351ad411..02587c0a2 100644 --- a/Session/Meta/Translations/pt_BR.lproj/Localizable.strings +++ b/Session/Meta/Translations/pt_BR.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Apagar para mim"; "delete_message_for_everyone" = "Apagar para todos"; "delete_message_for_me_and_recipient" = "Apagar para mim e para %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Responder"; "context_menu_save" = "Salvar"; "context_menu_ban_user" = "Banir Usuário"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/ru.lproj/Localizable.strings b/Session/Meta/Translations/ru.lproj/Localizable.strings index 26a64e4a4..d3f3b00b7 100644 --- a/Session/Meta/Translations/ru.lproj/Localizable.strings +++ b/Session/Meta/Translations/ru.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Удалить только для меня"; "delete_message_for_everyone" = "Удалить для всех"; "delete_message_for_me_and_recipient" = "Удалить для меня и %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Ответить"; "context_menu_save" = "Сохранить"; "context_menu_ban_user" = "Заблокировать пользователя"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/si.lproj/Localizable.strings b/Session/Meta/Translations/si.lproj/Localizable.strings index 349867b2c..c33b738d8 100644 --- a/Session/Meta/Translations/si.lproj/Localizable.strings +++ b/Session/Meta/Translations/si.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Delete just for me"; "delete_message_for_everyone" = "Delete for everyone"; "delete_message_for_me_and_recipient" = "Delete for me and %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "පිළිතුරු"; "context_menu_save" = "සුරකින්න"; "context_menu_ban_user" = "Ban User"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/sk.lproj/Localizable.strings b/Session/Meta/Translations/sk.lproj/Localizable.strings index 71cbe0cd5..4f924c6fe 100644 --- a/Session/Meta/Translations/sk.lproj/Localizable.strings +++ b/Session/Meta/Translations/sk.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Vymazať len u mňa"; "delete_message_for_everyone" = "Vymazať u všetkých"; "delete_message_for_me_and_recipient" = "Vymazať pre mňa a %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Odpovedať"; "context_menu_save" = "Uložiť"; "context_menu_ban_user" = "Zablokovanie používateľa"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/sv.lproj/Localizable.strings b/Session/Meta/Translations/sv.lproj/Localizable.strings index f0a1f421a..2fa76cecb 100644 --- a/Session/Meta/Translations/sv.lproj/Localizable.strings +++ b/Session/Meta/Translations/sv.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Delete just for me"; "delete_message_for_everyone" = "Delete for everyone"; "delete_message_for_me_and_recipient" = "Delete for me and %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Reply"; "context_menu_save" = "Spara"; "context_menu_ban_user" = "Ban User"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/th.lproj/Localizable.strings b/Session/Meta/Translations/th.lproj/Localizable.strings index e32ffabad..6aae143ff 100644 --- a/Session/Meta/Translations/th.lproj/Localizable.strings +++ b/Session/Meta/Translations/th.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Delete just for me"; "delete_message_for_everyone" = "Delete for everyone"; "delete_message_for_me_and_recipient" = "Delete for me and %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Reply"; "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings index 049e0782d..a146cdbf7 100644 --- a/Session/Meta/Translations/vi-VN.lproj/Localizable.strings +++ b/Session/Meta/Translations/vi-VN.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "Delete just for me"; "delete_message_for_everyone" = "Delete for everyone"; "delete_message_for_me_and_recipient" = "Delete for me and %@"; +"context_menu_info" = "Info"; "context_menu_reply" = "Reply"; "context_menu_save" = "Save"; "context_menu_ban_user" = "Ban User"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings index 2eb03bd1a..8725b7d5f 100644 --- a/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh-Hant.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "只為我自己刪除"; "delete_message_for_everyone" = "從所有人的裝置上刪除"; "delete_message_for_me_and_recipient" = "為我和 %@ 刪除"; +"context_menu_info" = "Info"; "context_menu_reply" = "回覆"; "context_menu_save" = "儲存"; "context_menu_ban_user" = "封鎖用戶"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings index 9e1b96ac2..d4f9a737b 100644 --- a/Session/Meta/Translations/zh_CN.lproj/Localizable.strings +++ b/Session/Meta/Translations/zh_CN.lproj/Localizable.strings @@ -366,6 +366,7 @@ "delete_message_for_me" = "仅为我删除"; "delete_message_for_everyone" = "为所有人删除"; "delete_message_for_me_and_recipient" = "为我和 %@ 删除"; +"context_menu_info" = "Info"; "context_menu_reply" = "回复"; "context_menu_save" = "保存"; "context_menu_ban_user" = "封禁用户"; @@ -592,6 +593,14 @@ "MESSAGE_DELIVERY_STATUS_SENT" = "Sent"; "MESSAGE_DELIVERY_STATUS_READ" = "Read"; "MESSAGE_DELIVERY_STATUS_FAILED" = "Failed to send"; +"MESSAGE_INFO_SENT" = "Sent"; +"MESSAGE_INFO_RECEIVED" = "Received"; +"MESSAGE_INFO_FROM" = "From"; +"ATTACHMENT_INFO_FILE_ID" = "File ID"; +"ATTACHMENT_INFO_FILE_TYPE" = "File Type"; +"ATTACHMENT_INFO_FILE_SIZE" = "File Size"; +"ATTACHMENT_INFO_RESOLUTION" = "Resolution"; +"ATTACHMENT_INFO_DURATION" = "Duration"; "MESSAGE_DELIVERY_STATUS_FAILED_SYNC" = "Failed to sync"; "MESSAGE_DELIVERY_STATUS_SYNCING" = "Syncing"; "MESSAGE_DELIVERY_FAILED_TITLE" = "Failed to send message"; @@ -601,6 +610,7 @@ "context_menu_resync" = "Resync"; "GIPHY_PERMISSION_TITLE" = "Search GIFs?"; "GIPHY_PERMISSION_MESSAGE" = "Session will connect to Giphy to provide search results. You will not have full metadata protection when sending GIFs."; +"message_info_title" = "Message Info"; "mute_button_text" = "Mute"; "unmute_button_text" = "Unmute"; "mark_read_button_text" = "Mark read"; @@ -613,3 +623,6 @@ "group_unable_to_leave" = "Unable to leave the Group, please try again"; "delete_conversation_confirmation_alert_message" = "Are you sure you want to delete your conversation with %@?"; "delete_conversation_confirmation_alert_title" = "Delete Conversation"; +"update_profile_modal_title" = "Set Display Picture"; +"update_profile_modal_upload" = "Upload"; +"update_profile_modal_remove" = "Remove"; diff --git a/Session/Onboarding/DisplayNameVC.swift b/Session/Onboarding/DisplayNameVC.swift index cf0fc7028..aaf6a297b 100644 --- a/Session/Onboarding/DisplayNameVC.swift +++ b/Session/Onboarding/DisplayNameVC.swift @@ -158,7 +158,7 @@ final class DisplayNameVC: BaseVC { targetView: self.view, info: ConfirmationModal.Info( title: title, - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Onboarding/LinkDeviceVC.swift b/Session/Onboarding/LinkDeviceVC.swift index fcab1aa90..da5802576 100644 --- a/Session/Onboarding/LinkDeviceVC.swift +++ b/Session/Onboarding/LinkDeviceVC.swift @@ -143,7 +143,7 @@ final class LinkDeviceVC: BaseVC, UIPageViewControllerDataSource, UIPageViewCont let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "invalid_recovery_phrase".localized(), - explanation: "INVALID_RECOVERY_PHRASE_MESSAGE".localized(), + body: .text("INVALID_RECOVERY_PHRASE_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, afterClosed: { [weak self] in @@ -321,7 +321,7 @@ private final class RecoveryPhraseVC: UIViewController { targetView: self.view, info: ConfirmationModal.Info( title: title, - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Onboarding/RestoreVC.swift b/Session/Onboarding/RestoreVC.swift index 5fbf82436..92f42d82d 100644 --- a/Session/Onboarding/RestoreVC.swift +++ b/Session/Onboarding/RestoreVC.swift @@ -186,7 +186,7 @@ final class RestoreVC: BaseVC { let modal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: title, - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Open Groups/JoinOpenGroupVC.swift b/Session/Open Groups/JoinOpenGroupVC.swift index 2b84bb734..7f0e0386b 100644 --- a/Session/Open Groups/JoinOpenGroupVC.swift +++ b/Session/Open Groups/JoinOpenGroupVC.swift @@ -211,7 +211,7 @@ final class JoinOpenGroupVC: BaseVC, UIPageViewControllerDataSource, UIPageViewC let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: title, - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Path/PathVC.swift b/Session/Path/PathVC.swift index 1b641f43f..6004e6ed9 100644 --- a/Session/Path/PathVC.swift +++ b/Session/Path/PathVC.swift @@ -218,9 +218,21 @@ final class PathVC: BaseVC { } private func getPathRow(snode: Snode, location: LineView.Location, dotAnimationStartDelay: Double, dotAnimationRepeatInterval: Double, isGuardSnode: Bool) -> UIStackView { - let country = IP2Country.isInitialized ? (IP2Country.shared.countryNamesCache[snode.ip] ?? "Resolving...") : "Resolving..." - let title = isGuardSnode ? NSLocalizedString("vc_path_guard_node_row_title", comment: "") : NSLocalizedString("vc_path_service_node_row_title", comment: "") - return getPathRow(title: title, subtitle: country, location: location, dotAnimationStartDelay: dotAnimationStartDelay, dotAnimationRepeatInterval: dotAnimationRepeatInterval) + let country: String = (IP2Country.isInitialized ? + IP2Country.shared.countryNamesCache.wrappedValue[snode.ip].defaulting(to: "Resolving...") : + "Resolving..." + ) + + return getPathRow( + title: (isGuardSnode ? + "vc_path_guard_node_row_title".localized() : + "vc_path_service_node_row_title".localized() + ), + subtitle: country, + location: location, + dotAnimationStartDelay: dotAnimationStartDelay, + dotAnimationRepeatInterval: dotAnimationRepeatInterval + ) } // MARK: - Interaction diff --git a/Session/Settings/BlockedContactsViewController.swift b/Session/Settings/BlockedContactsViewController.swift index 7fbdb00af..cc6cfb85d 100644 --- a/Session/Settings/BlockedContactsViewController.swift +++ b/Session/Settings/BlockedContactsViewController.swift @@ -145,7 +145,10 @@ class BlockedContactsViewController: BaseVC, UITableViewDelegate, UITableViewDat } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges(didReturnFromBackground: true) + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/Session/Settings/NukeDataModal.swift b/Session/Settings/NukeDataModal.swift index d8a791a70..e5dc30044 100644 --- a/Session/Settings/NukeDataModal.swift +++ b/Session/Settings/NukeDataModal.swift @@ -9,8 +9,8 @@ import SignalUtilitiesKit final class NukeDataModal: Modal { // MARK: - Initialization - override init(targetView: UIView? = nil, afterClosed: (() -> ())? = nil) { - super.init(targetView: targetView, afterClosed: afterClosed) + override init(targetView: UIView? = nil, dismissType: DismissType = .recursive, afterClosed: (() -> ())? = nil) { + super.init(targetView: targetView, dismissType: dismissType, afterClosed: afterClosed) self.modalPresentationStyle = .overFullScreen self.modalTransitionStyle = .crossDissolve @@ -135,7 +135,7 @@ final class NukeDataModal: Modal { let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "modal_clear_all_data_title".localized(), - explanation: "modal_clear_all_data_explanation_2".localized(), + body: .text("modal_clear_all_data_explanation_2".localized()), confirmTitle: "modal_clear_all_data_confirm".localized(), confirmStyle: .danger, cancelStyle: .alert_text, @@ -184,7 +184,7 @@ final class NukeDataModal: Modal { targetView: self?.view, info: ConfirmationModal.Info( title: "ALERT_ERROR_TITLE".localized(), - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) @@ -199,7 +199,7 @@ final class NukeDataModal: Modal { targetView: self?.view, info: ConfirmationModal.Info( title: "ALERT_ERROR_TITLE".localized(), - explanation: error.localizedDescription, + body: .text(error.localizedDescription), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Settings/PrivacySettingsViewModel.swift b/Session/Settings/PrivacySettingsViewModel.swift index b7a279b5e..e4f2b7690 100644 --- a/Session/Settings/PrivacySettingsViewModel.swift +++ b/Session/Settings/PrivacySettingsViewModel.swift @@ -209,8 +209,8 @@ class PrivacySettingsViewModel: SessionTableViewModel<PrivacySettingsViewModel.N accessibilityLabel: "Allow voice and video calls", confirmationInfo: ConfirmationModal.Info( title: "PRIVACY_CALLS_WARNING_TITLE".localized(), - explanation: "PRIVACY_CALLS_WARNING_DESCRIPTION".localized(), - stateToShow: .whenDisabled, + body: .text("PRIVACY_CALLS_WARNING_DESCRIPTION".localized()), + showCondition: .disabled, confirmTitle: "continue_2".localized(), confirmAccessibilityLabel: "Enable", confirmStyle: .textPrimary, diff --git a/Session/Settings/QRCodeVC.swift b/Session/Settings/QRCodeVC.swift index e9e91b78a..a47d0f684 100644 --- a/Session/Settings/QRCodeVC.swift +++ b/Session/Settings/QRCodeVC.swift @@ -131,7 +131,7 @@ final class QRCodeVC : BaseVC, UIPageViewControllerDataSource, UIPageViewControl targetView: self.view, info: ConfirmationModal.Info( title: "invalid_session_id".localized(), - explanation: "INVALID_SESSION_ID_MESSAGE".localized(), + body: .text("INVALID_SESSION_ID_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text ) diff --git a/Session/Settings/SeedModal.swift b/Session/Settings/SeedModal.swift index 105340154..7cd95456e 100644 --- a/Session/Settings/SeedModal.swift +++ b/Session/Settings/SeedModal.swift @@ -16,8 +16,8 @@ final class SeedModal: Modal { // MARK: - Initialization - override init(targetView: UIView? = nil, afterClosed: (() -> ())? = nil) { - super.init(targetView: targetView, afterClosed: afterClosed) + override init(targetView: UIView? = nil, dismissType: DismissType = .recursive, afterClosed: (() -> ())? = nil) { + super.init(targetView: targetView, dismissType: dismissType, afterClosed: afterClosed) self.modalPresentationStyle = .overFullScreen self.modalTransitionStyle = .crossDissolve diff --git a/Session/Settings/SettingsViewModel.swift b/Session/Settings/SettingsViewModel.swift index 476277407..509adaee6 100644 --- a/Session/Settings/SettingsViewModel.swift +++ b/Session/Settings/SettingsViewModel.swift @@ -50,6 +50,10 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett private lazy var imagePickerHandler: ImagePickerHandler = ImagePickerHandler(viewModel: self) fileprivate var oldDisplayName: String private var editedDisplayName: String? + private var editProfilePictureModal: ConfirmationModal? + private var editProfilePictureModalInfo: ConfirmationModal.Info? + private var editedProfilePicture: UIImage? + private var editedProfilePictureFileName: String? // MARK: - Initialization @@ -102,11 +106,13 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett } override var rightNavItems: AnyPublisher<[NavItem]?, Never> { - navState - .map { [weak self] navState -> [NavItem] in - switch navState { - case .standard: - return [ + let userSessionId: String = self.userSessionId + + return navState + .map { [weak self] navState -> [NavItem] in + switch navState { + case .standard: + return [ NavItem( id: .qrCode, image: UIImage(named: "QRCode")? @@ -117,10 +123,10 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett self?.transitionToScreen(QRCodeVC()) } ) - ] + ] - case .editing: - return [ + case .editing: + return [ NavItem( id: .done, systemItem: .done, @@ -161,7 +167,7 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett self?.updateProfile( name: updatedNickname, profilePicture: nil, - profilePictureFilePath: nil, + profilePictureFilePath: ProfileManager.profileAvatarFilepath(id: userSessionId), isUpdatingDisplayName: true, isUpdatingProfilePicture: false ) @@ -389,23 +395,90 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett } private func updateProfilePicture() { - let actionSheet: UIAlertController = UIAlertController( - title: "Update Profile Picture", - message: nil, - preferredStyle: .actionSheet - ) - let action = UIAlertAction( - title: "MEDIA_FROM_LIBRARY_BUTTON".localized(), - style: .default, - handler: { [weak self] _ in - self?.showPhotoLibraryForAvatar() + let existingDisplayName: String = self.oldDisplayName + let existingImage: UIImage? = ProfileManager + .profileAvatar(id: self.userSessionId) + .map { UIImage(data: $0) } + let editProfilePictureModalInfo: ConfirmationModal.Info = ConfirmationModal.Info( + title: "update_profile_modal_title".localized(), + body: .image( + placeholder: UIImage(named: "profile_placeholder"), + value: existingImage, + style: .circular, + onClick: { [weak self] in self?.showPhotoLibraryForAvatar() } + ), + confirmTitle: "update_profile_modal_upload".localized(), + confirmEnabled: false, + cancelTitle: "update_profile_modal_remove".localized(), + cancelEnabled: (existingImage != nil), + hasCloseButton: true, + dismissOnConfirm: false, + onConfirm: { [weak self] modal in + self?.updateProfile( + name: existingDisplayName, + profilePicture: self?.editedProfilePicture, + profilePictureFilePath: self?.editedProfilePictureFileName, + isUpdatingDisplayName: false, + isUpdatingProfilePicture: true, + onComplete: { [weak modal] in modal?.close() } + ) + }, + onCancel: { [weak self] modal in + self?.updateProfile( + name: existingDisplayName, + profilePicture: nil, + profilePictureFilePath: nil, + isUpdatingDisplayName: false, + isUpdatingProfilePicture: true, + onComplete: { [weak modal] in modal?.close() } + ) + }, + afterClosed: { [weak self] in + self?.editedProfilePicture = nil + self?.editedProfilePictureFileName = nil + self?.editProfilePictureModal = nil + self?.editProfilePictureModalInfo = nil } ) - action.accessibilityLabel = "Photo library" - actionSheet.addAction(action) - actionSheet.addAction(UIAlertAction(title: "cancel".localized(), style: .cancel, handler: nil)) + let modal: ConfirmationModal = ConfirmationModal(info: editProfilePictureModalInfo) + + self.editProfilePictureModalInfo = editProfilePictureModalInfo + self.editProfilePictureModal = modal + self.transitionToScreen(modal, transitionType: .present) + } + + fileprivate func updatedProfilePictureSelected(image: UIImage?, filePath: String?) { + guard let info: ConfirmationModal.Info = self.editProfilePictureModalInfo else { return } - self.transitionToScreen(actionSheet, transitionType: .present) + self.editedProfilePicture = image + self.editedProfilePictureFileName = filePath + + if let image: UIImage = image { + self.editProfilePictureModal?.updateContent( + with: info.with( + body: .image( + placeholder: UIImage(named: "profile_placeholder"), + value: image, + style: .circular, + onClick: { [weak self] in self?.showPhotoLibraryForAvatar() } + ), + confirmEnabled: true + ) + ) + } + else if let filePath: String = filePath { + self.editProfilePictureModal?.updateContent( + with: info.with( + body: .image( + placeholder: UIImage(named: "profile_placeholder"), + value: UIImage(contentsOfFile: filePath), + style: .circular, + onClick: { [weak self] in self?.showPhotoLibraryForAvatar() } + ), + confirmEnabled: true + ) + ) + } } private func showPhotoLibraryForAvatar() { @@ -421,24 +494,20 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett } } - fileprivate func updateProfile( + private func updateProfile( name: String, profilePicture: UIImage?, profilePictureFilePath: String?, isUpdatingDisplayName: Bool, - isUpdatingProfilePicture: Bool + isUpdatingProfilePicture: Bool, + onComplete: (() -> ())? = nil ) { - let imageFilePath: String? = ( - profilePictureFilePath ?? - ProfileManager.profileAvatarFilepath(id: self.userSessionId) - ) - let viewController = ModalActivityIndicatorViewController(canCancel: false) { [weak self] modalActivityIndicator in ProfileManager.updateLocal( queue: DispatchQueue.global(qos: .default), profileName: name, image: profilePicture, - imageFilePath: imageFilePath, + imageFilePath: profilePictureFilePath, success: { db, updatedProfile in if isUpdatingDisplayName { UserDefaults.standard[.lastDisplayNameUpdate] = Date() @@ -453,7 +522,9 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett // Wait for the database transaction to complete before updating the UI db.afterNextTransaction { _ in DispatchQueue.main.async { - modalActivityIndicator.dismiss(completion: {}) + modalActivityIndicator.dismiss(completion: { + onComplete?() + }) } } }, @@ -469,12 +540,13 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett "Maximum File Size Exceeded" : "Couldn't Update Profile" ), - explanation: (isMaxFileSizeExceeded ? + body: .text(isMaxFileSizeExceeded ? "Please select a smaller photo and try again" : "Please check your internet connection and try again" ), cancelTitle: "BUTTON_OK".localized(), - cancelStyle: .alert_text + cancelStyle: .alert_text, + dismissType: .single ) ), transitionType: .present @@ -497,8 +569,6 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett DispatchQueue.main.async { button.isUserInteractionEnabled = false - - UIView.transition( with: button, duration: 0.25, @@ -560,7 +630,6 @@ class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigati picker.presentingViewController?.dismiss(animated: true) return } - let name: String = self.viewModel.oldDisplayName picker.presentingViewController?.dismiss(animated: true) { [weak self] in // Check if the user selected an animated image (if so then don't crop, just @@ -574,12 +643,9 @@ class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigati let viewController: CropScaleImageViewController = CropScaleImageViewController( srcImage: rawAvatar, successCompletion: { resultImage in - self?.viewModel.updateProfile( - name: name, - profilePicture: resultImage, - profilePictureFilePath: nil, - isUpdatingDisplayName: false, - isUpdatingProfilePicture: true + self?.viewModel.updatedProfilePictureSelected( + image: resultImage, + filePath: nil ) } ) @@ -587,12 +653,9 @@ class ImagePickerHandler: NSObject, UIImagePickerControllerDelegate & UINavigati return } - self?.viewModel.updateProfile( - name: name, - profilePicture: nil, - profilePictureFilePath: imageUrl.path, - isUpdatingDisplayName: false, - isUpdatingProfilePicture: true + self?.viewModel.updatedProfilePictureSelected( + image: nil, + filePath: imageUrl.path ) } } diff --git a/Session/Shared/FullConversationCell.swift b/Session/Shared/FullConversationCell.swift index 61f61e2a0..c2de9ffa3 100644 --- a/Session/Shared/FullConversationCell.swift +++ b/Session/Shared/FullConversationCell.swift @@ -8,6 +8,11 @@ import SessionMessagingKit public final class FullConversationCell: UITableViewCell { public static let unreadCountViewSize: CGFloat = 20 private static let statusIndicatorSize: CGFloat = 14 + // If a message is much too long, it will take forever to calculate its width and + // cause the app to be frozen. So if a search result string is longer than 100 + // characters, we assume it cannot be shown within one line and need to be truncated + // to avoid the calculation. + private static let maxApproxCharactersCanBeShownInOneLine: Int = 100 // MARK: - UI @@ -657,14 +662,25 @@ public final class FullConversationCell: UITableViewCell { return authorPrefix .appending( truncatingIfNeeded( - approxWidth: (authorPrefix.size().width + result.size().width), + approxWidth: ( + authorPrefix.size().width + + ( + result.length > Self.maxApproxCharactersCanBeShownInOneLine ? + bounds.width : + result.size().width + ) + ), content: result ) ) } .defaulting( to: truncatingIfNeeded( - approxWidth: result.size().width, + approxWidth: ( + result.length > Self.maxApproxCharactersCanBeShownInOneLine ? + bounds.width : + result.size().width + ), content: result ) ) diff --git a/Session/Shared/ScreenLockUI.swift b/Session/Shared/ScreenLockUI.swift index 30db060b9..8b096493a 100644 --- a/Session/Shared/ScreenLockUI.swift +++ b/Session/Shared/ScreenLockUI.swift @@ -1,6 +1,7 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUIKit import SessionMessagingKit import SessionUtilitiesKit import SignalUtilitiesKit @@ -271,7 +272,7 @@ class ScreenLockUI { targetView: screenBlockingWindow.rootViewController?.view, info: ConfirmationModal.Info( title: "SCREEN_LOCK_UNLOCK_FAILED".localized(), - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, afterClosed: { [weak self] in self?.ensureUI() } // After the alert, update the UI diff --git a/Session/Shared/SessionCarouselView+Info.swift b/Session/Shared/SessionCarouselView+Info.swift new file mode 100644 index 000000000..62e7427c4 --- /dev/null +++ b/Session/Shared/SessionCarouselView+Info.swift @@ -0,0 +1,72 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + +extension SessionCarouselView { + public struct Info { + let slices: [UIView] + let copyOfFirstSlice: UIView? + let copyOfLastSlice: UIView? + let sliceSize: CGSize + let sliceCount: Int + let shouldShowPageControl: Bool + let pageControlStyle: PageControlStyle + let shouldShowArrows: Bool + let arrowsSize: CGSize + let cornerRadius: CGFloat + + // MARK: - Initialization + + init( + slices: [UIView] = [], + copyOfFirstSlice: UIView? = nil, + copyOfLastSlice: UIView? = nil, + sliceSize: CGSize = .zero, + shouldShowPageControl: Bool = true, + pageControlStyle: PageControlStyle, + shouldShowArrows: Bool = true, + arrowsSize: CGSize = .zero, + cornerRadius: CGFloat = 0 + ) { + self.slices = slices + self.copyOfFirstSlice = copyOfFirstSlice + self.copyOfLastSlice = copyOfLastSlice + self.sliceSize = sliceSize + self.sliceCount = slices.count + self.shouldShowPageControl = shouldShowPageControl && (self.sliceCount > 1) + self.pageControlStyle = pageControlStyle + self.shouldShowArrows = shouldShowArrows && (self.sliceCount > 1) + self.arrowsSize = arrowsSize + self.cornerRadius = cornerRadius + } + } + + public struct PageControlStyle { + enum DotSize: CGFloat { + case mini = 0.5 + case medium = 0.8 + case original = 1 + } + + let height: CGFloat? + let size: DotSize + let backgroundColor: UIColor + let bottomInset: CGFloat + + // MARK: - Initialization + + init( + height: CGFloat? = nil, + size: DotSize = .original, + backgroundColor: UIColor = .clear, + bottomInset: CGFloat = 0 + ) { + self.height = height + self.size = size + self.backgroundColor = backgroundColor + self.bottomInset = bottomInset + } + } +} diff --git a/Session/Shared/SessionCarouselView.swift b/Session/Shared/SessionCarouselView.swift new file mode 100644 index 000000000..6111f6df0 --- /dev/null +++ b/Session/Shared/SessionCarouselView.swift @@ -0,0 +1,202 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import UIKit +import SessionUIKit +import SessionUtilitiesKit + +final class SessionCarouselView: UIView, UIScrollViewDelegate { + private let slicesForLoop: [UIView] + private let info: SessionCarouselView.Info + var delegate: SessionCarouselViewDelegate? + + // MARK: - UI + private lazy var scrollView: UIScrollView = { + let result: UIScrollView = UIScrollView() + result.delegate = self + result.isPagingEnabled = true + result.showsHorizontalScrollIndicator = false + result.showsVerticalScrollIndicator = false + result.contentSize = CGSize( + width: self.info.sliceSize.width * CGFloat(self.slicesForLoop.count), + height: self.info.sliceSize.height + ) + result.layer.cornerRadius = self.info.cornerRadius + result.layer.masksToBounds = true + + return result + }() + + private lazy var pageControl: UIPageControl = { + let result: UIPageControl = UIPageControl() + result.numberOfPages = self.info.sliceCount + result.currentPage = 0 + result.isHidden = !self.info.shouldShowPageControl + result.transform = CGAffineTransform( + scaleX: self.info.pageControlStyle.size.rawValue, + y: self.info.pageControlStyle.size.rawValue + ) + + return result + }() + + private lazy var arrowLeft: UIButton = { + let result = UIButton(type: .custom) + result.setImage(UIImage(systemName: "chevron.left")?.withRenderingMode(.alwaysTemplate), for: .normal) + result.addTarget(self, action: #selector(scrollToPreviousSlice), for: .touchUpInside) + result.themeTintColor = .textPrimary + result.set(.width, to: self.info.arrowsSize.width) + result.set(.height, to: self.info.arrowsSize.height) + result.isHidden = !self.info.shouldShowArrows + + return result + }() + + private lazy var arrowRight: UIButton = { + let result = UIButton(type: .custom) + result.setImage(UIImage(systemName: "chevron.right")?.withRenderingMode(.alwaysTemplate), for: .normal) + result.addTarget(self, action: #selector(scrollToNextSlice), for: .touchUpInside) + result.themeTintColor = .textPrimary + result.set(.width, to: self.info.arrowsSize.width) + result.set(.height, to: self.info.arrowsSize.height) + result.isHidden = !self.info.shouldShowArrows + + return result + }() + + // MARK: - Lifecycle + + init(info: SessionCarouselView.Info) { + self.info = info + if self.info.sliceCount > 1, + let copyOfFirstSlice: UIView = self.info.copyOfFirstSlice, + let copyOfLastSlice: UIView = self.info.copyOfLastSlice + { + self.slicesForLoop = [copyOfLastSlice] + .appending(contentsOf: self.info.slices) + .appending(copyOfFirstSlice) + } else { + self.slicesForLoop = self.info.slices + } + + super.init(frame: CGRect.zero) + setUpViewHierarchy() + } + + override init(frame: CGRect) { + preconditionFailure("Use init(attachment:) instead.") + } + + required init?(coder: NSCoder) { + preconditionFailure("Use init(attachment:) instead.") + } + + private func setUpViewHierarchy() { + set(.width, to: self.info.sliceSize.width + Values.largeSpacing + 2 * self.info.arrowsSize.width) + set(.height, to: self.info.sliceSize.height) + + let stackView: UIStackView = UIStackView(arrangedSubviews: self.slicesForLoop) + stackView.axis = .horizontal + stackView.set(.width, to: self.info.sliceSize.width * CGFloat(self.slicesForLoop.count)) + stackView.set(.height, to: self.info.sliceSize.height) + + addSubview(self.scrollView) + scrollView.center(in: self) + scrollView.set(.width, to: self.info.sliceSize.width) + scrollView.set(.height, to: self.info.sliceSize.height) + scrollView.addSubview(stackView) + scrollView.setContentOffset( + CGPoint( + x: Int(self.info.sliceSize.width) * (self.info.sliceCount > 1 ? 1 : 0), + y: 0 + ), + animated: false + ) + + addSubview(self.pageControl) + self.pageControl.center(.horizontal, in: self) + self.pageControl.pin(.bottom, to: .bottom, of: self) + + addSubview(self.arrowLeft) + self.arrowLeft.pin(.leading, to: .leading, of: self) + self.arrowLeft.center(.vertical, in: self) + + addSubview(self.arrowRight) + self.arrowRight.pin(.trailing, to: .trailing, of: self) + self.arrowRight.center(.vertical, in: self) + } + + // MARK: - UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + let pageIndex: Int = { + let maybeCurrentPageIndex: Int = Int(round(scrollView.contentOffset.x/self.info.sliceSize.width)) + if self.info.sliceCount > 1 { + if maybeCurrentPageIndex == 0 { + return pageControl.numberOfPages - 1 + } + if maybeCurrentPageIndex == self.slicesForLoop.count - 1 { + return 0 + } + return maybeCurrentPageIndex - 1 + } + return maybeCurrentPageIndex + }() + + pageControl.currentPage = pageIndex + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + setCorrectCotentOffsetIfNeeded(scrollView) + delegate?.carouselViewDidScrollToNewSlice(currentPage: pageControl.currentPage) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + setCorrectCotentOffsetIfNeeded(scrollView) + delegate?.carouselViewDidScrollToNewSlice(currentPage: pageControl.currentPage) + } + + private func setCorrectCotentOffsetIfNeeded(_ scrollView: UIScrollView) { + if pageControl.currentPage == 0 { + scrollView.setContentOffset( + CGPoint( + x: Int(self.info.sliceSize.width) * 1, + y: 0 + ), + animated: false + ) + } + + if pageControl.currentPage == pageControl.numberOfPages - 1 { + let realLastIndex: Int = self.slicesForLoop.count - 2 + scrollView.setContentOffset( + CGPoint( + x: Int(self.info.sliceSize.width) * realLastIndex, + y: 0 + ), + animated: false + ) + } + } + + // MARK: - Interaction + + @objc func scrollToNextSlice() { + self.scrollView.setContentOffset( + CGPoint( + x: self.scrollView.contentOffset.x + self.info.sliceSize.width, + y: 0 + ), + animated: true + ) + } + + @objc func scrollToPreviousSlice() { + self.scrollView.setContentOffset( + CGPoint( + x: self.scrollView.contentOffset.x - self.info.sliceSize.width, + y: 0 + ), + animated: true + ) + } +} diff --git a/Session/Shared/SessionCarouselViewDelegate.swift b/Session/Shared/SessionCarouselViewDelegate.swift new file mode 100644 index 000000000..8cb2e19eb --- /dev/null +++ b/Session/Shared/SessionCarouselViewDelegate.swift @@ -0,0 +1,7 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation + +public protocol SessionCarouselViewDelegate: AnyObject { + func carouselViewDidScrollToNewSlice(currentPage: Int) +} diff --git a/Session/Shared/SessionTableViewController.swift b/Session/Shared/SessionTableViewController.swift index 24b6cd82d..4dbdba19c 100644 --- a/Session/Shared/SessionTableViewController.swift +++ b/Session/Shared/SessionTableViewController.swift @@ -132,7 +132,10 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges() + } } @objc func applicationDidResignActive(_ notification: Notification) { @@ -336,13 +339,15 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect self?.navigationController?.pushViewController(viewController, animated: true) case .present: + let presenter: UIViewController? = (self?.presentedViewController ?? self) + if UIDevice.current.isIPad { viewController.popoverPresentationController?.permittedArrowDirections = [] - viewController.popoverPresentationController?.sourceView = self?.view - viewController.popoverPresentationController?.sourceRect = (self?.view.bounds ?? UIScreen.main.bounds) + viewController.popoverPresentationController?.sourceView = presenter?.view + viewController.popoverPresentationController?.sourceRect = (presenter?.view.bounds ?? UIScreen.main.bounds) } - self?.present(viewController, animated: true) + presenter?.present(viewController, animated: true) } } .store(in: &disposables) @@ -520,7 +525,7 @@ class SessionTableViewController<NavItemId: Equatable, Section: SessionTableSect guard let confirmationInfo: ConfirmationModal.Info = info.confirmationInfo, - confirmationInfo.stateToShow.shouldShow(for: info.currentBoolValue) + confirmationInfo.showCondition.shouldShow(for: info.currentBoolValue) else { performAction() return diff --git a/Session/Shared/Views/SessionCell+AccessoryView.swift b/Session/Shared/Views/SessionCell+AccessoryView.swift index dffda5793..5b43c9cd0 100644 --- a/Session/Shared/Views/SessionCell+AccessoryView.swift +++ b/Session/Shared/Views/SessionCell+AccessoryView.swift @@ -301,19 +301,33 @@ extension SessionCell { let wasOldSelection: Bool = (!isSelected && storedSelection) radioBorderView.isHidden = false - radioBorderView.themeBorderColor = (isSelected ? - .radioButton_selectedBorder : - .radioButton_unselectedBorder - ) + radioBorderView.themeBorderColor = { + guard isEnabled else { return .radioButton_disabledBorder } + + return (isSelected ? + .radioButton_selectedBorder : + .radioButton_unselectedBorder + ) + }() + radioBorderView.layer.cornerRadius = (size.borderSize / 2) radioView.accessibilityLabel = accessibilityLabel radioView.alpha = (wasOldSelection ? 0.3 : 1) radioView.isHidden = (!isSelected && !storedSelection) - radioView.themeBackgroundColor = (isSelected || wasOldSelection ? - .radioButton_selectedBackground : - .radioButton_unselectedBackground - ) + radioView.themeBackgroundColor = { + guard isEnabled else { + return (isSelected || wasOldSelection ? + .radioButton_disabledSelectedBackground : + .radioButton_disabledUnselectedBackground + ) + } + + return (isSelected || wasOldSelection ? + .radioButton_selectedBackground : + .radioButton_unselectedBackground + ) + }() radioView.layer.cornerRadius = (size.selectionSize / 2) radioViewWidthConstraint.constant = size.selectionSize diff --git a/Session/Utilities/Date+Utilities.swift b/Session/Utilities/Date+Utilities.swift index 5336c20b4..ed7aab4f4 100644 --- a/Session/Utilities/Date+Utilities.swift +++ b/Session/Utilities/Date+Utilities.swift @@ -29,6 +29,14 @@ public extension Date { return "DATE_NOW".localized() } + + var fromattedForMessageInfo: String { + let formatter: DateFormatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "h:mm a EEE, DD/MM/YYYY" + + return formatter.string(from: self) + } } // MARK: - Formatters diff --git a/Session/Utilities/IP2Country.swift b/Session/Utilities/IP2Country.swift index a1f13ebab..d4ce36559 100644 --- a/Session/Utilities/IP2Country.swift +++ b/Session/Utilities/IP2Country.swift @@ -3,16 +3,17 @@ import GRDB import SessionSnodeKit final class IP2Country { - var countryNamesCache: [String:String] = [:] + var countryNamesCache: Atomic<[String: String]> = Atomic([:]) + private static let workQueue = DispatchQueue(label: "IP2Country.workQueue", qos: .utility) // It's important that this is a serial queue static var isInitialized = false // MARK: Tables - /// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains the **lower** bound of an IP - /// range and the "registered_country_geoname_id" column contains the ID of the country corresponding to that range. We look up an IP by finding the first index in the - /// network column where the value is greater than the IP we're looking up (converted to an integer). The IP we're looking up must then be in the range **before** that - /// range. + /// This table has two columns: the "network" column and the "registered_country_geoname_id" column. The network column contains + /// the **lower** bound of an IP range and the "registered_country_geoname_id" column contains the ID of the country corresponding + /// to that range. We look up an IP by finding the first index in the network column where the value is greater than the IP we're looking + /// up (converted to an integer). The IP we're looking up must then be in the range **before** that range. private lazy var ipv4Table: [String:[Int]] = { let url = Bundle.main.url(forResource: "GeoLite2-Country-Blocks-IPv4", withExtension: nil)! let data = try! Data(contentsOf: url) @@ -36,15 +37,23 @@ final class IP2Country { NotificationCenter.default.removeObserver(self) } - // MARK: Implementation - private func cacheCountry(for ip: String) -> String { - if let result = countryNamesCache[ip] { return result } - let ipAsInt = IPv4.toInt(ip) - guard let ipv4TableIndex = given(ipv4Table["network"]!.firstIndex(where: { $0 > ipAsInt }), { $0 - 1 }) else { return "Unknown Country" } // Relies on the array being sorted - let countryID = ipv4Table["registered_country_geoname_id"]![ipv4TableIndex] - guard let countryNamesTableIndex = countryNamesTable["geoname_id"]!.firstIndex(of: String(countryID)) else { return "Unknown Country" } - let result = countryNamesTable["country_name"]![countryNamesTableIndex] - countryNamesCache[ip] = result + // MARK: - Implementation + + @discardableResult private func cacheCountry(for ip: String, inCache cache: inout [String: String]) -> String { + if let result: String = cache[ip] { return result } + + let ipAsInt: Int = IPv4.toInt(ip) + + guard + let ipv4TableIndex = ipv4Table["network"]?.firstIndex(where: { $0 > ipAsInt }).map({ $0 - 1 }), + let countryID: Int = ipv4Table["registered_country_geoname_id"]?[ipv4TableIndex], + let countryNamesTableIndex = countryNamesTable["geoname_id"]?.firstIndex(of: String(countryID)), + let result: String = countryNamesTable["country_name"]?[countryNamesTableIndex] + else { + return "Unknown Country" // Relies on the array being sorted + } + + cache[ip] = result return result } @@ -58,9 +67,12 @@ final class IP2Country { func populateCacheIfNeeded() -> Bool { guard let pathToDisplay: [Snode] = OnionRequestAPI.paths.first else { return false } - pathToDisplay.forEach { snode in - let _ = self.cacheCountry(for: snode.ip) // Preload if needed + countryNamesCache.mutate { [weak self] cache in + pathToDisplay.forEach { snode in + self?.cacheCountry(for: snode.ip, inCache: &cache) // Preload if needed + } } + DispatchQueue.main.async { IP2Country.isInitialized = true NotificationCenter.default.post(name: .onionRequestPathCountriesLoaded, object: nil) diff --git a/Session/Utilities/Permissions.swift b/Session/Utilities/Permissions.swift index 5f80debc7..a965e743e 100644 --- a/Session/Utilities/Permissions.swift +++ b/Session/Utilities/Permissions.swift @@ -3,7 +3,9 @@ import UIKit import Photos import PhotosUI +import SessionUIKit import SessionUtilitiesKit +import SessionMessagingKit public enum Permissions { @discardableResult public static func requestCameraPermissionIfNeeded( @@ -21,9 +23,11 @@ public enum Permissions { let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "Session", - explanation: String( - format: "modal_permission_explanation".localized(), - "modal_permission_camera".localized() + body: .text( + String( + format: "modal_permission_explanation".localized(), + "modal_permission_camera".localized() + ) ), confirmTitle: "modal_permission_settings_title".localized(), dismissOnConfirm: false @@ -59,9 +63,11 @@ public enum Permissions { let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "Session", - explanation: String( - format: "modal_permission_explanation".localized(), - "modal_permission_microphone".localized() + body: .text( + String( + format: "modal_permission_explanation".localized(), + "modal_permission_microphone".localized() + ) ), confirmTitle: "modal_permission_settings_title".localized(), dismissOnConfirm: false, @@ -128,9 +134,11 @@ public enum Permissions { let confirmationModal: ConfirmationModal = ConfirmationModal( info: ConfirmationModal.Info( title: "Session", - explanation: String( - format: "modal_permission_explanation".localized(), - "modal_permission_library".localized() + body: .text( + String( + format: "modal_permission_explanation".localized(), + "modal_permission_library".localized() + ) ), confirmTitle: "modal_permission_settings_title".localized(), dismissOnConfirm: false diff --git a/SessionMessagingKit/Calls/WebRTCSession+UI.swift b/SessionMessagingKit/Calls/WebRTCSession+UI.swift index 305d1bfb7..2594d8a15 100644 --- a/SessionMessagingKit/Calls/WebRTCSession+UI.swift +++ b/SessionMessagingKit/Calls/WebRTCSession+UI.swift @@ -6,6 +6,10 @@ extension WebRTCSession { localVideoTrack.add(renderer) } + public func removeLocalRenderer(_ renderer: RTCVideoRenderer) { + localVideoTrack.remove(renderer) + } + public func attachRemoteRenderer(_ renderer: RTCVideoRenderer) { remoteVideoTrack?.add(renderer) } diff --git a/SessionMessagingKit/Database/Models/Attachment.swift b/SessionMessagingKit/Database/Models/Attachment.swift index ab5a4024c..3f661fc20 100644 --- a/SessionMessagingKit/Database/Models/Attachment.swift +++ b/SessionMessagingKit/Database/Models/Attachment.swift @@ -493,6 +493,7 @@ extension Attachment { public let interactionId: Int64 public let state: Attachment.State public let downloadUrl: String? + public let albumIndex: Int } public static func stateInfo(authorId: String, state: State? = nil) -> SQLRequest<Attachment.StateInfo> { @@ -510,7 +511,8 @@ extension Attachment { \(attachment[.id]) AS attachmentId, \(interaction[.id]) AS interactionId, \(attachment[.state]) AS state, - \(attachment[.downloadUrl]) AS downloadUrl + \(attachment[.downloadUrl]) AS downloadUrl, + IFNULL(\(interactionAttachment[.albumIndex]), 0) AS albumIndex FROM \(Attachment.self) @@ -520,8 +522,7 @@ extension Attachment { \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - /* Note: This equation MUST match the `linkPreviewFilterLiteral` logic in Interaction.swift */ - (ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp]) + \(Interaction.linkPreviewFilterLiteral) ) ) @@ -556,7 +557,8 @@ extension Attachment { \(attachment[.id]) AS attachmentId, \(interaction[.id]) AS interactionId, \(attachment[.state]) AS state, - \(attachment[.downloadUrl]) AS downloadUrl + \(attachment[.downloadUrl]) AS downloadUrl, + IFNULL(\(interactionAttachment[.albumIndex]), 0) AS albumIndex FROM \(Attachment.self) @@ -566,8 +568,7 @@ extension Attachment { \(interaction[.id]) = \(interactionAttachment[.interactionId]) OR ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - /* Note: This equation MUST match the `linkPreviewFilterLiteral` logic in Interaction.swift */ - (ROUND((\(interaction[.timestampMs]) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp]) + \(Interaction.linkPreviewFilterLiteral) ) ) diff --git a/SessionMessagingKit/Database/Models/Interaction.swift b/SessionMessagingKit/Database/Models/Interaction.swift index 0fd032333..b163bc01a 100644 --- a/SessionMessagingKit/Database/Models/Interaction.swift +++ b/SessionMessagingKit/Database/Models/Interaction.swift @@ -29,13 +29,13 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu /// Whenever using this `linkPreview` association make sure to filter the result using /// `.filter(literal: Interaction.linkPreviewFilterLiteral)` to ensure the correct LinkPreview is returned public static let linkPreview = hasOne(LinkPreview.self, using: LinkPreview.interactionForeignKey) - public static func linkPreviewFilterLiteral( - timestampColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) - ) -> SQL { + public static var linkPreviewFilterLiteral: SQL = { + let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias() - - return "(ROUND((\(Interaction.self).\(timestampColumn) / 1000 / 100000) - 0.5) * 100000) = \(linkPreview[.timestamp])" - } + let halfResolution: Double = LinkPreview.timstampResolution + + return "(\(interaction[.timestampMs]) BETWEEN (\(linkPreview[.timestamp]) - \(halfResolution)) * 1000 AND (\(linkPreview[.timestamp]) + \(halfResolution)) * 1000)" + }() public static let recipientStates = hasMany(RecipientState.self, using: RecipientState.interactionForeignKey) public typealias Columns = CodingKeys @@ -87,6 +87,10 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu // MARK: - Convenience + public static let variantsToIncrementUnreadCount: [Variant] = [ + .standardIncoming, .infoCall + ] + public var isInfoMessage: Bool { switch self { case .infoClosedGroupCreated, .infoClosedGroupUpdated, @@ -246,10 +250,13 @@ public struct Interaction: Codable, Identifiable, Equatable, FetchableRecord, Mu public var linkPreview: QueryInterfaceRequest<LinkPreview> { /// **Note:** This equation **MUST** match the `linkPreviewFilterLiteral` logic - let roundedTimestamp: Double = (round(((Double(timestampMs) / 1000) / 100000) - 0.5) * 100000) + let halfResolution: Double = LinkPreview.timstampResolution return request(for: Interaction.linkPreview) - .filter(LinkPreview.Columns.timestamp == roundedTimestamp) + .filter( + (timestampMs >= (LinkPreview.Columns.timestamp - halfResolution) * 1000) && + (timestampMs <= (LinkPreview.Columns.timestamp + halfResolution) * 1000) + ) } public var recipientStates: QueryInterfaceRequest<RecipientState> { diff --git a/SessionMessagingKit/File Server/FileServerAPI.swift b/SessionMessagingKit/File Server/FileServerAPI.swift index 5c0363133..964a09ffe 100644 --- a/SessionMessagingKit/File Server/FileServerAPI.swift +++ b/SessionMessagingKit/File Server/FileServerAPI.swift @@ -19,8 +19,9 @@ public final class FileServerAPI: NSObject { /// exactly will be fine but a single byte more will result in an error public static let maxFileSize = 10_000_000 - /// Standard timeout is 10 seconds which is a little too short fir file upload/download with slightly larger files - public static let fileTimeout: TimeInterval = 30 + /// Standard timeout is 10 seconds which is a little too short for file upload/download with slightly larger files + public static let fileDownloadTimeout: TimeInterval = 30 + public static let fileUploadTimeout: TimeInterval = 60 // MARK: - File Storage @@ -36,7 +37,7 @@ public final class FileServerAPI: NSObject { body: Array(file) ) - return send(request, serverPublicKey: serverPublicKey) + return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileUploadTimeout) .decoded(as: FileUploadResponse.self, on: .global(qos: .userInitiated)) } @@ -47,7 +48,7 @@ public final class FileServerAPI: NSObject { endpoint: .fileIndividual(fileId: fileId) ) - return send(request, serverPublicKey: serverPublicKey) + return send(request, serverPublicKey: serverPublicKey, timeout: FileServerAPI.fileDownloadTimeout) } public static func getVersion(_ platform: String) -> Promise<String> { @@ -59,14 +60,18 @@ public final class FileServerAPI: NSObject { ] ) - return send(request, serverPublicKey: serverPublicKey) + return send(request, serverPublicKey: serverPublicKey, timeout: HTTP.timeout) .decoded(as: VersionResponse.self, on: .global(qos: .userInitiated)) .map { response in response.version } } // MARK: - Convenience - private static func send<T: Encodable>(_ request: Request<T, Endpoint>, serverPublicKey: String) -> Promise<Data> { + private static func send<T: Encodable>( + _ request: Request<T, Endpoint>, + serverPublicKey: String, + timeout: TimeInterval + ) -> Promise<Data> { let urlRequest: URLRequest do { @@ -76,7 +81,13 @@ public final class FileServerAPI: NSObject { return Promise(error: error) } - return OnionRequestAPI.sendOnionRequest(urlRequest, to: request.server, with: serverPublicKey, timeout: FileServerAPI.fileTimeout) + return OnionRequestAPI + .sendOnionRequest( + urlRequest, + to: request.server, + with: serverPublicKey, + timeout: timeout + ) .map2 { _, response in guard let response: Data = response else { throw HTTP.Error.parsingFailed } diff --git a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift index 46d776cd3..68e487e95 100644 --- a/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift +++ b/SessionMessagingKit/Jobs/Types/GarbageCollectionJob.swift @@ -142,7 +142,7 @@ public enum GarbageCollectionJob: JobExecutor { FROM \(LinkPreview.self) LEFT JOIN \(Interaction.self) ON ( \(interaction[.linkPreviewUrl]) = \(linkPreview[.url]) AND - \(Interaction.linkPreviewFilterLiteral()) + \(Interaction.linkPreviewFilterLiteral) ) WHERE \(interaction[.id]) IS NULL ) diff --git a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift index ef9ae90d4..b36cb6645 100644 --- a/SessionMessagingKit/Jobs/Types/MessageSendJob.swift +++ b/SessionMessagingKit/Jobs/Types/MessageSendJob.swift @@ -58,6 +58,7 @@ public enum MessageSendJob: JobExecutor { .stateInfo(interactionId: interactionId) .fetchAll(db) let maybeFileIds: [String?] = allAttachmentStateInfo + .sorted { lhs, rhs in lhs.albumIndex < rhs.albumIndex } .map { Attachment.fileId(for: $0.downloadUrl) } let fileIds: [String] = maybeFileIds.compactMap { $0 } diff --git a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift index 2780ae0eb..7132723b9 100644 --- a/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift +++ b/SessionMessagingKit/Messages/Visible Messages/VisibleMessage.swift @@ -160,14 +160,21 @@ public final class VisibleMessage: Message { // Attachments - let attachments: [Attachment]? = try? Attachment.fetchAll(db, ids: self.attachmentIds) + let attachmentIdIndexes: [String: Int] = (try? InteractionAttachment + .filter(self.attachmentIds.contains(InteractionAttachment.Columns.attachmentId)) + .fetchAll(db)) + .defaulting(to: []) + .reduce(into: [:]) { result, next in result[next.attachmentId] = next.albumIndex } + let attachments: [Attachment] = (try? Attachment.fetchAll(db, ids: self.attachmentIds)) + .defaulting(to: []) + .sorted { lhs, rhs in (attachmentIdIndexes[lhs.id] ?? 0) < (attachmentIdIndexes[rhs.id] ?? 0) } - if !(attachments ?? []).allSatisfy({ $0.state == .uploaded }) { + if !attachments.allSatisfy({ $0.state == .uploaded }) { #if DEBUG preconditionFailure("Sending a message before all associated attachments have been uploaded.") #endif } - let attachmentProtos = (attachments ?? []).compactMap { $0.buildProto() } + let attachmentProtos = attachments.compactMap { $0.buildProto() } dataMessage.setAttachments(attachmentProtos) // Open group invitation diff --git a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift index 9b4984627..f4af87fe4 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupAPI.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupAPI.swift @@ -871,7 +871,7 @@ public enum OpenGroupAPI { ], body: bytes ), - timeout: FileServerAPI.fileTimeout, + timeout: FileServerAPI.fileUploadTimeout, using: dependencies ) .decoded(as: FileUploadResponse.self, on: OpenGroupAPI.workQueue, using: dependencies) @@ -891,7 +891,7 @@ public enum OpenGroupAPI { server: server, endpoint: .roomFileIndividual(roomToken, fileId) ), - timeout: FileServerAPI.fileTimeout, + timeout: FileServerAPI.fileDownloadTimeout, using: dependencies ) .map { responseInfo, maybeData in diff --git a/SessionMessagingKit/Open Groups/OpenGroupManager.swift b/SessionMessagingKit/Open Groups/OpenGroupManager.swift index 928ffaaea..e2076f350 100644 --- a/SessionMessagingKit/Open Groups/OpenGroupManager.swift +++ b/SessionMessagingKit/Open Groups/OpenGroupManager.swift @@ -1083,7 +1083,11 @@ public final class OpenGroupManager: NSObject { } public static func parseOpenGroup(from string: String) -> (room: String, server: String, publicKey: String)? { - guard let url = URL(string: string), let host = url.host ?? given(string.split(separator: "/").first, { String($0) }), let query = url.query else { return nil } + guard + let url = URL(string: string), + let host = (url.host ?? string.split(separator: "/").first.map({ String($0) })), + let query = url.query + else { return nil } // Inputs that should work: // https://sessionopengroup.co/r/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c // https://sessionopengroup.co/main?public_key=658d29b91892a2389505596b135e76a53db6e11d613a51dbd3d0816adffb231c diff --git a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift index 2f00d7d4d..20aa57cdc 100644 --- a/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift +++ b/SessionMessagingKit/Sending & Receiving/MessageReceiver.swift @@ -327,10 +327,9 @@ public enum MessageReceiver { if let name = name, !name.isEmpty, name != profile.name { let shouldUpdate: Bool if isCurrentUser { - shouldUpdate = given(UserDefaults.standard[.lastDisplayNameUpdate]) { - sentTimestamp > $0.timeIntervalSince1970 - } - .defaulting(to: true) + shouldUpdate = UserDefaults.standard[.lastDisplayNameUpdate] + .map { sentTimestamp > $0.timeIntervalSince1970 } + .defaulting(to: true) } else { shouldUpdate = true @@ -354,10 +353,9 @@ public enum MessageReceiver { { let shouldUpdate: Bool if isCurrentUser { - shouldUpdate = given(UserDefaults.standard[.lastProfilePictureUpdate]) { - sentTimestamp > $0.timeIntervalSince1970 - } - .defaulting(to: true) + shouldUpdate = UserDefaults.standard[.lastProfilePictureUpdate] + .map { sentTimestamp > $0.timeIntervalSince1970 } + .defaulting(to: true) } else { shouldUpdate = true diff --git a/SessionMessagingKit/Shared Models/MessageViewModel.swift b/SessionMessagingKit/Shared Models/MessageViewModel.swift index e2a1917cc..18aa47248 100644 --- a/SessionMessagingKit/Shared Models/MessageViewModel.swift +++ b/SessionMessagingKit/Shared Models/MessageViewModel.swift @@ -74,6 +74,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, public let id: Int64 public let variant: Interaction.Variant public let timestampMs: Int64 + public let receivedAtTimestampMs: Int64 public let authorId: String private let authorNameInternal: String? public let body: String? @@ -123,6 +124,9 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, /// This value will be used to populate the Context Menu and date header (if present) public var dateForUI: Date { Date(timeIntervalSince1970: (TimeInterval(self.timestampMs) / 1000)) } + /// This value will be used to populate the Message Info (if present) + public var receivedDateForUI: Date { Date(timeIntervalSince1970: (TimeInterval(self.receivedAtTimestampMs) / 1000)) } + /// This value specifies whether the body contains only emoji characters public let containsOnlyEmoji: Bool? @@ -164,6 +168,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, id: self.id, variant: self.variant, timestampMs: self.timestampMs, + receivedAtTimestampMs: self.receivedAtTimestampMs, authorId: self.authorId, authorNameInternal: self.authorNameInternal, body: self.body, @@ -321,6 +326,7 @@ public struct MessageViewModel: FetchableRecordWithRowId, Decodable, Equatable, id: self.id, variant: self.variant, timestampMs: self.timestampMs, + receivedAtTimestampMs: self.receivedAtTimestampMs, authorId: self.authorId, authorNameInternal: self.authorNameInternal, body: (!self.variant.isInfoMessage ? @@ -500,6 +506,7 @@ public extension MessageViewModel { init( variant: Interaction.Variant = .standardOutgoing, timestampMs: Int64 = Int64.max, + receivedAtTimestampMs: Int64 = Int64.max, body: String? = nil, quote: Quote? = nil, cellType: CellType = .typingIndicator, @@ -527,6 +534,7 @@ public extension MessageViewModel { self.id = targetId self.variant = variant self.timestampMs = timestampMs + self.receivedAtTimestampMs = receivedAtTimestampMs self.authorId = "" self.authorNameInternal = nil self.body = body @@ -637,29 +645,35 @@ public extension MessageViewModel { let interaction: TypedTableAlias<Interaction> = TypedTableAlias() let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias() + let groupMember: TypedTableAlias<GroupMember> = TypedTableAlias() let recipientState: TypedTableAlias<RecipientState> = TypedTableAlias() let contact: TypedTableAlias<Contact> = TypedTableAlias() let disappearingMessagesConfig: TypedTableAlias<DisappearingMessagesConfiguration> = TypedTableAlias() let profile: TypedTableAlias<Profile> = TypedTableAlias() let quote: TypedTableAlias<Quote> = TypedTableAlias() - let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias() let linkPreview: TypedTableAlias<LinkPreview> = TypedTableAlias() - let threadProfileTableLiteral: SQL = SQL(stringLiteral: "threadProfile") - let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let profileNicknameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) - let profileNameColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.name.name) - let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) - let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") - let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) - let attachmentIdColumnLiteral: SQL = SQL(stringLiteral: Attachment.Columns.id.name) - let groupMemberModeratorTableLiteral: SQL = SQL(stringLiteral: "groupMemberModerator") - let groupMemberAdminTableLiteral: SQL = SQL(stringLiteral: "groupMemberAdmin") - let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) - let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name) - let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name) + let threadProfile: SQL = SQL(stringLiteral: "threadProfile") + let quoteInteraction: SQL = SQL(stringLiteral: "quoteInteraction") + let quoteInteractionAttachment: SQL = SQL(stringLiteral: "quoteInteractionAttachment") + let readReceipt: SQL = SQL(stringLiteral: "readReceipt") + let idColumn: SQL = SQL(stringLiteral: Interaction.Columns.id.name) + let interactionBodyColumn: SQL = SQL(stringLiteral: Interaction.Columns.body.name) + let profileIdColumn: SQL = SQL(stringLiteral: Profile.Columns.id.name) + let nicknameColumn: SQL = SQL(stringLiteral: Profile.Columns.nickname.name) + let nameColumn: SQL = SQL(stringLiteral: Profile.Columns.name.name) + let quoteBodyColumn: SQL = SQL(stringLiteral: Quote.Columns.body.name) + let quoteAttachmentIdColumn: SQL = SQL(stringLiteral: Quote.Columns.attachmentId.name) + let readReceiptInteractionIdColumn: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) + let readTimestampMsColumn: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) + let timestampMsColumn: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + let authorIdColumn: SQL = SQL(stringLiteral: Interaction.Columns.authorId.name) + let attachmentIdColumn: SQL = SQL(stringLiteral: Attachment.Columns.id.name) + let interactionAttachmentInteractionIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) + let interactionAttachmentAttachmentIdColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) + let interactionAttachmentAlbumIndexColumn: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - let numColumnsBeforeLinkedRecords: Int = 20 + let numColumnsBeforeLinkedRecords: Int = 21 let finalGroupSQL: SQL = (groupSQL ?? "") let request: SQLRequest<ViewModel> = """ SELECT @@ -671,12 +685,13 @@ public extension MessageViewModel { IFNULL(\(disappearingMessagesConfig[.isEnabled]), false) AS \(ViewModel.threadHasDisappearingMessagesEnabledKey), \(openGroup[.server]) AS \(ViewModel.threadOpenGroupServerKey), \(openGroup[.publicKey]) AS \(ViewModel.threadOpenGroupPublicKeyKey), - IFNULL(\(threadProfileTableLiteral).\(profileNicknameColumnLiteral), \(threadProfileTableLiteral).\(profileNameColumnLiteral)) AS \(ViewModel.threadContactNameInternalKey), + IFNULL(\(threadProfile).\(nicknameColumn), \(threadProfile).\(nameColumn)) AS \(ViewModel.threadContactNameInternalKey), \(interaction.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(interaction[.id]), \(interaction[.variant]), \(interaction[.timestampMs]), + \(interaction[.receivedAtTimestampMs]), \(interaction[.authorId]), IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), \(interaction[.body]), @@ -685,20 +700,30 @@ public extension MessageViewModel { -- Default to 'sending' assuming non-processed interaction when null IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.stateKey), - (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), + (\(readReceipt).\(readTimestampMsColumn) IS NOT NULL) AS \(ViewModel.hasAtLeastOneReadReceiptKey), \(recipientState[.mostRecentFailureText]) AS \(ViewModel.mostRecentFailureTextKey), - ( - \(groupMemberModeratorTableLiteral).\(groupMemberProfileIdColumnLiteral) IS NOT NULL OR - \(groupMemberAdminTableLiteral).\(groupMemberProfileIdColumnLiteral) IS NOT NULL + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(interaction[.threadId]) AND + \(groupMember[.profileId]) = \(interaction[.authorId]) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND + \(SQL("\(groupMember[.role]) IN \([GroupMember.Role.moderator, GroupMember.Role.admin])")) + ) ) AS \(ViewModel.isSenderOpenGroupModeratorKey), \(ViewModel.profileKey).*, - \(ViewModel.quoteKey).*, + \(quote[.interactionId]), + \(quote[.authorId]), + \(quote[.timestampMs]), + \(quoteInteraction).\(interactionBodyColumn) AS \(quoteBodyColumn), + \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) AS \(quoteAttachmentIdColumn), \(ViewModel.quoteAttachmentKey).*, \(ViewModel.linkPreviewKey).*, \(ViewModel.linkPreviewAttachmentKey).*, - + \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey), -- All of the below properties are set in post-query processing but to prevent the @@ -715,54 +740,40 @@ public extension MessageViewModel { FROM \(Interaction.self) JOIN \(SessionThread.self) ON \(thread[.id]) = \(interaction[.threadId]) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(interaction[.threadId]) - LEFT JOIN \(Profile.self) AS \(threadProfileTableLiteral) ON \(threadProfileTableLiteral).\(profileIdColumnLiteral) = \(interaction[.threadId]) + LEFT JOIN \(Profile.self) AS \(threadProfile) ON \(threadProfile).\(profileIdColumn) = \(interaction[.threadId]) LEFT JOIN \(DisappearingMessagesConfiguration.self) ON \(disappearingMessagesConfig[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(interaction[.threadId]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - LEFT JOIN ( - SELECT \(quote[.interactionId]), - \(quote[.authorId]), - \(quote[.timestampMs]), - \(interaction[.body]) AS \(Quote.Columns.body), - \(interactionAttachment[.attachmentId]) AS \(Quote.Columns.attachmentId) - FROM \(Quote.self) - LEFT JOIN \(Interaction.self) ON ( - ( - \(quote[.authorId]) = \(interaction[.authorId]) OR ( - \(quote[.authorId]) = \(blindedPublicKey ?? "") AND - \(userPublicKey) = \(interaction[.authorId]) - ) - ) AND - \(quote[.timestampMs]) = \(interaction[.timestampMs]) + LEFT JOIN \(Quote.self) ON \(quote[.interactionId]) = \(interaction[.id]) + LEFT JOIN \(Interaction.self) AS \(quoteInteraction) ON ( + \(quoteInteraction).\(timestampMsColumn) = \(quote[.timestampMs]) AND ( + \(quoteInteraction).\(authorIdColumn) = \(quote[.authorId]) OR ( + -- A users outgoing message is stored in some cases using their standard id + -- but the quote will use their blinded id so handle that case + \(quote[.authorId]) = \(blindedPublicKey ?? "''") AND + \(quoteInteraction).\(authorIdColumn) = \(userPublicKey) + ) ) - LEFT JOIN \(InteractionAttachment.self) ON \(interaction[.id]) = \(interactionAttachment[.interactionId]) - ) AS \(ViewModel.quoteKey) ON \(quote[.interactionId]) = \(interaction[.id]) - LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumnLiteral) = \(quote[.attachmentId]) + ) + LEFT JOIN \(InteractionAttachment.self) AS \(quoteInteractionAttachment) ON ( + \(quoteInteractionAttachment).\(interactionAttachmentInteractionIdColumn) = \(quoteInteraction).\(idColumn) AND + \(quoteInteractionAttachment).\(interactionAttachmentAlbumIndexColumn) = 0 + ) + LEFT JOIN \(Attachment.self) AS \(ViewModel.quoteAttachmentKey) ON \(ViewModel.quoteAttachmentKey).\(attachmentIdColumn) = \(quoteInteractionAttachment).\(interactionAttachmentAttachmentIdColumn) + LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(Interaction.linkPreviewFilterLiteral()) + \(Interaction.linkPreviewFilterLiteral) ) - LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumnLiteral) = \(linkPreview[.attachmentId]) + LEFT JOIN \(Attachment.self) AS \(ViewModel.linkPreviewAttachmentKey) ON \(ViewModel.linkPreviewAttachmentKey).\(attachmentIdColumn) = \(linkPreview[.attachmentId]) LEFT JOIN \(RecipientState.self) ON ( -- Ignore 'skipped' states \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND \(recipientState[.interactionId]) = \(interaction[.id]) ) - LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( - \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND - \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) - ) - LEFT JOIN \(GroupMember.self) AS \(groupMemberModeratorTableLiteral) ON ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND - \(groupMemberModeratorTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(interaction[.threadId]) AND - \(groupMemberModeratorTableLiteral).\(groupMemberProfileIdColumnLiteral) = \(interaction[.authorId]) AND - \(SQL("\(groupMemberModeratorTableLiteral).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.moderator)")) - ) - LEFT JOIN \(GroupMember.self) AS \(groupMemberAdminTableLiteral) ON ( - \(SQL("\(thread[.variant]) = \(SessionThread.Variant.openGroup)")) AND - \(groupMemberAdminTableLiteral).\(groupMemberGroupIdColumnLiteral) = \(interaction[.threadId]) AND - \(groupMemberAdminTableLiteral).\(groupMemberProfileIdColumnLiteral) = \(interaction[.authorId]) AND - \(SQL("\(groupMemberAdminTableLiteral).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) + LEFT JOIN \(RecipientState.self) AS \(readReceipt) ON ( + \(readReceipt).\(readTimestampMsColumn) IS NOT NULL AND + \(readReceipt).\(readReceiptInteractionIdColumn) = \(interaction[.id]) ) WHERE \(interaction.alias[Column.rowID]) IN \(rowIds) \(finalGroupSQL) diff --git a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift index 6b20a0fe0..793c07aaf 100644 --- a/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift +++ b/SessionMessagingKit/Shared Models/SessionThreadViewModel.swift @@ -100,8 +100,14 @@ public struct SessionThreadViewModel: FetchableRecordWithRowId, Decodable, Equat public var canWrite: Bool { switch threadVariant { case .contact: return true - case .closedGroup: return (currentUserIsClosedGroupMember == true) && (interactionVariant?.isGroupLeavingStatus != true) - case .openGroup: return openGroupPermissions?.contains(.write) ?? false + case .closedGroup: + return ( + currentUserIsClosedGroupMember == true && + interactionVariant?.isGroupLeavingStatus != true + ) + + case .openGroup: + return (openGroupPermissions?.contains(.write) ?? false) } } @@ -241,6 +247,7 @@ public extension SessionThreadViewModel { threadIsNoteToSelf: Bool = false, contactProfile: Profile? = nil, currentUserIsClosedGroupMember: Bool? = nil, + openGroupPermissions: OpenGroup.Permissions? = nil, unreadCount: UInt = 0 ) { self.rowId = -1 @@ -279,7 +286,7 @@ public extension SessionThreadViewModel { self.openGroupPublicKey = nil self.openGroupProfilePictureData = nil self.openGroupUserCount = nil - self.openGroupPermissions = nil + self.openGroupPermissions = openGroupPermissions // Interaction display info @@ -448,7 +455,8 @@ public extension SessionThreadViewModel { let interactionAttachment: TypedTableAlias<InteractionAttachment> = TypedTableAlias() let profile: TypedTableAlias<Profile> = TypedTableAlias() - let interactionTimestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") + let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) let interactionStateInteractionIdColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.interactionId.name) let readReceiptTableLiteral: SQL = SQL(stringLiteral: "readReceipt") let readReceiptReadTimestampMsColumnLiteral: SQL = SQL(stringLiteral: RecipientState.Columns.readTimestampMs.name) @@ -459,9 +467,7 @@ public extension SessionThreadViewModel { let interactionAttachmentAttachmentIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.attachmentId.name) let interactionAttachmentInteractionIdColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.interactionId.name) let interactionAttachmentAlbumIndexColumnLiteral: SQL = SQL(stringLiteral: InteractionAttachment.Columns.albumIndex.name) - let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name) - let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name) - let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) + /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to @@ -470,124 +476,136 @@ public extension SessionThreadViewModel { /// Explicitly set default values for the fields ignored for search results let numColumnsBeforeProfiles: Int = 12 let numColumnsBetweenProfilesAndAttachmentInfo: Int = 12 // The attachment info columns will be combined - let request: SQLRequest<ViewModel> = """ SELECT \(thread.alias[Column.rowID]) AS \(ViewModel.rowIdKey), \(thread[.id]) AS \(ViewModel.threadIdKey), \(thread[.variant]) AS \(ViewModel.threadVariantKey), \(thread[.creationDateTimestamp]) AS \(ViewModel.threadCreationDateTimestampKey), - + (\(SQL("\(thread[.id]) = \(userPublicKey)"))) AS \(ViewModel.threadIsNoteToSelfKey), \(thread[.isPinned]) AS \(ViewModel.threadIsPinnedKey), \(contact[.isBlocked]) AS \(ViewModel.threadIsBlockedKey), \(thread[.mutedUntilTimestamp]) AS \(ViewModel.threadMutedUntilTimestampKey), \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), - + (\(typingIndicator[.threadId]) IS NOT NULL) AS \(ViewModel.threadContactIsTypingKey), - \(Interaction.self).\(ViewModel.threadUnreadCountKey), - \(Interaction.self).\(ViewModel.threadUnreadMentionCountKey), - + \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), + \(aggregateInteractionLiteral).\(ViewModel.threadUnreadMentionCountKey), + \(ViewModel.contactProfileKey).*, \(ViewModel.closedGroupProfileFrontKey).*, \(ViewModel.closedGroupProfileBackKey).*, \(ViewModel.closedGroupProfileBackFallbackKey).*, \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - (\(ViewModel.currentUserIsClosedGroupMemberKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey), - (\(ViewModel.currentUserIsClosedGroupAdminKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + ) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.imageData]) AS \(ViewModel.openGroupProfilePictureDataKey), - - \(Interaction.self).\(ViewModel.interactionIdKey), - \(Interaction.self).\(ViewModel.interactionVariantKey), - \(Interaction.self).\(interactionTimestampMsColumnLiteral) AS \(ViewModel.interactionTimestampMsKey), - \(Interaction.self).\(ViewModel.interactionBodyKey), - + + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), + \(interaction[.timestampMs]) AS \(ViewModel.interactionTimestampMsKey), + \(interaction[.body]) AS \(ViewModel.interactionBodyKey), + -- Default to 'sending' assuming non-processed interaction when null - IFNULL(MIN(\(recipientState[.state])), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), + IFNULL(( + SELECT \(recipientState[.state]) + FROM \(RecipientState.self) + WHERE ( + \(recipientState[.interactionId]) = \(interaction[.id]) AND + -- Ignore 'skipped' states + \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) + ) + LIMIT 1 + ), \(SQL("\(RecipientState.State.sending)"))) AS \(ViewModel.interactionStateKey), + (\(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL) AS \(ViewModel.interactionHasAtLeastOneReadReceiptKey), (\(linkPreview[.url]) IS NOT NULL) AS \(ViewModel.interactionIsOpenGroupInvitationKey), - + -- These 4 properties will be combined into 'Attachment.DescriptionInfo' \(attachment[.id]), \(attachment[.variant]), \(attachment[.contentType]), \(attachment[.sourceFilename]), COUNT(\(interactionAttachment[.interactionId])) AS \(ViewModel.interactionAttachmentCountKey), - + \(interaction[.authorId]), IFNULL(\(ViewModel.contactProfileKey).\(profileNicknameColumnLiteral), \(ViewModel.contactProfileKey).\(profileNameColumnLiteral)) AS \(ViewModel.threadContactNameInternalKey), IFNULL(\(profile[.nickname]), \(profile[.name])) AS \(ViewModel.authorNameInternalKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) - + FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id]) + LEFT JOIN ( - -- Fetch all interaction-specific data in a subquery to be more efficient SELECT \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]), - \(interaction[.variant]) AS \(ViewModel.interactionVariantKey), - MAX(\(interaction[.timestampMs])) AS \(interactionTimestampMsColumnLiteral), - \(interaction[.body]) AS \(ViewModel.interactionBodyKey), - \(interaction[.authorId]), - \(interaction[.linkPreviewUrl]), - + \(interaction[.threadId]) AS \(ViewModel.threadIdKey), + MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral), SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey), SUM(\(interaction[.wasRead]) = false AND \(interaction[.hasMention]) = true) AS \(ViewModel.threadUnreadMentionCountKey) - FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) - ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) - LEFT JOIN \(RecipientState.self) ON ( - -- Ignore 'skipped' states - \(SQL("\(recipientState[.state]) != \(RecipientState.State.skipped)")) AND - \(recipientState[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + LEFT JOIN \(Interaction.self) ON ( + \(interaction[.threadId]) = \(thread[.id]) AND + \(interaction[.id]) = \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey) ) + LEFT JOIN \(RecipientState.self) AS \(readReceiptTableLiteral) ON ( - \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL AND - \(Interaction.self).\(ViewModel.interactionIdKey) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) + \(interaction[.id]) = \(readReceiptTableLiteral).\(interactionStateInteractionIdColumnLiteral) AND + \(readReceiptTableLiteral).\(readReceiptReadTimestampMsColumnLiteral) IS NOT NULL ) LEFT JOIN \(LinkPreview.self) ON ( \(linkPreview[.url]) = \(interaction[.linkPreviewUrl]) AND - \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) AND - \(Interaction.linkPreviewFilterLiteral(timestampColumn: interactionTimestampMsColumnLiteral)) + \(Interaction.linkPreviewFilterLiteral) AND + \(SQL("\(linkPreview[.variant]) = \(LinkPreview.Variant.openGroupInvitation)")) ) LEFT JOIN \(InteractionAttachment.self) AS \(firstInteractionAttachmentLiteral) ON ( - \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 AND - \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(Interaction.self).\(ViewModel.interactionIdKey) + \(firstInteractionAttachmentLiteral).\(interactionAttachmentInteractionIdColumnLiteral) = \(interaction[.id]) AND + \(firstInteractionAttachmentLiteral).\(interactionAttachmentAlbumIndexColumnLiteral) = 0 ) LEFT JOIN \(Attachment.self) ON \(attachment[.id]) = \(firstInteractionAttachmentLiteral).\(interactionAttachmentAttachmentIdColumnLiteral) - LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(Interaction.self).\(ViewModel.interactionIdKey) + LEFT JOIN \(InteractionAttachment.self) ON \(interactionAttachment[.interactionId]) = \(interaction[.id]) LEFT JOIN \(Profile.self) ON \(profile[.id]) = \(interaction[.authorId]) - + -- Thread naming & avatar content - + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupMemberKey) ON ( - \(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberRoleColumnLiteral) != \(GroupMember.Role.zombie)")) AND - \(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND - \(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)")) - ) - LEFT JOIN \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupAdminKey) ON ( - \(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) AND - \(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND - \(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)")) - ) - + LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( SELECT MIN(\(groupMember[.profileId])) FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) ) ) @@ -599,8 +617,8 @@ public extension SessionThreadViewModel { FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) ) ) @@ -610,7 +628,7 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackKey).\(profileIdColumnLiteral) IS NULL AND \(ViewModel.closedGroupProfileBackFallbackKey).\(profileIdColumnLiteral) = \(SQL("\(userPublicKey)")) ) - + WHERE \(thread.alias[Column.rowID]) IN \(rowIds) \(groupSQL) ORDER BY \(orderSQL) @@ -643,14 +661,14 @@ public extension SessionThreadViewModel { let contact: TypedTableAlias<Contact> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias() - let interactionTimestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) + let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) return """ LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( SELECT \(interaction[.threadId]), - MAX(\(interaction[.timestampMs])) AS \(interactionTimestampMsColumnLiteral) + MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral) FROM \(Interaction.self) WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) @@ -701,7 +719,10 @@ public extension SessionThreadViewModel { let thread: TypedTableAlias<SessionThread> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias() - return SQL("\(thread[.isPinned]) DESC, IFNULL(\(interaction[.timestampMs]), (\(thread[.creationDateTimestamp]) * 1000)) DESC") + return SQL(""" + \(thread[.isPinned]) DESC, + CASE WHEN \(interaction[.timestampMs]) IS NOT NULL THEN \(interaction[.timestampMs]) ELSE (\(thread[.creationDateTimestamp]) * 1000) END DESC + """) }() static let messageRequetsOrderSQL: SQL = { @@ -725,6 +746,8 @@ public extension SessionThreadViewModel { let openGroup: TypedTableAlias<OpenGroup> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias() + let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") + let timestampMsColumnLiteral: SQL = SQL(stringLiteral: Interaction.Columns.timestampMs.name) let closedGroupUserCountTableLiteral: SQL = SQL(stringLiteral: "\(ViewModel.closedGroupUserCountString)_table") let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) @@ -760,12 +783,22 @@ public extension SessionThreadViewModel { \(thread[.onlyNotifyForMentions]) AS \(ViewModel.threadOnlyNotifyForMentionsKey), \(thread[.messageDraft]) AS \(ViewModel.threadMessageDraftKey), - \(Interaction.self).\(ViewModel.threadUnreadCountKey), + \(aggregateInteractionLiteral).\(ViewModel.threadUnreadCountKey), \(ViewModel.contactProfileKey).*, \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), \(closedGroupUserCountTableLiteral).\(ViewModel.closedGroupUserCountKey) AS \(ViewModel.closedGroupUserCountKey), - (\(groupMember[.profileId]) IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), @@ -773,33 +806,28 @@ public extension SessionThreadViewModel { \(openGroup[.userCount]) AS \(ViewModel.openGroupUserCountKey), \(openGroup[.permissions]) AS \(ViewModel.openGroupPermissionsKey), - \(Interaction.self).\(ViewModel.interactionIdKey), + \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey), \(SQL("\(userPublicKey)")) AS \(ViewModel.currentUserPublicKeyKey) FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) LEFT JOIN ( - -- Fetch all interaction-specific data in a subquery to be more efficient SELECT \(interaction[.id]) AS \(ViewModel.interactionIdKey), - \(interaction[.threadId]), - MAX(\(interaction[.timestampMs])), - + \(interaction[.threadId]) AS \(ViewModel.threadIdKey), + MAX(\(interaction[.timestampMs])) AS \(timestampMsColumnLiteral), SUM(\(interaction[.wasRead]) = false) AS \(ViewModel.threadUnreadCountKey) - FROM \(Interaction.self) - WHERE \(SQL("\(interaction[.threadId]) = \(threadId)")) - ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) - + WHERE ( + \(SQL("\(interaction[.threadId]) = \(threadId)")) AND + \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) + ) + ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(GroupMember.self) ON ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND - \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND - \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) - ) LEFT JOIN ( SELECT \(groupMember[.groupId]), @@ -836,10 +864,6 @@ public extension SessionThreadViewModel { let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) - let groupMemberProfileIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.profileId.name) - let groupMemberRoleColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.role.name) - let groupMemberGroupIdColumnLiteral: SQL = SQL(stringLiteral: GroupMember.Columns.groupId.name) - /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before /// the `ViewModel.contactProfileKey` entry below otherwise the query will fail to /// parse and might throw @@ -866,8 +890,27 @@ public extension SessionThreadViewModel { \(ViewModel.closedGroupProfileBackFallbackKey).*, \(closedGroup[.name]) AS \(ViewModel.closedGroupNameKey), - (\(ViewModel.currentUserIsClosedGroupMemberKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupMemberKey), - (\(ViewModel.currentUserIsClosedGroupAdminKey).profileId IS NOT NULL) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) != \(GroupMember.Role.zombie)")) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + ) AS \(ViewModel.currentUserIsClosedGroupMemberKey), + + EXISTS ( + SELECT 1 + FROM \(GroupMember.self) + WHERE ( + \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.admin)")) AND + \(SQL("\(groupMember[.profileId]) = \(userPublicKey)")) + ) + ) AS \(ViewModel.currentUserIsClosedGroupAdminKey), + \(openGroup[.name]) AS \(ViewModel.openGroupNameKey), \(openGroup[.server]) AS \(ViewModel.openGroupServerKey), \(openGroup[.roomToken]) AS \(ViewModel.openGroupRoomTokenKey), @@ -881,16 +924,6 @@ public extension SessionThreadViewModel { LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) - LEFT JOIN \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupMemberKey) ON ( - \(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberRoleColumnLiteral) != \(GroupMember.Role.zombie)")) AND - \(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND - \(SQL("\(ViewModel.currentUserIsClosedGroupMemberKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)")) - ) - LEFT JOIN \(GroupMember.self) AS \(ViewModel.currentUserIsClosedGroupAdminKey) ON ( - \(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberRoleColumnLiteral) = \(GroupMember.Role.admin)")) AND - \(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberGroupIdColumnLiteral) = \(closedGroup[.threadId]) AND - \(SQL("\(ViewModel.currentUserIsClosedGroupAdminKey).\(groupMemberProfileIdColumnLiteral) = \(userPublicKey)")) - ) LEFT JOIN \(Profile.self) AS \(ViewModel.closedGroupProfileFrontKey) ON ( \(ViewModel.closedGroupProfileFrontKey).\(profileIdColumnLiteral) = ( @@ -898,8 +931,8 @@ public extension SessionThreadViewModel { FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) ) ) @@ -911,8 +944,8 @@ public extension SessionThreadViewModel { FROM \(GroupMember.self) JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) WHERE ( - \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(groupMember[.groupId]) = \(closedGroup[.threadId]) AND + \(SQL("\(groupMember[.role]) = \(GroupMember.Role.standard)")) AND \(SQL("\(groupMember[.profileId]) != \(userPublicKey)")) ) ) @@ -1549,6 +1582,7 @@ public extension SessionThreadViewModel { let profile: TypedTableAlias<Profile> = TypedTableAlias() let interaction: TypedTableAlias<Interaction> = TypedTableAlias() + let aggregateInteractionLiteral: SQL = SQL(stringLiteral: "aggregateInteraction") let profileIdColumnLiteral: SQL = SQL(stringLiteral: Profile.Columns.id.name) /// **Note:** The `numColumnsBeforeProfiles` value **MUST** match the number of fields before @@ -1582,11 +1616,21 @@ public extension SessionThreadViewModel { FROM \(SessionThread.self) LEFT JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id]) + LEFT JOIN ( - SELECT *, MAX(\(interaction[.timestampMs])) + SELECT + \(interaction[.id]) AS \(ViewModel.interactionIdKey), + \(interaction[.threadId]) AS \(ViewModel.threadIdKey), + MAX(\(interaction[.timestampMs])) FROM \(Interaction.self) + WHERE \(SQL("\(interaction[.variant]) != \(Interaction.Variant.standardIncomingDeleted)")) GROUP BY \(interaction[.threadId]) - ) AS \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + ) AS \(aggregateInteractionLiteral) ON \(aggregateInteractionLiteral).\(ViewModel.threadIdKey) = \(thread[.id]) + LEFT JOIN \(Interaction.self) ON ( + \(interaction[.threadId]) = \(thread[.id]) AND + \(interaction[.id]) = \(aggregateInteractionLiteral).\(ViewModel.interactionIdKey) + ) + LEFT JOIN \(Profile.self) AS \(ViewModel.contactProfileKey) ON \(ViewModel.contactProfileKey).\(profileIdColumnLiteral) = \(thread[.id]) LEFT JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id]) LEFT JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id]) diff --git a/SessionMessagingKit/Utilities/Preferences.swift b/SessionMessagingKit/Utilities/Preferences.swift index 293c529c5..0b3c91209 100644 --- a/SessionMessagingKit/Utilities/Preferences.swift +++ b/SessionMessagingKit/Utilities/Preferences.swift @@ -308,9 +308,16 @@ public enum Preferences { } public static var isCallKitSupported: Bool { +#if targetEnvironment(simulator) + /// The iOS simulator doesn't support CallKit, when receiving a call on the simulator and routing it via CallKit it + /// will immediately trigger a hangup making it difficult to test - instead we just should just avoid using CallKit + /// entirely on the simulator + return false +#else guard let regionCode: String = NSLocale.current.regionCode else { return false } guard !regionCode.contains("CN") && !regionCode.contains("CHN") else { return false } return true +#endif } } diff --git a/SessionShareExtension/SAEScreenLockViewController.swift b/SessionShareExtension/SAEScreenLockViewController.swift index ab735e459..c0d0f9b0e 100644 --- a/SessionShareExtension/SAEScreenLockViewController.swift +++ b/SessionShareExtension/SAEScreenLockViewController.swift @@ -163,7 +163,7 @@ final class SAEScreenLockViewController: ScreenLockViewController { targetView: self.view, info: ConfirmationModal.Info( title: "SCREEN_LOCK_UNLOCK_FAILED".localized(), - explanation: message, + body: .text(message), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, afterClosed: { [weak self] in self?.ensureUI() } // After the alert, update the UI diff --git a/SessionShareExtension/ShareVC.swift b/SessionShareExtension/ShareVC.swift index 07c83873c..135a83e82 100644 --- a/SessionShareExtension/ShareVC.swift +++ b/SessionShareExtension/ShareVC.swift @@ -226,7 +226,7 @@ final class ShareVC: UINavigationController, ShareViewDelegate { targetView: self.view, info: ConfirmationModal.Info( title: "Session", - explanation: error.localizedDescription, + body: .text(error.localizedDescription), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text, afterClosed: { [weak self] in self?.extensionContext?.cancelRequest(withError: error) } diff --git a/SessionShareExtension/ThreadPickerVC.swift b/SessionShareExtension/ThreadPickerVC.swift index 5fb7aaf2a..77fa71796 100644 --- a/SessionShareExtension/ThreadPickerVC.swift +++ b/SessionShareExtension/ThreadPickerVC.swift @@ -85,7 +85,10 @@ final class ThreadPickerVC: UIViewController, UITableViewDataSource, UITableView } @objc func applicationDidBecomeActive(_ notification: Notification) { - startObservingChanges() + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges() + } } @objc func applicationDidResignActive(_ notification: Notification) { diff --git a/SessionSnodeKit/SnodeAPI.swift b/SessionSnodeKit/SnodeAPI.swift index eff7c6632..d3a542f03 100644 --- a/SessionSnodeKit/SnodeAPI.swift +++ b/SessionSnodeKit/SnodeAPI.swift @@ -47,7 +47,14 @@ public final class SnodeAPI { private static let maxRetryCount: UInt = 8 private static let minSwarmSnodeCount = 3 - private static let seedNodePool: Set<String> = Features.useTestnet ? [ "http://public.loki.foundation:38157" ] : [ "https://storage.seed1.loki.network:4433", "https://storage.seed3.loki.network:4433", "https://public.loki.foundation:4433" ] + private static let seedNodePool: Set<String> = (Features.useTestnet ? + [ "http://public.loki.foundation:38157" ] : + [ + "https://seed1.getsession.org:4432", + "https://seed2.getsession.org:4432", + "https://seed3.getsession.org:4432" + ] + ) private static let snodeFailureThreshold = 3 private static let targetSwarmSnodeCount = 2 private static let minSnodePoolCount = 12 @@ -305,9 +312,9 @@ public final class SnodeAPI { public static func getSnodePool() -> Promise<Set<Snode>> { loadSnodePoolIfNeeded() let now = Date() - let hasSnodePoolExpired = given(Storage.shared[.lastSnodePoolRefreshDate]) { - now.timeIntervalSince($0) > 2 * 60 * 60 - }.defaulting(to: true) + let hasSnodePoolExpired: Bool = Storage.shared[.lastSnodePoolRefreshDate] + .map { now.timeIntervalSince($0) > 2 * 60 * 60 } + .defaulting(to: true) let snodePool: Set<Snode> = SnodeAPI.snodePool.wrappedValue guard hasInsufficientSnodes || hasSnodePoolExpired else { @@ -316,46 +323,56 @@ public final class SnodeAPI { if let getSnodePoolPromise = getSnodePoolPromise.wrappedValue { return getSnodePoolPromise } - let promise: Promise<Set<Snode>> - if snodePool.count < minSnodePoolCount { - promise = getSnodePoolFromSeedNode() - } - else { - promise = getSnodePoolFromSnode().recover2 { _ in - getSnodePoolFromSeedNode() + return getSnodePoolPromise.mutate { result in + /// It was possible for multiple threads to call this at the same time resulting in duplicate promises getting created, while + /// this should no longer be possible (as the `wrappedValue` should now properly be blocked) this is a sanity check + /// to make sure we don't create an additional promise when one already exists + if let previouslyBlockedPromise: Promise<Set<Snode>> = result { + return previouslyBlockedPromise } - } - - getSnodePoolPromise.mutate { $0 = promise } - promise.map2 { snodePool -> Set<Snode> in - guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } - - return snodePool - } - - promise.then2 { snodePool -> Promise<Set<Snode>> in - let (promise, seal) = Promise<Set<Snode>>.pending() - - Storage.shared.writeAsync( - updates: { db in - db[.lastSnodePoolRefreshDate] = now - setSnodePool(to: snodePool, db: db) - }, - completion: { _, _ in - seal.fulfill(snodePool) + + let promise: Promise<Set<Snode>> + + if snodePool.count < minSnodePoolCount { + promise = getSnodePoolFromSeedNode() + } + else { + promise = getSnodePoolFromSnode().recover2 { _ in + getSnodePoolFromSeedNode() } - ) - + } + + promise.map2 { snodePool -> Set<Snode> in + guard !snodePool.isEmpty else { throw SnodeAPIError.snodePoolUpdatingFailed } + + return snodePool + } + + promise.then2 { snodePool -> Promise<Set<Snode>> in + let (promise, seal) = Promise<Set<Snode>>.pending() + + Storage.shared.writeAsync( + updates: { db in + db[.lastSnodePoolRefreshDate] = now + setSnodePool(to: snodePool, db: db) + }, + completion: { _, _ in + seal.fulfill(snodePool) + } + ) + + return promise + } + promise.done2 { _ in + getSnodePoolPromise.mutate { $0 = nil } + } + promise.catch2 { _ in + getSnodePoolPromise.mutate { $0 = nil } + } + + result = promise return promise } - promise.done2 { _ in - getSnodePoolPromise.mutate { $0 = nil } - } - promise.catch2 { _ in - getSnodePoolPromise.mutate { $0 = nil } - } - - return promise } public static func getSessionID(for onsName: String) -> Promise<String> { diff --git a/SessionUIKit/Components/ConfirmationModal.swift b/SessionUIKit/Components/ConfirmationModal.swift index 2ffe5947a..93d1be709 100644 --- a/SessionUIKit/Components/ConfirmationModal.swift +++ b/SessionUIKit/Components/ConfirmationModal.swift @@ -1,135 +1,16 @@ // Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. import UIKit +import SessionUtilitiesKit +// FIXME: Refactor as part of the Groups Rebuild public class ConfirmationModal: Modal { - public struct Info: Equatable, Hashable { - public enum State { - case whenEnabled - case whenDisabled - case always - - public func shouldShow(for value: Bool) -> Bool { - switch self { - case .whenEnabled: return (value == true) - case .whenDisabled: return (value == false) - case .always: return true - } - } - } - - let title: String - let explanation: String? - let attributedExplanation: NSAttributedString? - let accessibilityLabel: String? - let accessibilityIdentifier: String? - public let stateToShow: State - let confirmTitle: String? - let confirmAccessibilityLabel: String? - let confirmStyle: ThemeValue - let cancelTitle: String - let cancelAccessibilityLabel: String? - let cancelStyle: ThemeValue - let dismissOnConfirm: Bool - let onConfirm: ((UIViewController) -> ())? - let afterClosed: (() -> ())? - - // MARK: - Initialization - - public init( - title: String, - explanation: String? = nil, - attributedExplanation: NSAttributedString? = nil, - accessibilityLabel: String? = nil, - accessibilityId: String? = nil, - stateToShow: State = .always, - confirmTitle: String? = nil, - confirmAccessibilityLabel: String? = nil, - confirmStyle: ThemeValue = .alert_text, - cancelTitle: String = "TXT_CANCEL_TITLE".localized(), - cancelAccessibilityLabel: String? = nil, - cancelStyle: ThemeValue = .danger, - dismissOnConfirm: Bool = true, - onConfirm: ((UIViewController) -> ())? = nil, - afterClosed: (() -> ())? = nil - ) { - self.title = title - self.explanation = explanation - self.attributedExplanation = attributedExplanation - self.accessibilityLabel = accessibilityLabel - self.accessibilityIdentifier = accessibilityId - self.stateToShow = stateToShow - self.confirmTitle = confirmTitle - self.confirmAccessibilityLabel = confirmAccessibilityLabel - self.confirmStyle = confirmStyle - self.cancelTitle = cancelTitle - self.cancelAccessibilityLabel = cancelAccessibilityLabel - self.cancelStyle = cancelStyle - self.dismissOnConfirm = dismissOnConfirm - self.onConfirm = onConfirm - self.afterClosed = afterClosed - } - - // MARK: - Mutation - - public func with( - onConfirm: ((UIViewController) -> ())? = nil, - afterClosed: (() -> ())? = nil - ) -> Info { - return Info( - title: self.title, - explanation: self.explanation, - attributedExplanation: self.attributedExplanation, - accessibilityLabel: self.accessibilityLabel, - stateToShow: self.stateToShow, - confirmTitle: self.confirmTitle, - confirmAccessibilityLabel: self.confirmAccessibilityLabel, - confirmStyle: self.confirmStyle, - cancelTitle: self.cancelTitle, - cancelAccessibilityLabel: self.cancelAccessibilityLabel, - cancelStyle: self.cancelStyle, - dismissOnConfirm: self.dismissOnConfirm, - onConfirm: (onConfirm ?? self.onConfirm), - afterClosed: (afterClosed ?? self.afterClosed) - ) - } - - // MARK: - Confirmance - - public static func == (lhs: ConfirmationModal.Info, rhs: ConfirmationModal.Info) -> Bool { - return ( - lhs.title == rhs.title && - lhs.explanation == rhs.explanation && - lhs.attributedExplanation == rhs.attributedExplanation && - lhs.accessibilityLabel == rhs.accessibilityLabel && - lhs.stateToShow == rhs.stateToShow && - lhs.confirmTitle == rhs.confirmTitle && - lhs.confirmAccessibilityLabel == rhs.confirmAccessibilityLabel && - lhs.confirmStyle == rhs.confirmStyle && - lhs.cancelTitle == rhs.cancelTitle && - lhs.cancelAccessibilityLabel == rhs.cancelAccessibilityLabel && - lhs.cancelStyle == rhs.cancelStyle && - lhs.dismissOnConfirm == rhs.dismissOnConfirm - ) - } - - public func hash(into hasher: inout Hasher) { - title.hash(into: &hasher) - explanation.hash(into: &hasher) - attributedExplanation.hash(into: &hasher) - accessibilityLabel.hash(into: &hasher) - stateToShow.hash(into: &hasher) - confirmTitle.hash(into: &hasher) - confirmAccessibilityLabel.hash(into: &hasher) - confirmStyle.hash(into: &hasher) - cancelTitle.hash(into: &hasher) - cancelAccessibilityLabel.hash(into: &hasher) - cancelStyle.hash(into: &hasher) - dismissOnConfirm.hash(into: &hasher) - } - } + private static let imageSize: CGFloat = 80 + private static let closeSize: CGFloat = 24 - private let internalOnConfirm: (UIViewController) -> () + private var internalOnConfirm: ((ConfirmationModal) -> ())? = nil + private var internalOnCancel: ((ConfirmationModal) -> ())? = nil + private var internalOnBodyTap: (() -> ())? = nil // MARK: - Components @@ -151,6 +32,24 @@ public class ConfirmationModal: Modal { result.textAlignment = .center result.lineBreakMode = .byWordWrapping result.numberOfLines = 0 + result.isHidden = true + + return result + }() + + private lazy var imageViewContainer: UIView = { + let result: UIView = UIView() + result.isHidden = true + + return result + }() + + private lazy var imageView: UIImageView = { + let result: UIImageView = UIImageView() + result.clipsToBounds = true + result.contentMode = .scaleAspectFill + result.set(.width, to: ConfirmationModal.imageSize) + result.set(.height, to: ConfirmationModal.imageSize) return result }() @@ -174,7 +73,7 @@ public class ConfirmationModal: Modal { }() private lazy var contentStackView: UIStackView = { - let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel ]) + let result = UIStackView(arrangedSubviews: [ titleLabel, explanationLabel, imageViewContainer ]) result.axis = .vertical result.spacing = Values.smallSpacing result.isLayoutMarginsRelativeArrangement = true @@ -185,13 +84,41 @@ public class ConfirmationModal: Modal { right: Values.largeSpacing ) + let gestureRecogniser: UITapGestureRecognizer = UITapGestureRecognizer( + target: self, + action: #selector(bodyTapped) + ) + result.addGestureRecognizer(gestureRecogniser) + return result }() private lazy var mainStackView: UIStackView = { let result = UIStackView(arrangedSubviews: [ contentStackView, buttonStackView ]) result.axis = .vertical - result.spacing = Values.largeSpacing - Values.smallFontSize / 2 + + return result + }() + + private lazy var closeButton: UIButton = { + let result: UIButton = UIButton() + result.setImage( + UIImage(named: "X")? + .withRenderingMode(.alwaysTemplate), + for: .normal + ) + result.imageView?.contentMode = .scaleAspectFit + result.themeTintColor = .textPrimary + result.contentEdgeInsets = UIEdgeInsets( + top: 6, + left: 6, + bottom: 6, + right: 6 + ) + result.set(.width, to: ConfirmationModal.closeSize) + result.set(.height, to: ConfirmationModal.closeSize) + result.addTarget(self, action: #selector(close), for: .touchUpInside) + result.isHidden = true return result }() @@ -199,50 +126,11 @@ public class ConfirmationModal: Modal { // MARK: - Lifecycle public init(targetView: UIView? = nil, info: Info) { - self.internalOnConfirm = { viewController in - if info.dismissOnConfirm { - viewController.dismiss(animated: true) - } - - info.onConfirm?(viewController) - } - - super.init(targetView: targetView, afterClosed: info.afterClosed) + super.init(targetView: targetView, dismissType: info.dismissType, afterClosed: info.afterClosed) self.modalPresentationStyle = .overFullScreen self.modalTransitionStyle = .crossDissolve - - // Set the content based on the provided info - titleLabel.text = info.title - - // Note: We should only set the appropriate explanation/attributedExplanation value (as - // setting both when one is null can result in the other being removed) - if let explanation: String = info.explanation { - explanationLabel.text = explanation - } - - if let attributedExplanation: NSAttributedString = info.attributedExplanation { - explanationLabel.attributedText = attributedExplanation - } - - explanationLabel.isHidden = ( - info.explanation == nil && - info.attributedExplanation == nil - ) - confirmButton.accessibilityLabel = info.confirmAccessibilityLabel - confirmButton.accessibilityIdentifier = info.confirmAccessibilityLabel - confirmButton.isAccessibilityElement = true - confirmButton.setTitle(info.confirmTitle, for: .normal) - confirmButton.setThemeTitleColor(info.confirmStyle, for: .normal) - confirmButton.isHidden = (info.confirmTitle == nil) - cancelButton.accessibilityLabel = info.cancelAccessibilityLabel - cancelButton.accessibilityIdentifier = info.cancelAccessibilityLabel - cancelButton.isAccessibilityElement = true - cancelButton.setTitle(info.cancelTitle, for: .normal) - cancelButton.setThemeTitleColor(info.cancelStyle, for: .normal) - - self.contentView.accessibilityLabel = info.accessibilityLabel - self.contentView.accessibilityIdentifier = info.accessibilityIdentifier + self.updateContent(with: info) } required init?(coder: NSCoder) { @@ -251,13 +139,321 @@ public class ConfirmationModal: Modal { public override func populateContentView() { contentView.addSubview(mainStackView) + contentView.addSubview(closeButton) + + imageViewContainer.addSubview(imageView) + imageView.center(.horizontal, in: imageViewContainer) + imageView.pin(.top, to: .top, of: imageViewContainer, withInset: 15) + imageView.pin(.bottom, to: .bottom, of: imageViewContainer, withInset: -15) mainStackView.pin(to: contentView) + closeButton.pin(.top, to: .top, of: contentView, withInset: 8) + closeButton.pin(.right, to: .right, of: contentView, withInset: -8) + } + + // MARK: - Content + + public func updateContent(with info: Info) { + internalOnBodyTap = nil + internalOnConfirm = { modal in + if info.dismissOnConfirm { + modal.close() + } + + info.onConfirm?(modal) + } + internalOnCancel = { modal in + guard info.onCancel != nil else { return modal.close() } + + info.onCancel?(modal) + } + + // Set the content based on the provided info + titleLabel.text = info.title + + switch info.body { + case .none: + mainStackView.spacing = Values.smallSpacing + + case .text(let text): + mainStackView.spacing = Values.smallSpacing + explanationLabel.text = text + explanationLabel.isHidden = false + + case .attributedText(let attributedText): + mainStackView.spacing = Values.smallSpacing + explanationLabel.attributedText = attributedText + explanationLabel.isHidden = false + + case .image(let placeholder, let value, let style, let onClick): + mainStackView.spacing = 0 + imageView.image = (value ?? placeholder) + imageView.layer.cornerRadius = (style == .circular ? + (ConfirmationModal.imageSize / 2) : + 0 + ) + imageViewContainer.isHidden = false + internalOnBodyTap = onClick + } + + confirmButton.accessibilityLabel = info.confirmAccessibilityLabel + confirmButton.accessibilityIdentifier = info.confirmAccessibilityLabel + confirmButton.isAccessibilityElement = true + confirmButton.setTitle(info.confirmTitle, for: .normal) + confirmButton.setThemeTitleColor(info.confirmStyle, for: .normal) + confirmButton.setThemeTitleColor(.disabled, for: .disabled) + confirmButton.isHidden = (info.confirmTitle == nil) + confirmButton.isEnabled = info.confirmEnabled + cancelButton.accessibilityLabel = info.cancelAccessibilityLabel + cancelButton.accessibilityIdentifier = info.cancelAccessibilityLabel + cancelButton.isAccessibilityElement = true + cancelButton.setTitle(info.cancelTitle, for: .normal) + cancelButton.setThemeTitleColor(info.cancelStyle, for: .normal) + cancelButton.setThemeTitleColor(.disabled, for: .disabled) + cancelButton.isEnabled = info.cancelEnabled + closeButton.isHidden = !info.hasCloseButton + + contentView.accessibilityLabel = info.accessibilityLabel + contentView.accessibilityIdentifier = info.accessibilityIdentifier } // MARK: - Interaction + @objc private func bodyTapped() { + internalOnBodyTap?() + } + @objc private func confirmationPressed() { - internalOnConfirm(self) + internalOnConfirm?(self) + } + + override public func cancel() { + internalOnCancel?(self) + } +} + +// MARK: - Types + +public extension ConfirmationModal { + struct Info: Equatable, Hashable { + let title: String + let body: Body + let accessibilityLabel: String? + let accessibilityIdentifier: String? + public let showCondition: ShowCondition + let confirmTitle: String? + let confirmAccessibilityLabel: String? + let confirmStyle: ThemeValue + let confirmEnabled: Bool + let cancelTitle: String + let cancelAccessibilityLabel: String? + let cancelStyle: ThemeValue + let cancelEnabled: Bool + let hasCloseButton: Bool + let dismissOnConfirm: Bool + let dismissType: Modal.DismissType + let onConfirm: ((ConfirmationModal) -> ())? + let onCancel: ((ConfirmationModal) -> ())? + let afterClosed: (() -> ())? + + // MARK: - Initialization + + public init( + title: String, + body: Body = .none, + accessibilityLabel: String? = nil, + accessibilityId: String? = nil, + showCondition: ShowCondition = .none, + confirmTitle: String? = nil, + confirmAccessibilityLabel: String? = nil, + confirmStyle: ThemeValue = .alert_text, + confirmEnabled: Bool = true, + cancelTitle: String = "TXT_CANCEL_TITLE".localized(), + cancelAccessibilityLabel: String? = nil, + cancelStyle: ThemeValue = .danger, + cancelEnabled: Bool = true, + hasCloseButton: Bool = false, + dismissOnConfirm: Bool = true, + dismissType: Modal.DismissType = .recursive, + onConfirm: ((ConfirmationModal) -> ())? = nil, + onCancel: ((ConfirmationModal) -> ())? = nil, + afterClosed: (() -> ())? = nil + ) { + self.title = title + self.body = body + self.accessibilityLabel = accessibilityLabel + self.accessibilityIdentifier = accessibilityId + self.showCondition = showCondition + self.confirmTitle = confirmTitle + self.confirmAccessibilityLabel = confirmAccessibilityLabel + self.confirmStyle = confirmStyle + self.confirmEnabled = confirmEnabled + self.cancelTitle = cancelTitle + self.cancelAccessibilityLabel = cancelAccessibilityLabel + self.cancelStyle = cancelStyle + self.cancelEnabled = cancelEnabled + self.hasCloseButton = hasCloseButton + self.dismissOnConfirm = dismissOnConfirm + self.dismissType = dismissType + self.onConfirm = onConfirm + self.onCancel = onCancel + self.afterClosed = afterClosed + } + + // MARK: - Mutation + + public func with( + body: Body? = nil, + confirmEnabled: Bool? = nil, + cancelEnabled: Bool? = nil, + onConfirm: ((ConfirmationModal) -> ())? = nil, + onCancel: ((ConfirmationModal) -> ())? = nil, + afterClosed: (() -> ())? = nil + ) -> Info { + return Info( + title: self.title, + body: (body ?? self.body), + accessibilityLabel: self.accessibilityLabel, + showCondition: self.showCondition, + confirmTitle: self.confirmTitle, + confirmAccessibilityLabel: self.confirmAccessibilityLabel, + confirmStyle: self.confirmStyle, + confirmEnabled: (confirmEnabled ?? self.confirmEnabled), + cancelTitle: self.cancelTitle, + cancelAccessibilityLabel: self.cancelAccessibilityLabel, + cancelStyle: self.cancelStyle, + cancelEnabled: (cancelEnabled ?? self.cancelEnabled), + hasCloseButton: self.hasCloseButton, + dismissOnConfirm: self.dismissOnConfirm, + dismissType: self.dismissType, + onConfirm: (onConfirm ?? self.onConfirm), + onCancel: (onCancel ?? self.onCancel), + afterClosed: (afterClosed ?? self.afterClosed) + ) + } + + // MARK: - Confirmance + + public static func == (lhs: ConfirmationModal.Info, rhs: ConfirmationModal.Info) -> Bool { + return ( + lhs.title == rhs.title && + lhs.body == rhs.body && + lhs.accessibilityLabel == rhs.accessibilityLabel && + lhs.showCondition == rhs.showCondition && + lhs.confirmTitle == rhs.confirmTitle && + lhs.confirmAccessibilityLabel == rhs.confirmAccessibilityLabel && + lhs.confirmStyle == rhs.confirmStyle && + lhs.confirmEnabled == rhs.confirmEnabled && + lhs.cancelTitle == rhs.cancelTitle && + lhs.cancelAccessibilityLabel == rhs.cancelAccessibilityLabel && + lhs.cancelStyle == rhs.cancelStyle && + lhs.cancelEnabled == rhs.cancelEnabled && + lhs.hasCloseButton == rhs.hasCloseButton && + lhs.dismissOnConfirm == rhs.dismissOnConfirm && + lhs.dismissType == rhs.dismissType + ) + } + + public func hash(into hasher: inout Hasher) { + title.hash(into: &hasher) + body.hash(into: &hasher) + accessibilityLabel.hash(into: &hasher) + showCondition.hash(into: &hasher) + confirmTitle.hash(into: &hasher) + confirmAccessibilityLabel.hash(into: &hasher) + confirmStyle.hash(into: &hasher) + confirmEnabled.hash(into: &hasher) + cancelTitle.hash(into: &hasher) + cancelAccessibilityLabel.hash(into: &hasher) + cancelStyle.hash(into: &hasher) + cancelEnabled.hash(into: &hasher) + hasCloseButton.hash(into: &hasher) + dismissOnConfirm.hash(into: &hasher) + dismissType.hash(into: &hasher) + } + } +} + +public extension ConfirmationModal.Info { + // MARK: - ShowCondition + + enum ShowCondition { + case none + case enabled + case disabled + + public func shouldShow(for value: Bool) -> Bool { + switch self { + case .none: return true + case .enabled: return (value == true) + case .disabled: return (value == false) + } + } + } + + // MARK: - Body + + enum Body: Equatable, Hashable { + public enum ImageStyle: Equatable, Hashable { + case inherit + case circular + } + + case none + case text(String) + case attributedText(NSAttributedString) + // FIXME: Implement these + // case input(placeholder: String, value: String?) + // case radio(explanation: NSAttributedString?, options: [(title: String, selected: Bool)]) + case image( + placeholder: UIImage?, + value: UIImage?, + style: ImageStyle, + onClick: (() -> ()) + ) + + public static func == (lhs: ConfirmationModal.Info.Body, rhs: ConfirmationModal.Info.Body) -> Bool { + switch (lhs, rhs) { + case (.none, .none): return true + case (.text(let lhsText), .text(let rhsText)): return (lhsText == rhsText) + case (.attributedText(let lhsText), .attributedText(let rhsText)): return (lhsText == rhsText) + + // FIXME: Implement these + //case (.input(let lhsPlaceholder, let lhsValue), .input(let rhsPlaceholder, let rhsValue)): + // return ( + // lhsPlaceholder == rhsPlaceholder && + // lhsValue == rhsValue && + // ) + + // FIXME: Implement these + //case (.radio(let lhsExplanation, let lhsOptions), .radio(let rhsExplanation, let rhsOptions)): + // return ( + // lhsExplanation == rhsExplanation && + // lhsOptions.map { "\($0.0)-\($0.1)" } == rhsValue.map { "\($0.0)-\($0.1)" } + // ) + + case (.image(let lhsPlaceholder, let lhsValue, let lhsStyle, _), .image(let rhsPlaceholder, let rhsValue, let rhsStyle, _)): + return ( + lhsPlaceholder == rhsPlaceholder && + lhsValue == rhsValue && + lhsStyle == rhsStyle + ) + + default: return false + } + } + + public func hash(into hasher: inout Hasher) { + switch self { + case .none: break + case .text(let text): text.hash(into: &hasher) + case .attributedText(let text): text.hash(into: &hasher) + + case .image(let placeholder, let value, let style, _): + placeholder.hash(into: &hasher) + value.hash(into: &hasher) + style.hash(into: &hasher) + } + } } } diff --git a/SessionUIKit/Components/Modal.swift b/SessionUIKit/Components/Modal.swift index f42d9bdd0..9d52c5748 100644 --- a/SessionUIKit/Components/Modal.swift +++ b/SessionUIKit/Components/Modal.swift @@ -6,6 +6,12 @@ import SessionUtilitiesKit open class Modal: UIViewController, UIGestureRecognizerDelegate { private static let cornerRadius: CGFloat = 11 + public enum DismissType: Equatable, Hashable { + case single + case recursive + } + + private let dismissType: DismissType private let afterClosed: (() -> ())? // MARK: - Components @@ -47,14 +53,19 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { public lazy var cancelButton: UIButton = { let result: UIButton = Modal.createButton(title: "cancel".localized(), titleColor: .textPrimary) - result.addTarget(self, action: #selector(close), for: .touchUpInside) + result.addTarget(self, action: #selector(cancel), for: .touchUpInside) return result }() // MARK: - Lifecycle - public init(targetView: UIView? = nil, afterClosed: (() -> ())? = nil) { + public init( + targetView: UIView? = nil, + dismissType: DismissType = .recursive, + afterClosed: (() -> ())? = nil + ) { + self.dismissType = dismissType self.afterClosed = afterClosed super.init(nibName: nil, bundle: nil) @@ -129,13 +140,22 @@ open class Modal: UIViewController, UIGestureRecognizerDelegate { // MARK: - Interaction - @objc func close() { + @objc public func cancel() { + close() + } + + @objc public final func close() { // Recursively dismiss all modals (ie. find the first modal presented by a non-modal // and get that to dismiss it's presented view controller) var targetViewController: UIViewController? = self - while targetViewController?.presentingViewController is Modal { - targetViewController = targetViewController?.presentingViewController + switch dismissType { + case .single: break + + case .recursive: + while targetViewController?.presentingViewController is Modal { + targetViewController = targetViewController?.presentingViewController + } } targetViewController?.presentingViewController?.dismiss(animated: true) { [weak self] in diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift index 61ead815f..69242e54b 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift @@ -48,6 +48,9 @@ internal enum Theme_ClassicDark: ThemeColors { .radioButton_unselectedBackground: .clear, .radioButton_selectedBorder: .classicDark6, .radioButton_unselectedBorder: .classicDark6, + .radioButton_disabledSelectedBackground: .disabledDark, + .radioButton_disabledUnselectedBackground: .clear, + .radioButton_disabledBorder: .disabledDark, // SessionButton .sessionButton_text: .primary, diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift index 645ec9631..f1e1a9d80 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicLight.swift @@ -48,6 +48,9 @@ internal enum Theme_ClassicLight: ThemeColors { .radioButton_unselectedBackground: .clear, .radioButton_selectedBorder: .classicLight0, .radioButton_unselectedBorder: .classicLight0, + .radioButton_disabledSelectedBackground: .disabledLight, + .radioButton_disabledUnselectedBackground: .clear, + .radioButton_disabledBorder: .disabledLight, // OutlineButton .sessionButton_text: .classicLight0, diff --git a/SessionUIKit/Style Guide/Themes/Theme+Colors.swift b/SessionUIKit/Style Guide/Themes/Theme+Colors.swift index 057157bfd..625a96eb2 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+Colors.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+Colors.swift @@ -44,8 +44,8 @@ internal extension UIColor { static let warning: UIColor = #colorLiteral(red: 0.9882352941, green: 0.6941176471, blue: 0.3490196078, alpha: 1) // #FCB159 static let dangerDark: UIColor = #colorLiteral(red: 1, green: 0.2274509804, blue: 0.2274509804, alpha: 1) // #FF3A3A static let dangerLight: UIColor = #colorLiteral(red: 0.8823529412, green: 0.1764705882, blue: 0.09803921569, alpha: 1) // #E12D19 - static let disabledDark: UIColor = #colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1) // #A1A2A1 - static let disabledLight: UIColor = #colorLiteral(red: 0.4274509804, green: 0.4274509804, blue: 0.4274509804, alpha: 1) // #6D6D6D + static let disabledDark: UIColor = #colorLiteral(red: 0.4274509804, green: 0.4274509804, blue: 0.4274509804, alpha: 1 ) // #6D6D6D + static let disabledLight: UIColor = #colorLiteral(red: 0.631372549, green: 0.6352941176, blue: 0.631372549, alpha: 1) // #A1A2A1 static let black_06: UIColor = #colorLiteral(red: 0, green: 0, blue: 0, alpha: 0.06) // #000000 static let pathConnected: UIColor = #colorLiteral(red: 0.1921568627, green: 0.9450980392, blue: 0.5882352941, alpha: 1) // #31F196 diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift index 4b4b33efe..beb4cba54 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift @@ -48,6 +48,9 @@ internal enum Theme_OceanDark: ThemeColors { .radioButton_unselectedBackground: .clear, .radioButton_selectedBorder: .oceanDark7, .radioButton_unselectedBorder: .oceanDark7, + .radioButton_disabledSelectedBackground: .disabledDark, + .radioButton_disabledUnselectedBackground: .clear, + .radioButton_disabledBorder: .disabledDark, // SessionButton .sessionButton_text: .primary, diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift index f752f21de..c1e72799e 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanLight.swift @@ -48,6 +48,9 @@ internal enum Theme_OceanLight: ThemeColors { .radioButton_unselectedBackground: .clear, .radioButton_selectedBorder: .oceanLight1, .radioButton_unselectedBorder: .oceanLight3, + .radioButton_disabledSelectedBackground: .disabledLight, + .radioButton_disabledUnselectedBackground: .clear, + .radioButton_disabledBorder: .disabledLight, // SessionButton .sessionButton_text: .oceanLight1, diff --git a/SessionUIKit/Style Guide/Themes/Theme.swift b/SessionUIKit/Style Guide/Themes/Theme.swift index 57a5fb200..f2766cc6d 100644 --- a/SessionUIKit/Style Guide/Themes/Theme.swift +++ b/SessionUIKit/Style Guide/Themes/Theme.swift @@ -136,6 +136,9 @@ public indirect enum ThemeValue: Hashable { case radioButton_unselectedBackground case radioButton_selectedBorder case radioButton_unselectedBorder + case radioButton_disabledSelectedBackground + case radioButton_disabledUnselectedBackground + case radioButton_disabledBorder // SessionButton case sessionButton_text diff --git a/SessionUIKit/Style Guide/Values.swift b/SessionUIKit/Style Guide/Values.swift index fd12c70c9..a66d47801 100644 --- a/SessionUIKit/Style Guide/Values.swift +++ b/SessionUIKit/Style Guide/Values.swift @@ -21,7 +21,7 @@ public final class Values : NSObject { @objc public static let smallButtonHeight = isIPhone5OrSmaller ? CGFloat(24) : CGFloat(28) @objc public static let mediumButtonHeight = isIPhone5OrSmaller ? CGFloat(30) : CGFloat(34) @objc public static let largeButtonHeight = isIPhone5OrSmaller ? CGFloat(40) : CGFloat(45) - @objc public static let alertButtonHeight: CGFloat = 50 + @objc public static let alertButtonHeight: CGFloat = 51 // 19px tall font with 16px margins @objc public static let accentLineThickness = CGFloat(4) diff --git a/SessionUIKit/Utilities/UIView+Utilities.swift b/SessionUIKit/Utilities/UIView+Utilities.swift index 7d37a2185..5e3e5af30 100644 --- a/SessionUIKit/Utilities/UIView+Utilities.swift +++ b/SessionUIKit/Utilities/UIView+Utilities.swift @@ -17,13 +17,13 @@ public extension UIView { class func spacer(withWidth width: CGFloat) -> UIView { let view = UIView() - view.autoSetDimension(.width, toSize: width) + view.set(.width, to: width) return view } class func spacer(withHeight height: CGFloat) -> UIView { let view = UIView() - view.autoSetDimension(.height, toSize: height) + view.set(.height, to: height) return view } diff --git a/SessionUtilitiesKit/Database/Models/Job.swift b/SessionUtilitiesKit/Database/Models/Job.swift index 0c98cbf81..737cf9616 100644 --- a/SessionUtilitiesKit/Database/Models/Job.swift +++ b/SessionUtilitiesKit/Database/Models/Job.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -public struct Job: Codable, Hashable, Equatable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { +public struct Job: Codable, Equatable, Hashable, Identifiable, FetchableRecord, MutablePersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "job" } internal static let dependencyForeignKey = ForeignKey([Columns.id], to: [JobDependencies.Columns.dependantId]) public static let dependantJobDependency = hasMany( diff --git a/SessionUtilitiesKit/Database/Models/JobDependencies.swift b/SessionUtilitiesKit/Database/Models/JobDependencies.swift index 3613d6912..16201367b 100644 --- a/SessionUtilitiesKit/Database/Models/JobDependencies.swift +++ b/SessionUtilitiesKit/Database/Models/JobDependencies.swift @@ -3,7 +3,7 @@ import Foundation import GRDB -public struct JobDependencies: Codable, Hashable, Equatable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { +public struct JobDependencies: Codable, Equatable, Hashable, FetchableRecord, PersistableRecord, TableRecord, ColumnExpressible { public static var databaseTableName: String { "jobDependencies" } internal static let jobForeignKey = ForeignKey([Columns.jobId], to: [Job.Columns.id]) internal static let dependantForeignKey = ForeignKey([Columns.dependantId], to: [Job.Columns.id]) diff --git a/SessionUtilitiesKit/General/Atomic.swift b/SessionUtilitiesKit/General/Atomic.swift index 865745e14..d16ac102c 100644 --- a/SessionUtilitiesKit/General/Atomic.swift +++ b/SessionUtilitiesKit/General/Atomic.swift @@ -1,4 +1,4 @@ -// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved. +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. import Foundation @@ -6,23 +6,28 @@ import Foundation /// The `Atomic<Value>` wrapper is a generic wrapper providing a thread-safe way to get and set a value /// -/// A write-up on the need for this class and it's approach can be found here: +/// A write-up on the need for this class and it's approaches can be found at these links: +/// https://www.vadimbulavin.com/atomic-properties/ /// https://www.vadimbulavin.com/swift-atomic-properties-with-property-wrappers/ /// there is also another approach which can be taken but it requires separate types for collections and results in /// a somewhat inconsistent interface between different `Atomic` wrappers +/// +/// We use a Read-write lock approach because the `DispatchQueue` approach means mutating the property +/// occurs on a different thread, and GRDB requires it's changes to be executed on specific threads so using a lock +/// is more compatible (and Read-write locks allow for concurrent reads which shouldn't be a huge issue but could +/// help reduce cases of blocking) @propertyWrapper public class Atomic<Value> { - // Note: Using 'userInteractive' to ensure this can't be blockedby higher priority queues - // which could result in the main thread getting blocked - private let queue: DispatchQueue = DispatchQueue( - label: "io.oxen.\(UUID().uuidString)", - qos: .userInteractive - ) private var value: Value + private let lock: ReadWriteLock = ReadWriteLock() /// In order to change the value you **must** use the `mutate` function public var wrappedValue: Value { - return queue.sync { return value } + lock.readLock() + let result: Value = value + lock.unlock() + + return result } /// For more information see https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md#projections @@ -36,12 +41,34 @@ public class Atomic<Value> { self.value = initialValue } + public init(wrappedValue: Value) { + self.value = wrappedValue + } + // MARK: - Functions @discardableResult public func mutate<T>(_ mutation: (inout Value) -> T) -> T { - return queue.sync { - return mutation(&value) + lock.writeLock() + let result: T = mutation(&value) + lock.unlock() + + return result + } + + @discardableResult public func mutate<T>(_ mutation: (inout Value) throws -> T) throws -> T { + let result: T + + do { + lock.writeLock() + result = try mutation(&value) + lock.unlock() } + catch { + lock.unlock() + throw error + } + + return result } } @@ -50,3 +77,25 @@ extension Atomic where Value: CustomDebugStringConvertible { return value.debugDescription } } + +// MARK: - ReadWriteLock + +private class ReadWriteLock { + private var rwlock: pthread_rwlock_t = { + var rwlock = pthread_rwlock_t() + pthread_rwlock_init(&rwlock, nil) + return rwlock + }() + + func writeLock() { + pthread_rwlock_wrlock(&rwlock) + } + + func readLock() { + pthread_rwlock_rdlock(&rwlock) + } + + func unlock() { + pthread_rwlock_unlock(&rwlock) + } +} diff --git a/SessionUtilitiesKit/General/General.swift b/SessionUtilitiesKit/General/General.swift index 72576fb56..b901af73a 100644 --- a/SessionUtilitiesKit/General/General.swift +++ b/SessionUtilitiesKit/General/General.swift @@ -35,14 +35,3 @@ public func getUserHexEncodedPublicKey(_ db: Database? = nil, dependencies: Depe return "" } - -/// Does nothing, but is never inlined and thus evaluating its argument will never be optimized away. -/// -/// Useful for forcing the instantiation of lazy properties like globals. -@inline(never) -public func touch<Value>(_ value: Value) { /* Do nothing */ } - -/// Returns `f(x!)` if `x != nil`, or `nil` otherwise. -public func given<T, U>(_ x: T?, _ f: (T) throws -> U) rethrows -> U? { return try x.map(f) } - -public func with<T, U>(_ x: T, _ f: (T) throws -> U) rethrows -> U { return try f(x) } diff --git a/SessionUtilitiesKit/General/String+Utilities.swift b/SessionUtilitiesKit/General/String+Utilities.swift index 39bbbb913..44074f0da 100644 --- a/SessionUtilitiesKit/General/String+Utilities.swift +++ b/SessionUtilitiesKit/General/String+Utilities.swift @@ -84,6 +84,15 @@ public extension String { let secondsPerWeek: TimeInterval = (secondsPerDay * 7) switch format { + case .videoDuration: + let seconds: Int = Int(duration.truncatingRemainder(dividingBy: 60)) + let minutes: Int = Int((duration / 60).truncatingRemainder(dividingBy: 60)) + let hours: Int = Int(duration / 3600) + + guard hours > 0 else { return String(format: "%02ld:%02ld", minutes, seconds) } + + return String(format: "%ld:%02ld:%02ld", hours, minutes, seconds) + case .hoursMinutesSeconds: let seconds: Int = Int(duration.truncatingRemainder(dividingBy: 60)) let minutes: Int = Int((duration / 60).truncatingRemainder(dividingBy: 60)) diff --git a/SessionUtilitiesKit/General/TimeInterval+Utilities.swift b/SessionUtilitiesKit/General/TimeInterval+Utilities.swift index 89b3cc596..c0c668374 100644 --- a/SessionUtilitiesKit/General/TimeInterval+Utilities.swift +++ b/SessionUtilitiesKit/General/TimeInterval+Utilities.swift @@ -7,6 +7,7 @@ public extension TimeInterval { case short case long case hoursMinutesSeconds + case videoDuration } func formatted(format: DurationFormat) -> String { diff --git a/SessionUtilitiesKit/General/UIView+OWS.h b/SessionUtilitiesKit/General/UIView+OWS.h index 70bb155b2..77d68311e 100644 --- a/SessionUtilitiesKit/General/UIView+OWS.h +++ b/SessionUtilitiesKit/General/UIView+OWS.h @@ -2,7 +2,6 @@ // Copyright (c) 2019 Open Whisper Systems. All rights reserved. // -#import <PureLayout/PureLayout.h> #import <UIKit/UIKit.h> NS_ASSUME_NONNULL_BEGIN diff --git a/SessionUtilitiesKit/General/UIView+OWS.m b/SessionUtilitiesKit/General/UIView+OWS.m index 490ab1efd..7c831764a 100644 --- a/SessionUtilitiesKit/General/UIView+OWS.m +++ b/SessionUtilitiesKit/General/UIView+OWS.m @@ -5,6 +5,7 @@ #import "UIView+OWS.h" #import "OWSMath.h" +#import <PureLayout/PureLayout.h> #import <SessionUtilitiesKit/AppContext.h> NS_ASSUME_NONNULL_BEGIN diff --git a/SessionUtilitiesKit/JobRunner/JobRunner.swift b/SessionUtilitiesKit/JobRunner/JobRunner.swift index 4404ba7ca..96e517ebf 100644 --- a/SessionUtilitiesKit/JobRunner/JobRunner.swift +++ b/SessionUtilitiesKit/JobRunner/JobRunner.swift @@ -790,17 +790,18 @@ public final class JobQueue { } } - fileprivate func appDidBecomeActive( - with jobs: [Job], - canStart: Bool, - dependencies: Dependencies - ) { + fileprivate func appDidBecomeActive(with jobs: [Job], canStart: Bool) { + let currentlyRunningJobIds: Set<Int64> = jobsCurrentlyRunning.wrappedValue + pendingJobsQueue.mutate { queue in - // Avoid re-adding jobs to the pendingJobsQueue that are already in it (this can + // Avoid re-adding jobs to the queue that are already in it (this can // happen if the user sends the app to the background before the 'onActive' // jobs and then brings it back to the foreground) let jobsNotAlreadyInQueue: [Job] = jobs - .filter { job in !queue.contains(where: { $0.id == job.id }) } + .filter { job in + !currentlyRunningJobIds.contains(job.id ?? -1) && + !queue.contains(where: { $0.id == job.id }) + } queue.append(contentsOf: jobsNotAlreadyInQueue) } @@ -822,6 +823,16 @@ public final class JobQueue { } } + fileprivate func hasPendingOrRunningJob(with detailsData: Data?) -> Bool { + guard let detailsData: Data = detailsData else { return false } + + let pendingJobs: [Job] = queue.wrappedValue + + guard !pendingJobs.contains(where: { job in job.details == detailsData }) else { return true } + + return detailsForCurrentlyRunningJobs.wrappedValue.values.contains(detailsData) + } + fileprivate func removePendingJob(_ jobId: Int64) { pendingJobsQueue.mutate { queue in queue = queue.filter { $0.id != jobId } @@ -997,16 +1008,22 @@ public final class JobQueue { guard dependencyInfo.jobs.isEmpty else { SNLog("[JobRunner] \(queueContext) found job with \(dependencyInfo.jobs.count) dependencies, running those first") - /// Remove all jobs this one is dependant on from the queue and re-insert them at the start of the queue + /// Remove all jobs this one is dependant on that aren't currently running from the queue and re-insert them at the start + /// of the queue /// /// **Note:** We don't add the current job back the the queue because it should only be re-added if it's dependencies /// are successfully completed + let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys) + let dependencyJobsNotCurrentlyRunning: [Job] = dependencyInfo.jobs + .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } + .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } + pendingJobsQueue.mutate { queue in queue = queue - .filter { !dependencyInfo.jobs.contains($0) } - .inserting(contentsOf: Array(dependencyInfo.jobs), at: 0) + .filter { !dependencyJobsNotCurrentlyRunning.contains($0) } + .inserting(contentsOf: dependencyJobsNotCurrentlyRunning, at: 0) } - handleJobDeferred(nextJob, dependencies: dependencies) + handleJobDeferred(nextJob) return } @@ -1194,17 +1211,22 @@ public final class JobQueue { default: break } - /// Now that the job has been completed we want to insert any jobs that were dependant on it to the start of the queue (the - /// most likely case is that we want an entire job chain to be completed at the same time rather than being blocked by other - /// unrelated jobs) + /// Now that the job has been completed we want to insert any jobs that were dependant on it, that aren't already running + /// to the start of the queue (the most likely case is that we want an entire job chain to be completed at the same time rather + /// than being blocked by other unrelated jobs) /// /// **Note:** If any of these `dependantJobs` have other dependencies then when they attempt to start they will be /// removed from the queue, replaced by their dependencies if !dependantJobs.isEmpty { + let currentlyRunningJobIds: [Int64] = Array(detailsForCurrentlyRunningJobs.wrappedValue.keys) + let dependantJobsNotCurrentlyRunning: [Job] = dependantJobs + .filter { job in !currentlyRunningJobIds.contains(job.id ?? -1) } + .sorted { lhs, rhs in (lhs.id ?? -1) < (rhs.id ?? -1) } + pendingJobsQueue.mutate { queue in queue = queue - .filter { !dependantJobs.contains($0) } - .inserting(contentsOf: dependantJobs, at: 0) + .filter { !dependantJobsNotCurrentlyRunning.contains($0) } + .inserting(contentsOf: dependantJobsNotCurrentlyRunning, at: 0) } } @@ -1275,15 +1297,16 @@ public final class JobQueue { .select(.id) .asRequest(of: Int64.self) .fetchAll(db) - + if !dependantJobIds.isEmpty { pendingJobsQueue.mutate { queue in queue = queue.filter { !dependantJobIds.contains($0.id ?? -1) } } } - + /// Delete/update the failed jobs and any dependencies let updatedFailureCount: UInt = (job.failureCount + 1) + guard !permanentFailure && ( maxFailureCount < 0 || diff --git a/SessionUtilitiesKit/Networking/HTTP.swift b/SessionUtilitiesKit/Networking/HTTP.swift index cbe2ec9de..1c5f586c0 100644 --- a/SessionUtilitiesKit/Networking/HTTP.swift +++ b/SessionUtilitiesKit/Networking/HTTP.swift @@ -8,20 +8,24 @@ public enum HTTP { private static let snodeURLSessionDelegate = SnodeURLSessionDelegateImplementation() // MARK: Certificates + + /// **Note:** These certificates will need to be regenerated and replaced at the start of April 2025, iOS has a restriction after iOS 13 + /// where certificates can have a maximum lifetime of 825 days (https://support.apple.com/en-au/HT210176) as a result we + /// can't use the 10 year certificates that the other platforms use private static let storageSeed1Cert: SecCertificate = { - let path = Bundle.main.path(forResource: "storage-seed-1", ofType: "der")! + let path = Bundle.main.path(forResource: "seed1-2023-2y", ofType: "der")! let data = try! Data(contentsOf: URL(fileURLWithPath: path)) return SecCertificateCreateWithData(nil, data as CFData)! }() - + + private static let storageSeed2Cert: SecCertificate = { + let path = Bundle.main.path(forResource: "seed2-2023-2y", ofType: "der")! + let data = try! Data(contentsOf: URL(fileURLWithPath: path)) + return SecCertificateCreateWithData(nil, data as CFData)! + }() + private static let storageSeed3Cert: SecCertificate = { - let path = Bundle.main.path(forResource: "storage-seed-3", ofType: "der")! - let data = try! Data(contentsOf: URL(fileURLWithPath: path)) - return SecCertificateCreateWithData(nil, data as CFData)! - }() - - private static let publicLokiFoundationCert: SecCertificate = { - let path = Bundle.main.path(forResource: "public-loki-foundation", ofType: "der")! + let path = Bundle.main.path(forResource: "seed3-2023-2y", ofType: "der")! let data = try! Data(contentsOf: URL(fileURLWithPath: path)) return SecCertificateCreateWithData(nil, data as CFData)! }() @@ -37,41 +41,51 @@ public enum HTTP { return completionHandler(.cancelAuthenticationChallenge, nil) } // Mark the seed node certificates as trusted - let certificates = [ storageSeed1Cert, storageSeed3Cert, publicLokiFoundationCert ] + let certificates = [ storageSeed1Cert, storageSeed2Cert, storageSeed3Cert ] guard SecTrustSetAnchorCertificates(trust, certificates as CFArray) == errSecSuccess else { + SNLog("Failed to set seed node certificates.") return completionHandler(.cancelAuthenticationChallenge, nil) } - // We want to make sure that the pinned certification was valid during it's validity - // period (which has now expired) so set the date to validate against to be within the - // valid period - let dateFormatter: DateFormatter = DateFormatter() - dateFormatter.dateFormat = "dd/MM/yyyy HH:mm:ss" - - if let validDate: Date = dateFormatter.date(from: "01/01/2022 12:00:00") { - if SecTrustSetVerifyDate(trust, validDate as CFDate) != errSecSuccess { - SNLog("Unable to set date for seed node certificate validation.") - } - } - else { - SNLog("Unable to set date for seed node certificate validation.") - } - // Check that the presented certificate is one of the seed node certificates - var result: SecTrustResultType = .invalid + var error: CFError? + guard SecTrustEvaluateWithError(trust, &error) else { + // Extract the result for further processing (since we are defaulting to `invalid` we + // don't care if extracting the result type fails) + var result: SecTrustResultType = .invalid + _ = SecTrustGetTrustResult(trust, &result) + + switch result { + case .proceed, .unspecified: + /// Unspecified indicates that evaluation reached an (implicitly trusted) anchor certificate without any evaluation + /// failures, but never encountered any explicitly stated user-trust preference. This is the most common return + /// value. The Keychain Access utility refers to this value as the "Use System Policy," which is the default user setting. + return completionHandler(.useCredential, URLCredential(trust: trust)) + + case .recoverableTrustFailure: + /// A recoverable failure generally suggests that the certificate was mostly valid but something minor didn't line up, + /// while we don't want to recover in this case it's probably a good idea to include the reason in the logs to simplify + /// debugging if it does end up happening + let reason: String = { + guard + let validationResult: [String: Any] = SecTrustCopyResult(trust) as? [String: Any], + let details: [String: Any] = (validationResult["TrustResultDetails"] as? [[String: Any]])? + .reduce(into: [:], { result, next in next.forEach { result[$0.key] = $0.value } }) + else { return "Unknown" } + + return "\(details)" + }() + + SNLog("Failed to validate a seed certificate with a recoverable error: \(reason)") + return completionHandler(.cancelAuthenticationChallenge, nil) + + default: + SNLog("Failed to validate a seed certificate with an unrecoverable error.") + return completionHandler(.cancelAuthenticationChallenge, nil) + } + } - guard SecTrustEvaluate(trust, &result) == errSecSuccess else { - return completionHandler(.cancelAuthenticationChallenge, nil) - } - switch result { - case .proceed, .unspecified: - // Unspecified indicates that evaluation reached an (implicitly trusted) anchor certificate without - // any evaluation failures, but never encountered any explicitly stated user-trust preference. This - // is the most common return value. The Keychain Access utility refers to this value as the "Use System - // Policy," which is the default user setting. - return completionHandler(.useCredential, URLCredential(trust: trust)) - default: return completionHandler(.cancelAuthenticationChallenge, nil) - } + return completionHandler(.useCredential, URLCredential(trust: trust)) } } diff --git a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift index f290c43f5..842fb68c6 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/Attachment Approval/AttachmentTextToolbar.swift @@ -5,6 +5,7 @@ import Foundation import UIKit import SessionUIKit +import PureLayout // Coincides with Android's max text message length let kMaxMessageBodyCharacterCount = 2000 diff --git a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift index 99cd2c61b..f59f8357e 100644 --- a/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift +++ b/SignalUtilitiesKit/Media Viewing & Editing/MediaMessageView.swift @@ -653,7 +653,7 @@ public class MediaMessageView: UIView, OWSAudioPlayerDelegate { targetView: CurrentAppContext().frontmostViewController()?.view, info: ConfirmationModal.Info( title: CommonStrings.errorAlertTitle, - explanation: "INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized(), + body: .text("INVALID_AUDIO_FILE_ALERT_ERROR_MESSAGE".localized()), cancelTitle: "BUTTON_OK".localized(), cancelStyle: .alert_text )