Added toast and info message deletion, fixed layout issues & unit tests
Added a toast when hitting the emoji reacts rate limit Added the ability to delete info messages Fixed some odd layout behaviours with the VisibleMessageCell Fixed some layout issues with reactions Removed some unneeded custom code
This commit is contained in:
parent
db54bf657e
commit
27e0981913
|
@ -573,6 +573,13 @@
|
||||||
FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; };
|
FD17D7E727F6A16700122BE0 /* _003_YDBToGRDBMigration.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */; };
|
||||||
FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */; };
|
FD17D7EA27F6A1C600122BE0 /* SUKLegacy.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */; };
|
||||||
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; };
|
FD1C98E4282E3C5B00B76F9E /* UINavigationBar+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */; };
|
||||||
|
FD23EA5C28ED00F80058676E /* Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A527D860CE005DAE71 /* Mock.swift */; };
|
||||||
|
FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD83B9BD27CF2243005E1583 /* TestConstants.swift */; };
|
||||||
|
FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */; };
|
||||||
|
FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD078E4727E02561000769AF /* CommonMockedExtensions.swift */; };
|
||||||
|
FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; };
|
||||||
|
FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; };
|
||||||
|
FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD23EA6028ED0B260058676E /* CombineExtensions.swift */; };
|
||||||
FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; };
|
FD245C50285065C700B966DD /* VisibleMessage+Quote.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3C2A7552553A3AB00C340D1 /* VisibleMessage+Quote.swift */; };
|
||||||
FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; };
|
FD245C51285065CC00B966DD /* MessageReceiver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5FB2554B0A000555489 /* MessageReceiver.swift */; };
|
||||||
FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; };
|
FD245C52285065D500B966DD /* SignalAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = C38EF224255B6D5D007E1867 /* SignalAttachment.swift */; };
|
||||||
|
@ -599,6 +606,11 @@
|
||||||
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; };
|
FD245C6B2850667400B966DD /* VisibleMessage+Profile.swift in Sources */ = {isa = PBXBuildFile; fileRef = C300A5B12554AF9800555489 /* VisibleMessage+Profile.swift */; };
|
||||||
FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; };
|
FD245C6C2850669200B966DD /* MessageReceiveJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A31225574F5200338F3E /* MessageReceiveJob.swift */; };
|
||||||
FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; };
|
FD245C6D285066A400B966DD /* NotifyPushServerJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = C352A32E2557549C00338F3E /* NotifyPushServerJob.swift */; };
|
||||||
|
FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
|
||||||
|
FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */; };
|
||||||
|
FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
|
||||||
|
FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
|
||||||
|
FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */; };
|
||||||
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; };
|
FD37E9C328A1C6F3003AE748 /* ThemeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */; };
|
||||||
FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; };
|
FD37E9C628A1D4EC003AE748 /* Theme+ClassicDark.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */; };
|
||||||
FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */; };
|
FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */ = {isa = PBXBuildFile; fileRef = FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */; };
|
||||||
|
@ -1678,8 +1690,10 @@
|
||||||
FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
|
FD17D7E627F6A16700122BE0 /* _003_YDBToGRDBMigration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = _003_YDBToGRDBMigration.swift; sourceTree = "<group>"; };
|
||||||
FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = "<group>"; };
|
FD17D7E927F6A1C600122BE0 /* SUKLegacy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SUKLegacy.swift; sourceTree = "<group>"; };
|
||||||
FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = "<group>"; };
|
FD1C98E3282E3C5B00B76F9E /* UINavigationBar+Utilities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationBar+Utilities.swift"; sourceTree = "<group>"; };
|
||||||
|
FD23EA6028ED0B260058676E /* CombineExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineExtensions.swift; sourceTree = "<group>"; };
|
||||||
FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
|
FD245C612850664300B966DD /* Configuration.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Configuration.swift; sourceTree = "<group>"; };
|
||||||
FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
|
FD28A4F527EAD44C00FF65E7 /* Storage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Storage.swift; sourceTree = "<group>"; };
|
||||||
|
FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SynchronousStorage.swift; sourceTree = "<group>"; };
|
||||||
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
FD37E9C228A1C6F3003AE748 /* ThemeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeManager.swift; sourceTree = "<group>"; };
|
||||||
FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = "<group>"; };
|
FD37E9C528A1D4EC003AE748 /* Theme+ClassicDark.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+ClassicDark.swift"; sourceTree = "<group>"; };
|
||||||
FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = "<group>"; };
|
FD37E9C728A1D73F003AE748 /* Theme+Colors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+Colors.swift"; sourceTree = "<group>"; };
|
||||||
|
@ -3374,7 +3388,7 @@
|
||||||
C3C2A5A0255385C100C340D1 /* SessionSnodeKit */,
|
C3C2A5A0255385C100C340D1 /* SessionSnodeKit */,
|
||||||
C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */,
|
C3C2A67A255388CC00C340D1 /* SessionUtilitiesKit */,
|
||||||
C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */,
|
C33FD9AC255A548A00E217F9 /* SignalUtilitiesKit */,
|
||||||
FD83B9BC27CF2215005E1583 /* SharedTest */,
|
FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */,
|
||||||
FD71160A28D00BAE00B47552 /* SessionTests */,
|
FD71160A28D00BAE00B47552 /* SessionTests */,
|
||||||
FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */,
|
FDC4388F27B9FFC700C60D73 /* SessionMessagingKitTests */,
|
||||||
FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */,
|
FD83B9B027CF200A005E1583 /* SessionUtilitiesKitTests */,
|
||||||
|
@ -3897,15 +3911,18 @@
|
||||||
path = General;
|
path = General;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
FD83B9BC27CF2215005E1583 /* SharedTest */ = {
|
FD83B9BC27CF2215005E1583 /* _SharedTestUtilities */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FDC290A527D860CE005DAE71 /* Mock.swift */,
|
FDC290A527D860CE005DAE71 /* Mock.swift */,
|
||||||
|
FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */,
|
||||||
FD83B9BD27CF2243005E1583 /* TestConstants.swift */,
|
FD83B9BD27CF2243005E1583 /* TestConstants.swift */,
|
||||||
FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */,
|
FDC290A727D9B46D005DAE71 /* NimbleExtensions.swift */,
|
||||||
FD078E4727E02561000769AF /* CommonMockedExtensions.swift */,
|
FD078E4727E02561000769AF /* CommonMockedExtensions.swift */,
|
||||||
|
FD23EA6028ED0B260058676E /* CombineExtensions.swift */,
|
||||||
|
FD2AAAEF28ED57B500A49611 /* SynchronousStorage.swift */,
|
||||||
);
|
);
|
||||||
path = SharedTest;
|
path = _SharedTestUtilities;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
FD83B9C127CF33EE005E1583 /* Models */ = {
|
FD83B9C127CF33EE005E1583 /* Models */ = {
|
||||||
|
@ -4052,7 +4069,6 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
FDC438BC27BB2AB400C60D73 /* Mockable.swift */,
|
FDC438BC27BB2AB400C60D73 /* Mockable.swift */,
|
||||||
FDFD645C27F273F300808CA1 /* MockGeneralCache.swift */,
|
|
||||||
FD859EF327C2F49200510D0C /* MockSodium.swift */,
|
FD859EF327C2F49200510D0C /* MockSodium.swift */,
|
||||||
FD3C906E27E43E8700CD579F /* MockBox.swift */,
|
FD3C906E27E43E8700CD579F /* MockBox.swift */,
|
||||||
FD859EF927C2F5C500510D0C /* MockGenericHash.swift */,
|
FD859EF927C2F5C500510D0C /* MockGenericHash.swift */,
|
||||||
|
@ -5752,8 +5768,15 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */,
|
FD71161728D00DA400B47552 /* ThreadSettingsViewModelSpec.swift in Sources */,
|
||||||
|
FD2AAAF028ED57B500A49611 /* SynchronousStorage.swift in Sources */,
|
||||||
FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */,
|
FD71161528D00D6700B47552 /* ThreadDisappearingMessagesViewModelSpec.swift in Sources */,
|
||||||
|
FD23EA5E28ED00FD0058676E /* NimbleExtensions.swift in Sources */,
|
||||||
|
FD23EA5F28ED00FF0058676E /* CommonMockedExtensions.swift in Sources */,
|
||||||
|
FD23EA5D28ED00FA0058676E /* TestConstants.swift in Sources */,
|
||||||
FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */,
|
FD71161A28D00E1100B47552 /* NotificationContentViewModelSpec.swift in Sources */,
|
||||||
|
FD23EA5C28ED00F80058676E /* Mock.swift in Sources */,
|
||||||
|
FD23EA6128ED0B260058676E /* CombineExtensions.swift in Sources */,
|
||||||
|
FD2AAAED28ED3E1000A49611 /* MockGeneralCache.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -5761,10 +5784,13 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
FD2AAAF228ED57B500A49611 /* SynchronousStorage.swift in Sources */,
|
||||||
FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */,
|
FD078E4927E02576000769AF /* CommonMockedExtensions.swift in Sources */,
|
||||||
FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */,
|
FD83B9BF27CF2294005E1583 /* TestConstants.swift in Sources */,
|
||||||
FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */,
|
FD83B9BB27CF20AF005E1583 /* SessionIdSpec.swift in Sources */,
|
||||||
FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */,
|
FDC290A927D9B46D005DAE71 /* NimbleExtensions.swift in Sources */,
|
||||||
|
FD23EA6328ED0B260058676E /* CombineExtensions.swift in Sources */,
|
||||||
|
FD2AAAEE28ED3E1100A49611 /* MockGeneralCache.swift in Sources */,
|
||||||
FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */,
|
FD37EA1528AB42CB003AE748 /* IdentitySpec.swift in Sources */,
|
||||||
FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */,
|
FDC290AA27D9B6FD005DAE71 /* Mock.swift in Sources */,
|
||||||
);
|
);
|
||||||
|
@ -5789,6 +5815,7 @@
|
||||||
FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */,
|
FD83B9C727CF3F10005E1583 /* CapabilitiesSpec.swift in Sources */,
|
||||||
FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */,
|
FDC2909A27D71376005DAE71 /* NonceGeneratorSpec.swift in Sources */,
|
||||||
FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */,
|
FD3C906427E4122F00CD579F /* RequestSpec.swift in Sources */,
|
||||||
|
FD2AAAF128ED57B500A49611 /* SynchronousStorage.swift in Sources */,
|
||||||
FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */,
|
FD078E4827E02561000769AF /* CommonMockedExtensions.swift in Sources */,
|
||||||
FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */,
|
FD859EF827C2F58900510D0C /* MockAeadXChaCha20Poly1305Ietf.swift in Sources */,
|
||||||
FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */,
|
FDC2909827D7129B005DAE71 /* PersonalizationSpec.swift in Sources */,
|
||||||
|
@ -5800,6 +5827,7 @@
|
||||||
FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */,
|
FD83B9C027CF2294005E1583 /* TestConstants.swift in Sources */,
|
||||||
FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */,
|
FD3C906F27E43E8700CD579F /* MockBox.swift in Sources */,
|
||||||
FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */,
|
FDC4389A27BA002500C60D73 /* OpenGroupAPISpec.swift in Sources */,
|
||||||
|
FD23EA6228ED0B260058676E /* CombineExtensions.swift in Sources */,
|
||||||
FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */,
|
FD83B9C527CF3E2A005E1583 /* OpenGroupSpec.swift in Sources */,
|
||||||
FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */,
|
FDC2908B27D707F3005DAE71 /* SendMessageRequestSpec.swift in Sources */,
|
||||||
FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */,
|
FDC290A827D9B46D005DAE71 /* NimbleExtensions.swift in Sources */,
|
||||||
|
|
|
@ -118,6 +118,18 @@
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</CodeCoverageTargets>
|
</CodeCoverageTargets>
|
||||||
<Testables>
|
<Testables>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO"
|
||||||
|
parallelizable = "YES"
|
||||||
|
testExecutionOrdering = "random">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "FD71160828D00BAE00B47552"
|
||||||
|
BuildableName = "SessionTests.xctest"
|
||||||
|
BlueprintName = "SessionTests"
|
||||||
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
<TestableReference
|
<TestableReference
|
||||||
skipped = "NO"
|
skipped = "NO"
|
||||||
parallelizable = "YES"
|
parallelizable = "YES"
|
||||||
|
@ -142,18 +154,6 @@
|
||||||
ReferencedContainer = "container:Session.xcodeproj">
|
ReferencedContainer = "container:Session.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
<TestableReference
|
|
||||||
skipped = "NO"
|
|
||||||
parallelizable = "YES"
|
|
||||||
testExecutionOrdering = "random">
|
|
||||||
<BuildableReference
|
|
||||||
BuildableIdentifier = "primary"
|
|
||||||
BlueprintIdentifier = "FD71160828D00BAE00B47552"
|
|
||||||
BuildableName = "SessionTests.xctest"
|
|
||||||
BlueprintName = "SessionTests"
|
|
||||||
ReferencedContainer = "container:Session.xcodeproj">
|
|
||||||
</BuildableReference>
|
|
||||||
</TestableReference>
|
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
|
|
|
@ -45,27 +45,18 @@ final class CallVC: UIViewController, VideoPreviewDelegate {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var fadeView: UIView = {
|
private lazy var fadeView: GradientView = {
|
||||||
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top).map { $0 + Values.veryLargeSpacing } ?? 64)
|
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top)
|
||||||
|
.map { $0 + Values.veryLargeSpacing })
|
||||||
let result = UIView()
|
.defaulting(to: 64)
|
||||||
var frame = UIScreen.main.bounds
|
|
||||||
frame.size.height = height
|
let result: GradientView = GradientView()
|
||||||
|
result.themeBackgroundGradient = [
|
||||||
let layer = CAGradientLayer()
|
.value(.backgroundPrimary, alpha: 0.4),
|
||||||
layer.frame = frame
|
.value(.backgroundPrimary, alpha: 0)
|
||||||
result.layer.insertSublayer(layer, at: 0)
|
]
|
||||||
result.set(.height, to: height)
|
result.set(.height, to: height)
|
||||||
|
|
||||||
ThemeManager.onThemeChange(observer: result) { [weak layer] theme, _ in
|
|
||||||
guard let backgroundPrimary: UIColor = theme.color(for: .backgroundPrimary) else { return }
|
|
||||||
|
|
||||||
layer?.colors = [
|
|
||||||
backgroundPrimary.withAlphaComponent(0.4).cgColor,
|
|
||||||
backgroundPrimary.withAlphaComponent(0).cgColor
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -26,27 +26,18 @@ class VideoPreviewVC: UIViewController, CameraManagerDelegate {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var fadeView: UIView = {
|
private lazy var fadeView: GradientView = {
|
||||||
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top).map { $0 + Values.veryLargeSpacing } ?? 64)
|
let height: CGFloat = ((UIApplication.shared.keyWindow?.safeAreaInsets.top)
|
||||||
|
.map { $0 + Values.veryLargeSpacing })
|
||||||
let result = UIView()
|
.defaulting(to: 64)
|
||||||
var frame = UIScreen.main.bounds
|
|
||||||
frame.size.height = height
|
let result: GradientView = GradientView()
|
||||||
|
result.themeBackgroundGradient = [
|
||||||
let layer = CAGradientLayer()
|
.value(.backgroundPrimary, alpha: 0.4),
|
||||||
layer.frame = frame
|
.value(.backgroundPrimary, alpha: 0)
|
||||||
result.layer.insertSublayer(layer, at: 0)
|
]
|
||||||
result.set(.height, to: height)
|
result.set(.height, to: height)
|
||||||
|
|
||||||
ThemeManager.onThemeChange(observer: result) { [weak layer] theme, _ in
|
|
||||||
guard let backgroundPrimary: UIColor = theme.color(for: .backgroundPrimary) else { return }
|
|
||||||
|
|
||||||
layer?.colors = [
|
|
||||||
backgroundPrimary.withAlphaComponent(0.4).cgColor,
|
|
||||||
backgroundPrimary.withAlphaComponent(0).cgColor
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
|
@ -108,12 +108,15 @@ extension ContextMenuVC {
|
||||||
currentThreadIsMessageRequest: Bool,
|
currentThreadIsMessageRequest: Bool,
|
||||||
delegate: ContextMenuActionDelegate?
|
delegate: ContextMenuActionDelegate?
|
||||||
) -> [Action]? {
|
) -> [Action]? {
|
||||||
// No context items for info messages
|
switch cellViewModel.variant {
|
||||||
guard cellViewModel.variant != .standardIncomingDeleted else {
|
case .standardIncomingDeleted, .infoCall,
|
||||||
return [ Action.delete(cellViewModel, delegate) ]
|
.infoScreenshotNotification, .infoMediaSavedNotification,
|
||||||
}
|
.infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
|
||||||
guard cellViewModel.variant == .standardOutgoing || cellViewModel.variant == .standardIncoming else {
|
.infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
|
||||||
return nil
|
// Let the user delete info messages and unsent messages
|
||||||
|
return [ Action.delete(cellViewModel, delegate) ]
|
||||||
|
|
||||||
|
case .standardOutgoing, .standardIncoming: break
|
||||||
}
|
}
|
||||||
|
|
||||||
let canReply: Bool = (
|
let canReply: Bool = (
|
||||||
|
|
|
@ -225,7 +225,8 @@ final class ContextMenuVC: UIViewController {
|
||||||
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
|
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
|
||||||
emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
|
emojiBar.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
|
||||||
|
|
||||||
default: break // Should never occur
|
default: // Should generally only be the 'delete' action
|
||||||
|
menuView.pin(.left, to: .left, of: view, withInset: targetFrame.minX)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tap gesture
|
// Tap gesture
|
||||||
|
|
|
@ -766,8 +766,9 @@ extension ConversationVC:
|
||||||
let index = self.viewModel.interactionData[sectionIndex]
|
let index = self.viewModel.interactionData[sectionIndex]
|
||||||
.elements
|
.elements
|
||||||
.firstIndex(of: cellViewModel),
|
.firstIndex(of: cellViewModel),
|
||||||
let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? VisibleMessageCell,
|
let cell = tableView.cellForRow(at: IndexPath(row: index, section: sectionIndex)) as? MessageCell,
|
||||||
let snapshot = cell.snContentView.snapshotView(afterScreenUpdates: false),
|
let contextSnapshotView: UIView = cell.contextSnapshotView,
|
||||||
|
let snapshot = contextSnapshotView.snapshotView(afterScreenUpdates: false),
|
||||||
contextMenuWindow == nil,
|
contextMenuWindow == nil,
|
||||||
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
|
let actions: [ContextMenuVC.Action] = ContextMenuVC.actions(
|
||||||
for: cellViewModel,
|
for: cellViewModel,
|
||||||
|
@ -789,7 +790,7 @@ extension ConversationVC:
|
||||||
self.contextMenuWindow = ContextMenuWindow()
|
self.contextMenuWindow = ContextMenuWindow()
|
||||||
self.contextMenuVC = ContextMenuVC(
|
self.contextMenuVC = ContextMenuVC(
|
||||||
snapshot: snapshot,
|
snapshot: snapshot,
|
||||||
frame: cell.convert(cell.snContentView.frame, to: keyWindow),
|
frame: contextSnapshotView.convert(contextSnapshotView.bounds, to: keyWindow),
|
||||||
cellViewModel: cellViewModel,
|
cellViewModel: cellViewModel,
|
||||||
actions: actions
|
actions: actions
|
||||||
) { [weak self] in
|
) { [weak self] in
|
||||||
|
@ -1218,7 +1219,18 @@ extension ConversationVC:
|
||||||
guard
|
guard
|
||||||
recentReactionTimestamps.count < 20 ||
|
recentReactionTimestamps.count < 20 ||
|
||||||
(sentTimestamp - (recentReactionTimestamps.first ?? sentTimestamp)) > (60 * 1000)
|
(sentTimestamp - (recentReactionTimestamps.first ?? sentTimestamp)) > (60 * 1000)
|
||||||
else { return }
|
else {
|
||||||
|
let toastController: ToastController = ToastController(
|
||||||
|
text: "EMOJI_REACTS_RATE_LIMIT_TOAST".localized(),
|
||||||
|
background: .backgroundSecondary
|
||||||
|
)
|
||||||
|
toastController.presentToastView(
|
||||||
|
fromBottomOfView: self.view,
|
||||||
|
inset: (snInputView.bounds.height + Values.largeSpacing),
|
||||||
|
duration: .milliseconds(2500)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
General.cache.mutate {
|
General.cache.mutate {
|
||||||
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
|
$0.recentReactionTimestamps = Array($0.recentReactionTimestamps
|
||||||
|
@ -1593,17 +1605,22 @@ extension ConversationVC:
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(_ cellViewModel: MessageViewModel) {
|
func delete(_ cellViewModel: MessageViewModel) {
|
||||||
// Only allow deletion on incoming and outgoing messages
|
switch cellViewModel.variant {
|
||||||
guard cellViewModel.variant != .standardIncomingDeleted else {
|
case .standardIncomingDeleted, .infoCall,
|
||||||
Storage.shared.writeAsync { db in
|
.infoScreenshotNotification, .infoMediaSavedNotification,
|
||||||
_ = try Interaction
|
.infoClosedGroupCreated, .infoClosedGroupUpdated, .infoClosedGroupCurrentUserLeft,
|
||||||
.filter(id: cellViewModel.id)
|
.infoMessageRequestAccepted, .infoDisappearingMessagesUpdate:
|
||||||
.deleteAll(db)
|
// Info messages and unsent messages should just trigger a local
|
||||||
}
|
// deletion (they are created as side effects so we wouldn't be
|
||||||
return
|
// able to delete them for all participants anyway)
|
||||||
}
|
Storage.shared.writeAsync { db in
|
||||||
guard cellViewModel.variant == .standardIncoming || cellViewModel.variant == .standardOutgoing else {
|
_ = try Interaction
|
||||||
return
|
.filter(id: cellViewModel.id)
|
||||||
|
.deleteAll(db)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
|
||||||
|
case .standardOutgoing, .standardIncoming: break
|
||||||
}
|
}
|
||||||
|
|
||||||
let threadId: String = self.viewModel.threadData.threadId
|
let threadId: String = self.viewModel.threadData.threadId
|
||||||
|
|
|
@ -705,10 +705,23 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }),
|
let newSectionIndex: Int = updatedData.firstIndex(where: { $0.model == .messages }),
|
||||||
let newFirstItemIndex: Int = updatedData[newSectionIndex].elements
|
let newFirstItemIndex: Int = updatedData[newSectionIndex].elements
|
||||||
.firstIndex(where: { item -> Bool in
|
.firstIndex(where: { item -> Bool in
|
||||||
item.id == self.viewModel.interactionData[oldSectionIndex].elements.first?.id
|
// Since the first item is probably a `DateHeaderCell` (which would likely
|
||||||
|
// be removed when inserting items above it) we check if the id matches
|
||||||
|
// either the first or second item
|
||||||
|
let messages: [MessageViewModel] = self.viewModel
|
||||||
|
.interactionData[oldSectionIndex]
|
||||||
|
.elements
|
||||||
|
|
||||||
|
return (
|
||||||
|
item.id == messages[safe: 0]?.id ||
|
||||||
|
item.id == messages[safe: 1]?.id
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
|
let firstVisibleIndexPath: IndexPath = self.tableView.indexPathsForVisibleRows?
|
||||||
.filter({ $0.section == oldSectionIndex })
|
.filter({
|
||||||
|
$0.section == oldSectionIndex &&
|
||||||
|
self.viewModel.interactionData[$0.section].elements[$0.row].cellType != .dateHeader
|
||||||
|
})
|
||||||
.sorted()
|
.sorted()
|
||||||
.first,
|
.first,
|
||||||
let newVisibleIndex: Int = updatedData[newSectionIndex].elements
|
let newVisibleIndex: Int = updatedData[newSectionIndex].elements
|
||||||
|
@ -722,7 +735,9 @@ final class ConversationVC: BaseVC, ConversationSearchControllerDelegate, UITabl
|
||||||
return ItemChangeInfo(
|
return ItemChangeInfo(
|
||||||
isInsertAtTop: (
|
isInsertAtTop: (
|
||||||
newSectionIndex > oldSectionIndex ||
|
newSectionIndex > oldSectionIndex ||
|
||||||
newFirstItemIndex > 0
|
// Note: Using `1` here instead of `0` as the first item will generally
|
||||||
|
// be a `DateHeaderCell` instead of a message
|
||||||
|
newFirstItemIndex > 1
|
||||||
),
|
),
|
||||||
firstIndexIsVisible: (firstVisibleIndexPath.row == 0),
|
firstIndexIsVisible: (firstVisibleIndexPath.row == 0),
|
||||||
visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex),
|
visibleIndexPath: IndexPath(row: newVisibleIndex, section: newSectionIndex),
|
||||||
|
|
|
@ -9,14 +9,18 @@ final class CallMessageCell: MessageCell {
|
||||||
private static let inset = Values.mediumSpacing
|
private static let inset = Values.mediumSpacing
|
||||||
private static let margin = UIScreen.main.bounds.width * 0.1
|
private static let margin = UIScreen.main.bounds.width * 0.1
|
||||||
|
|
||||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: 0)
|
private var isHandlingLongPress: Bool = false
|
||||||
private lazy var iconImageViewHeightConstraint = iconImageView.set(.height, to: 0)
|
|
||||||
|
|
||||||
private lazy var infoImageViewWidthConstraint = infoImageView.set(.width, to: 0)
|
override var contextSnapshotView: UIView? { return container }
|
||||||
private lazy var infoImageViewHeightConstraint = infoImageView.set(.height, to: 0)
|
|
||||||
|
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
|
private lazy var topConstraint: NSLayoutConstraint = container.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
||||||
|
private lazy var iconImageViewWidthConstraint: NSLayoutConstraint = iconImageView.set(.width, to: 0)
|
||||||
|
private lazy var iconImageViewHeightConstraint: NSLayoutConstraint = iconImageView.set(.height, to: 0)
|
||||||
|
private lazy var infoImageViewWidthConstraint: NSLayoutConstraint = infoImageView.set(.width, to: 0)
|
||||||
|
private lazy var infoImageViewHeightConstraint: NSLayoutConstraint = infoImageView.set(.height, to: 0)
|
||||||
|
|
||||||
private lazy var iconImageView: UIImageView = UIImageView()
|
private lazy var iconImageView: UIImageView = UIImageView()
|
||||||
private lazy var infoImageView: UIImageView = {
|
private lazy var infoImageView: UIImageView = {
|
||||||
let result: UIImageView = UIImageView(
|
let result: UIImageView = UIImageView(
|
||||||
|
@ -28,15 +32,6 @@ final class CallMessageCell: MessageCell {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var timestampLabel: UILabel = {
|
|
||||||
let result: UILabel = UILabel()
|
|
||||||
result.font = .boldSystemFont(ofSize: Values.verySmallFontSize)
|
|
||||||
result.themeTextColor = .textPrimary
|
|
||||||
result.textAlignment = .center
|
|
||||||
|
|
||||||
return result
|
|
||||||
}()
|
|
||||||
|
|
||||||
private lazy var label: UILabel = {
|
private lazy var label: UILabel = {
|
||||||
let result: UILabel = UILabel()
|
let result: UILabel = UILabel()
|
||||||
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
result.font = .boldSystemFont(ofSize: Values.smallFontSize)
|
||||||
|
@ -80,15 +75,6 @@ final class CallMessageCell: MessageCell {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var stackView: UIStackView = {
|
|
||||||
let result: UIStackView = UIStackView(arrangedSubviews: [ timestampLabel, container ])
|
|
||||||
result.axis = .vertical
|
|
||||||
result.alignment = .center
|
|
||||||
result.spacing = Values.smallSpacing
|
|
||||||
|
|
||||||
return result
|
|
||||||
}()
|
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
override func setUpViewHierarchy() {
|
override func setUpViewHierarchy() {
|
||||||
|
@ -96,16 +82,18 @@ final class CallMessageCell: MessageCell {
|
||||||
|
|
||||||
iconImageViewWidthConstraint.isActive = true
|
iconImageViewWidthConstraint.isActive = true
|
||||||
iconImageViewHeightConstraint.isActive = true
|
iconImageViewHeightConstraint.isActive = true
|
||||||
addSubview(stackView)
|
addSubview(container)
|
||||||
|
|
||||||
container.autoPinWidthToSuperview()
|
topConstraint.isActive = true
|
||||||
stackView.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
container.pin(.left, to: .left, of: self, withInset: CallMessageCell.margin)
|
||||||
stackView.pin(.top, to: .top, of: self, withInset: CallMessageCell.inset)
|
container.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
|
||||||
stackView.pin(.right, to: .right, of: self, withInset: -CallMessageCell.margin)
|
container.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
|
||||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -CallMessageCell.inset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setUpGestureRecognizers() {
|
override func setUpGestureRecognizers() {
|
||||||
|
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||||
|
addGestureRecognizer(longPressRecognizer)
|
||||||
|
|
||||||
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(handleTap))
|
||||||
tapGestureRecognizer.numberOfTapsRequired = 1
|
tapGestureRecognizer.numberOfTapsRequired = 1
|
||||||
addGestureRecognizer(tapGestureRecognizer)
|
addGestureRecognizer(tapGestureRecognizer)
|
||||||
|
@ -130,6 +118,7 @@ final class CallMessageCell: MessageCell {
|
||||||
else { return }
|
else { return }
|
||||||
|
|
||||||
self.viewModel = cellViewModel
|
self.viewModel = cellViewModel
|
||||||
|
self.topConstraint.constant = (cellViewModel.shouldShowDateHeader ? 0 : CallMessageCell.inset)
|
||||||
|
|
||||||
iconImageView.image = {
|
iconImageView.image = {
|
||||||
switch messageInfo.state {
|
switch messageInfo.state {
|
||||||
|
@ -157,12 +146,24 @@ final class CallMessageCell: MessageCell {
|
||||||
infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
|
infoImageViewHeightConstraint.constant = (shouldShowInfoIcon ? CallMessageCell.iconSize : 0)
|
||||||
|
|
||||||
label.text = cellViewModel.body
|
label.text = cellViewModel.body
|
||||||
timestampLabel.text = cellViewModel.dateForUI.formattedForDisplay
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction
|
||||||
|
|
||||||
|
@objc func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||||
|
if [ .ended, .cancelled, .failed ].contains(gestureRecognizer.state) {
|
||||||
|
isHandlingLongPress = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isHandlingLongPress, let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||||
|
|
||||||
|
delegate?.handleItemLongPressed(cellViewModel)
|
||||||
|
isHandlingLongPress = true
|
||||||
|
}
|
||||||
|
|
||||||
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
@objc private func handleTap(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||||
guard
|
guard
|
||||||
let cellViewModel: MessageViewModel = self.viewModel,
|
let cellViewModel: MessageViewModel = self.viewModel,
|
||||||
|
|
|
@ -76,15 +76,11 @@ final class DocumentView: UIView {
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
stackView.spacing = Values.mediumSpacing
|
stackView.spacing = Values.mediumSpacing
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
stackView.layoutMargins = UIEdgeInsets(
|
|
||||||
top: Values.smallSpacing,
|
|
||||||
leading: Values.mediumSpacing,
|
|
||||||
bottom: Values.smallSpacing,
|
|
||||||
trailing: Values.mediumSpacing
|
|
||||||
)
|
|
||||||
stackView.isLayoutMarginsRelativeArrangement = true
|
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
stackView.pin(to: self)
|
stackView.pin(.top, to: .top, of: self, withInset: Values.smallSpacing)
|
||||||
|
stackView.pin(.leading, to: .leading, of: self, withInset: Values.mediumSpacing)
|
||||||
|
stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.mediumSpacing)
|
||||||
|
stackView.pin(.bottom, to: .bottom, of: self, withInset: -Values.smallSpacing)
|
||||||
|
|
||||||
imageBackgroundView.pin(.top, to: .top, of: self)
|
imageBackgroundView.pin(.top, to: .top, of: self)
|
||||||
imageBackgroundView.pin(.leading, to: .leading, of: self)
|
imageBackgroundView.pin(.leading, to: .leading, of: self)
|
||||||
|
|
|
@ -2,11 +2,17 @@
|
||||||
|
|
||||||
import UIKit
|
import UIKit
|
||||||
import SessionUIKit
|
import SessionUIKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
import SignalUtilitiesKit
|
import SignalUtilitiesKit
|
||||||
|
|
||||||
final class ReactionContainerView: UIView {
|
final class ReactionContainerView: UIView {
|
||||||
var showingAllReactions = false
|
private static let arrowSize: CGSize = CGSize(width: 15, height: 13)
|
||||||
private var showNumbers = true
|
private static let arrowSpacing: CGFloat = Values.verySmallSpacing
|
||||||
|
|
||||||
|
private var maxWidth: CGFloat = 0
|
||||||
|
private var collapsedCount: Int = 0
|
||||||
|
private var showingAllReactions: Bool = false
|
||||||
|
private var showNumbers: Bool = true
|
||||||
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
private var maxEmojisPerLine = isIPhone6OrSmaller ? 5 : 6
|
||||||
private var oldSize: CGSize = .zero
|
private var oldSize: CGSize = .zero
|
||||||
|
|
||||||
|
@ -15,6 +21,16 @@ final class ReactionContainerView: UIView {
|
||||||
|
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
|
private var collapseTextLabelRightConstraint: NSLayoutConstraint?
|
||||||
|
|
||||||
|
private let dummyReactionButton: ReactionButton = ReactionButton(
|
||||||
|
viewModel: ReactionViewModel(
|
||||||
|
emoji: EmojiWithSkinTones(baseEmoji: .a, skinTones: nil),
|
||||||
|
number: 0,
|
||||||
|
showBorder: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
private lazy var mainStackView: UIStackView = {
|
private lazy var mainStackView: UIStackView = {
|
||||||
let result: UIStackView = UIStackView(arrangedSubviews: [ reactionContainerView, collapseButton ])
|
let result: UIStackView = UIStackView(arrangedSubviews: [ reactionContainerView, collapseButton ])
|
||||||
result.axis = .vertical
|
result.axis = .vertical
|
||||||
|
@ -24,6 +40,8 @@ final class ReactionContainerView: UIView {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
var expandButton: ExpandingReactionButton?
|
||||||
|
|
||||||
private lazy var reactionContainerView: UIStackView = {
|
private lazy var reactionContainerView: UIStackView = {
|
||||||
let result: UIStackView = UIStackView()
|
let result: UIStackView = UIStackView()
|
||||||
result.axis = .vertical
|
result.axis = .vertical
|
||||||
|
@ -33,34 +51,36 @@ final class ReactionContainerView: UIView {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var expandButton: ExpandingReactionButton?
|
lazy var collapseButton: UIView = {
|
||||||
|
let arrow: UIImageView = UIImageView(
|
||||||
var collapseButton: UIStackView = {
|
|
||||||
let arrow = UIImageView(
|
|
||||||
image: UIImage(named: "ic_chevron_up")?
|
image: UIImage(named: "ic_chevron_up")?
|
||||||
.resizedImage(to: CGSize(width: 15, height: 13))?
|
.resizedImage(to: ReactionContainerView.arrowSize)?
|
||||||
.withRenderingMode(.alwaysTemplate)
|
.withRenderingMode(.alwaysTemplate)
|
||||||
)
|
)
|
||||||
arrow.themeTintColor = .textPrimary
|
arrow.themeTintColor = .textPrimary
|
||||||
|
|
||||||
let textLabel: UILabel = UILabel()
|
let textLabel: UILabel = UILabel()
|
||||||
|
textLabel.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
textLabel.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
textLabel.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
|
textLabel.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
textLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
textLabel.font = .systemFont(ofSize: Values.verySmallFontSize)
|
||||||
textLabel.text = "Show less"
|
textLabel.text = "Show less"
|
||||||
textLabel.themeTextColor = .textPrimary
|
textLabel.themeTextColor = .textPrimary
|
||||||
|
|
||||||
let leftSpacer: UIView = UIView.hStretchingSpacer()
|
let result: UIView = UIView()
|
||||||
let rightSpacer: UIView = UIView.hStretchingSpacer()
|
|
||||||
let result: UIStackView = UIStackView(arrangedSubviews: [
|
|
||||||
leftSpacer,
|
|
||||||
arrow,
|
|
||||||
textLabel,
|
|
||||||
rightSpacer
|
|
||||||
])
|
|
||||||
result.isLayoutMarginsRelativeArrangement = true
|
|
||||||
result.spacing = Values.verySmallSpacing
|
|
||||||
result.alignment = .center
|
|
||||||
result.isHidden = true
|
result.isHidden = true
|
||||||
rightSpacer.set(.width, to: .width, of: leftSpacer)
|
result.addSubview(arrow)
|
||||||
|
result.addSubview(textLabel)
|
||||||
|
|
||||||
|
arrow.pin(.top, to: .top, of: result)
|
||||||
|
arrow.pin(.leading, to: .leading, of: result)
|
||||||
|
arrow.pin(.bottom, to: .bottom, of: result)
|
||||||
|
|
||||||
|
textLabel.pin(.top, to: .top, of: result)
|
||||||
|
textLabel.pin(.leading, to: .trailing, of: arrow, withInset: ReactionContainerView.arrowSpacing)
|
||||||
|
collapseTextLabelRightConstraint = textLabel.pin(.trailing, to: .trailing, of: result)
|
||||||
|
textLabel.pin(.bottom, to: .bottom, of: result)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
@ -84,123 +104,181 @@ final class ReactionContainerView: UIView {
|
||||||
addSubview(mainStackView)
|
addSubview(mainStackView)
|
||||||
|
|
||||||
mainStackView.pin(to: self)
|
mainStackView.pin(to: self)
|
||||||
collapseButton.set(.width, to: .width, of: mainStackView)
|
reactionContainerView.set(.width, to: .width, of: mainStackView)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func layoutSubviews() {
|
override func layoutSubviews() {
|
||||||
super.layoutSubviews()
|
super.layoutSubviews()
|
||||||
|
|
||||||
// Note: We update the 'collapseButton.layoutMargins' to try to make the "show less"
|
// Note: We update the 'collapseTextLabelRightConstraint' to try to make the "show less"
|
||||||
// button appear horizontally centered (if we don't do this it gets offset to one side)
|
// button appear horizontally centered (if we don't do this it gets offset to one side)
|
||||||
guard frame != CGRect.zero, frame.size != oldSize else { return }
|
guard frame != CGRect.zero, frame.size != oldSize else { return }
|
||||||
|
|
||||||
collapseButton.layoutMargins = UIEdgeInsets(
|
var targetSuperview: UIView? = {
|
||||||
top: 0,
|
var result: UIView? = self.superview
|
||||||
leading: -frame.minX,
|
|
||||||
bottom: 0,
|
while result != nil, result?.isKind(of: UITableViewCell.self) != true {
|
||||||
trailing: -((superview?.frame.width ?? 0) - frame.maxX)
|
result = result?.superview
|
||||||
)
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
if let targetSuperview: UIView = targetSuperview {
|
||||||
|
let parentWidth: CGFloat = targetSuperview.bounds.width
|
||||||
|
let frameInParent: CGRect = targetSuperview.convert(self.bounds, from: self)
|
||||||
|
let centeredWidth: CGFloat = (parentWidth - (frameInParent.minX * 2))
|
||||||
|
let diff: CGFloat = (frameInParent.width - centeredWidth)
|
||||||
|
collapseTextLabelRightConstraint?.constant = -(
|
||||||
|
diff +
|
||||||
|
((ReactionContainerView.arrowSize.width + ReactionContainerView.arrowSpacing) / 2)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
oldSize = frame.size
|
oldSize = frame.size
|
||||||
}
|
}
|
||||||
|
|
||||||
public func update(_ reactions: [ReactionViewModel], showNumbers: Bool) {
|
public func update(
|
||||||
|
_ reactions: [ReactionViewModel],
|
||||||
|
maxWidth: CGFloat,
|
||||||
|
showingAllReactions: Bool,
|
||||||
|
showNumbers: Bool
|
||||||
|
) {
|
||||||
self.reactions = reactions
|
self.reactions = reactions
|
||||||
|
self.maxWidth = maxWidth
|
||||||
|
self.collapsedCount = {
|
||||||
|
var numReactions: Int = 0
|
||||||
|
var runningWidth: CGFloat = 0
|
||||||
|
let estimatedExpandingButtonWidth: CGFloat = 52
|
||||||
|
let itemSpacing: CGFloat = self.reactionContainerView.spacing
|
||||||
|
|
||||||
|
for reaction in reactions {
|
||||||
|
let reactionViewWidth: CGFloat = dummyReactionButton
|
||||||
|
.updating(with: reaction, showNumber: showNumbers)
|
||||||
|
.systemLayoutSizeFitting(CGSize(width: maxWidth, height: 9999))
|
||||||
|
.width
|
||||||
|
let estimatedFullWidth: CGFloat = (
|
||||||
|
runningWidth +
|
||||||
|
(reactionViewWidth + itemSpacing) +
|
||||||
|
estimatedExpandingButtonWidth
|
||||||
|
)
|
||||||
|
|
||||||
|
if estimatedFullWidth >= maxWidth {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
runningWidth += (reactionViewWidth + itemSpacing)
|
||||||
|
numReactions += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return numReactions
|
||||||
|
}()
|
||||||
self.showNumbers = showNumbers
|
self.showNumbers = showNumbers
|
||||||
|
self.reactionViews = []
|
||||||
|
self.reactionContainerView.arrangedSubviews.forEach { $0.removeFromSuperview() }
|
||||||
|
|
||||||
prepareForUpdate()
|
// Generate the lines of reactions (if the 'collapsedCount' matches the total number of
|
||||||
|
// reactions then just show them app)
|
||||||
if showingAllReactions {
|
if showingAllReactions || self.collapsedCount >= reactions.count {
|
||||||
updateAllReactions()
|
self.updateAllReactions(reactions, maxWidth: maxWidth, showNumbers: showNumbers)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
updateCollapsedReactions(reactions)
|
self.updateCollapsedReactions(reactions, maxWidth: maxWidth, showNumbers: showNumbers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Just in case we couldn't show everything for some reason update this based on the
|
||||||
|
// internal logic
|
||||||
|
self.collapseButton.isHidden = (self.reactionContainerView.arrangedSubviews.count <= 1)
|
||||||
|
self.showingAllReactions = !self.collapseButton.isHidden
|
||||||
|
self.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateCollapsedReactions(_ reactions: [ReactionViewModel]) {
|
private func createLineStackView() -> UIStackView {
|
||||||
let stackView = UIStackView()
|
let result: UIStackView = UIStackView()
|
||||||
stackView.axis = .horizontal
|
result.axis = .horizontal
|
||||||
stackView.spacing = Values.smallSpacing
|
result.spacing = Values.smallSpacing
|
||||||
stackView.alignment = .center
|
result.alignment = .center
|
||||||
|
result.set(.height, to: ReactionButton.height)
|
||||||
|
|
||||||
var displayedReactions: [ReactionViewModel]
|
return result
|
||||||
var expandButtonReactions: [EmojiWithSkinTones]
|
}
|
||||||
|
|
||||||
|
private func updateCollapsedReactions(
|
||||||
|
_ reactions: [ReactionViewModel],
|
||||||
|
maxWidth: CGFloat,
|
||||||
|
showNumbers: Bool
|
||||||
|
) {
|
||||||
|
guard !reactions.isEmpty else { return }
|
||||||
|
|
||||||
if reactions.count > maxEmojisPerLine {
|
let maxSize: CGSize = CGSize(width: maxWidth, height: 9999)
|
||||||
displayedReactions = Array(reactions[0...(maxEmojisPerLine - 3)])
|
let stackView: UIStackView = createLineStackView()
|
||||||
expandButtonReactions = Array(reactions[(maxEmojisPerLine - 2)...maxEmojisPerLine])
|
let displayedReactions: [ReactionViewModel] = Array(reactions.prefix(upTo: self.collapsedCount))
|
||||||
.map { $0.emoji }
|
let expandButtonReactions: [EmojiWithSkinTones] = reactions
|
||||||
}
|
.suffix(from: self.collapsedCount)
|
||||||
else {
|
.prefix(3)
|
||||||
displayedReactions = reactions
|
.map { $0.emoji }
|
||||||
expandButtonReactions = []
|
|
||||||
}
|
|
||||||
|
|
||||||
for reaction in displayedReactions {
|
for reaction in displayedReactions {
|
||||||
let reactionView = ReactionButton(viewModel: reaction, showNumber: showNumbers)
|
let reactionView = ReactionButton(viewModel: reaction, showNumber: showNumbers)
|
||||||
|
let reactionViewWidth: CGFloat = reactionView.systemLayoutSizeFitting(maxSize).width
|
||||||
stackView.addArrangedSubview(reactionView)
|
stackView.addArrangedSubview(reactionView)
|
||||||
reactionViews.append(reactionView)
|
reactionViews.append(reactionView)
|
||||||
|
reactionView.set(.width, to: reactionViewWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
if expandButtonReactions.count > 0 {
|
self.expandButton = {
|
||||||
let expandButton: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions)
|
guard !expandButtonReactions.isEmpty else { return nil }
|
||||||
stackView.addArrangedSubview(expandButton)
|
|
||||||
|
let result: ExpandingReactionButton = ExpandingReactionButton(emojis: expandButtonReactions)
|
||||||
|
stackView.addArrangedSubview(result)
|
||||||
|
|
||||||
self.expandButton = expandButton
|
return result
|
||||||
}
|
}()
|
||||||
else {
|
|
||||||
expandButton = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
reactionContainerView.addArrangedSubview(stackView)
|
reactionContainerView.addArrangedSubview(stackView)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func updateAllReactions() {
|
private func updateAllReactions(
|
||||||
var reactions = self.reactions
|
_ reactions: [ReactionViewModel],
|
||||||
var numberOfLines = 0
|
maxWidth: CGFloat,
|
||||||
|
showNumbers: Bool
|
||||||
|
) {
|
||||||
|
guard !reactions.isEmpty else { return }
|
||||||
|
|
||||||
while reactions.count > 0 {
|
let maxSize: CGSize = CGSize(width: maxWidth, height: 9999)
|
||||||
var line: [ReactionViewModel] = []
|
var lineStackView: UIStackView = createLineStackView()
|
||||||
|
reactionContainerView.addArrangedSubview(lineStackView)
|
||||||
|
|
||||||
|
for reaction in self.reactions {
|
||||||
|
let reactionView: ReactionButton = ReactionButton(viewModel: reaction, showNumber: showNumbers)
|
||||||
|
let reactionViewWidth: CGFloat = reactionView.systemLayoutSizeFitting(maxSize).width
|
||||||
|
reactionViews.append(reactionView)
|
||||||
|
|
||||||
while reactions.count > 0 && line.count < maxEmojisPerLine {
|
// Check if we need to create a new line
|
||||||
line.append(reactions.removeFirst())
|
let stackViewWidth: CGFloat = (lineStackView.arrangedSubviews.isEmpty ?
|
||||||
|
0 :
|
||||||
|
lineStackView.systemLayoutSizeFitting(maxSize).width
|
||||||
|
)
|
||||||
|
|
||||||
|
if stackViewWidth + reactionViewWidth > maxWidth {
|
||||||
|
lineStackView = createLineStackView()
|
||||||
|
reactionContainerView.addArrangedSubview(lineStackView)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCollapsedReactions(line)
|
lineStackView.addArrangedSubview(reactionView)
|
||||||
numberOfLines += 1
|
reactionView.set(.width, to: reactionViewWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
if numberOfLines > 1 {
|
|
||||||
collapseButton.isHidden = false
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
showingAllReactions = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func prepareForUpdate() {
|
|
||||||
for subview in reactionContainerView.arrangedSubviews {
|
|
||||||
reactionContainerView.removeArrangedSubview(subview)
|
|
||||||
subview.removeFromSuperview()
|
|
||||||
}
|
|
||||||
|
|
||||||
collapseButton.isHidden = true
|
|
||||||
reactionViews = []
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func showAllEmojis() {
|
public func showAllEmojis() {
|
||||||
guard !showingAllReactions else { return }
|
guard !showingAllReactions else { return }
|
||||||
|
|
||||||
showingAllReactions = true
|
update(reactions, maxWidth: maxWidth, showingAllReactions: true, showNumbers: showNumbers)
|
||||||
update(reactions, showNumbers: showNumbers)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public func showLessEmojis() {
|
public func showLessEmojis() {
|
||||||
guard showingAllReactions else { return }
|
guard showingAllReactions else { return }
|
||||||
|
|
||||||
showingAllReactions = false
|
update(reactions, maxWidth: maxWidth, showingAllReactions: false, showNumbers: showNumbers)
|
||||||
update(reactions, showNumbers: showNumbers)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -15,10 +15,29 @@ final class ReactionButton: UIView {
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
private var height: CGFloat = 22
|
public static var height: CGFloat = 22
|
||||||
private var fontSize: CGFloat = Values.verySmallFontSize
|
private var fontSize: CGFloat = Values.verySmallFontSize
|
||||||
private var spacing: CGFloat = Values.verySmallSpacing
|
private var spacing: CGFloat = Values.verySmallSpacing
|
||||||
|
|
||||||
|
// MARK: - UI
|
||||||
|
|
||||||
|
private lazy var emojiLabel: UILabel = {
|
||||||
|
let result: UILabel = UILabel()
|
||||||
|
result.setContentHuggingPriority(.required, for: .horizontal)
|
||||||
|
result.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
|
result.font = .systemFont(ofSize: fontSize)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var numberLabel: UILabel = {
|
||||||
|
let result: UILabel = UILabel()
|
||||||
|
result.font = .systemFont(ofSize: fontSize)
|
||||||
|
result.themeTextColor = .textPrimary
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
init(viewModel: ReactionViewModel, showNumber: Bool = true) {
|
init(viewModel: ReactionViewModel, showNumber: Bool = true) {
|
||||||
|
@ -28,6 +47,7 @@ final class ReactionButton: UIView {
|
||||||
super.init(frame: CGRect.zero)
|
super.init(frame: CGRect.zero)
|
||||||
|
|
||||||
setUpViewHierarchy()
|
setUpViewHierarchy()
|
||||||
|
update(with: viewModel, showNumber: showNumber)
|
||||||
}
|
}
|
||||||
|
|
||||||
override init(frame: CGRect) {
|
override init(frame: CGRect) {
|
||||||
|
@ -39,35 +59,45 @@ final class ReactionButton: UIView {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setUpViewHierarchy() {
|
private func setUpViewHierarchy() {
|
||||||
let emojiLabel: UILabel = UILabel()
|
|
||||||
emojiLabel.font = .systemFont(ofSize: fontSize)
|
|
||||||
emojiLabel.text = viewModel.emoji.rawValue
|
emojiLabel.text = viewModel.emoji.rawValue
|
||||||
|
|
||||||
let stackView: UIStackView = UIStackView(arrangedSubviews: [ emojiLabel ])
|
let stackView: UIStackView = UIStackView(arrangedSubviews: [ emojiLabel, numberLabel ])
|
||||||
stackView.axis = .horizontal
|
stackView.axis = .horizontal
|
||||||
stackView.spacing = spacing
|
stackView.spacing = spacing
|
||||||
stackView.alignment = .center
|
stackView.alignment = .center
|
||||||
stackView.layoutMargins = UIEdgeInsets(top: 0, left: Values.smallSpacing, bottom: 0, right: Values.smallSpacing)
|
|
||||||
stackView.isLayoutMarginsRelativeArrangement = true
|
|
||||||
addSubview(stackView)
|
addSubview(stackView)
|
||||||
stackView.pin(to: self)
|
stackView.pin(.top, to: .top, of: self)
|
||||||
|
stackView.pin(.leading, to: .leading, of: self, withInset: Values.smallSpacing)
|
||||||
|
stackView.pin(.trailing, to: .trailing, of: self, withInset: -Values.smallSpacing)
|
||||||
|
stackView.pin(.bottom, to: .bottom, of: self)
|
||||||
|
|
||||||
themeBorderColor = (viewModel.showBorder ? .primary : .clear)
|
themeBorderColor = (viewModel.showBorder ? .primary : .clear)
|
||||||
themeBackgroundColor = .messageBubble_incomingBackground
|
themeBackgroundColor = .messageBubble_incomingBackground
|
||||||
layer.cornerRadius = (self.height / 2)
|
layer.cornerRadius = (ReactionButton.height / 2)
|
||||||
layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness')
|
layer.borderWidth = 1 // Intentionally 1pt (instead of 'Values.separatorThickness')
|
||||||
set(.height, to: self.height)
|
set(.height, to: ReactionButton.height)
|
||||||
|
|
||||||
if showNumber || viewModel.number > 1 {
|
numberLabel.isHidden = (!showNumber && viewModel.number <= 1)
|
||||||
let numberLabel = UILabel()
|
}
|
||||||
numberLabel.font = .systemFont(ofSize: fontSize)
|
|
||||||
numberLabel.text = (viewModel.number < 1000 ?
|
func update(with viewModel: ReactionViewModel, showNumber: Bool) {
|
||||||
"\(viewModel.number)" :
|
_ = updating(with: viewModel, showNumber: showNumber)
|
||||||
String(format: "%.1f", Float(viewModel.number) / 1000) + "k"
|
}
|
||||||
)
|
|
||||||
numberLabel.themeTextColor = .textPrimary
|
func updating(with viewModel: ReactionViewModel, showNumber: Bool) -> ReactionButton {
|
||||||
stackView.addArrangedSubview(numberLabel)
|
emojiLabel.text = viewModel.emoji.rawValue
|
||||||
|
numberLabel.text = (viewModel.number < 1000 ?
|
||||||
|
"\(viewModel.number)" :
|
||||||
|
String(format: "%.1f", Float(viewModel.number) / 1000) + "k"
|
||||||
|
)
|
||||||
|
numberLabel.isHidden = (!showNumber && viewModel.number <= 1)
|
||||||
|
|
||||||
|
UIView.performWithoutAnimation {
|
||||||
|
self.setNeedsLayout()
|
||||||
|
self.layoutIfNeeded()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return self
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,10 @@ final class InfoMessageCell: MessageCell {
|
||||||
private static let iconSize: CGFloat = 16
|
private static let iconSize: CGFloat = 16
|
||||||
private static let inset = Values.mediumSpacing
|
private static let inset = Values.mediumSpacing
|
||||||
|
|
||||||
|
private var isHandlingLongPress: Bool = false
|
||||||
|
|
||||||
|
override var contextSnapshotView: UIView? { return label }
|
||||||
|
|
||||||
// MARK: - UI
|
// MARK: - UI
|
||||||
|
|
||||||
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
|
private lazy var iconImageViewWidthConstraint = iconImageView.set(.width, to: InfoMessageCell.iconSize)
|
||||||
|
@ -49,6 +53,11 @@ final class InfoMessageCell: MessageCell {
|
||||||
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
|
stackView.pin(.right, to: .right, of: self, withInset: -InfoMessageCell.inset)
|
||||||
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
|
stackView.pin(.bottom, to: .bottom, of: self, withInset: -InfoMessageCell.inset)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override func setUpGestureRecognizers() {
|
||||||
|
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
|
||||||
|
addGestureRecognizer(longPressRecognizer)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Updating
|
// MARK: - Updating
|
||||||
|
|
||||||
|
@ -90,4 +99,17 @@ final class InfoMessageCell: MessageCell {
|
||||||
|
|
||||||
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
override func dynamicUpdate(with cellViewModel: MessageViewModel, playbackInfo: ConversationViewModel.PlaybackInfo?) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction
|
||||||
|
|
||||||
|
@objc func handleLongPress(_ gestureRecognizer: UITapGestureRecognizer) {
|
||||||
|
if [ .ended, .cancelled, .failed ].contains(gestureRecognizer.state) {
|
||||||
|
isHandlingLongPress = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !isHandlingLongPress, let cellViewModel: MessageViewModel = self.viewModel else { return }
|
||||||
|
|
||||||
|
delegate?.handleItemLongPressed(cellViewModel)
|
||||||
|
isHandlingLongPress = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,8 +10,9 @@ public enum SwipeState {
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MessageCell: UITableViewCell {
|
public class MessageCell: UITableViewCell {
|
||||||
weak var delegate: MessageCellDelegate?
|
|
||||||
var viewModel: MessageViewModel?
|
var viewModel: MessageViewModel?
|
||||||
|
weak var delegate: MessageCellDelegate?
|
||||||
|
open var contextSnapshotView: UIView? { return nil }
|
||||||
|
|
||||||
// MARK: - Lifecycle
|
// MARK: - Lifecycle
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,8 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
var voiceMessageView: VoiceMessageView?
|
var voiceMessageView: VoiceMessageView?
|
||||||
var audioStateChanged: ((TimeInterval, Bool) -> ())?
|
var audioStateChanged: ((TimeInterval, Bool) -> ())?
|
||||||
|
|
||||||
|
override var contextSnapshotView: UIView? { return snContentView }
|
||||||
|
|
||||||
// Constraints
|
// Constraints
|
||||||
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
|
private lazy var authorLabelTopConstraint = authorLabel.pin(.top, to: .top, of: self)
|
||||||
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
|
private lazy var authorLabelHeightConstraint = authorLabel.set(.height, to: 0)
|
||||||
|
@ -25,13 +27,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
|
private lazy var contentViewTopConstraint = snContentView.pin(.top, to: .bottom, of: authorLabel, withInset: VisibleMessageCell.authorLabelBottomSpacing)
|
||||||
private lazy var contentViewRightConstraint1 = snContentView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
|
private lazy var contentViewRightConstraint1 = snContentView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
|
||||||
private lazy var contentViewRightConstraint2 = snContentView.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -VisibleMessageCell.gutterSize)
|
private lazy var contentViewRightConstraint2 = snContentView.rightAnchor.constraint(lessThanOrEqualTo: rightAnchor, constant: -VisibleMessageCell.gutterSize)
|
||||||
|
private lazy var contentBottomConstraint = snContentView.bottomAnchor
|
||||||
|
.constraint(lessThanOrEqualTo: self.bottomAnchor, constant: -1)
|
||||||
|
|
||||||
private lazy var reactionContainerViewLeftConstraint = reactionContainerView.pin(.left, to: .left, of: snContentView)
|
private lazy var underBubbleStackViewIncomingLeadingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.leading, to: .leading, of: snContentView)
|
||||||
private lazy var reactionContainerViewRightConstraint = reactionContainerView.pin(.right, to: .right, of: snContentView)
|
private lazy var underBubbleStackViewIncomingTrailingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.trailing, to: .trailing, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
|
||||||
|
private lazy var underBubbleStackViewOutgoingLeadingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.leading, to: .leading, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
||||||
private lazy var messageStatusImageViewTopConstraint = messageStatusImageView.pin(.top, to: .bottom, of: reactionContainerView, withInset: 0)
|
private lazy var underBubbleStackViewOutgoingTrailingConstraint: NSLayoutConstraint = underBubbleStackView.pin(.trailing, to: .trailing, of: snContentView)
|
||||||
private lazy var messageStatusImageViewWidthConstraint = messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize)
|
private lazy var underBubbleStackViewNoHeightConstraint: NSLayoutConstraint = underBubbleStackView.set(.height, to: 0)
|
||||||
private lazy var messageStatusImageViewHeightConstraint = messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize)
|
|
||||||
|
|
||||||
private lazy var timerViewOutgoingMessageConstraint = timerView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
private lazy var timerViewOutgoingMessageConstraint = timerView.pin(.left, to: .left, of: self, withInset: VisibleMessageCell.contactThreadHSpacing)
|
||||||
private lazy var timerViewIncomingMessageConstraint = timerView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
|
private lazy var timerViewIncomingMessageConstraint = timerView.pin(.right, to: .right, of: self, withInset: -VisibleMessageCell.contactThreadHSpacing)
|
||||||
|
@ -92,16 +95,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
return result
|
return result
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var reactionContainerView = ReactionContainerView()
|
|
||||||
|
|
||||||
internal lazy var messageStatusImageView: UIImageView = {
|
|
||||||
let result = UIImageView()
|
|
||||||
result.contentMode = .scaleAspectFit
|
|
||||||
result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2
|
|
||||||
result.layer.masksToBounds = true
|
|
||||||
return result
|
|
||||||
}()
|
|
||||||
|
|
||||||
private lazy var replyButton: UIView = {
|
private lazy var replyButton: UIView = {
|
||||||
let result = UIView()
|
let result = UIView()
|
||||||
let size = VisibleMessageCell.replyButtonSize + 8
|
let size = VisibleMessageCell.replyButtonSize + 8
|
||||||
|
@ -128,6 +121,27 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
}()
|
}()
|
||||||
|
|
||||||
private lazy var timerView: OWSMessageTimerView = OWSMessageTimerView()
|
private lazy var timerView: OWSMessageTimerView = OWSMessageTimerView()
|
||||||
|
|
||||||
|
lazy var underBubbleStackView: UIStackView = {
|
||||||
|
let result = UIStackView(arrangedSubviews: [])
|
||||||
|
result.setContentHuggingPriority(.required, for: .vertical)
|
||||||
|
result.setContentCompressionResistancePriority(.required, for: .vertical)
|
||||||
|
result.axis = .vertical
|
||||||
|
result.spacing = Values.verySmallSpacing
|
||||||
|
result.alignment = .trailing
|
||||||
|
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
|
private lazy var reactionContainerView = ReactionContainerView()
|
||||||
|
|
||||||
|
internal lazy var messageStatusImageView: UIImageView = {
|
||||||
|
let result = UIImageView()
|
||||||
|
result.contentMode = .scaleAspectFit
|
||||||
|
result.layer.cornerRadius = VisibleMessageCell.messageStatusImageViewSize / 2
|
||||||
|
result.layer.masksToBounds = true
|
||||||
|
return result
|
||||||
|
}()
|
||||||
|
|
||||||
// MARK: - Settings
|
// MARK: - Settings
|
||||||
|
|
||||||
|
@ -197,19 +211,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
timerView.center(.vertical, in: snContentView)
|
timerView.center(.vertical, in: snContentView)
|
||||||
timerViewOutgoingMessageConstraint.isActive = true
|
timerViewOutgoingMessageConstraint.isActive = true
|
||||||
|
|
||||||
// Reaction view
|
|
||||||
addSubview(reactionContainerView)
|
|
||||||
reactionContainerView.pin(.top, to: .bottom, of: snContentView, withInset: Values.verySmallSpacing)
|
|
||||||
reactionContainerViewLeftConstraint.isActive = true
|
|
||||||
|
|
||||||
// Message status image view
|
|
||||||
addSubview(messageStatusImageView)
|
|
||||||
messageStatusImageViewTopConstraint.isActive = true
|
|
||||||
messageStatusImageView.pin(.right, to: .right, of: snContentView, withInset: -1)
|
|
||||||
messageStatusImageView.pin(.bottom, to: .bottom, of: self, withInset: -1)
|
|
||||||
messageStatusImageViewWidthConstraint.isActive = true
|
|
||||||
messageStatusImageViewHeightConstraint.isActive = true
|
|
||||||
|
|
||||||
// Reply button
|
// Reply button
|
||||||
addSubview(replyButton)
|
addSubview(replyButton)
|
||||||
replyButton.addSubview(replyIconImageView)
|
replyButton.addSubview(replyIconImageView)
|
||||||
|
@ -219,6 +220,20 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
|
|
||||||
// Remaining constraints
|
// Remaining constraints
|
||||||
authorLabel.pin(.left, to: .left, of: snContentView, withInset: VisibleMessageCell.authorLabelInset)
|
authorLabel.pin(.left, to: .left, of: snContentView, withInset: VisibleMessageCell.authorLabelInset)
|
||||||
|
|
||||||
|
// Under bubble content
|
||||||
|
addSubview(underBubbleStackView)
|
||||||
|
underBubbleStackView.pin(.top, to: .bottom, of: snContentView, withInset: 5)
|
||||||
|
underBubbleStackView.pin(.bottom, to: .bottom, of: self)
|
||||||
|
|
||||||
|
underBubbleStackView.addArrangedSubview(reactionContainerView)
|
||||||
|
underBubbleStackView.addArrangedSubview(messageStatusImageView)
|
||||||
|
|
||||||
|
reactionContainerView.widthAnchor
|
||||||
|
.constraint(lessThanOrEqualTo: underBubbleStackView.widthAnchor)
|
||||||
|
.isActive = true
|
||||||
|
messageStatusImageView.set(.width, to: VisibleMessageCell.messageStatusImageViewSize)
|
||||||
|
messageStatusImageView.set(.height, to: VisibleMessageCell.messageStatusImageViewSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
override func setUpGestureRecognizers() {
|
override func setUpGestureRecognizers() {
|
||||||
|
@ -298,12 +313,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
lastSearchText: lastSearchText
|
lastSearchText: lastSearchText
|
||||||
)
|
)
|
||||||
|
|
||||||
// Reaction view
|
|
||||||
reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty == true)
|
|
||||||
reactionContainerViewLeftConstraint.isActive = (cellViewModel.variant == .standardIncoming)
|
|
||||||
reactionContainerViewRightConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
|
|
||||||
populateReaction(for: cellViewModel, showExpandedReactions: showExpandedReactions)
|
|
||||||
|
|
||||||
// Author label
|
// Author label
|
||||||
authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0)
|
authorLabelTopConstraint.constant = (shouldAddTopInset ? Values.mediumSpacing : 0)
|
||||||
authorLabel.isHidden = (cellViewModel.senderName == nil)
|
authorLabel.isHidden = (cellViewModel.senderName == nil)
|
||||||
|
@ -315,27 +324,6 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace)
|
let authorLabelSize = authorLabel.sizeThatFits(authorLabelAvailableSpace)
|
||||||
authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0)
|
authorLabelHeightConstraint.constant = (cellViewModel.senderName != nil ? authorLabelSize.height : 0)
|
||||||
|
|
||||||
// Message status image view
|
|
||||||
let (image, tintColor) = cellViewModel.state.statusIconInfo(
|
|
||||||
variant: cellViewModel.variant,
|
|
||||||
hasAtLeastOneReadReceipt: cellViewModel.hasAtLeastOneReadReceipt
|
|
||||||
)
|
|
||||||
messageStatusImageView.image = image
|
|
||||||
messageStatusImageView.themeTintColor = tintColor
|
|
||||||
messageStatusImageView.isHidden = (
|
|
||||||
cellViewModel.variant != .standardOutgoing ||
|
|
||||||
cellViewModel.variant == .infoCall ||
|
|
||||||
(
|
|
||||||
cellViewModel.state == .sent &&
|
|
||||||
!cellViewModel.isLast
|
|
||||||
)
|
|
||||||
)
|
|
||||||
messageStatusImageViewTopConstraint.constant = (messageStatusImageView.isHidden ? 0 : 5)
|
|
||||||
[ messageStatusImageViewWidthConstraint, messageStatusImageViewHeightConstraint ]
|
|
||||||
.forEach {
|
|
||||||
$0.constant = (messageStatusImageView.isHidden ? 0 : VisibleMessageCell.messageStatusImageViewSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timer
|
// Timer
|
||||||
if
|
if
|
||||||
let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs,
|
let expiresStartedAtMs: Double = cellViewModel.expiresStartedAtMs,
|
||||||
|
@ -367,6 +355,43 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
else {
|
else {
|
||||||
addGestureRecognizer(panGestureRecognizer)
|
addGestureRecognizer(panGestureRecognizer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Under bubble content
|
||||||
|
underBubbleStackView.alignment = (cellViewModel.variant == .standardOutgoing ?
|
||||||
|
.trailing :
|
||||||
|
.leading
|
||||||
|
)
|
||||||
|
underBubbleStackViewIncomingLeadingConstraint.isActive = (cellViewModel.variant != .standardOutgoing)
|
||||||
|
underBubbleStackViewIncomingTrailingConstraint.isActive = (cellViewModel.variant != .standardOutgoing)
|
||||||
|
underBubbleStackViewOutgoingLeadingConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
|
||||||
|
underBubbleStackViewOutgoingTrailingConstraint.isActive = (cellViewModel.variant == .standardOutgoing)
|
||||||
|
|
||||||
|
// Reaction view
|
||||||
|
reactionContainerView.isHidden = (cellViewModel.reactionInfo?.isEmpty != false)
|
||||||
|
populateReaction(
|
||||||
|
for: cellViewModel,
|
||||||
|
maxWidth: VisibleMessageCell.getMaxWidth(
|
||||||
|
for: cellViewModel,
|
||||||
|
includingOppositeGutter: false
|
||||||
|
),
|
||||||
|
showExpandedReactions: showExpandedReactions
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message status image view
|
||||||
|
let (image, tintColor) = cellViewModel.state.statusIconInfo(
|
||||||
|
variant: cellViewModel.variant,
|
||||||
|
hasAtLeastOneReadReceipt: cellViewModel.hasAtLeastOneReadReceipt
|
||||||
|
)
|
||||||
|
messageStatusImageView.image = image
|
||||||
|
messageStatusImageView.themeTintColor = tintColor
|
||||||
|
messageStatusImageView.isHidden = (
|
||||||
|
cellViewModel.variant != .standardOutgoing ||
|
||||||
|
cellViewModel.variant == .infoCall ||
|
||||||
|
(
|
||||||
|
cellViewModel.state == .sent &&
|
||||||
|
!cellViewModel.isLast
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func populateContentView(
|
private func populateContentView(
|
||||||
|
@ -595,7 +620,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func populateReaction(for cellViewModel: MessageViewModel, showExpandedReactions: Bool) {
|
private func populateReaction(
|
||||||
|
for cellViewModel: MessageViewModel,
|
||||||
|
maxWidth: CGFloat,
|
||||||
|
showExpandedReactions: Bool
|
||||||
|
) {
|
||||||
let reactions: OrderedDictionary<EmojiWithSkinTones, ReactionViewModel> = (cellViewModel.reactionInfo ?? [])
|
let reactions: OrderedDictionary<EmojiWithSkinTones, ReactionViewModel> = (cellViewModel.reactionInfo ?? [])
|
||||||
.reduce(into: OrderedDictionary()) { result, reactionInfo in
|
.reduce(into: OrderedDictionary()) { result, reactionInfo in
|
||||||
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
|
guard let emoji: EmojiWithSkinTones = EmojiWithSkinTones(rawValue: reactionInfo.reaction.emoji) else {
|
||||||
|
@ -626,9 +655,10 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reactionContainerView.showingAllReactions = showExpandedReactions
|
|
||||||
reactionContainerView.update(
|
reactionContainerView.update(
|
||||||
reactions.orderedValues,
|
reactions.orderedValues,
|
||||||
|
maxWidth: maxWidth,
|
||||||
|
showingAllReactions: showExpandedReactions,
|
||||||
showNumbers: (
|
showNumbers: (
|
||||||
cellViewModel.threadVariant == .closedGroup ||
|
cellViewModel.threadVariant == .closedGroup ||
|
||||||
cellViewModel.threadVariant == .openGroup
|
cellViewModel.threadVariant == .openGroup
|
||||||
|
@ -752,7 +782,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
|
|
||||||
let location = gestureRecognizer.location(in: self)
|
let location = gestureRecognizer.location(in: self)
|
||||||
|
|
||||||
if reactionContainerView.frame.contains(location) {
|
if reactionContainerView.bounds.contains(reactionContainerView.convert(location, from: self)) {
|
||||||
let convertedLocation = reactionContainerView.convert(location, from: self)
|
let convertedLocation = reactionContainerView.convert(location, from: self)
|
||||||
|
|
||||||
for reactionView in reactionContainerView.reactionViews {
|
for reactionView in reactionContainerView.reactionViews {
|
||||||
|
@ -774,7 +804,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
|
|
||||||
let location = gestureRecognizer.location(in: self)
|
let location = gestureRecognizer.location(in: self)
|
||||||
|
|
||||||
if profilePictureView.frame.contains(location), cellViewModel.shouldShowProfile {
|
if profilePictureView.bounds.contains(profilePictureView.convert(location, from: self)), cellViewModel.shouldShowProfile {
|
||||||
// For open groups only attempt to start a conversation if the author has a blinded id
|
// For open groups only attempt to start a conversation if the author has a blinded id
|
||||||
guard cellViewModel.threadVariant != .openGroup else {
|
guard cellViewModel.threadVariant != .openGroup else {
|
||||||
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return }
|
guard SessionId.Prefix(from: cellViewModel.authorId) == .blinded else { return }
|
||||||
|
@ -793,11 +823,11 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
openGroupPublicKey: nil
|
openGroupPublicKey: nil
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
else if replyButton.alpha > 0 && replyButton.frame.contains(location) {
|
else if replyButton.alpha > 0 && replyButton.bounds.contains(replyButton.convert(location, from: self)) {
|
||||||
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
UIImpactFeedbackGenerator(style: .heavy).impactOccurred()
|
||||||
reply()
|
reply()
|
||||||
}
|
}
|
||||||
else if reactionContainerView.frame.contains(location) {
|
else if reactionContainerView.bounds.contains(reactionContainerView.convert(location, from: self)) {
|
||||||
let convertedLocation = reactionContainerView.convert(location, from: self)
|
let convertedLocation = reactionContainerView.convert(location, from: self)
|
||||||
|
|
||||||
for reactionView in reactionContainerView.reactionViews {
|
for reactionView in reactionContainerView.reactionViews {
|
||||||
|
@ -813,7 +843,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let expandButton = reactionContainerView.expandButton, expandButton.frame.contains(convertedLocation) {
|
if let expandButton = reactionContainerView.expandButton, expandButton.bounds.contains(expandButton.convert(location, from: self)) {
|
||||||
reactionContainerView.showAllEmojis()
|
reactionContainerView.showAllEmojis()
|
||||||
delegate?.needsLayout(for: cellViewModel, expandingReactions: true)
|
delegate?.needsLayout(for: cellViewModel, expandingReactions: true)
|
||||||
}
|
}
|
||||||
|
@ -823,7 +853,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
delegate?.needsLayout(for: cellViewModel, expandingReactions: false)
|
delegate?.needsLayout(for: cellViewModel, expandingReactions: false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if snContentView.frame.contains(location) {
|
else if snContentView.bounds.contains(snContentView.convert(location, from: self)) {
|
||||||
delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
|
delegate?.handleItemTapped(cellViewModel, gestureRecognizer: gestureRecognizer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -966,11 +996,14 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
return CGSize(width: width, height: height)
|
return CGSize(width: width, height: height)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func getMaxWidth(for cellViewModel: MessageViewModel) -> CGFloat {
|
static func getMaxWidth(for cellViewModel: MessageViewModel, includingOppositeGutter: Bool = true) -> CGFloat {
|
||||||
let screen: CGRect = UIScreen.main.bounds
|
let screen: CGRect = UIScreen.main.bounds
|
||||||
|
let oppositeEdgePadding: CGFloat = (includingOppositeGutter ? gutterSize : contactThreadHSpacing)
|
||||||
|
|
||||||
switch cellViewModel.variant {
|
switch cellViewModel.variant {
|
||||||
case .standardOutgoing: return (screen.width - contactThreadHSpacing - gutterSize)
|
case .standardOutgoing:
|
||||||
|
return (screen.width - contactThreadHSpacing - oppositeEdgePadding)
|
||||||
|
|
||||||
case .standardIncoming, .standardIncomingDeleted:
|
case .standardIncoming, .standardIncomingDeleted:
|
||||||
let isGroupThread = (
|
let isGroupThread = (
|
||||||
cellViewModel.threadVariant == .openGroup ||
|
cellViewModel.threadVariant == .openGroup ||
|
||||||
|
@ -978,7 +1011,7 @@ final class VisibleMessageCell: MessageCell, TappableLabelDelegate {
|
||||||
)
|
)
|
||||||
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)
|
let leftGutterSize = (isGroupThread ? leftGutterSize : contactThreadHSpacing)
|
||||||
|
|
||||||
return (screen.width - leftGutterSize - gutterSize)
|
return (screen.width - leftGutterSize - oppositeEdgePadding)
|
||||||
|
|
||||||
default: preconditionFailure()
|
default: preconditionFailure()
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,8 +28,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
|
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
private let storage: Storage
|
private let dependencies: Dependencies
|
||||||
private let scheduler: ValueObservationScheduler
|
|
||||||
private let threadId: String
|
private let threadId: String
|
||||||
private let config: DisappearingMessagesConfiguration
|
private let config: DisappearingMessagesConfiguration
|
||||||
private var storedSelection: TimeInterval
|
private var storedSelection: TimeInterval
|
||||||
|
@ -38,13 +37,11 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(
|
init(
|
||||||
storage: Storage = Storage.shared,
|
dependencies: Dependencies = Dependencies(),
|
||||||
scheduling scheduler: ValueObservationScheduler = Storage.defaultPublisherScheduler,
|
|
||||||
threadId: String,
|
threadId: String,
|
||||||
config: DisappearingMessagesConfiguration
|
config: DisappearingMessagesConfiguration
|
||||||
) {
|
) {
|
||||||
self.storage = storage
|
self.dependencies = dependencies
|
||||||
self.scheduler = scheduler
|
|
||||||
self.threadId = threadId
|
self.threadId = threadId
|
||||||
self.config = config
|
self.config = config
|
||||||
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
|
self.storedSelection = (config.isEnabled ? config.durationSeconds : 0)
|
||||||
|
@ -133,7 +130,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: storage, scheduling: scheduler)
|
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
|
@ -152,7 +149,7 @@ class ThreadDisappearingMessagesViewModel: SessionTableViewModel<ThreadDisappear
|
||||||
|
|
||||||
guard self.config != updatedConfig else { return }
|
guard self.config != updatedConfig else { return }
|
||||||
|
|
||||||
storage.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
|
guard let thread: SessionThread = try SessionThread.fetchOne(db, id: threadId) else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,6 +46,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
|
|
||||||
// MARK: - Variables
|
// MARK: - Variables
|
||||||
|
|
||||||
|
private let dependencies: Dependencies
|
||||||
private let threadId: String
|
private let threadId: String
|
||||||
private let threadVariant: SessionThread.Variant
|
private let threadVariant: SessionThread.Variant
|
||||||
private let didTriggerSearch: () -> ()
|
private let didTriggerSearch: () -> ()
|
||||||
|
@ -54,13 +55,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
|
|
||||||
// MARK: - Initialization
|
// MARK: - Initialization
|
||||||
|
|
||||||
init(threadId: String, threadVariant: SessionThread.Variant, didTriggerSearch: @escaping () -> ()) {
|
init(
|
||||||
|
dependencies: Dependencies = Dependencies(),
|
||||||
|
threadId: String,
|
||||||
|
threadVariant: SessionThread.Variant,
|
||||||
|
didTriggerSearch: @escaping () -> ()
|
||||||
|
) {
|
||||||
|
self.dependencies = dependencies
|
||||||
self.threadId = threadId
|
self.threadId = threadId
|
||||||
self.threadVariant = threadVariant
|
self.threadVariant = threadVariant
|
||||||
self.didTriggerSearch = didTriggerSearch
|
self.didTriggerSearch = didTriggerSearch
|
||||||
self.oldDisplayName = (threadVariant != .contact ?
|
self.oldDisplayName = (threadVariant != .contact ?
|
||||||
nil :
|
nil :
|
||||||
Storage.shared.read { db in
|
dependencies.storage.read { db in
|
||||||
try Profile
|
try Profile
|
||||||
.filter(id: threadId)
|
.filter(id: threadId)
|
||||||
.select(.nickname)
|
.select(.nickname)
|
||||||
|
@ -73,55 +80,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
lazy var navState: AnyPublisher<NavState, Never> = {
|
lazy var navState: AnyPublisher<NavState, Never> = {
|
||||||
Publishers
|
isEditing
|
||||||
.MergeMany(
|
.map { isEditing in (isEditing ? .editing : .standard) }
|
||||||
isEditing
|
|
||||||
.filter { $0 }
|
|
||||||
.map { _ in .editing }
|
|
||||||
.eraseToAnyPublisher(),
|
|
||||||
navItemTapped
|
|
||||||
.filter { $0 == .edit }
|
|
||||||
.map { _ in .editing }
|
|
||||||
.handleEvents(receiveOutput: { [weak self] _ in
|
|
||||||
self?.setIsEditing(true)
|
|
||||||
})
|
|
||||||
.eraseToAnyPublisher(),
|
|
||||||
navItemTapped
|
|
||||||
.filter { $0 == .cancel }
|
|
||||||
.map { _ in .standard }
|
|
||||||
.handleEvents(receiveOutput: { [weak self] _ in
|
|
||||||
self?.setIsEditing(false)
|
|
||||||
self?.editedDisplayName = self?.oldDisplayName
|
|
||||||
})
|
|
||||||
.eraseToAnyPublisher(),
|
|
||||||
navItemTapped
|
|
||||||
.filter { $0 == .done }
|
|
||||||
.filter { [weak self] _ in self?.threadVariant == .contact }
|
|
||||||
.handleEvents(receiveOutput: { [weak self] _ in
|
|
||||||
self?.setIsEditing(false)
|
|
||||||
|
|
||||||
guard
|
|
||||||
let threadId: String = self?.threadId,
|
|
||||||
let editedDisplayName: String = self?.editedDisplayName
|
|
||||||
else { return }
|
|
||||||
|
|
||||||
let updatedNickname: String = editedDisplayName
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
|
|
||||||
|
|
||||||
Storage.shared.writeAsync { db in
|
|
||||||
try Profile
|
|
||||||
.filter(id: threadId)
|
|
||||||
.updateAll(
|
|
||||||
db,
|
|
||||||
Profile.Columns.nickname
|
|
||||||
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.map { _ in .standard }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
)
|
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.prepend(.standard) // Initial value
|
.prepend(.standard) // Initial value
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -139,7 +99,10 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
id: .cancel,
|
id: .cancel,
|
||||||
systemItem: .cancel,
|
systemItem: .cancel,
|
||||||
accessibilityIdentifier: "Cancel button"
|
accessibilityIdentifier: "Cancel button"
|
||||||
)
|
) { [weak self] in
|
||||||
|
self?.setIsEditing(false)
|
||||||
|
self?.editedDisplayName = self?.oldDisplayName
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -147,7 +110,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
|
|
||||||
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
|
override var rightNavItems: AnyPublisher<[NavItem]?, Never> {
|
||||||
navState
|
navState
|
||||||
.map { [weak self] navState -> [NavItem] in
|
.map { [weak self, dependencies] navState -> [NavItem] in
|
||||||
// Only show the 'Edit' button if it's a contact thread
|
// Only show the 'Edit' button if it's a contact thread
|
||||||
guard self?.threadVariant == .contact else { return [] }
|
guard self?.threadVariant == .contact else { return [] }
|
||||||
|
|
||||||
|
@ -158,7 +121,29 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
id: .done,
|
id: .done,
|
||||||
systemItem: .done,
|
systemItem: .done,
|
||||||
accessibilityIdentifier: "Done button"
|
accessibilityIdentifier: "Done button"
|
||||||
)
|
) { [weak self] in
|
||||||
|
self?.setIsEditing(false)
|
||||||
|
|
||||||
|
guard
|
||||||
|
self?.threadVariant == .contact,
|
||||||
|
let threadId: String = self?.threadId,
|
||||||
|
let editedDisplayName: String = self?.editedDisplayName
|
||||||
|
else { return }
|
||||||
|
|
||||||
|
let updatedNickname: String = editedDisplayName
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
self?.oldDisplayName = (updatedNickname.isEmpty ? nil : editedDisplayName)
|
||||||
|
|
||||||
|
dependencies.storage.writeAsync { db in
|
||||||
|
try Profile
|
||||||
|
.filter(id: threadId)
|
||||||
|
.updateAll(
|
||||||
|
db,
|
||||||
|
Profile.Columns.nickname
|
||||||
|
.set(to: (updatedNickname.isEmpty ? nil : editedDisplayName))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
case .standard:
|
case .standard:
|
||||||
|
@ -167,7 +152,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
id: .edit,
|
id: .edit,
|
||||||
systemItem: .edit,
|
systemItem: .edit,
|
||||||
accessibilityIdentifier: "Edit button"
|
accessibilityIdentifier: "Edit button"
|
||||||
)
|
) { [weak self] in self?.setIsEditing(true) }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -196,8 +181,8 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
/// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`)
|
/// 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
|
/// 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
|
private lazy var _observableSettingsData: ObservableData = ValueObservation
|
||||||
.trackingConstantRegion { [weak self, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
|
.trackingConstantRegion { [weak self, dependencies, threadId = self.threadId, threadVariant = self.threadVariant] db -> [SectionModel] in
|
||||||
let userPublicKey: String = getUserHexEncodedPublicKey(db)
|
let userPublicKey: String = getUserHexEncodedPublicKey(db, dependencies: dependencies)
|
||||||
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
let maybeThreadViewModel: SessionThreadViewModel? = try SessionThreadViewModel
|
||||||
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
|
.conversationSettingsQuery(threadId: threadId, userPublicKey: userPublicKey)
|
||||||
.fetchOne(db)
|
.fetchOne(db)
|
||||||
|
@ -387,7 +372,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
cancelStyle: .alert_text
|
cancelStyle: .alert_text
|
||||||
),
|
),
|
||||||
onTap: { [weak self] in
|
onTap: { [weak self] in
|
||||||
Storage.shared.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
try MessageSender.leave(db, groupPublicKey: threadId)
|
try MessageSender.leave(db, groupPublicKey: threadId)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -435,7 +420,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
onTap: {
|
onTap: {
|
||||||
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
let newValue: Bool = !(threadViewModel.threadOnlyNotifyForMentions == true)
|
||||||
|
|
||||||
Storage.shared.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
try SessionThread
|
try SessionThread
|
||||||
.filter(id: threadId)
|
.filter(id: threadId)
|
||||||
.updateAll(
|
.updateAll(
|
||||||
|
@ -465,15 +450,19 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
),
|
),
|
||||||
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
|
accessibilityIdentifier: "\(ThreadSettingsViewModel.self).mute",
|
||||||
onTap: {
|
onTap: {
|
||||||
let newValue: Bool = !(threadViewModel.threadMutedUntilTimestamp != nil)
|
dependencies.storage.writeAsync { db in
|
||||||
|
let currentValue: TimeInterval? = try SessionThread
|
||||||
Storage.shared.writeAsync { db in
|
.filter(id: threadId)
|
||||||
|
.select(.mutedUntilTimestamp)
|
||||||
|
.asRequest(of: TimeInterval.self)
|
||||||
|
.fetchOne(db)
|
||||||
|
|
||||||
try SessionThread
|
try SessionThread
|
||||||
.filter(id: threadId)
|
.filter(id: threadId)
|
||||||
.updateAll(
|
.updateAll(
|
||||||
db,
|
db,
|
||||||
SessionThread.Columns.mutedUntilTimestamp.set(
|
SessionThread.Columns.mutedUntilTimestamp.set(
|
||||||
to: (newValue ?
|
to: (currentValue == nil ?
|
||||||
Date.distantFuture.timeIntervalSince1970 :
|
Date.distantFuture.timeIntervalSince1970 :
|
||||||
nil
|
nil
|
||||||
)
|
)
|
||||||
|
@ -538,7 +527,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.publisher(in: Storage.shared)
|
.publisher(in: dependencies.storage, scheduling: dependencies.scheduler)
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
|
@ -575,7 +564,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
private func addUsersToOpenGoup(selectedUsers: Set<String>) {
|
private func addUsersToOpenGoup(selectedUsers: Set<String>) {
|
||||||
let threadId: String = self.threadId
|
let threadId: String = self.threadId
|
||||||
|
|
||||||
Storage.shared.writeAsync { db in
|
dependencies.storage.writeAsync { db in
|
||||||
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return }
|
guard let openGroup: OpenGroup = try OpenGroup.fetchOne(db, id: threadId) else { return }
|
||||||
|
|
||||||
let urlString: String = "\(openGroup.server)/\(openGroup.roomToken)?public_key=\(openGroup.publicKey)"
|
let urlString: String = "\(openGroup.server)/\(openGroup.roomToken)?public_key=\(openGroup.publicKey)"
|
||||||
|
@ -622,7 +611,7 @@ class ThreadSettingsViewModel: SessionTableViewModel<ThreadSettingsViewModel.Nav
|
||||||
) {
|
) {
|
||||||
guard oldBlockedState != isBlocked else { return }
|
guard oldBlockedState != isBlocked else { return }
|
||||||
|
|
||||||
Storage.shared.writeAsync(
|
dependencies.storage.writeAsync(
|
||||||
updates: { db in
|
updates: { db in
|
||||||
try Contact
|
try Contact
|
||||||
.fetchOrCreate(db, id: threadId)
|
.fetchOrCreate(db, id: threadId)
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -476,6 +476,7 @@
|
||||||
"EMOJI_REACTS_NOTIFICATION" = "%@ reacts to a message with %@.";
|
"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_ONE" = "And 1 other has reacted %@ to this message.";
|
||||||
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
"EMOJI_REACTS_MORE_REACTORS_MUTIPLE" = "And %@ others have reacted %@ to this message.";
|
||||||
|
"EMOJI_REACTS_RATE_LIMIT_TOAST" = "Slow down! You've sent too many emoji reacts. Try again soon.";
|
||||||
/* New conversation screen*/
|
/* New conversation screen*/
|
||||||
"vc_new_conversation_title" = "New Conversation";
|
"vc_new_conversation_title" = "New Conversation";
|
||||||
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
"CREATE_GROUP_BUTTON_TITLE" = "Create";
|
||||||
|
|
|
@ -63,66 +63,8 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
// MARK: - Navigation
|
// MARK: - Navigation
|
||||||
|
|
||||||
lazy var navState: AnyPublisher<NavState, Never> = {
|
lazy var navState: AnyPublisher<NavState, Never> = {
|
||||||
Publishers
|
isEditing
|
||||||
.MergeMany(
|
.map { isEditing in (isEditing ? .editing : .standard) }
|
||||||
isEditing
|
|
||||||
.filter { $0 }
|
|
||||||
.map { _ in .editing }
|
|
||||||
.eraseToAnyPublisher(),
|
|
||||||
navItemTapped
|
|
||||||
.filter { $0 == .cancel }
|
|
||||||
.map { _ in .standard }
|
|
||||||
.handleEvents(receiveOutput: { [weak self] _ in
|
|
||||||
self?.setIsEditing(false)
|
|
||||||
self?.editedDisplayName = self?.oldDisplayName
|
|
||||||
})
|
|
||||||
.eraseToAnyPublisher(),
|
|
||||||
navItemTapped
|
|
||||||
.filter { $0 == .done }
|
|
||||||
.handleEvents(receiveOutput: { [weak self] _ in
|
|
||||||
let updatedNickname: String = (self?.editedDisplayName ?? "")
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
|
|
||||||
guard !updatedNickname.isEmpty else {
|
|
||||||
self?.transitionToScreen(
|
|
||||||
ConfirmationModal(
|
|
||||||
info: ConfirmationModal.Info(
|
|
||||||
title: "vc_settings_display_name_missing_error".localized(),
|
|
||||||
cancelTitle: "BUTTON_OK".localized(),
|
|
||||||
cancelStyle: .alert_text
|
|
||||||
)
|
|
||||||
),
|
|
||||||
transitionType: .present
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard !ProfileManager.isToLong(profileName: updatedNickname) else {
|
|
||||||
self?.transitionToScreen(
|
|
||||||
ConfirmationModal(
|
|
||||||
info: ConfirmationModal.Info(
|
|
||||||
title: "vc_settings_display_name_too_long_error".localized(),
|
|
||||||
cancelTitle: "BUTTON_OK".localized(),
|
|
||||||
cancelStyle: .alert_text
|
|
||||||
)
|
|
||||||
),
|
|
||||||
transitionType: .present
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
self?.setIsEditing(false)
|
|
||||||
self?.oldDisplayName = updatedNickname
|
|
||||||
self?.updateProfile(
|
|
||||||
name: updatedNickname,
|
|
||||||
profilePicture: nil,
|
|
||||||
profilePictureFilePath: nil,
|
|
||||||
isUpdatingDisplayName: true,
|
|
||||||
isUpdatingProfilePicture: false
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map { _ in .standard }
|
|
||||||
.eraseToAnyPublisher()
|
|
||||||
)
|
|
||||||
.removeDuplicates()
|
.removeDuplicates()
|
||||||
.prepend(.standard) // Initial value
|
.prepend(.standard) // Initial value
|
||||||
.eraseToAnyPublisher()
|
.eraseToAnyPublisher()
|
||||||
|
@ -149,7 +91,10 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
id: .cancel,
|
id: .cancel,
|
||||||
systemItem: .cancel,
|
systemItem: .cancel,
|
||||||
accessibilityIdentifier: "Cancel button"
|
accessibilityIdentifier: "Cancel button"
|
||||||
)
|
) { [weak self] in
|
||||||
|
self?.setIsEditing(false)
|
||||||
|
self?.editedDisplayName = self?.oldDisplayName
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -180,7 +125,47 @@ class SettingsViewModel: SessionTableViewModel<SettingsViewModel.NavButton, Sett
|
||||||
id: .done,
|
id: .done,
|
||||||
systemItem: .done,
|
systemItem: .done,
|
||||||
accessibilityIdentifier: "Done button"
|
accessibilityIdentifier: "Done button"
|
||||||
)
|
) { [weak self] in
|
||||||
|
let updatedNickname: String = (self?.editedDisplayName ?? "")
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
guard !updatedNickname.isEmpty else {
|
||||||
|
self?.transitionToScreen(
|
||||||
|
ConfirmationModal(
|
||||||
|
info: ConfirmationModal.Info(
|
||||||
|
title: "vc_settings_display_name_missing_error".localized(),
|
||||||
|
cancelTitle: "BUTTON_OK".localized(),
|
||||||
|
cancelStyle: .alert_text
|
||||||
|
)
|
||||||
|
),
|
||||||
|
transitionType: .present
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard !ProfileManager.isToLong(profileName: updatedNickname) else {
|
||||||
|
self?.transitionToScreen(
|
||||||
|
ConfirmationModal(
|
||||||
|
info: ConfirmationModal.Info(
|
||||||
|
title: "vc_settings_display_name_too_long_error".localized(),
|
||||||
|
cancelTitle: "BUTTON_OK".localized(),
|
||||||
|
cancelStyle: .alert_text
|
||||||
|
)
|
||||||
|
),
|
||||||
|
transitionType: .present
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self?.setIsEditing(false)
|
||||||
|
self?.oldDisplayName = updatedNickname
|
||||||
|
self?.updateProfile(
|
||||||
|
name: updatedNickname,
|
||||||
|
profilePicture: nil,
|
||||||
|
profilePictureFilePath: nil,
|
||||||
|
isUpdatingDisplayName: true,
|
||||||
|
isUpdatingProfilePicture: false
|
||||||
|
)
|
||||||
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1344,9 +1344,12 @@ public enum OpenGroupAPI {
|
||||||
let method: String = (request.httpMethod ?? "GET")
|
let method: String = (request.httpMethod ?? "GET")
|
||||||
let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970))
|
let timestamp: Int = Int(floor(dependencies.date.timeIntervalSince1970))
|
||||||
let nonce: Data = Data(dependencies.nonceGenerator16.nonce())
|
let nonce: Data = Data(dependencies.nonceGenerator16.nonce())
|
||||||
|
let serverPublicKeyData: Data = Data(hex: serverPublicKey)
|
||||||
|
|
||||||
guard let serverPublicKeyData: Data = serverPublicKey.dataFromHex() else { return nil }
|
guard
|
||||||
guard let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes else { return nil }
|
!serverPublicKeyData.isEmpty,
|
||||||
|
let timestampBytes: Bytes = "\(timestamp)".data(using: .ascii)?.bytes
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
/// Get a hash of any body content
|
/// Get a hash of any body content
|
||||||
let bodyHash: Bytes? = {
|
let bodyHash: Bytes? = {
|
||||||
|
|
|
@ -1129,6 +1129,7 @@ extension OpenGroupManager {
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
box: BoxType? = nil,
|
box: BoxType? = nil,
|
||||||
genericHash: GenericHashType? = nil,
|
genericHash: GenericHashType? = nil,
|
||||||
|
@ -1146,6 +1147,7 @@ extension OpenGroupManager {
|
||||||
onionApi: onionApi,
|
onionApi: onionApi,
|
||||||
generalCache: generalCache,
|
generalCache: generalCache,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
|
scheduler: scheduler,
|
||||||
sodium: sodium,
|
sodium: sodium,
|
||||||
box: box,
|
box: box,
|
||||||
genericHash: genericHash,
|
genericHash: genericHash,
|
||||||
|
|
|
@ -510,10 +510,12 @@ public extension MessageViewModel {
|
||||||
|
|
||||||
// Interaction Info
|
// Interaction Info
|
||||||
|
|
||||||
let targetId: Int64 = (isTypingIndicator == true ?
|
let targetId: Int64 = {
|
||||||
MessageViewModel.typingIndicatorId :
|
guard isTypingIndicator != true else { return MessageViewModel.typingIndicatorId }
|
||||||
MessageViewModel.genericId
|
guard cellType != .dateHeader else { return -timestampMs }
|
||||||
)
|
|
||||||
|
return MessageViewModel.genericId
|
||||||
|
}()
|
||||||
self.rowId = targetId
|
self.rowId = targetId
|
||||||
self.id = targetId
|
self.id = targetId
|
||||||
self.variant = variant
|
self.variant = variant
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import GRDB
|
||||||
import Sodium
|
import Sodium
|
||||||
import SessionSnodeKit
|
import SessionSnodeKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
@ -66,6 +67,7 @@ public class SMKDependencies: Dependencies {
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
box: BoxType? = nil,
|
box: BoxType? = nil,
|
||||||
genericHash: GenericHashType? = nil,
|
genericHash: GenericHashType? = nil,
|
||||||
|
@ -90,6 +92,7 @@ public class SMKDependencies: Dependencies {
|
||||||
super.init(
|
super.init(
|
||||||
generalCache: generalCache,
|
generalCache: generalCache,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
|
scheduler: scheduler,
|
||||||
standardUserDefaults: standardUserDefaults,
|
standardUserDefaults: standardUserDefaults,
|
||||||
date: date
|
date: date
|
||||||
)
|
)
|
||||||
|
|
|
@ -25,8 +25,9 @@ extension Sodium {
|
||||||
/// 64-byte blake2b hash then reduce to get the blinding factor
|
/// 64-byte blake2b hash then reduce to get the blinding factor
|
||||||
public func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? {
|
public func generateBlindingFactor(serverPublicKey: String, genericHash: GenericHashType) -> Bytes? {
|
||||||
/// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
|
/// k = salt.crypto_core_ed25519_scalar_reduce(blake2b(server_pk, digest_size=64).digest())
|
||||||
guard let serverPubKeyData: Data = serverPublicKey.dataFromHex() else { return nil }
|
let serverPubKeyData: Data = Data(hex: serverPublicKey)
|
||||||
guard let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else {
|
|
||||||
|
guard !serverPubKeyData.isEmpty, let serverPublicKeyHashBytes: Bytes = genericHash.hash(message: [UInt8](serverPubKeyData), outputLength: 64) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import GRDB
|
||||||
import SessionSnodeKit
|
import SessionSnodeKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
@ -11,6 +12,7 @@ extension SMKDependencies {
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
box: BoxType? = nil,
|
box: BoxType? = nil,
|
||||||
genericHash: GenericHashType? = nil,
|
genericHash: GenericHashType? = nil,
|
||||||
|
@ -26,6 +28,7 @@ extension SMKDependencies {
|
||||||
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
||||||
generalCache: (generalCache ?? self._generalCache.wrappedValue),
|
generalCache: (generalCache ?? self._generalCache.wrappedValue),
|
||||||
storage: (storage ?? self._storage.wrappedValue),
|
storage: (storage ?? self._storage.wrappedValue),
|
||||||
|
scheduler: (scheduler ?? self._scheduler.wrappedValue),
|
||||||
sodium: (sodium ?? self._sodium.wrappedValue),
|
sodium: (sodium ?? self._sodium.wrappedValue),
|
||||||
box: (box ?? self._box.wrappedValue),
|
box: (box ?? self._box.wrappedValue),
|
||||||
genericHash: (genericHash ?? self._genericHash.wrappedValue),
|
genericHash: (genericHash ?? self._genericHash.wrappedValue),
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import GRDB
|
||||||
import SessionSnodeKit
|
import SessionSnodeKit
|
||||||
import SessionUtilitiesKit
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
@ -12,6 +13,7 @@ extension OpenGroupManager.OGMDependencies {
|
||||||
onionApi: OnionRequestAPIType.Type? = nil,
|
onionApi: OnionRequestAPIType.Type? = nil,
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
sodium: SodiumType? = nil,
|
sodium: SodiumType? = nil,
|
||||||
box: BoxType? = nil,
|
box: BoxType? = nil,
|
||||||
genericHash: GenericHashType? = nil,
|
genericHash: GenericHashType? = nil,
|
||||||
|
@ -28,6 +30,7 @@ extension OpenGroupManager.OGMDependencies {
|
||||||
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
onionApi: (onionApi ?? self._onionApi.wrappedValue),
|
||||||
generalCache: (generalCache ?? self._generalCache.wrappedValue),
|
generalCache: (generalCache ?? self._generalCache.wrappedValue),
|
||||||
storage: (storage ?? self._storage.wrappedValue),
|
storage: (storage ?? self._storage.wrappedValue),
|
||||||
|
scheduler: (scheduler ?? self._scheduler.wrappedValue),
|
||||||
sodium: (sodium ?? self._sodium.wrappedValue),
|
sodium: (sodium ?? self._sodium.wrappedValue),
|
||||||
box: (box ?? self._box.wrappedValue),
|
box: (box ?? self._box.wrappedValue),
|
||||||
genericHash: (genericHash ?? self._genericHash.wrappedValue),
|
genericHash: (genericHash ?? self._genericHash.wrappedValue),
|
||||||
|
|
|
@ -14,8 +14,8 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
|
||||||
|
|
||||||
override func spec() {
|
override func spec() {
|
||||||
var mockStorage: Storage!
|
var mockStorage: Storage!
|
||||||
var dataChangeCancellable: AnyCancellable?
|
var cancellables: [AnyCancellable] = []
|
||||||
var otherCancellables: [AnyCancellable] = []
|
var dependencies: Dependencies!
|
||||||
var viewModel: ThreadDisappearingMessagesViewModel!
|
var viewModel: ThreadDisappearingMessagesViewModel!
|
||||||
|
|
||||||
describe("a ThreadDisappearingMessagesViewModel") {
|
describe("a ThreadDisappearingMessagesViewModel") {
|
||||||
|
@ -31,6 +31,10 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
|
||||||
SNUIKit.migrations()
|
SNUIKit.migrations()
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
dependencies = Dependencies(
|
||||||
|
storage: mockStorage,
|
||||||
|
scheduler: .immediate
|
||||||
|
)
|
||||||
mockStorage.write { db in
|
mockStorage.write { db in
|
||||||
try SessionThread(
|
try SessionThread(
|
||||||
id: "TestId",
|
id: "TestId",
|
||||||
|
@ -38,26 +42,26 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
|
||||||
).insert(db)
|
).insert(db)
|
||||||
}
|
}
|
||||||
viewModel = ThreadDisappearingMessagesViewModel(
|
viewModel = ThreadDisappearingMessagesViewModel(
|
||||||
storage: mockStorage,
|
dependencies: dependencies,
|
||||||
scheduling: .immediate,
|
|
||||||
threadId: "TestId",
|
threadId: "TestId",
|
||||||
config: DisappearingMessagesConfiguration.defaultWith("TestId")
|
config: DisappearingMessagesConfiguration.defaultWith("TestId")
|
||||||
)
|
)
|
||||||
dataChangeCancellable = viewModel.observableSettingsData
|
cancellables.append(
|
||||||
.receiveOnMain(immediately: true)
|
viewModel.observableSettingsData
|
||||||
.sink(
|
.receiveOnMain(immediately: true)
|
||||||
receiveCompletion: { _ in },
|
.sink(
|
||||||
receiveValue: { viewModel.updateSettings($0) }
|
receiveCompletion: { _ in },
|
||||||
)
|
receiveValue: { viewModel.updateSettings($0) }
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach {
|
afterEach {
|
||||||
dataChangeCancellable?.cancel()
|
cancellables.forEach { $0.cancel() }
|
||||||
otherCancellables.forEach { $0.cancel() }
|
|
||||||
|
|
||||||
mockStorage = nil
|
mockStorage = nil
|
||||||
dataChangeCancellable = nil
|
cancellables = []
|
||||||
otherCancellables = []
|
dependencies = nil
|
||||||
viewModel = nil
|
viewModel = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,17 +122,18 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
|
||||||
_ = try config.saved(db)
|
_ = try config.saved(db)
|
||||||
}
|
}
|
||||||
viewModel = ThreadDisappearingMessagesViewModel(
|
viewModel = ThreadDisappearingMessagesViewModel(
|
||||||
storage: mockStorage,
|
dependencies: dependencies,
|
||||||
scheduling: .immediate,
|
|
||||||
threadId: "TestId",
|
threadId: "TestId",
|
||||||
config: config
|
config: config
|
||||||
)
|
)
|
||||||
dataChangeCancellable = viewModel.observableSettingsData
|
cancellables.append(
|
||||||
.receiveOnMain(immediately: true)
|
viewModel.observableSettingsData
|
||||||
.sink(
|
.receiveOnMain(immediately: true)
|
||||||
receiveCompletion: { _ in },
|
.sink(
|
||||||
receiveValue: { viewModel.updateSettings($0) }
|
receiveCompletion: { _ in },
|
||||||
)
|
receiveValue: { viewModel.updateSettings($0) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
expect(viewModel.settingsData.first?.elements.first)
|
expect(viewModel.settingsData.first?.elements.first)
|
||||||
.to(
|
.to(
|
||||||
|
@ -165,7 +170,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
|
||||||
it("has no right bar button") {
|
it("has no right bar button") {
|
||||||
var items: [ParentType.NavItem]?
|
var items: [ParentType.NavItem]?
|
||||||
|
|
||||||
otherCancellables.append(
|
cancellables.append(
|
||||||
viewModel.rightNavItems
|
viewModel.rightNavItems
|
||||||
.receiveOnMain(immediately: true)
|
.receiveOnMain(immediately: true)
|
||||||
.sink(
|
.sink(
|
||||||
|
@ -181,7 +186,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
|
||||||
var items: [ParentType.NavItem]?
|
var items: [ParentType.NavItem]?
|
||||||
|
|
||||||
beforeEach {
|
beforeEach {
|
||||||
otherCancellables.append(
|
cancellables.append(
|
||||||
viewModel.rightNavItems
|
viewModel.rightNavItems
|
||||||
.receiveOnMain(immediately: true)
|
.receiveOnMain(immediately: true)
|
||||||
.sink(
|
.sink(
|
||||||
|
@ -208,7 +213,7 @@ class ThreadDisappearingMessagesViewModelSpec: QuickSpec {
|
||||||
it("dismisses the screen") {
|
it("dismisses the screen") {
|
||||||
var didDismissScreen: Bool = false
|
var didDismissScreen: Bool = false
|
||||||
|
|
||||||
otherCancellables.append(
|
cancellables.append(
|
||||||
viewModel.dismissScreen
|
viewModel.dismissScreen
|
||||||
.receiveOnMain(immediately: true)
|
.receiveOnMain(immediately: true)
|
||||||
.sink(
|
.sink(
|
||||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -6,7 +6,7 @@ import GRDB
|
||||||
import PromiseKit
|
import PromiseKit
|
||||||
import SignalCoreKit
|
import SignalCoreKit
|
||||||
|
|
||||||
public final class Storage {
|
open class Storage {
|
||||||
private static let dbFileName: String = "Session.sqlite"
|
private static let dbFileName: String = "Session.sqlite"
|
||||||
private static let keychainService: String = "TSKeyChainService"
|
private static let keychainService: String = "TSKeyChainService"
|
||||||
private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec"
|
private static let dbCipherKeySpecKey: String = "GRDBDatabaseCipherKeySpec"
|
||||||
|
@ -305,17 +305,17 @@ public final class Storage {
|
||||||
|
|
||||||
// MARK: - Functions
|
// MARK: - Functions
|
||||||
|
|
||||||
@discardableResult public func write<T>(updates: (Database) throws -> T?) -> T? {
|
@discardableResult public final func write<T>(updates: (Database) throws -> T?) -> T? {
|
||||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
||||||
|
|
||||||
return try? dbWriter.write(updates)
|
return try? dbWriter.write(updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
public func writeAsync<T>(updates: @escaping (Database) throws -> T) {
|
open func writeAsync<T>(updates: @escaping (Database) throws -> T) {
|
||||||
writeAsync(updates: updates, completion: { _, _ in })
|
writeAsync(updates: updates, completion: { _, _ in })
|
||||||
}
|
}
|
||||||
|
|
||||||
public func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
|
open func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
|
||||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return }
|
||||||
|
|
||||||
dbWriter.asyncWrite(
|
dbWriter.asyncWrite(
|
||||||
|
@ -326,7 +326,7 @@ public final class Storage {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@discardableResult public func read<T>(_ value: (Database) throws -> T?) -> T? {
|
@discardableResult public final func read<T>(_ value: (Database) throws -> T?) -> T? {
|
||||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else { return nil }
|
||||||
|
|
||||||
return try? dbWriter.read(value)
|
return try? dbWriter.read(value)
|
||||||
|
@ -401,6 +401,7 @@ public extension Storage {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME: Can't overrwrite this in `SynchronousStorage` since it's in an extension
|
||||||
@discardableResult func writeAsync<T>(updates: @escaping (Database) throws -> Promise<T>) -> Promise<T> {
|
@discardableResult func writeAsync<T>(updates: @escaping (Database) throws -> Promise<T>) -> Promise<T> {
|
||||||
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
guard isValid, let dbWriter: DatabaseWriter = dbWriter else {
|
||||||
return Promise(error: StorageError.databaseInvalid)
|
return Promise(error: StorageError.databaseInvalid)
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
import Foundation
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
|
||||||
open class Dependencies {
|
open class Dependencies {
|
||||||
public var _generalCache: Atomic<Atomic<GeneralCacheType>?>
|
public var _generalCache: Atomic<Atomic<GeneralCacheType>?>
|
||||||
|
@ -15,6 +16,12 @@ open class Dependencies {
|
||||||
set { _storage.mutate { $0 = newValue } }
|
set { _storage.mutate { $0 = newValue } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public var _scheduler: Atomic<ValueObservationScheduler?>
|
||||||
|
public var scheduler: ValueObservationScheduler {
|
||||||
|
get { Dependencies.getValueSettingIfNull(&_scheduler) { Storage.defaultPublisherScheduler } }
|
||||||
|
set { _scheduler.mutate { $0 = newValue } }
|
||||||
|
}
|
||||||
|
|
||||||
public var _standardUserDefaults: Atomic<UserDefaultsType?>
|
public var _standardUserDefaults: Atomic<UserDefaultsType?>
|
||||||
public var standardUserDefaults: UserDefaultsType {
|
public var standardUserDefaults: UserDefaultsType {
|
||||||
get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } }
|
get { Dependencies.getValueSettingIfNull(&_standardUserDefaults) { UserDefaults.standard } }
|
||||||
|
@ -32,11 +39,13 @@ open class Dependencies {
|
||||||
public init(
|
public init(
|
||||||
generalCache: Atomic<GeneralCacheType>? = nil,
|
generalCache: Atomic<GeneralCacheType>? = nil,
|
||||||
storage: Storage? = nil,
|
storage: Storage? = nil,
|
||||||
|
scheduler: ValueObservationScheduler? = nil,
|
||||||
standardUserDefaults: UserDefaultsType? = nil,
|
standardUserDefaults: UserDefaultsType? = nil,
|
||||||
date: Date? = nil
|
date: Date? = nil
|
||||||
) {
|
) {
|
||||||
_generalCache = Atomic(generalCache)
|
_generalCache = Atomic(generalCache)
|
||||||
_storage = Atomic(storage)
|
_storage = Atomic(storage)
|
||||||
|
_scheduler = Atomic(scheduler)
|
||||||
_standardUserDefaults = Atomic(standardUserDefaults)
|
_standardUserDefaults = Atomic(standardUserDefaults)
|
||||||
_date = Atomic(date)
|
_date = Atomic(date)
|
||||||
}
|
}
|
||||||
|
@ -52,12 +61,4 @@ open class Dependencies {
|
||||||
|
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
// 0 libswiftCore.dylib 0x00000001999fd40c _swift_release_dealloc + 32 (HeapObject.cpp:703)
|
|
||||||
// 1 SessionMessagingKit 0x0000000106aa958c 0x106860000 + 2397580
|
|
||||||
// 2 libswiftCore.dylib 0x00000001999fd424 _swift_release_dealloc + 56 (HeapObject.cpp:703)
|
|
||||||
// 3 SessionUtilitiesKit 0x0000000106cbd980 static Dependencies.getValueSettingIfNull<A>(_:_:) + 264 (Dependencies.swift:49)
|
|
||||||
// 4 SessionMessagingKit 0x0000000106aa90f4 closure #1 in SMKDependencies.sign.getter + 112 (SMKDependencies.swift:17)
|
|
||||||
// 5 SessionUtilitiesKit 0x0000000106cbd974 static Dependencies.getValueSettingIfNull<A>(_:_:) + 252 (Dependencies.swift:48)
|
|
||||||
// 6 SessionMessagingKit 0x000000010697aef8 specialized static OpenGroupAPI.sign(_:messageBytes:for:fallbackSigningType:using:) + 1158904 (OpenGroupAPI.swift:1190)
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,20 +44,6 @@ public extension String {
|
||||||
return localizedString
|
return localizedString
|
||||||
}
|
}
|
||||||
|
|
||||||
func dataFromHex() -> Data? {
|
|
||||||
guard self.count > 0 && (self.count % 2) == 0 else { return nil }
|
|
||||||
|
|
||||||
let chars = self.map { $0 }
|
|
||||||
let bytes: [UInt8] = stride(from: 0, to: chars.count, by: 2)
|
|
||||||
.map { index -> String in String(chars[index]) + String(chars[index + 1]) }
|
|
||||||
.compactMap { (str: String) -> UInt8? in UInt8(str, radix: 16) }
|
|
||||||
|
|
||||||
guard bytes.count > 0 else { return nil }
|
|
||||||
guard (self.count / bytes.count) == 2 else { return nil }
|
|
||||||
|
|
||||||
return Data(bytes)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range<Index>] {
|
func ranges(of substring: String, options: CompareOptions = [], locale: Locale? = nil) -> [Range<Index>] {
|
||||||
var ranges: [Range<Index>] = []
|
var ranges: [Range<Index>] = []
|
||||||
|
|
||||||
|
|
|
@ -22,7 +22,11 @@ public class ToastController: ToastViewDelegate {
|
||||||
|
|
||||||
// MARK: Public
|
// MARK: Public
|
||||||
|
|
||||||
public func presentToastView(fromBottomOfView view: UIView, inset: CGFloat) {
|
public func presentToastView(
|
||||||
|
fromBottomOfView view: UIView,
|
||||||
|
inset: CGFloat,
|
||||||
|
duration: DispatchTimeInterval = .milliseconds(1500)
|
||||||
|
) {
|
||||||
Logger.debug("")
|
Logger.debug("")
|
||||||
toastView.alpha = 0
|
toastView.alpha = 0
|
||||||
view.addSubview(toastView)
|
view.addSubview(toastView)
|
||||||
|
@ -46,7 +50,7 @@ public class ToastController: ToastViewDelegate {
|
||||||
self.toastView.alpha = 1
|
self.toastView.alpha = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 1.5) {
|
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
|
||||||
// intentional strong reference to self.
|
// intentional strong reference to self.
|
||||||
// As with an AlertController, the caller likely expects toast to
|
// As with an AlertController, the caller likely expects toast to
|
||||||
// be presented and dismissed without maintaining a strong reference to ToastController
|
// be presented and dismissed without maintaining a strong reference to ToastController
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import Combine
|
||||||
|
|
||||||
|
public extension AnyPublisher {
|
||||||
|
func firstValue() -> Output? {
|
||||||
|
var value: Output?
|
||||||
|
|
||||||
|
_ = self
|
||||||
|
.receiveOnMain(immediately: true)
|
||||||
|
.sink(
|
||||||
|
receiveCompletion: { _ in },
|
||||||
|
receiveValue: { result in value = result }
|
||||||
|
)
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,16 +6,16 @@ import Curve25519Kit
|
||||||
|
|
||||||
extension Box.KeyPair: Mocked {
|
extension Box.KeyPair: Mocked {
|
||||||
static var mockValue: Box.KeyPair = Box.KeyPair(
|
static var mockValue: Box.KeyPair = Box.KeyPair(
|
||||||
publicKey: Data.data(fromHex: TestConstants.publicKey)!.bytes,
|
publicKey: Data(hex: TestConstants.publicKey).bytes,
|
||||||
secretKey: Data.data(fromHex: TestConstants.edSecretKey)!.bytes
|
secretKey: Data(hex: TestConstants.edSecretKey).bytes
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension ECKeyPair: Mocked {
|
extension ECKeyPair: Mocked {
|
||||||
static var mockValue: Self {
|
static var mockValue: Self {
|
||||||
try! Self.init(
|
try! Self.init(
|
||||||
publicKeyData: Data.data(fromHex: TestConstants.publicKey)!,
|
publicKeyData: Data(hex: TestConstants.publicKey),
|
||||||
privateKeyData: Data.data(fromHex: TestConstants.privateKey)!
|
privateKeyData: Data(hex: TestConstants.privateKey)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,28 @@
|
||||||
|
// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import GRDB
|
||||||
|
import PromiseKit
|
||||||
|
import SessionUtilitiesKit
|
||||||
|
|
||||||
|
class SynchronousStorage: Storage {
|
||||||
|
override func writeAsync<T>(updates: @escaping (Database) throws -> T) {
|
||||||
|
super.write(updates: updates)
|
||||||
|
}
|
||||||
|
|
||||||
|
override func writeAsync<T>(updates: @escaping (Database) throws -> T, completion: @escaping (Database, Swift.Result<T, Error>) throws -> Void) {
|
||||||
|
super.write { db in
|
||||||
|
do {
|
||||||
|
var result: T?
|
||||||
|
try db.inTransaction {
|
||||||
|
result = try updates(db)
|
||||||
|
return .commit
|
||||||
|
}
|
||||||
|
try? completion(db, .success(result!))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
try? completion(db, .failure(error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue