UI: update smart list
- use new design for smart list
- migrate to swiftUI
Change-Id: Iadbd93b631743640b7b7ab0a62a64333da3b0bd4
diff --git a/Ring/Ring.xcodeproj/project.pbxproj b/Ring/Ring.xcodeproj/project.pbxproj
index f38a1ba..d463a11 100644
--- a/Ring/Ring.xcodeproj/project.pbxproj
+++ b/Ring/Ring.xcodeproj/project.pbxproj
@@ -149,7 +149,6 @@
1A2D18C71F29180700B2C785 /* DeviceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2D18BF1F29180700B2C785 /* DeviceModel.swift */; };
1A2D18D11F29182500B2C785 /* ConversationViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2D18CC1F29182500B2C785 /* ConversationViewController.swift */; };
1A2D18DD1F29192D00B2C785 /* MessableBubble.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2D18DC1F29192D00B2C785 /* MessableBubble.swift */; };
- 1A2D18EB1F29197100B2C785 /* MessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2D18E41F29197100B2C785 /* MessageViewModel.swift */; };
1A2D18ED1F2919D800B2C785 /* MeViewController.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 1A2D18EC1F2919D800B2C785 /* MeViewController.storyboard */; };
1A2D18FC1F292DAD00B2C785 /* ConversationCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2D18FA1F292DAD00B2C785 /* ConversationCell.swift */; };
1A2D18FD1F292DAD00B2C785 /* SmartListCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 1A2D18FB1F292DAD00B2C785 /* SmartListCell.xib */; };
@@ -245,6 +244,10 @@
265DFB09292FD25000834B97 /* DefaultTransferView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265DFB08292FD25000834B97 /* DefaultTransferView.swift */; };
265DFB0B292FD41100834B97 /* MessageSwiftUIViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265DFB0A292FD41100834B97 /* MessageSwiftUIViews.swift */; };
265DFB1129302A8700834B97 /* ContactMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 265DFB1029302A8700834B97 /* ContactMessageView.swift */; };
+ 26625BB52BA9DF67009D2DDB /* ConversationsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26625BB42BA9DF67009D2DDB /* ConversationsView.swift */; };
+ 26625BB72BA9DF81009D2DDB /* ConversationsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26625BB62BA9DF81009D2DDB /* ConversationsViewModel.swift */; };
+ 26625BB92BA9FEFC009D2DDB /* SmartListContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26625BB82BA9FEFC009D2DDB /* SmartListContentView.swift */; };
+ 26625BBB2BAA0749009D2DDB /* SmartListContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26625BBA2BAA0749009D2DDB /* SmartListContainer.swift */; };
2662FC79246B1E1700FA7782 /* JamiSearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2662FC78246B1E1700FA7782 /* JamiSearchView.swift */; };
2662FC7B246B216B00FA7782 /* JamiSearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2662FC7A246B216B00FA7782 /* JamiSearchViewModel.swift */; };
2662FC7D246B78E800FA7782 /* IncognitoSmartListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2662FC7C246B78E800FA7782 /* IncognitoSmartListViewController.swift */; };
@@ -393,12 +396,18 @@
26D08AB9269628F400E37574 /* RequestsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D08AB8269628F400E37574 /* RequestsService.swift */; };
26D08ABB2696293100E37574 /* RequestsAdapterDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D08ABA2696293100E37574 /* RequestsAdapterDelegate.swift */; };
26D08ABE2696296300E37574 /* RequestsAdapter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 26D08ABD2696296300E37574 /* RequestsAdapter.mm */; };
+ 26D87CEB2BBB4A5F0086E4AA /* AccountsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D87CEA2BBB4A5F0086E4AA /* AccountsViewModel.swift */; };
26D8F54A2A6C08D20044398A /* TopView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26D8F5492A6C08D20044398A /* TopView.swift */; };
26DA813224B641A5006C6E23 /* ProfilesAdapter.mm in Sources */ = {isa = PBXBuildFile; fileRef = 26DA813124B641A5006C6E23 /* ProfilesAdapter.mm */; };
26DD506D2A86A58E0074E55F /* ActionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26DD506C2A86A58E0074E55F /* ActionsView.swift */; };
26DD506F2A86CFF20074E55F /* ActionsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26DD506E2A86CFF20074E55F /* ActionsViewModel.swift */; };
26E0731A2A915A15006B6F03 /* DraggableCaptureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26E073192A915A15006B6F03 /* DraggableCaptureView.swift */; };
26E0731C2A918F7A006B6F03 /* SwiftUIViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26E0731B2A918F7A006B6F03 /* SwiftUIViews.swift */; };
+ 26E081222BC03AB3001B4C18 /* AccountLists.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26E081212BC03AB3001B4C18 /* AccountLists.swift */; };
+ 26E081242BC07824001B4C18 /* RequestsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26E081232BC07824001B4C18 /* RequestsViewModel.swift */; };
+ 26E081262BC09534001B4C18 /* RequestsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26E081252BC09534001B4C18 /* RequestsView.swift */; };
+ 26EEB19C2BD98CCA00B8B04B /* SearchBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EEB19B2BD98CCA00B8B04B /* SearchBar.swift */; };
+ 26EEB19E2BDEDC0C00B8B04B /* UIControllersWrappers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EEB19D2BDEDC0C00B8B04B /* UIControllersWrappers.swift */; };
26EF35E728E3401800D97E14 /* ReplyHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF35E628E3401800D97E14 /* ReplyHistory.swift */; };
26EF35E928E3847100D97E14 /* MessageContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF35E828E3847100D97E14 /* MessageContentView.swift */; };
26EF35EB28E38DA200D97E14 /* MessageContentVM.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF35EA28E38DA200D97E14 /* MessageContentVM.swift */; };
@@ -775,7 +784,6 @@
1A2D18BF1F29180700B2C785 /* DeviceModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DeviceModel.swift; sourceTree = "<group>"; };
1A2D18CC1F29182500B2C785 /* ConversationViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationViewController.swift; sourceTree = "<group>"; };
1A2D18DC1F29192D00B2C785 /* MessableBubble.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessableBubble.swift; sourceTree = "<group>"; };
- 1A2D18E41F29197100B2C785 /* MessageViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MessageViewModel.swift; sourceTree = "<group>"; };
1A2D18EC1F2919D800B2C785 /* MeViewController.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = MeViewController.storyboard; sourceTree = "<group>"; };
1A2D18FA1F292DAD00B2C785 /* ConversationCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConversationCell.swift; sourceTree = "<group>"; };
1A2D18FB1F292DAD00B2C785 /* SmartListCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SmartListCell.xib; sourceTree = "<group>"; };
@@ -876,6 +884,10 @@
265DFB0A292FD41100834B97 /* MessageSwiftUIViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSwiftUIViews.swift; sourceTree = "<group>"; };
265DFB1029302A8700834B97 /* ContactMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactMessageView.swift; sourceTree = "<group>"; };
265E976226E8F610008801C0 /* NotificationService-debug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "NotificationService-debug.entitlements"; sourceTree = "<group>"; };
+ 26625BB42BA9DF67009D2DDB /* ConversationsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ConversationsView.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift; sourceTree = SOURCE_ROOT; };
+ 26625BB62BA9DF81009D2DDB /* ConversationsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ConversationsViewModel.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Models/ConversationsViewModel.swift; sourceTree = SOURCE_ROOT; };
+ 26625BB82BA9FEFC009D2DDB /* SmartListContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SmartListContentView.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift; sourceTree = SOURCE_ROOT; };
+ 26625BBA2BAA0749009D2DDB /* SmartListContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SmartListContainer.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContainer.swift; sourceTree = SOURCE_ROOT; };
2662FC78246B1E1700FA7782 /* JamiSearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JamiSearchView.swift; sourceTree = "<group>"; };
2662FC7A246B216B00FA7782 /* JamiSearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JamiSearchViewModel.swift; sourceTree = "<group>"; };
2662FC7C246B78E800FA7782 /* IncognitoSmartListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = IncognitoSmartListViewController.swift; path = Ring/Features/Conversations/SmartList/IncognitoSmartListViewController.swift; sourceTree = SOURCE_ROOT; };
@@ -987,6 +999,7 @@
26D08ABA2696293100E37574 /* RequestsAdapterDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestsAdapterDelegate.swift; sourceTree = "<group>"; };
26D08ABC2696296300E37574 /* RequestsAdapter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RequestsAdapter.h; sourceTree = "<group>"; };
26D08ABD2696296300E37574 /* RequestsAdapter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = RequestsAdapter.mm; sourceTree = "<group>"; };
+ 26D87CEA2BBB4A5F0086E4AA /* AccountsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountsViewModel.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Models/AccountsViewModel.swift; sourceTree = SOURCE_ROOT; };
26D8F5492A6C08D20044398A /* TopView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopView.swift; sourceTree = "<group>"; };
26DA813024B641A5006C6E23 /* ProfilesAdapter.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = ProfilesAdapter.h; sourceTree = "<group>"; };
26DA813124B641A5006C6E23 /* ProfilesAdapter.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = ProfilesAdapter.mm; sourceTree = "<group>"; };
@@ -994,6 +1007,11 @@
26DD506E2A86CFF20074E55F /* ActionsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActionsViewModel.swift; sourceTree = "<group>"; };
26E073192A915A15006B6F03 /* DraggableCaptureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableCaptureView.swift; sourceTree = "<group>"; };
26E0731B2A918F7A006B6F03 /* SwiftUIViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftUIViews.swift; sourceTree = "<group>"; };
+ 26E081212BC03AB3001B4C18 /* AccountLists.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AccountLists.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Views/AccountLists.swift; sourceTree = SOURCE_ROOT; };
+ 26E081232BC07824001B4C18 /* RequestsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RequestsViewModel.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Models/RequestsViewModel.swift; sourceTree = SOURCE_ROOT; };
+ 26E081252BC09534001B4C18 /* RequestsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = RequestsView.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift; sourceTree = SOURCE_ROOT; };
+ 26EEB19B2BD98CCA00B8B04B /* SearchBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SearchBar.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Views/SearchBar.swift; sourceTree = SOURCE_ROOT; };
+ 26EEB19D2BDEDC0C00B8B04B /* UIControllersWrappers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = UIControllersWrappers.swift; path = Ring/Features/Conversations/SmartList/SwiftUI/Views/UIControllersWrappers.swift; sourceTree = SOURCE_ROOT; };
26EF35E628E3401800D97E14 /* ReplyHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReplyHistory.swift; sourceTree = "<group>"; };
26EF35E828E3847100D97E14 /* MessageContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContentView.swift; sourceTree = "<group>"; };
26EF35EA28E38DA200D97E14 /* MessageContentVM.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageContentVM.swift; sourceTree = "<group>"; };
@@ -2043,6 +2061,7 @@
1A2D18AD1F29151E00B2C785 /* Smartlist */ = {
isa = PBXGroup;
children = (
+ 26625BB12BA9DF0C009D2DDB /* SwiftUI */,
1A2D18F91F292DA000B2C785 /* Cells */,
1A2D18B01F2915B600B2C785 /* SmartlistViewController.storyboard */,
1A5DC02F1F3565AE0075E8EF /* SmartlistViewController.swift */,
@@ -2064,7 +2083,6 @@
1A2D18B21F2915C500B2C785 /* ConversationViewController.storyboard */,
1A2D18CC1F29182500B2C785 /* ConversationViewController.swift */,
1A5DC02D1F3565640075E8EF /* ConversationViewModel.swift */,
- 1A2D18E41F29197100B2C785 /* MessageViewModel.swift */,
26D08AB02693474300E37574 /* InvitationViewController.storyboard */,
26D08AB42693480200E37574 /* InvitationViewController.swift */,
26D08AB62693481C00E37574 /* InvitationViewModel.swift */,
@@ -2339,6 +2357,39 @@
path = ViewModels;
sourceTree = "<group>";
};
+ 26625BB12BA9DF0C009D2DDB /* SwiftUI */ = {
+ isa = PBXGroup;
+ children = (
+ 26625BB32BA9DF22009D2DDB /* Views */,
+ 26625BB22BA9DF17009D2DDB /* Models */,
+ );
+ path = SwiftUI;
+ sourceTree = "<group>";
+ };
+ 26625BB22BA9DF17009D2DDB /* Models */ = {
+ isa = PBXGroup;
+ children = (
+ 26625BB62BA9DF81009D2DDB /* ConversationsViewModel.swift */,
+ 26D87CEA2BBB4A5F0086E4AA /* AccountsViewModel.swift */,
+ 26E081232BC07824001B4C18 /* RequestsViewModel.swift */,
+ );
+ path = Models;
+ sourceTree = "<group>";
+ };
+ 26625BB32BA9DF22009D2DDB /* Views */ = {
+ isa = PBXGroup;
+ children = (
+ 26625BB42BA9DF67009D2DDB /* ConversationsView.swift */,
+ 26625BB82BA9FEFC009D2DDB /* SmartListContentView.swift */,
+ 26625BBA2BAA0749009D2DDB /* SmartListContainer.swift */,
+ 26E081212BC03AB3001B4C18 /* AccountLists.swift */,
+ 26E081252BC09534001B4C18 /* RequestsView.swift */,
+ 26EEB19B2BD98CCA00B8B04B /* SearchBar.swift */,
+ 26EEB19D2BDEDC0C00B8B04B /* UIControllersWrappers.swift */,
+ );
+ path = Views;
+ sourceTree = "<group>";
+ };
2662FC77246B1DD600FA7782 /* JamiSearchView */ = {
isa = PBXGroup;
children = (
@@ -2849,6 +2900,7 @@
1A2D18AC1F29149D00B2C785 /* MeCoordinator.swift in Sources */,
260AE62529B65A4B00D66D5E /* ContactsUtils.swift in Sources */,
1A2D18C51F29180700B2C785 /* ContactModel.swift in Sources */,
+ 26625BB52BA9DF67009D2DDB /* ConversationsView.swift in Sources */,
BB4C6E2629229131001C901A /* ColorExtension.swift in Sources */,
269DA09928E23D37007D51D6 /* MessageRowView.swift in Sources */,
648AF76D24ED7CA90004D727 /* UITextView+Helpers.swift in Sources */,
@@ -2913,9 +2965,11 @@
26EF35E728E3401800D97E14 /* ReplyHistory.swift in Sources */,
1A2D18DD1F29192D00B2C785 /* MessableBubble.swift in Sources */,
260C73F329196C6C005C513F /* MessageStackVM.swift in Sources */,
+ 26625BBB2BAA0749009D2DDB /* SmartListContainer.swift in Sources */,
2640BA9A2B974E4B006D189A /* MessageProtocols.swift in Sources */,
0E96ED77225D06380016C07D /* GeneralSettingsViewController.swift in Sources */,
263F61782B45E8FD00240AEE /* MessageBubbleView.swift in Sources */,
+ 26E081242BC07824001B4C18 /* RequestsViewModel.swift in Sources */,
264FB66229775B1C00BEFBBF /* SystemService.swift in Sources */,
0E99F1A022417A0400CF8BD6 /* JamiURI.swift in Sources */,
1A5DC02E1F3565640075E8EF /* ConversationViewModel.swift in Sources */,
@@ -2923,6 +2977,7 @@
0EBB72A92034F44200D88F46 /* ProfilesService.swift in Sources */,
0E13A91C22B844B100A12A54 /* NSUserActivity+Call.swift in Sources */,
1A2D189C1F264AD900B2C785 /* UIViewController+Ring.swift in Sources */,
+ 26E081262BC09534001B4C18 /* RequestsView.swift in Sources */,
0ECA56852433949C0055D31E /* MigrateAccountViewModel.swift in Sources */,
02C9B63F1E1D4E8C00F82F0C /* ServiceEvent.swift in Sources */,
62B60AF420489E7C001BEACF /* DataTransferService.swift in Sources */,
@@ -2969,6 +3024,7 @@
1A2D18FC1F292DAD00B2C785 /* ConversationCell.swift in Sources */,
0E48F9D31FDF150700D6CC08 /* GeneratedInteractionsManager.swift in Sources */,
265C436A286254C900B4BE73 /* Constants.swift in Sources */,
+ 26E081222BC03AB3001B4C18 /* AccountLists.swift in Sources */,
264EA87E2977080300B6FB6F /* LogViewModel.swift in Sources */,
62B60AF92048A40D001BEACF /* DataTransferAdapter.mm in Sources */,
1A5DC0371F35675E0075E8EF /* ContactRequestCell.swift in Sources */,
@@ -3000,6 +3056,7 @@
1A2D18FF1F29352D00B2C785 /* MeViewModel.swift in Sources */,
BBB76E412966062C00A42DF5 /* MapView.swift in Sources */,
62A88D391F6C323500F8AB18 /* PresenceAdapter.mm in Sources */,
+ 26625BB72BA9DF81009D2DDB /* ConversationsViewModel.swift in Sources */,
1DF75AC6296E0C2A0055EA87 /* AddMoreParticipantsInSwarm.swift in Sources */,
BB6690072A99069900875848 /* WelcomeViewController.swift in Sources */,
1A2D18B71F29164700B2C785 /* SmartlistViewModel.swift in Sources */,
@@ -3024,6 +3081,7 @@
265DFB0B292FD41100834B97 /* MessageSwiftUIViews.swift in Sources */,
564C445B1E8EA44E000F92B1 /* Durations.swift in Sources */,
26D08AB72693481C00E37574 /* InvitationViewModel.swift in Sources */,
+ 26625BB92BA9FEFC009D2DDB /* SmartListContentView.swift in Sources */,
26074FD924F7FF9500374570 /* PreviewViewController.swift in Sources */,
0E320D54224ADFD00070B515 /* DialpadViewModel.swift in Sources */,
0EB1A5D11F8EBE23009923E2 /* DeviceCell.swift in Sources */,
@@ -3093,9 +3151,9 @@
0E49096E1FEAC0DE005CAA50 /* CallsService.swift in Sources */,
0273C2FF1E0C438F00CF00BA /* AccountAdapterDelegate.swift in Sources */,
1A2D18A41F27EF5200B2C785 /* AppCoordinator.swift in Sources */,
+ 26D87CEB2BBB4A5F0086E4AA /* AccountsViewModel.swift in Sources */,
1A2D18C31F29180700B2C785 /* AccountModel.swift in Sources */,
62A88D371F6C2ED400F8AB18 /* PresenceAdapterDelegate.swift in Sources */,
- 1A2D18EB1F29197100B2C785 /* MessageViewModel.swift in Sources */,
02B22DFF1DF755DB000358C9 /* AccountsService.swift in Sources */,
62E55B6F1F793ADE00D3FEF4 /* AvatarsColors.swift in Sources */,
BB06BD8129D491F80064F0FC /* CustomAnnotationModel.swift in Sources */,
@@ -3108,9 +3166,11 @@
564C44601E943C37000F92B1 /* NameRegistrationAdapter.mm in Sources */,
26BCBBD32A964ABC0001EE38 /* ConferenceActionsModel.swift in Sources */,
0E6F5453223C3C7500ECC3CE /* AccountItemView.swift in Sources */,
+ 26EEB19C2BD98CCA00B8B04B /* SearchBar.swift in Sources */,
0EF49AA123828CBC0064CD98 /* ParticipantProfileInfo.swift in Sources */,
BB68E6602AB1F03000F02AB7 /* ScreenHelper.swift in Sources */,
0EF49AA123828CBC0064CD98 /* ParticipantProfileInfo.swift in Sources */,
+ 26EEB19E2BDEDC0C00B8B04B /* UIControllersWrappers.swift in Sources */,
26DD506F2A86CFF20074E55F /* ActionsViewModel.swift in Sources */,
0E8E9A0520483E1200DA8E8B /* TitleView.swift in Sources */,
264FB66629775B9B00BEFBBF /* SystemAdapterDelegate.swift in Sources */,
diff --git a/Ring/Ring/Constants/Generated/Strings.swift b/Ring/Ring/Constants/Generated/Strings.swift
index 65cc2d4..52b8a2c 100644
--- a/Ring/Ring/Constants/Generated/Strings.swift
+++ b/Ring/Ring/Constants/Generated/Strings.swift
@@ -331,6 +331,10 @@
internal static let startVideoCall = L10n.tr("Localizable", "contactPage.startVideoCall", fallback: "Start Video Call")
}
internal enum Conversation {
+ /// Add to Contacts
+ internal static let addToContactsButton = L10n.tr("Localizable", "conversation.addToContactsButton", fallback: "Add to Contacts")
+ /// Add to contacts?
+ internal static let addToContactsLabel = L10n.tr("Localizable", "conversation.addToContactsLabel", fallback: "Add to contacts?")
/// deleted a message
internal static let deletedMessage = L10n.tr("Localizable", "conversation.deletedMessage", fallback: "deleted a message")
/// edited
@@ -351,6 +355,8 @@
internal static func notContact(_ p1: Any) -> String {
return L10n.tr("Localizable", "conversation.notContact", String(describing: p1), fallback: "%@ is not in your contact list.")
}
+ /// is not in your contact list
+ internal static let notContactLabel = L10n.tr("Localizable", "conversation.notContactLabel", fallback: "is not in your contact list")
/// %@ sent you a request for a conversation.
internal static func receivedRequest(_ p1: Any) -> String {
return L10n.tr("Localizable", "conversation.receivedRequest", String(describing: p1), fallback: "%@ sent you a request for a conversation.")
@@ -586,8 +592,18 @@
internal static let username = L10n.tr("Localizable", "global.username", fallback: "Username")
}
internal enum Invitations {
+ /// accepted
+ internal static let accepted = L10n.tr("Localizable", "invitations.accepted", fallback: "accepted")
+ /// banned
+ internal static let banned = L10n.tr("Localizable", "invitations.banned", fallback: "banned")
+ /// Invitations received
+ internal static let list = L10n.tr("Localizable", "invitations.list", fallback: "Invitations received")
/// No invitations
internal static let noInvitations = L10n.tr("Localizable", "invitations.noInvitations", fallback: "No invitations")
+ /// pending
+ internal static let pending = L10n.tr("Localizable", "invitations.pending", fallback: "pending")
+ /// refused
+ internal static let refused = L10n.tr("Localizable", "invitations.refused", fallback: "refused")
}
internal enum LinkDevice {
/// An error occurred during the export
@@ -688,12 +704,26 @@
internal static let disableDonation = L10n.tr("Localizable", "smartlist.disableDonation", fallback: "Not now")
/// If you enjoy using Jami and believe in our mission, would you make a donation?
internal static let donationExplanation = L10n.tr("Localizable", "smartlist.donationExplanation", fallback: "If you enjoy using Jami and believe in our mission, would you make a donation?")
+ /// conversation in synchronization
+ internal static let inSynchronization = L10n.tr("Localizable", "smartlist.inSynchronization", fallback: "conversation in synchronization")
+ /// Invitations received
+ internal static let invitationReceived = L10n.tr("Localizable", "smartlist.invitationReceived", fallback: "Invitations received")
/// Invitations
internal static let invitations = L10n.tr("Localizable", "smartlist.invitations", fallback: "Invitations")
/// Invite friends
internal static let inviteFriends = L10n.tr("Localizable", "smartlist.inviteFriends", fallback: "Invite friends")
+ /// Search Result
+ internal static let jamsResults = L10n.tr("Localizable", "smartlist.jamsResults", fallback: "Search Result")
+ /// New Contact
+ internal static let newContact = L10n.tr("Localizable", "smartlist.newContact", fallback: "New Contact")
+ /// New Message
+ internal static let newMessage = L10n.tr("Localizable", "smartlist.newMessage", fallback: "New Message")
+ /// New Swarm
+ internal static let newSwarm = L10n.tr("Localizable", "smartlist.newSwarm", fallback: "New Swarm")
/// No conversations
internal static let noConversation = L10n.tr("Localizable", "smartlist.noConversation", fallback: "No conversations")
+ /// No conversations match your search
+ internal static let noConversationsFound = L10n.tr("Localizable", "smartlist.noConversationsFound", fallback: "No conversations match your search")
/// No network connectivity
internal static let noNetworkConnectivity = L10n.tr("Localizable", "smartlist.noNetworkConnectivity", fallback: "No network connectivity")
/// Selected contact does not have any number
diff --git a/Ring/Ring/Extensions/ColorExtension.swift b/Ring/Ring/Extensions/ColorExtension.swift
index 91d86e4..d277ea4 100644
--- a/Ring/Ring/Extensions/ColorExtension.swift
+++ b/Ring/Ring/Extensions/ColorExtension.swift
@@ -86,3 +86,18 @@
}
}
}
+
+extension Color {
+ static let jamiPrimaryControl = Color("jamiPrimaryControl")
+ static let jamiSecondaryControl = Color("jamiSecondaryControl")
+ static let jamiTertiaryControl = Color("jamiTertiaryControl")
+ static let jamiRequestsColor = Color("jamiRequestsColor")
+ static let jamiColor = Color("jami")
+ static let availablePresenceColor = Color(UIColor.availablePresenceColor)
+ static let onlinePresenceColor = Color(UIColor.onlinePresenceColor)
+ static let unreadMessageColorText = Color(UIColor(hexString: "CC0022")!)
+ static let unreadMessageBackground = Color(UIColor(hexString: "EED4D8")!)
+ static let networkAlertBackground = Color(UIColor(red: 245, green: 110, blue: 88, alpha: 1))
+ static let requestBadgeForeground = Color("requestBadgeForeground")
+ static let requestsBadgeBackground = Color("requestsBadgeBackground")
+}
diff --git a/Ring/Ring/Extensions/Date+Helpers.swift b/Ring/Ring/Extensions/Date+Helpers.swift
index 5b12054..08a7425 100644
--- a/Ring/Ring/Extensions/Date+Helpers.swift
+++ b/Ring/Ring/Extensions/Date+Helpers.swift
@@ -43,4 +43,38 @@
string += String(format: "%02d:%02d", min, Int(sec))
return string
}
+
+ func conversationTimestamp() -> String {
+ var dateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ return formatter
+ }()
+ var hourFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "HH:mm"
+ return formatter
+ }()
+ let dateToday = Date()
+ var dateString = ""
+ let todayWeekOfYear = Calendar.current.component(.weekOfYear, from: dateToday)
+ let todayDay = Calendar.current.component(.day, from: dateToday)
+ let todayMonth = Calendar.current.component(.month, from: dateToday)
+ let todayYear = Calendar.current.component(.year, from: dateToday)
+ let weekOfYear = Calendar.current.component(.weekOfYear, from: self)
+ let day = Calendar.current.component(.day, from: self)
+ let month = Calendar.current.component(.month, from: self)
+ let year = Calendar.current.component(.year, from: self)
+ if todayDay == day && todayMonth == month && todayYear == year {
+ dateString = hourFormatter.string(from: self)
+ } else if day == todayDay - 1 {
+ dateString = L10n.Smartlist.yesterday
+ } else if todayYear == year && todayWeekOfYear == weekOfYear {
+ dateString = self.dayOfWeek()
+ } else {
+ dateString = dateFormatter.string(from: self)
+ }
+
+ return dateString
+ }
}
diff --git a/Ring/Ring/Extensions/UIImage+Helpers.swift b/Ring/Ring/Extensions/UIImage+Helpers.swift
index a53793c..3a98f61 100644
--- a/Ring/Ring/Extensions/UIImage+Helpers.swift
+++ b/Ring/Ring/Extensions/UIImage+Helpers.swift
@@ -265,6 +265,10 @@
func fillJamiBackgroundColor(inset: CGFloat) -> UIImage {
let color = UIColor.jamiMain
+ return self.fillBackgroundColor(color: color, inset: inset)
+ }
+
+ func fillBackgroundColor(color: UIColor, inset: CGFloat) -> UIImage {
let newSize = CGSize(width: self.size.width + 2 * inset, height: self.size.height + 2 * inset)
let drawingRect = CGRect(x: inset, y: inset, width: self.size.width, height: self.size.height)
@@ -380,6 +384,17 @@
return image
}
+ class func createSwarmAvatar(convId: String, size: CGSize) -> UIImage {
+ let image = UIImage(systemName: "person.2")!
+ let scanner = Scanner(string: convId.toMD5HexString().prefixString())
+ var index: UInt64 = 0
+ if scanner.scanHexInt64(&index) {
+ let fbaBGColor = avatarColors[Int(index)]
+ return image.fillBackgroundColor(color: fbaBGColor, inset: 10)
+ }
+ return image
+ }
+
class func createGroupAvatar(username: String, size: CGSize) -> UIImage {
let scanner = Scanner(string: username.toMD5HexString().prefixString())
var index: UInt64 = 0
diff --git a/Ring/Ring/Extensions/View+Helpers.swift b/Ring/Ring/Extensions/View+Helpers.swift
index 35d5045..c208d6e 100644
--- a/Ring/Ring/Extensions/View+Helpers.swift
+++ b/Ring/Ring/Extensions/View+Helpers.swift
@@ -99,6 +99,58 @@
}
}
+struct PlatformAdaptiveNavView<Content: View>: View {
+ let content: () -> Content
+
+ var body: some View {
+ if #available(iOS 16.0, *) {
+ NavigationStack {
+ content()
+ }
+ } else {
+ NavigationView {
+ content()
+ }
+ }
+ }
+}
+
+struct SlideTransition: ViewModifier {
+ let directionUp: Bool
+
+ func body(content: Content) -> some View {
+ content
+ .transition(.asymmetric(
+ insertion: .move(edge: directionUp ? .bottom : .top),
+ removal: .move(edge: directionUp ? .top : .bottom)
+ ))
+ }
+}
+
+extension View {
+ func applySlideTransition(directionUp: Bool) -> some View {
+ self.modifier(SlideTransition(directionUp: directionUp))
+ }
+}
+
+struct RowSeparatorHiddenModifier: ViewModifier {
+ func body(content: Content) -> some View {
+ if #available(iOS 15.0, *) {
+ content
+ .listRowSeparator(.hidden)
+ .listRowSeparatorTint(.clear)
+ } else {
+ content
+ }
+ }
+}
+
+extension View {
+ func hideRowSeparator() -> some View {
+ self.modifier(RowSeparatorHiddenModifier())
+ }
+}
+
extension Publishers {
static var keyboardHeight: AnyPublisher<CGFloat, Never> {
let willShow = NotificationCenter.default.publisher(for: UIApplication.keyboardWillShowNotification)
diff --git a/Ring/Ring/Features/ContactRequests/ContactRequestsViewModel.swift b/Ring/Ring/Features/ContactRequests/ContactRequestsViewModel.swift
index a177947..b55e5d7 100644
--- a/Ring/Ring/Features/ContactRequests/ContactRequestsViewModel.swift
+++ b/Ring/Ring/Features/ContactRequests/ContactRequestsViewModel.swift
@@ -191,7 +191,6 @@
conversationViewModel.displayName.accept(name)
conversationViewModel.profileImageData.accept(item.profileImageData.value)
conversationViewModel.conversation = conversation
- conversationViewModel.request = item.request
self.stateSubject.onNext(ConversationState.conversationDetail(conversationViewModel: conversationViewModel))
}
}
diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
index a71591f..815a022 100644
--- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewController.swift
@@ -87,22 +87,35 @@
private lazy var locationManager: CLLocationManager = { return CLLocationManager() }()
- func setIsComposing(isComposing: Bool) {
- self.viewModel.setIsComposingMsg(isComposing: isComposing)
- }
-
override func viewDidLoad() {
super.viewDidLoad()
self.configureNavigationBar()
self.setupUI()
self.setupBindings()
- NotificationCenter.default.addObserver(self,
- selector: #selector(applicationWillResignActive),
- name: UIApplication.willResignActiveNotification,
- object: nil)
screenTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(screenTapped))
self.view.addGestureRecognizer(screenTapRecognizer)
+ }
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ if !self.swiftUIViewAdded {
+ self.addSwiftUIView()
+ }
+ }
+
+ override func viewWillDisappear(_ animated: Bool) {
+ super.viewWillDisappear(animated)
+ self.navigationController?.navigationBar.shadowImage = UIImage()
+ self.navigationController?.navigationBar.layer.shadowOpacity = 0
+ self.viewModel.setMessagesAsRead()
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ self.setupNavTitle(profileImageData: self.viewModel.profileImageData.value,
+ displayName: self.viewModel.displayName.value,
+ username: self.viewModel.userName.value)
+ self.updateNavigationBarShadow()
}
@objc
@@ -112,14 +125,7 @@
private func addSwiftUIView() {
swiftUIViewAdded = true
- let transferHelper = TransferHelper(dataTransferService: self.viewModel.dataTransferService,
- conversationViewModel: self.viewModel)
- let swiftUIModel = MessagesListVM(injectionBag: self.viewModel.injectionBag,
- conversation: self.viewModel.conversation,
- transferHelper: transferHelper,
- bestName: self.viewModel.bestName,
- screenTapped: tapAction.asObservable())
- swiftUIModel.hideNavigationBar
+ self.viewModel.swiftUIModel.hideNavigationBar
.subscribe(onNext: { [weak self] (hide) in
guard let self = self else { return }
if self.navigationItem.rightBarButtonItems?.isEmpty == hide { return }
@@ -139,7 +145,9 @@
})
.disposed(by: self.disposeBag)
- swiftUIModel.messagePanelState
+ self.viewModel.swiftUIModel.subscribeScreenTapped(screenTapped: tapAction.asObservable())
+
+ self.viewModel.swiftUIModel.messagePanelState
.subscribe(onNext: { [weak self] (state) in
guard let self = self, let state = state as? MessagePanelState else { return }
switch state {
@@ -162,7 +170,7 @@
}
})
.disposed(by: self.disposeBag)
- swiftUIModel.contextMenuState
+ self.viewModel.swiftUIModel.contextMenuState
.subscribe(onNext: { [weak self] (state) in
guard let self = self, let state = state as? ContextMenu else { return }
switch state {
@@ -187,13 +195,13 @@
.disposed(by: self.disposeBag)
self.viewModel.conversationCreated
.observe(on: MainScheduler.instance)
- .subscribe { [weak self, weak swiftUIModel] update in
- guard let self = self, let swiftUIModel = swiftUIModel, update else { return }
- swiftUIModel.conversation = self.viewModel.conversation
+ .subscribe { [weak self] update in
+ guard let self = self, update else { return }
+ self.viewModel.swiftUIModel.conversation = self.viewModel.conversation
} onError: { _ in
}
.disposed(by: self.disposeBag)
- let messageListView = MessagesListView(model: swiftUIModel)
+ let messageListView = MessagesListView(model: self.viewModel.swiftUIModel)
let swiftUIView = UIHostingController(rootView: messageListView)
addChild(swiftUIView)
swiftUIView.view.frame = self.view.frame
@@ -211,19 +219,6 @@
}
}
- @objc
- private func applicationWillResignActive() {
- self.viewModel.setIsComposingMsg(isComposing: false)
- }
-
- override func viewWillAppear(_ animated: Bool) {
- super.viewWillAppear(animated)
- self.setupNavTitle(profileImageData: self.viewModel.profileImageData.value,
- displayName: self.viewModel.displayName.value,
- username: self.viewModel.userName.value)
- self.updateNavigationBarShadow()
- }
-
private func importDocument() {
currentDocumentPickerMode = .picking
let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: [UTType.item])
@@ -644,21 +639,6 @@
self.viewModel.startAudioCall()
}
- override func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- if !self.swiftUIViewAdded {
- self.addSwiftUIView()
- }
- }
-
- override func viewWillDisappear(_ animated: Bool) {
- super.viewWillDisappear(animated)
- self.navigationController?.navigationBar.shadowImage = UIImage()
- self.navigationController?.navigationBar.layer.shadowOpacity = 0
- self.viewModel.setIsComposingMsg(isComposing: false)
- self.viewModel.setMessagesAsRead()
- }
-
private func messagesLoadingFinished() {
self.spinnerView.isHidden = true
}
@@ -672,22 +652,6 @@
} onError: { _ in
}
.disposed(by: self.disposeBag)
- self.viewModel.showInvitation
- .observe(on: MainScheduler.instance)
- .subscribe { [weak self] show in
- guard let self = self else { return }
- if show {
- if self.view.window?.rootViewController is InvitationViewController {
- return
- }
- self.navigationItem.rightBarButtonItems = []
- self.viewModel.openInvitationView(parentView: self)
- } else {
- self.setRightNavigationButtons()
- }
- } onError: { _ in
- }
- .disposed(by: self.disposeBag)
self.viewModel.synchronizing
.startWith(self.viewModel.synchronizing.value)
.observe(on: MainScheduler.instance)
diff --git a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift
index f247b39..1e16bad 100644
--- a/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/ConversationViewModel.swift
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017-2021 Savoir-faire Linux Inc.
+ * Copyright (C) 2017-2024 Savoir-faire Linux Inc.
*
* Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
* Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
@@ -26,10 +26,40 @@
import RxSwift
import RxCocoa
import SwiftyBeaver
+import SwiftUI
+
+enum MessageSequencing {
+ case singleMessage
+ case firstOfSequence
+ case lastOfSequence
+ case middleOfSequence
+ case unknown
+}
+
+enum GeneratedMessageType: String {
+ case receivedContactRequest = "Contact request received"
+ case contactAdded = "Contact added"
+ case missedIncomingCall = "Missed incoming call"
+ case missedOutgoingCall = "Missed outgoing call"
+ case incomingCall = "Incoming call"
+ case outgoingCall = "Outgoing call"
+}
// swiftlint:disable type_body_length
// swiftlint:disable file_length
-class ConversationViewModel: Stateable, ViewModel {
+class ConversationViewModel: Stateable, ViewModel, ObservableObject, Identifiable {
+
+ @Published var avatar: UIImage?
+ @Published var name: String = ""
+ @Published var lastMessage: String = ""
+ @Published var lastMessageDate: String = ""
+ @Published var unreadMessages: Int = 0
+ @Published var presence: PresenceStatus = .offline
+ @Published var isSynchronizing: Bool = false
+
+ func getDefaultAvatar() -> UIImage {
+ return UIImage.createContactAvatar(username: (self.displayName.value?.isEmpty ?? true) ? self.userName.value : self.displayName.value!)
+ }
/// Logger
private let log = SwiftyBeaver.self
@@ -49,23 +79,11 @@
internal let disposeBag = DisposeBag()
- private var players = [String: PlayerViewModel]()
-
- func getPlayer(messageID: String) -> PlayerViewModel? {
- return players[messageID]
- }
- func setPlayer(messageID: String, player: PlayerViewModel) { players[messageID] = player }
func closeAllPlayers() {
- let queue = DispatchQueue.global(qos: .default)
- queue.sync {
- self.players.values.forEach { (player) in
- player.closePlayer()
- }
- self.players.removeAll()
- }
+ self.swiftUIModel.transferHelper.closeAllPlayers()
}
- let showInvitation = BehaviorRelay<Bool>(value: false)
+ let isTemporary = BehaviorRelay<Bool>(value: false)
let showIncomingLocationSharing = BehaviorRelay<Bool>(value: false)
let showOutgoingLocationSharing = BehaviorRelay<Bool>(value: false)
@@ -77,24 +95,6 @@
var synchronizing = BehaviorRelay<Bool>(value: false)
- lazy var typingIndicator: Observable<Bool> = {
- return self.conversationsService
- .sharedResponseStream
- .filter({ [weak self] (event) -> Bool in
- return event.eventType == ServiceEventType.messageTypingIndicator &&
- event.getEventInput(ServiceEventInput.accountId) == self?.conversation.accountId &&
- event.getEventInput(ServiceEventInput.peerUri) == self?.conversation.hash
- })
- .map({ (event) -> Bool in
- if let status: Int = event.getEventInput(ServiceEventInput.state), status == 1 {
- return true
- }
- return false
- })
- }()
-
- private var isJamsAccount: Bool { self.accountService.isJams(for: self.conversation.accountId) }
-
var isAccountSip: Bool = false
var displayName = BehaviorRelay<String?>(value: nil)
@@ -104,15 +104,14 @@
.combineLatest(userName.asObservable(),
displayName.asObservable(),
resultSelector: {(userName, displayname) in
- guard let displayname = displayname, !displayname.isEmpty else { return userName }
+ guard let displayname = displayname, !displayname.isEmpty else {
+ return userName }
return displayname
})
}()
/// Group's image data
var profileImageData = BehaviorRelay<Data?>(value: nil)
- /// My profile's image data
- var myOwnProfileImageData: Data?
var contactPresence = BehaviorRelay<PresenceStatus>(value: .offline)
var swarmInfo: SwarmInfoProtocol?
@@ -128,14 +127,69 @@
self.dataTransferService = injectionBag.dataTransferService
self.callService = injectionBag.callService
self.locationSharingService = injectionBag.locationSharingService
+ let transferHelper = TransferHelper(injectionBag: injectionBag)
+
+ swiftUIModel = MessagesListVM(injectionBag: self.injectionBag,
+ transferHelper: transferHelper)
+ swiftUIModel.subscribeBestName(bestName: self.bestName)
+ self.bestName
+ .share()
+ .asObservable()
+ .observe(on: MainScheduler.instance)
+ .startWith((self.displayName.value?.isEmpty ?? true) ? self.userName.value : self.displayName.value!)
+ .subscribe(onNext: { [weak self] bestName in
+ let name = bestName.replacingOccurrences(of: "\0", with: "")
+ guard !name.isEmpty else { return }
+ self?.name = name
+ self?.swiftUIModel.name = name
+ })
+ .disposed(by: self.disposeBag)
+
+ self.profileImageData
+ .share()
+ .asObservable()
+ .observe(on: MainScheduler.instance)
+ .startWith(self.profileImageData.value)
+ .subscribe(onNext: { [weak self] imageData in
+ if let imageData = imageData, !imageData.isEmpty {
+ if let image = UIImage(data: imageData) {
+ self?.avatar = image
+ }
+ }
+ })
+ .disposed(by: self.disposeBag)
+
+ self.lastMessageObservable
+ .share()
+ .asObservable()
+ .observe(on: MainScheduler.instance)
+ .startWith(swiftUIModel.lastMessage.value)
+ .subscribe(onNext: { [weak self] mesage in
+ self?.lastMessage = mesage
+ })
+ .disposed(by: self.disposeBag)
+
+ self.lastMessageDateObservable
+ .share()
+ .asObservable()
+ .observe(on: MainScheduler.instance)
+ .startWith(swiftUIModel.lastMessageDate.value)
+ .subscribe(onNext: { [weak self] mesageDate in
+ self?.lastMessageDate = mesageDate
+ })
+ .disposed(by: self.disposeBag)
+
+ self.isTemporary
+ .observe(on: MainScheduler.instance)
+ .subscribe { [weak self] show in
+ self?.swiftUIModel.isTemporary = show
+ } onError: { _ in
+ }
+ .disposed(by: self.disposeBag)
}
private func setConversation(_ conversation: ConversationModel) {
- // if self.conversation != nil {
- self.conversation = conversation
-// } else {
-// self.conversation = BehaviorRelay(value: conversation)
-// }
+ self.conversation = conversation
}
convenience init(with injectionBag: InjectionBag, conversation: ConversationModel, user: JamiSearchViewModel.JamsUserSearchModel) {
@@ -143,35 +197,32 @@
self.userName.accept(user.username)
self.displayName.accept(user.firstName + " " + user.lastName)
self.profileImageData.accept(user.profilePicture)
+ self.swiftUIModel.jamsAvatarData = user.profilePicture
+ self.swiftUIModel.jamsName = user.firstName + " " + user.lastName
self.setConversation(conversation) // required to trigger the didSet
}
- var request: RequestModel? {
- didSet {
- if request != nil && !self.showInvitation.value {
- self.showInvitation.accept(true)
- }
- }
+ var swiftUIModel: MessagesListVM
+
+ var lastMessageObservable: Observable <String> {
+ return swiftUIModel.lastMessage.asObservable()
+ }
+
+ var lastMessageDateObservable: Observable <String> {
+ return swiftUIModel.lastMessageDate.asObservable()
}
var conversation: ConversationModel! {
didSet {
self.subscribeUnreadMessages()
- self.subscribeProfileServiceMyPhoto()
+ self.swiftUIModel.conversation = conversation
guard let account = self.accountService.getAccount(fromAccountId: self.conversation.accountId) else { return }
if account.type == AccountType.sip {
self.userName.accept(self.conversation.hash)
self.isAccountSip = true
- self.subscribeLastMessagesUpdate()
return
}
- ///
- let showInv = self.request != nil || self.conversation.id.isEmpty
- if self.showInvitation.value != showInv {
- self.showInvitation.accept(showInv)
- }
-
self.subscribePresenceServiceContactPresence()
if self.shouldCreateSwarmInfo() {
self.createSwarmInfo()
@@ -199,10 +250,8 @@
*/
subscribeConversationReady()
}
- subscribeLastMessagesUpdate()
subscribeConversationSynchronization()
subscribeLocationEvents()
- // self.subscribeConversationServiceTypingIndicator()
}
}
@@ -216,6 +265,7 @@
.subscribe { [weak self] synchronizing in
guard let self = self else { return }
self.synchronizing.accept(synchronizing)
+ self.isSynchronizing = synchronizing
} onError: { _ in
}
.disposed(by: self.disposeBag)
@@ -246,7 +296,6 @@
func createSwarmInfo() {
self.swarmInfo = SwarmInfo(injectionBag: self.injectionBag, conversation: self.conversation)
self.swarmInfo!.finalAvatar.share()
- .observe(on: MainScheduler.instance)
.subscribe { [weak self] image in
self?.profileImageData.accept(image.pngData())
} onError: { _ in
@@ -280,59 +329,6 @@
.disposed(by: self.disposeBag)
}
- private func subscribeLastMessagesUpdate() {
- conversation.newMessages
- .subscribe { [weak self] _ in
- guard let self = self, let lastMessage = self.conversation.lastMessage else { return }
- self.lastMessage.accept(lastMessage.content)
- let lastMessageDate = lastMessage.receivedDate
- let dateToday = Date()
- var dateString = ""
- let todayWeekOfYear = Calendar.current.component(.weekOfYear, from: dateToday)
- let todayDay = Calendar.current.component(.day, from: dateToday)
- let todayMonth = Calendar.current.component(.month, from: dateToday)
- let todayYear = Calendar.current.component(.year, from: dateToday)
- let weekOfYear = Calendar.current.component(.weekOfYear, from: lastMessageDate)
- let day = Calendar.current.component(.day, from: lastMessageDate)
- let month = Calendar.current.component(.month, from: lastMessageDate)
- let year = Calendar.current.component(.year, from: lastMessageDate)
- if todayDay == day && todayMonth == month && todayYear == year {
- dateString = self.hourFormatter.string(from: lastMessageDate)
- } else if day == todayDay - 1 {
- dateString = L10n.Smartlist.yesterday
- } else if todayYear == year && todayWeekOfYear == weekOfYear {
- dateString = lastMessageDate.dayOfWeek()
- } else {
- dateString = self.dateFormatter.string(from: lastMessageDate)
- }
- self.lastMessageReceivedDate.accept(dateString)
- } onError: { _ in
- }
- .disposed(by: self.disposeBag)
- }
-
- /// Displays the entire date ( for messages received before the current week )
- private lazy var dateFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateStyle = .medium
- return formatter
- }()
- /// Displays the hour of the message reception ( for messages received today )
- private lazy var hourFormatter: DateFormatter = {
- let formatter = DateFormatter()
- formatter.dateFormat = "HH:mm"
- return formatter
- }()
-
- var unreadMessages = BehaviorRelay<String>(value: "")
-
- var lastMessage = BehaviorRelay<String>(value: "")
- var lastMessageReceivedDate = BehaviorRelay<String>(value: "")
-
- var hideNewMessagesLabel = BehaviorRelay<Bool>(value: true)
-
- var hideDate: Bool { self.conversation.messages.isEmpty }
-
func editMessage(content: String, messageId: String) {
guard let conversation = self.conversation else { return }
self.conversationsService.editSwarmMessage(conversationId: conversation.id, accountId: conversation.accountId, message: content, parentId: messageId)
@@ -402,7 +398,7 @@
}
func showContactInfo() {
- if self.showInvitation.value {
+ if self.isTemporary.value {
return
}
self.closeAllPlayers()
@@ -495,50 +491,6 @@
self.closeAllPlayers()
}
- func setIsComposingMsg(isComposing: Bool) {
- // if composingMessage == isComposing {
- // return
- // }
- // composingMessage = isComposing
- // guard let account = self.accountService.currentAccount else { return }
- // conversationsService
- // .setIsComposingMsg(to: self.conversation.participantUri,
- // from: account.id,
- // isComposing: isComposing)
- }
-
- func addComposingIndicatorMsg() {
- // if peerComposingMessage {
- // return
- // }
- // peerComposingMessage = true
- // var messagesValue = self.messages.value
- // let msgModel = MessageModel(withId: "",
- // receivedDate: Date(),
- // content: " ",
- // authorURI: self.conversation.participantUri,
- // incoming: true)
- // let composingIndicator = MessageViewModel(withInjectionBag: self.injectionBag, withMessage: msgModel, isLastDisplayed: false)
- // composingIndicator.isComposingIndicator = true
- // messagesValue.append(composingIndicator)
- // self.messages.accept(messagesValue)
- }
-
- var composingMessage: Bool = false
- // var peerComposingMessage: Bool = false
-
- func removeComposingIndicatorMsg() {
- // if !peerComposingMessage {
- // return
- // }
- // peerComposingMessage = false
- // let messagesValue = self.messages.value
- // let conversationsMsg = messagesValue.filter { (messageModel) -> Bool in
- // !messageModel.isComposingIndicator
- // }
- // self.messages.accept(conversationsMsg)
- }
-
var myContactsLocation = BehaviorSubject<CLLocationCoordinate2D?>(value: nil)
let shouldDismiss = BehaviorRelay<Bool>(value: false)
@@ -547,56 +499,11 @@
}
var conversationCreated = BehaviorRelay(value: true)
-
- func openInvitationView(parentView: UIViewController) {
- let name = self.displayName.value?.isEmpty ?? true ? self.userName.value : self.displayName.value ?? ""
- let handler: ((String) -> Void) = { [weak self] conversationId in
- guard let self = self else { return }
- guard let conversation = self.conversationsService.getConversationForId(conversationId: conversationId, accountId: self.conversation.accountId),
- !conversationId.isEmpty else {
- self.shouldDismiss.accept(true)
- return
- }
- self.request = nil
- self.conversation = conversation
- self.conversationCreated.accept(true)
- if self.showInvitation.value {
- self.showInvitation.accept(false)
- }
- }
- if let request = self.request {
- // show incoming request
- self.stateSubject.onNext(ConversationState.openIncomingInvitationView(displayName: name, request: request, parentView: parentView, invitationHandeledCB: handler))
- } else if self.conversation.id.isEmpty {
- // send invitation for search result
- let alias = (self.conversation.type == .jams ? self.displayName.value : "") ?? ""
- self.stateSubject.onNext(ConversationState
- .openOutgoingInvitationView(displayName: name, alias: alias, avatar: self.profileImageData.value,
- contactJamiId: self.conversation.hash,
- accountId: self.conversation.accountId,
- parentView: parentView,
- invitationHandeledCB: handler))
- }
- }
}
// MARK: Conversation didSet functions
extension ConversationViewModel {
- private func subscribeProfileServiceMyPhoto() {
- guard let account = self.accountService.currentAccount else { return }
- self.profileService
- .getAccountProfile(accountId: account.id)
- .subscribe(onNext: { [weak self] profile in
- guard let self = self else { return }
- if let photo = profile.photo,
- let data = NSData(base64Encoded: photo, options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) as Data? {
- self.myOwnProfileImageData = data
- }
- })
- .disposed(by: self.disposeBag)
- }
-
private func subscribePresenceServiceContactPresence() {
if !self.conversation.isDialog() {
return
@@ -611,7 +518,7 @@
.filter({ [weak self] serviceEvent in
guard let uri: String = serviceEvent.getEventInput(ServiceEventInput.uri),
let accountID: String = serviceEvent.getEventInput(ServiceEventInput.accountId),
- let conversation = self?.conversation else { return false }
+ let conversation = self?.conversation else { return false }
return uri == conversation.getParticipants().first?.jamiId && accountID == conversation.accountId
})
.subscribe(onNext: { [weak self] _ in
@@ -620,14 +527,21 @@
.disposed(by: self.disposeBag)
self.presenceService.subscribeBuddy(withAccountId: self.conversation.accountId, withUri: self.conversation.getParticipants().first!.jamiId, withFlag: true)
}
+ self.contactPresence
+ .observe(on: MainScheduler.instance)
+ .subscribe { [weak self] presence in
+ self?.presence = presence
+ } onError: { _ in
+ }
+ .disposed(by: self.disposeBag)
}
private func subscribeUnreadMessages() {
self.conversation.numberOfUnreadMessages
+ .observe(on: MainScheduler.instance)
.subscribe { [weak self] unreadMessages in
guard let self = self else { return }
- self.hideNewMessagesLabel.accept(unreadMessages == 0)
- self.unreadMessages.accept(String(unreadMessages.description))
+ self.unreadMessages = unreadMessages
} onError: { _ in
}
.disposed(by: self.disposeBag)
@@ -651,8 +565,8 @@
.usernameLookupStatus
.filter({ [weak self] lookupNameResponse in
return lookupNameResponse.address != nil &&
- (lookupNameResponse.address == self?.conversation.getParticipants().first?.jamiId ||
- lookupNameResponse.address == self?.conversation.getParticipants().first?.jamiId)
+ (lookupNameResponse.address == self?.conversation.getParticipants().first?.jamiId ||
+ lookupNameResponse.address == self?.conversation.getParticipants().first?.jamiId)
})
.subscribe(onNext: { [weak self] lookupNameResponse in
if let name = lookupNameResponse.name, !name.isEmpty {
@@ -664,18 +578,6 @@
})
.disposed(by: disposeBag)
}
-
- private func subscribeConversationServiceTypingIndicator() {
- self.typingIndicator
- .subscribe(onNext: { [weak self] (typing) in
- if typing {
- self?.addComposingIndicatorMsg()
- } else {
- self?.removeComposingIndicatorMsg()
- }
- })
- .disposed(by: self.disposeBag)
- }
}
// MARK: Location sharing
@@ -812,3 +714,6 @@
lhs.conversation == rhs.conversation
}
}
+
+// swiftlint:enable type_body_length
+// swiftlint:enable file_length
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/ContactMessageVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/ContactMessageVM.swift
index da1ae75..72d44f2 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/ContactMessageVM.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/ContactMessageVM.swift
@@ -21,10 +21,15 @@
import Foundation
import SwiftUI
import RxSwift
+import RxRelay
class ContactMessageVM: ObservableObject, MessageAppearanceProtocol, AvatarImageObserver, NameObserver {
@Published var avatarImage: UIImage?
- @Published var content: String
+ @Published var content: String {
+ didSet {
+ self.observableContent.accept(content)
+ }
+ }
@Published var borderColor: Color
@Published var backgroundColor: Color
var disposeBag = DisposeBag()
@@ -33,6 +38,7 @@
var inset: CGFloat
var height: CGFloat
var styling: MessageStyling = MessageStyling()
+ var observableContent = BehaviorRelay<String>(value: "")
var message: MessageModel
var username = "" {
@@ -49,6 +55,7 @@
self.height = message.type == .initial ? 25 : 45
self.borderColor = message.type == .initial ? Color(UIColor.clear) : Color(UIColor.secondaryLabel)
self.content = message.content
+ self.observableContent.accept(message.content)
if message.type != .initial {
self.styling.textFont = self.styling.secondaryFont
self.styling.textColor = self.styling.defaultSecondaryTextColor
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift
index 623ee09..ad42356 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagePanelVM.swift
@@ -36,8 +36,11 @@
let disposeBag = DisposeBag()
- init(messagePanelState: PublishSubject<State>, bestName: Observable<String>) {
+ init(messagePanelState: PublishSubject<State>) {
self.messagePanelState = messagePanelState
+ }
+
+ func subscribeBestName(bestName: Observable<String>) {
bestName
.share()
.asObservable()
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift
index 866d1d1..397dfc5 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/MessagesListVM.swift
@@ -117,11 +117,16 @@
@Published var shouldShowMap: Bool = false
@Published var coordinates = [LocationSharingAnnotation]()
@Published var locationSharingiewModel: LocationSharingViewModel = LocationSharingViewModel()
+ @Published var isTemporary: Bool = false
+ @Published var name: String = ""
private let log = SwiftyBeaver.self
var contactAvatar: UIImage = UIImage()
var currentAccountAvatar: UIImage = UIImage()
var myContactsLocation: CLLocationCoordinate2D?
var myCoordinate: CLLocationCoordinate2D?
+ // jams
+ var jamsAvatarData: Data?
+ var jamsName: String = ""
var accountService: AccountsService
var profileService: ProfilesService
@@ -130,6 +135,7 @@
var conversationService: ConversationsService
var contactsService: ContactsService
var nameService: NameService
+ var requestsService: RequestsService
var transferHelper: TransferHelper
var messagePanel: MessagePanelVM
@@ -144,6 +150,10 @@
return self.messagePanelStateSubject.asObservable()
}()
+ var lastMessage = BehaviorRelay<String>(value: "")
+ var lastMessageDate = BehaviorRelay<String>(value: "")
+ var lastMessageDisposeBag = DisposeBag()
+
var hideNavigationBar = BehaviorRelay(value: false)
let disposeBag = DisposeBag()
var messagesDisposeBag = DisposeBag()
@@ -173,7 +183,7 @@
}
}
}
- var conversation: ConversationModel {
+ var conversation: ConversationModel! {
didSet {
messagesDisposeBag = DisposeBag()
conversation.newMessages.share()
@@ -219,12 +229,6 @@
}
.disposed(by: self.messagesDisposeBag)
self.updateLastDisplayed()
- }
- }
-
- init (injectionBag: InjectionBag, conversation: ConversationModel, transferHelper: TransferHelper, bestName: Observable<String>, screenTapped: Observable<Bool>) {
- defer {
- self.conversation = conversation
self.subscribeSwarmPreferences()
self.updateColorPreference()
self.subscribeUserAvatarForLocationSharing()
@@ -233,6 +237,10 @@
self.subscribeMessageUpdates()
self.subscribeMessagesActions()
}
+ }
+
+ init (injectionBag: InjectionBag, transferHelper: TransferHelper) {
+ self.requestsService = injectionBag.requestsService
self.conversation = ConversationModel()
self.accountService = injectionBag.accountService
self.profileService = injectionBag.profileService
@@ -242,8 +250,11 @@
self.nameService = injectionBag.nameService
self.transferHelper = transferHelper
self.locationSharingService = injectionBag.locationSharingService
- self.messagePanel = MessagePanelVM(messagePanelState: self.messagePanelStateSubject, bestName: bestName)
+ self.messagePanel = MessagePanelVM(messagePanelState: self.messagePanelStateSubject)
self.subscribeLocationEvents()
+ }
+
+ func subscribeScreenTapped(screenTapped: Observable<Bool>) {
screenTapped
.subscribe(onNext: { [weak self] event in
guard let self = self else { return }
@@ -252,6 +263,31 @@
.disposed(by: self.disposeBag)
}
+ func subscribeBestName(bestName: Observable<String>) {
+ self.messagePanel.subscribeBestName(bestName: bestName)
+ }
+
+ func sendRequest() {
+ guard let conversation = self.conversation,
+ let jamiId = conversation.getParticipants().first?.jamiId else { return }
+ var avatar: String?
+ if let avatarData = self.jamsAvatarData {
+ avatar = String(data: avatarData, encoding: .utf8)
+ }
+ self.requestsService
+ .sendContactRequest(to: jamiId,
+ withAccountId: conversation.accountId,
+ avatar: avatar,
+ alias: jamsName)
+ .subscribe(onCompleted: { [weak self] in
+ self?.isTemporary = false
+ self?.log.info("contact request sent")
+ }, onError: { [weak self] (_) in
+ self?.log.error("error sending contact request")
+ })
+ .disposed(by: self.disposeBag)
+ }
+
func receiveReply(newMessage: MessageContainerModel, fromHistory: Bool) {
let replyId = newMessage.message.reply
if let replyContentTarget = self.getReplyContentTarget(for: replyId) {
@@ -443,7 +479,11 @@
messageModel.message.id == newMessage.id
}) { return false }
let isHistory = newMessage.isReply()
- let container = MessageContainerModel(message: newMessage, contextMenuState: self.contextStateSubject, isHistory: isHistory, localJamiId: localJamiId, preferencesColor: self.conversation.preferences.getColor())
+ let container =
+ MessageContainerModel(message: newMessage,
+ contextMenuState: self.contextStateSubject,
+ isHistory: isHistory,
+ localJamiId: localJamiId, preferencesColor: self.conversation.preferences.getColor())
self.subscribeMessage(container: container)
if fromHistory {
self.messagesModels.append(container)
@@ -455,6 +495,24 @@
if newMessage.isReply() {
self.receiveReply(newMessage: container, fromHistory: fromHistory)
}
+
+ if self.messagesModels.count > 1 && fromHistory {
+ return true
+ }
+ lastMessageDisposeBag = DisposeBag()
+ self.lastMessageDate.accept(newMessage.receivedDate.conversationTimestamp())
+ if newMessage.type != .contact {
+ self.lastMessage.accept(newMessage.content)
+ } else {
+ container.contactViewModel.observableContent
+ .startWith( container.contactViewModel.observableContent.value)
+ .subscribe { [weak self] content in
+ guard let self = self else { return }
+ self.lastMessage.accept(content)
+ } onError: { _ in
+ }
+ .disposed(by: lastMessageDisposeBag)
+ }
return true
}
@@ -566,6 +624,7 @@
.disposed(by: container.disposeBag)
container.listenerForInfoStateAdded()
}
+ // swiftlint:enable cyclomatic_complexity
private func subscribeSwarmPreferences() {
self.conversationService
@@ -1024,3 +1083,4 @@
}
}
}
+// swiftlint:enable type_body_length
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/TransferHelper.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/TransferHelper.swift
index 328f2d5..103dc26 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/TransferHelper.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/ViewModels/TransferHelper.swift
@@ -34,12 +34,30 @@
}
class TransferHelper {
+ let injectionBag: InjectionBag
let dataTransferService: DataTransferService
- let conversationViewModel: ConversationViewModel
- init (dataTransferService: DataTransferService, conversationViewModel: ConversationViewModel) {
- self.dataTransferService = dataTransferService
- self.conversationViewModel = conversationViewModel
+ private var players = [String: PlayerViewModel]()
+
+ func getPlayer(messageID: String) -> PlayerViewModel? {
+ return players[messageID]
+ }
+
+ func setPlayer(messageID: String, player: PlayerViewModel) { players[messageID] = player }
+
+ func closeAllPlayers() {
+ let queue = DispatchQueue.global(qos: .default)
+ queue.sync {
+ self.players.values.forEach { (player) in
+ player.closePlayer()
+ }
+ self.players.removeAll()
+ }
+ }
+
+ init (injectionBag: InjectionBag) {
+ self.dataTransferService = injectionBag.dataTransferService
+ self.injectionBag = injectionBag
}
func acceptTransfer(conversation: ConversationModel, message: MessageModel) -> NSDataTransferError {
@@ -99,7 +117,7 @@
return nil
}
- if let playerModel = conversationViewModel.getPlayer(messageID: String(message.id)) {
+ if let playerModel = self.getPlayer(messageID: String(message.id)) {
return playerModel
}
let transferInfo = self.getTransferFileData(content: message.content)
@@ -117,8 +135,8 @@
if pathString.isEmpty {
return nil
}
- let model = PlayerViewModel(injectionBag: conversationViewModel.injectionBag, path: pathString)
- conversationViewModel.setPlayer(messageID: String(message.id), player: model)
+ let model = PlayerViewModel(injectionBag: self.injectionBag, path: pathString)
+ self.setPlayer(messageID: String(message.id), player: model)
return model
}
// first search for incoming video in downloads folder and for outgoing in recorded
@@ -143,8 +161,8 @@
return nil
}
}
- let model = PlayerViewModel(injectionBag: conversationViewModel.injectionBag, path: pathString)
- conversationViewModel.setPlayer(messageID: String(message.id), player: model)
+ let model = PlayerViewModel(injectionBag: self.injectionBag, path: pathString)
+ self.setPlayer(messageID: String(message.id), player: model)
return model
}
return nil
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift
index 75bebfa..f1e0bb4 100644
--- a/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift
+++ b/Ring/Ring/Features/Conversations/Conversation/MessageSwiftUI/Views/MessagesListView.swift
@@ -46,7 +46,7 @@
}
struct MessagesListView: View {
- @StateObject var model: MessagesListVM
+ @ObservedObject var model: MessagesListVM
@SwiftUI.State var showScrollToLatestButton = false
let scrollReserved = UIScreen.main.bounds.height * 1.5
@@ -125,6 +125,9 @@
}
}
}
+ if model.isTemporary {
+ temporaryConversationView()
+ }
}
.onChange(of: model.screenTapped, perform: { _ in
/* We cannot use SwiftUI's onTapGesture here because it would
@@ -268,6 +271,36 @@
.shadowForConversation()
}
+ func temporaryConversationView() -> some View {
+ ZStack {
+ Color(UIColor.systemBackground).edgesIgnoringSafeArea(.all)
+ VStack {
+ VStack {
+ Text(model.name + " " + L10n.Conversation.notContactLabel)
+ .frame(maxWidth: .infinity)
+ .multilineTextAlignment(.center)
+ Text(L10n.Conversation.addToContactsLabel)
+ }
+ .padding()
+ .background(Color(UIColor.secondarySystemBackground))
+ Spacer()
+ Button(action: {
+ model.sendRequest()
+ }) {
+ Text(L10n.Conversation.addToContactsButton)
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(Color.green)
+ .foregroundColor(.white)
+ .cornerRadius(12)
+ .padding(.horizontal)
+ }
+ Spacer()
+ .frame(height: 20)
+ }
+ }
+ }
+
private func hideKeyboardIfNeed() {
if keyboardHeight > 0 {
withAnimation {
diff --git a/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift b/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift
deleted file mode 100644
index 4c7d502..0000000
--- a/Ring/Ring/Features/Conversations/Conversation/MessageViewModel.swift
+++ /dev/null
@@ -1,367 +0,0 @@
-/*
- * Copyright (C) 2017-2020 Savoir-faire Linux Inc.
- *
- * Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
- * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
- * Author: Andreas Traczyk <andreas.traczyk@savoirfairelinux.com>
- * Author: Raphaël Brulé <raphael.brule@savoirfairelinux.com>
- *
- * This program is free software; you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation; either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program; if not, write to the Free Software
- * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
- */
-
-import RxSwift
-import RxCocoa
-import SwiftyBeaver
-import MobileCoreServices
-
-enum BubblePosition {
- case received
- case sent
- case generated
-}
-
-enum MessageSequencing {
- case singleMessage
- case firstOfSequence
- case lastOfSequence
- case middleOfSequence
- case unknown
-}
-
-enum GeneratedMessageType: String {
- case receivedContactRequest = "Contact request received"
- case contactAdded = "Contact added"
- case missedIncomingCall = "Missed incoming call"
- case missedOutgoingCall = "Missed outgoing call"
- case incomingCall = "Incoming call"
- case outgoingCall = "Outgoing call"
-}
-
-class MessageViewModel {
-
- private let log = SwiftyBeaver.self
-
- private let accountService: AccountsService
- private let conversationsService: ConversationsService
- private let dataTransferService: DataTransferService
- private let profileService: ProfilesService
- var message: MessageModel
- var metaData: LPLinkMetadata?
-
- var profileImageData = BehaviorRelay<Data?>(value: nil)
-
- var shouldShowTimeString: Bool = false
- lazy var timeStringShown: String = { [weak self] in
- guard let self = self else { return "" }
- return MessageViewModel.getTimeLabelString(forTime: self.receivedDate)
- }()
-
- var sequencing: MessageSequencing = .unknown
- var isComposingIndicator: Bool = false
-
- var isText: Bool { return self.message.type == .text }
-
- private let disposeBag = DisposeBag()
- let injectBug: InjectionBag
-
- init(withInjectionBag injectionBag: InjectionBag,
- withMessage message: MessageModel, isLastDisplayed: Bool) {
- self.accountService = injectionBag.accountService
- self.conversationsService = injectionBag.conversationsService
- self.dataTransferService = injectionBag.dataTransferService
- self.profileService = injectionBag.profileService
- self.injectBug = injectionBag
- self.message = message
- self.initialTransferStatus = message.transferStatus
- self.status.onNext(message.status)
- self.displayReadIndicator = BehaviorRelay<Bool>(value: isLastDisplayed)
- // self.displayReadIndicator.accept(isLastDisplayed)
- self.subscribeProfileServiceContactPhoto()
-
- if isTransfer {
- self.conversationsService
- .sharedResponseStream
- .filter({ [weak self] (transferEvent) in
- guard let transferId: String = transferEvent.getEventInput(ServiceEventInput.transferId) else { return false }
- return transferEvent.eventType == ServiceEventType.dataTransferMessageUpdated &&
- self?.daemonId == transferId
- })
- .subscribe(onNext: { [weak self] transferEvent in
- guard let transferId: String = transferEvent.getEventInput(ServiceEventInput.transferId),
- let transferStatus: DataTransferStatus = transferEvent.getEventInput(ServiceEventInput.state) else {
- return
- }
- self?.log.debug("MessageViewModel: dataTransferMessageUpdated - id:\(transferId) status:\(transferStatus)")
- self?.message.transferStatus = transferStatus
- self?.transferStatus.onNext(transferStatus)
- })
- .disposed(by: disposeBag)
- } else {
- // subscribe to message status updates for outgoing messages
- self.conversationsService
- .sharedResponseStream
- .filter({ [weak self] messageUpdateEvent in
- let event = messageUpdateEvent.eventType == ServiceEventType.lastDisplayedMessageUpdated
- let message = messageUpdateEvent
- .getEventInput(.oldDisplayedMessage) == self?.message.id ||
- messageUpdateEvent
- .getEventInput(.newDisplayedMessage) == self?.message.id
- return event && message
- })
- .subscribe(onNext: { [weak self] messageUpdateEvent in
- if let oldMessage: String = messageUpdateEvent.getEventInput(.oldDisplayedMessage),
- oldMessage == self?.message.id {
- print("@@@@@last displayed removed", message.id)
- self?.displayReadIndicator.accept(false)
- } else if let newMessage: String = messageUpdateEvent.getEventInput(.newDisplayedMessage),
- newMessage == self?.message.id {
- print("@@@@@last displayed added", message.id)
- self?.displayReadIndicator.accept(true)
- }
- })
- .disposed(by: self.disposeBag)
- }
- }
-
- private func subscribeProfileServiceContactPhoto() {
- guard let account = self.accountService.currentAccount else { return }
- let schema: URIType = account.type == .sip ? .sip : .ring
- guard let contactURI = JamiURI(schema: schema, infoHash: self.message.authorId).uriString else { return }
- self.profileService
- .getProfile(uri: contactURI,
- createIfNotexists: false,
- accountId: account.id)
- .subscribe(onNext: { [weak self] profile in
- if let photo = profile.photo,
- let data = NSData(base64Encoded: photo, options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) as Data? {
- self?.profileImageData.accept(data)
- }
- })
- .disposed(by: disposeBag)
- }
-
- var content: String {
- return self.message.content
- }
-
- var receivedDate: Date {
- return self.message.receivedDate
- }
-
- var daemonId: String {
- return self.message.daemonId
- }
-
- var messageId: String {
- return self.message.id
- }
-
- var isTransfer: Bool {
- return self.message.type == .fileTransfer
- }
-
- var shouldDisplayTransferedImage: Bool {
- if !self.isTransfer {
- return false
- }
- if !self.message.incoming &&
- ( self.message.transferStatus != .error ||
- self.message.transferStatus != .canceled) {
- return true
- }
-
- if self.message.transferStatus == .success {
- return true
- }
-
- return false
- }
-
- var status = BehaviorSubject<MessageStatus>(value: .unknown)
- let displayReadIndicator: BehaviorRelay<Bool>
-
- var transferStatus = BehaviorSubject<DataTransferStatus>(value: .unknown)
- var lastTransferStatus: DataTransferStatus = .unknown
- var initialTransferStatus: DataTransferStatus
-
- func bubblePosition() -> BubblePosition {
- if self.message.type == .call || self.message.type == .contact {
- return .generated
- }
- if self.message.incoming {
- return .received
- } else {
- return .sent
- }
- }
-
- typealias TransferParsingTuple = (fileName: String, fileSize: String?, identifier: String?)
-
- var transferFileData: TransferParsingTuple {
- let contentArr = self.content.components(separatedBy: "\n")
- var name: String
- var identifier: String?
- var size: String?
- if contentArr.count > 2 {
- name = contentArr[0]
- size = contentArr[1]
- identifier = contentArr[2]
- } else if contentArr.count > 1 {
- name = contentArr[0]
- size = contentArr[1]
- } else {
- name = content
- }
- return (name, size, identifier)
- }
-
- func getURLFromPhotoLibrary(conversationID: String, completionHandler: @escaping (URL?) -> Void) -> Bool {
- return false
- }
-
- func removeFile(conversationID: String, accountId: String, isSwarm: Bool) {
- guard let url = self.transferedFile(conversationID: conversationID, accountId: accountId, isSwarm: isSwarm) else { return }
- self.dataTransferService.removeFile(at: url)
- }
-
- func transferedFile(conversationID: String, accountId: String, isSwarm: Bool) -> URL? {
- if self.lastTransferStatus != .success &&
- self.message.transferStatus != .success {
- return nil
- }
- let transferInfo = transferFileData
- if isSwarm {
- return self.dataTransferService.getFileUrlForSwarm(fileName: self.message.daemonId, accountID: accountId, conversationID: conversationID)
- }
- if self.message.incoming {
- return self.dataTransferService
- .getFileUrlNonSwarm(fileName: transferInfo.fileName,
- inFolder: Directories.downloads.rawValue,
- accountID: accountId,
- conversationID: conversationID)
- }
-
- let recorded = self.dataTransferService
- .getFileUrlNonSwarm(fileName: transferInfo.fileName,
- inFolder: Directories.recorded.rawValue,
- accountID: accountId,
- conversationID: conversationID)
- guard recorded == nil, recorded?.path.isEmpty ?? true else { return recorded }
- return self.dataTransferService
- .getFileUrlNonSwarm(fileName: transferInfo.fileName,
- inFolder: Directories.downloads.rawValue,
- accountID: accountId,
- conversationID: conversationID)
- }
-
- func getPlayer(conversationViewModel: ConversationViewModel) -> PlayerViewModel? {
- if self.lastTransferStatus != .success &&
- self.message.transferStatus != .success {
- return nil
- }
-
- if let playerModel = conversationViewModel.getPlayer(messageID: String(self.messageId)) {
- return playerModel
- }
- let transferInfo = transferFileData
- let name = !conversationViewModel.conversation.isSwarm() ? transferInfo.fileName : self.message.daemonId
- guard let fileExtension = NSURL(fileURLWithPath: name).pathExtension else {
- return nil
- }
- if fileExtension.isMediaExtension() {
- if conversationViewModel.conversation.isSwarm() {
- let path = self.dataTransferService
- .getFileUrlForSwarm(fileName: self.message.daemonId,
- accountID: conversationViewModel.conversation.accountId,
- conversationID: conversationViewModel.conversation.id)
- let pathString = path?.path ?? ""
- if pathString.isEmpty {
- return nil
- }
- let model = PlayerViewModel(injectionBag: injectBug, path: pathString)
- conversationViewModel.setPlayer(messageID: String(self.messageId), player: model)
- return model
- }
- // first search for incoming video in downloads folder and for outgoing in recorded
- let folderName = self.message.incoming ? Directories.downloads.rawValue : Directories.recorded.rawValue
- var path = self.dataTransferService
- .getFileUrlNonSwarm(fileName: name,
- inFolder: folderName,
- accountID: conversationViewModel.conversation.accountId,
- conversationID: conversationViewModel.conversation.id)
- var pathString = path?.path ?? ""
- if pathString.isEmpty && self.message.incoming {
- return nil
- } else if pathString.isEmpty {
- // try to search outgoing video in downloads folder
- path = self.dataTransferService
- .getFileUrlNonSwarm(fileName: name,
- inFolder: Directories.downloads.rawValue,
- accountID: conversationViewModel.conversation.accountId,
- conversationID: conversationViewModel.conversation.id)
- pathString = path?.path ?? ""
- if pathString.isEmpty {
- return nil
- }
- }
- let model = PlayerViewModel(injectionBag: injectBug, path: pathString)
- conversationViewModel.setPlayer(messageID: String(self.messageId), player: model)
- return model
- }
- return nil
- }
-
- func getTransferedImage(maxSize: CGFloat,
- conversationID: String,
- accountId: String,
- isSwarm: Bool) -> URL? {
- guard let account = self.accountService
- .getAccount(fromAccountId: accountId) else { return nil }
- if self.message.incoming &&
- self.lastTransferStatus != .success &&
- self.message.transferStatus != .success {
- return nil
- }
- let transferInfo = transferFileData
- let name = isSwarm ? self.message.daemonId : transferInfo.fileName
- return self.dataTransferService
- .getFileUrlForSwarm(fileName: name, accountID: account.id, conversationID: conversationID)
- }
-
- private static func getTimeLabelString(forTime time: Date) -> String {
- // get the current time
- let currentDateTime = Date()
-
- // prepare formatter
- let dateFormatter = DateFormatter()
-
- if Calendar.current.compare(currentDateTime, to: time, toGranularity: .day) == .orderedSame {
- // age: [0, received the previous day[
- dateFormatter.dateFormat = "h:mma"
- } else if Calendar.current.compare(currentDateTime, to: time, toGranularity: .weekOfYear) == .orderedSame {
- // age: [received the previous day, received 7 days ago[
- dateFormatter.dateFormat = "E h:mma"
- } else if Calendar.current.compare(currentDateTime, to: time, toGranularity: .year) == .orderedSame {
- // age: [received 7 days ago, received the previous year[
- dateFormatter.dateFormat = "MMM d, h:mma"
- } else {
- // age: [received the previous year, inf[
- dateFormatter.dateFormat = "MMM d, yyyy h:mma"
- }
-
- // generate the string containing the message time
- return dateFormatter.string(from: time).uppercased()
- }
-}
diff --git a/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift b/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift
index b773086..4b83da3 100644
--- a/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift
+++ b/Ring/Ring/Features/Conversations/ConversationsCoordinator.swift
@@ -308,12 +308,6 @@
}
if let model = getConversationViewModelForId(conversationId: conversationId) {
self.showConversation(withConversationViewModel: model)
- } else if let request = self.requestsService.getRequest(withId: conversationId, accountId: accountId) {
- let conversationViewModel = ConversationViewModel(with: self.injectionBag)
- let conversation = ConversationModel(request: request)
- conversationViewModel.conversation = conversation
- conversationViewModel.request = request
- self.showConversation(withConversationViewModel: conversationViewModel)
}
if !shouldOpenSmarList {
let viewControllers = navigationViewController.viewControllers
@@ -351,14 +345,8 @@
smartListViewController = smartViewController
return
}
+ // let smartViewController = SwarmCreationViewController.instantiate(with: self.injectionBag)
let smartViewController = SmartlistViewController.instantiate(with: self.injectionBag)
- let contactRequestsViewController = ContactRequestsViewController.instantiate(with: self.injectionBag)
- contactRequestsViewController.viewModel.state.take(until: contactRequestsViewController.rx.deallocated)
- .subscribe(onNext: { [weak self] (state) in
- self?.stateSubject.onNext(state)
- })
- .disposed(by: self.disposeBag)
- smartViewController.addContactRequestVC(controller: contactRequestsViewController)
self.present(viewController: smartViewController, withStyle: .show, withAnimation: true, withStateable: smartViewController.viewModel)
smartListViewController = smartViewController
}
@@ -371,8 +359,8 @@
let viewControllers = self.navigationViewController.children
for controller in viewControllers {
if let smartController = controller as? SmartlistViewController {
- for model in smartController.viewModel.conversationViewModels where
- model.conversation.isCoredialog() && model.conversation.getParticipants().first?.jamiId == jamiId {
+ for model in smartController.viewModel.conversationsModel.conversationViewModels where
+ model.conversation.isCoredialog() && model.conversation.getParticipants().first?.jamiId == jamiId {
return model
}
}
@@ -384,8 +372,8 @@
let viewControllers = self.navigationViewController.children
for controller in viewControllers {
if let smartController = controller as? SmartlistViewController {
- for model in smartController.viewModel.conversationViewModels where
- model.conversation.id == conversationId {
+ for model in smartController.viewModel.conversationsModel.conversationViewModels where
+ model.conversation.id == conversationId {
return model
}
}
@@ -398,3 +386,5 @@
presentingVC[VCType.conversation.rawValue] = false
}
}
+// swiftlint:enable cyclomatic_complexity
+// swiftlint:enable type_body_length
diff --git a/Ring/Ring/Features/Conversations/SmartList/Cells/ConversationCell.swift b/Ring/Ring/Features/Conversations/SmartList/Cells/ConversationCell.swift
index 70e674d..137404a 100644
--- a/Ring/Ring/Features/Conversations/SmartList/Cells/ConversationCell.swift
+++ b/Ring/Ring/Features/Conversations/SmartList/Cells/ConversationCell.swift
@@ -109,21 +109,6 @@
})
.disposed(by: self.disposeBag)
- // unread messages
- if let unreadMessages = self.newMessagesLabel {
- item.unreadMessages
- .observe(on: MainScheduler.instance)
- .startWith(item.unreadMessages.value)
- .bind(to: unreadMessages.rx.text)
- .disposed(by: self.disposeBag)
- }
- if let unreadMessagesIndicator = self.newMessagesIndicator {
- item.hideNewMessagesLabel
- .observe(on: MainScheduler.instance)
- .startWith(item.hideNewMessagesLabel.value)
- .bind(to: unreadMessagesIndicator.rx.isHidden)
- .disposed(by: self.disposeBag)
- }
// presence
if self.presenceIndicator != nil {
item.contactPresence.asObservable()
@@ -147,21 +132,13 @@
.bind(to: self.nameLabel.rx.text)
.disposed(by: self.disposeBag)
self.nameLabel.lineBreakMode = .byTruncatingTail
- // last message date
- if let lastMessageTime = self.lastMessageDateLabel {
- item.lastMessageReceivedDate
- .observe(on: MainScheduler.instance)
- .startWith(item.lastMessageReceivedDate.value)
- .bind(to: lastMessageTime.rx.text)
- .disposed(by: self.disposeBag)
- }
// last message preview
if let lastMessage = self.lastMessagePreviewLabel {
lastMessage.lineBreakMode = .byTruncatingTail
- item.lastMessage
+ item.lastMessageObservable
.observe(on: MainScheduler.instance)
- .startWith(item.lastMessage.value)
+ .startWith(item.lastMessage)
.bind(to: lastMessage.rx.text)
.disposed(by: self.disposeBag)
}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.storyboard b/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.storyboard
index 7125b54..055507d 100644
--- a/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.storyboard
+++ b/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.storyboard
@@ -4,7 +4,6 @@
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="22131"/>
- <capability name="Named colors" minToolsVersion="9.0"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="System colors in document resources" minToolsVersion="11.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
@@ -16,287 +15,21 @@
<viewController id="Raw-Ee-7sK" customClass="SmartlistViewController" customModule="Ring" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="2dZ-8A-4nq">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
- <autoresizingMask key="autoresizingMask" flexibleMinX="YES" widthSizable="YES" flexibleMaxX="YES" flexibleMinY="YES" heightSizable="YES" flexibleMaxY="YES"/>
- <subviews>
- <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="Rda-vE-7T5">
- <rect key="frame" x="0.0" y="20" width="375" height="647"/>
- <subviews>
- <label hidden="YES" opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="searching" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="sy4-4D-ah4">
- <rect key="frame" x="0.0" y="-40" width="375" height="40"/>
- <constraints>
- <constraint firstAttribute="height" constant="40" id="4tZ-iV-gfE"/>
- </constraints>
- <fontDescription key="fontDescription" type="system" pointSize="17"/>
- <nil key="textColor"/>
- <nil key="highlightedColor"/>
- </label>
- <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="fK7-Ou-K2i">
- <rect key="frame" x="0.0" y="0.0" width="375" height="58.5"/>
- <subviews>
- <stackView opaque="NO" contentMode="scaleToFill" distribution="fillProportionally" translatesAutoresizingMaskIntoConstraints="NO" id="goP-9T-lJ1">
- <rect key="frame" x="10" y="10" width="355" height="38.5"/>
- <subviews>
- <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="fillProportionally" alignment="center" spacing="2" translatesAutoresizingMaskIntoConstraints="NO" id="3qc-Hq-jRW">
- <rect key="frame" x="0.0" y="0.0" width="320" height="38.5"/>
- <subviews>
- <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="252" verticalCompressionResistancePriority="749" text="No network connectivity" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="gFu-6Z-dl9">
- <rect key="frame" x="67.5" y="0.0" width="185" height="20.5"/>
- <fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
- <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
- <nil key="highlightedColor"/>
- </label>
- <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Be sure cellular access is granted on your settings" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="wJ3-e7-CMi">
- <rect key="frame" x="8" y="22.5" width="304" height="16"/>
- <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
- <color key="textColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
- <nil key="highlightedColor"/>
- </label>
- </subviews>
- <viewLayoutGuide key="safeArea" id="7kw-IK-33Z"/>
- </stackView>
- <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="aUA-Rj-5gi">
- <rect key="frame" x="320" y="0.0" width="35" height="38.5"/>
- <constraints>
- <constraint firstAttribute="width" constant="35" id="M1l-Hd-D5Z"/>
- </constraints>
- <color key="tintColor" white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
- <state key="normal" title="Button"/>
- <buttonConfiguration key="configuration" style="plain" image="gearshape" catalog="system"/>
- </button>
- </subviews>
- <viewLayoutGuide key="safeArea" id="QVu-d0-XQT"/>
- <constraints>
- <constraint firstAttribute="height" constant="38.5" id="DvO-3i-ryD"/>
- </constraints>
- </stackView>
- </subviews>
- <viewLayoutGuide key="safeArea" id="yx6-GK-nm1"/>
- <color key="backgroundColor" red="0.89803922179999995" green="0.54509806630000002" blue="0.52549022440000004" alpha="1" colorSpace="custom" customColorSpace="displayP3"/>
- <constraints>
- <constraint firstItem="goP-9T-lJ1" firstAttribute="leading" secondItem="fK7-Ou-K2i" secondAttribute="leading" constant="10" id="hTx-27-IZX"/>
- <constraint firstAttribute="trailing" secondItem="goP-9T-lJ1" secondAttribute="trailing" constant="10" id="hnI-Kq-jjD"/>
- <constraint firstAttribute="bottom" secondItem="goP-9T-lJ1" secondAttribute="bottom" constant="10" id="qbw-52-D1n"/>
- <constraint firstItem="goP-9T-lJ1" firstAttribute="top" secondItem="fK7-Ou-K2i" secondAttribute="top" constant="10" id="qmt-fC-8L0"/>
- </constraints>
- </view>
- <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="muN-Wj-ggZ">
- <rect key="frame" x="0.0" y="58.5" width="375" height="100"/>
- <subviews>
- <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="LfN-ON-tfu">
- <rect key="frame" x="15" y="0.0" width="345" height="85"/>
- <subviews>
- <stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="D3q-LJ-usz">
- <rect key="frame" x="6" y="6" width="333" height="75"/>
- <subviews>
- <stackView opaque="NO" contentMode="scaleToFill" alignment="bottom" translatesAutoresizingMaskIntoConstraints="NO" id="uSX-OA-C81">
- <rect key="frame" x="0.0" y="0.0" width="333" height="75"/>
- <subviews>
- <imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="donation" translatesAutoresizingMaskIntoConstraints="NO" id="Acg-jm-Z3h">
- <rect key="frame" x="0.0" y="0.0" width="60" height="75"/>
- <constraints>
- <constraint firstAttribute="height" constant="75" id="FA4-KN-Xib"/>
- <constraint firstAttribute="width" constant="60" id="STa-ce-tka"/>
- </constraints>
- <preferredSymbolConfiguration key="preferredSymbolConfiguration" scale="default" weight="regular"/>
- </imageView>
- <stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" alignment="top" translatesAutoresizingMaskIntoConstraints="NO" id="E6U-Dl-DvQ">
- <rect key="frame" x="60" y="0.0" width="273" height="75"/>
- <subviews>
- <stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="UJA-F7-jD1">
- <rect key="frame" x="0.0" y="0.0" width="273" height="45"/>
- <subviews>
- <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ViS-TN-KCr">
- <rect key="frame" x="0.0" y="0.0" width="12" height="45"/>
- <constraints>
- <constraint firstAttribute="width" constant="12" id="gxG-cu-hKy"/>
- </constraints>
- </view>
- <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="If you enjoy using Jami and believe in our mission, would you make a donation?" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" minimumFontSize="9" adjustsFontForContentSizeCategory="YES" translatesAutoresizingMaskIntoConstraints="NO" id="zuu-Bz-eqP">
- <rect key="frame" x="12" y="0.0" width="261" height="45"/>
- <fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
- <nil key="textColor"/>
- <nil key="highlightedColor"/>
- </label>
- </subviews>
- <constraints>
- <constraint firstAttribute="trailing" secondItem="zuu-Bz-eqP" secondAttribute="trailing" id="h5D-yE-blJ"/>
- </constraints>
- </stackView>
- <stackView opaque="NO" contentMode="scaleToFill" distribution="equalCentering" translatesAutoresizingMaskIntoConstraints="NO" id="rLw-rO-feu">
- <rect key="frame" x="0.0" y="45" width="146" height="30"/>
- <subviews>
- <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="Nki-qc-gWk">
- <rect key="frame" x="0.0" y="0.0" width="73" height="30"/>
- <state key="normal" title="Button"/>
- <buttonConfiguration key="configuration" style="plain">
- <attributedString key="attributedTitle">
- <fragment content="Not now">
- <attributes>
- <color key="NSColor" systemColor="systemGrayColor"/>
- <font key="NSFont" size="14" name=".SFNS-Regular"/>
- </attributes>
- </fragment>
- </attributedString>
- </buttonConfiguration>
- </button>
- <button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="system" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="riP-u7-388">
- <rect key="frame" x="81.5" y="0.0" width="64.5" height="30"/>
- <state key="normal" title="Button"/>
- <buttonConfiguration key="configuration" style="plain">
- <attributedString key="attributedTitle">
- <fragment content="Donate">
- <attributes>
- <color key="NSColor" name="linkColor" catalog="System" colorSpace="catalog"/>
- <font key="NSFont" metaFont="system" size="14"/>
- </attributes>
- </fragment>
- </attributedString>
- </buttonConfiguration>
- </button>
- </subviews>
- </stackView>
- </subviews>
- <constraints>
- <constraint firstItem="rLw-rO-feu" firstAttribute="leading" secondItem="E6U-Dl-DvQ" secondAttribute="leading" id="PcW-DF-LFq"/>
- <constraint firstAttribute="trailing" secondItem="UJA-F7-jD1" secondAttribute="trailing" id="gfF-09-JK8"/>
- </constraints>
- </stackView>
- </subviews>
- </stackView>
- </subviews>
- <constraints>
- <constraint firstItem="uSX-OA-C81" firstAttribute="top" secondItem="D3q-LJ-usz" secondAttribute="top" id="9ke-xL-HeK"/>
- <constraint firstAttribute="trailing" secondItem="uSX-OA-C81" secondAttribute="trailing" id="LFs-mk-OIK"/>
- <constraint firstItem="uSX-OA-C81" firstAttribute="leading" secondItem="D3q-LJ-usz" secondAttribute="leading" id="e4h-j9-7bj"/>
- <constraint firstAttribute="bottom" secondItem="uSX-OA-C81" secondAttribute="bottom" id="olb-7a-Qek"/>
- </constraints>
- <userDefinedRuntimeAttributes>
- <userDefinedRuntimeAttribute type="boolean" keyPath="roundedCorners" value="NO"/>
- <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius">
- <real key="value" value="0.0"/>
- </userDefinedRuntimeAttribute>
- </userDefinedRuntimeAttributes>
- </stackView>
- </subviews>
- <color key="backgroundColor" name="donationBanner"/>
- <constraints>
- <constraint firstItem="D3q-LJ-usz" firstAttribute="leading" secondItem="LfN-ON-tfu" secondAttribute="leading" constant="6" id="6lR-gq-xXJ"/>
- <constraint firstAttribute="height" constant="85" id="aKY-dv-R46"/>
- <constraint firstAttribute="trailing" secondItem="D3q-LJ-usz" secondAttribute="trailing" constant="6" id="grf-wA-y1h"/>
- <constraint firstAttribute="bottom" secondItem="D3q-LJ-usz" secondAttribute="bottom" constant="4" id="lDI-FG-y9X"/>
- <constraint firstItem="D3q-LJ-usz" firstAttribute="top" secondItem="LfN-ON-tfu" secondAttribute="top" constant="6" id="lj6-6v-GhF"/>
- </constraints>
- <userDefinedRuntimeAttributes>
- <userDefinedRuntimeAttribute type="boolean" keyPath="roundedCorners" value="YES"/>
- <userDefinedRuntimeAttribute type="number" keyPath="cornerRadius">
- <real key="value" value="8"/>
- </userDefinedRuntimeAttribute>
- </userDefinedRuntimeAttributes>
- </view>
- </subviews>
- <constraints>
- <constraint firstAttribute="bottom" secondItem="LfN-ON-tfu" secondAttribute="bottom" constant="15" id="6ZN-gT-a7Y"/>
- <constraint firstItem="LfN-ON-tfu" firstAttribute="top" secondItem="muN-Wj-ggZ" secondAttribute="top" id="A9s-8k-rSx"/>
- <constraint firstItem="LfN-ON-tfu" firstAttribute="leading" secondItem="muN-Wj-ggZ" secondAttribute="leading" constant="15" id="E0T-1W-Utc"/>
- <constraint firstAttribute="trailing" secondItem="LfN-ON-tfu" secondAttribute="trailing" constant="15" id="OyX-LA-Vlc"/>
- </constraints>
- </view>
- <view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="uHv-0y-Afe">
- <rect key="frame" x="0.0" y="158.5" width="375" height="488.5"/>
- <subviews>
- <label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="uHt-ds-l8p">
- <rect key="frame" x="187.5" y="244.5" width="0.0" height="0.0"/>
- <color key="backgroundColor" systemColor="systemBackgroundColor"/>
- <fontDescription key="fontDescription" type="system" pointSize="17"/>
- <nil key="textColor"/>
- <nil key="highlightedColor"/>
- </label>
- <tableView clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="44" sectionHeaderHeight="1" sectionFooterHeight="1" translatesAutoresizingMaskIntoConstraints="NO" id="HFM-G6-hMN">
- <rect key="frame" x="0.0" y="0.0" width="375" height="488.5"/>
- <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
- </tableView>
- <tableView hidden="YES" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" showsHorizontalScrollIndicator="NO" showsVerticalScrollIndicator="NO" dataMode="prototypes" style="grouped" separatorStyle="default" rowHeight="44" sectionHeaderHeight="1" sectionFooterHeight="1" translatesAutoresizingMaskIntoConstraints="NO" id="opE-y7-3Rm">
- <rect key="frame" x="0.0" y="0.0" width="375" height="488.5"/>
- <color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
- </tableView>
- </subviews>
- <color key="backgroundColor" systemColor="systemBackgroundColor"/>
- <constraints>
- <constraint firstItem="opE-y7-3Rm" firstAttribute="top" secondItem="uHv-0y-Afe" secondAttribute="top" id="6oJ-4c-sHy"/>
- <constraint firstItem="HFM-G6-hMN" firstAttribute="top" secondItem="uHv-0y-Afe" secondAttribute="top" id="ErI-lP-Clv"/>
- <constraint firstItem="uHt-ds-l8p" firstAttribute="centerX" secondItem="uHv-0y-Afe" secondAttribute="centerX" id="GzM-zr-tQ3"/>
- <constraint firstAttribute="bottom" secondItem="HFM-G6-hMN" secondAttribute="bottom" id="IZp-Zd-9TF"/>
- <constraint firstItem="uHt-ds-l8p" firstAttribute="centerY" secondItem="uHv-0y-Afe" secondAttribute="centerY" id="Izd-id-gKk"/>
- <constraint firstAttribute="trailing" secondItem="HFM-G6-hMN" secondAttribute="trailing" id="J7y-5I-hiu"/>
- <constraint firstItem="opE-y7-3Rm" firstAttribute="leading" secondItem="uHv-0y-Afe" secondAttribute="leading" id="KHo-YY-C3y"/>
- <constraint firstAttribute="bottom" secondItem="opE-y7-3Rm" secondAttribute="bottom" id="Shr-dr-27u"/>
- <constraint firstItem="HFM-G6-hMN" firstAttribute="leading" secondItem="uHv-0y-Afe" secondAttribute="leading" id="SxW-MG-L7u"/>
- <constraint firstAttribute="trailing" secondItem="opE-y7-3Rm" secondAttribute="trailing" id="gbS-5H-tkZ"/>
- </constraints>
- </view>
- </subviews>
- <constraints>
- <constraint firstItem="uHv-0y-Afe" firstAttribute="top" secondItem="muN-Wj-ggZ" secondAttribute="bottom" id="0GT-be-Z1K"/>
- <constraint firstAttribute="trailing" secondItem="uHv-0y-Afe" secondAttribute="trailing" id="CwS-8Z-7Bl"/>
- <constraint firstItem="uHv-0y-Afe" firstAttribute="leading" secondItem="Rda-vE-7T5" secondAttribute="leading" id="VcF-tI-pwu"/>
- <constraint firstItem="muN-Wj-ggZ" firstAttribute="top" secondItem="fK7-Ou-K2i" secondAttribute="bottom" id="a4H-vT-1y6"/>
- <constraint firstAttribute="trailing" secondItem="muN-Wj-ggZ" secondAttribute="trailing" id="cF9-Lw-OAo"/>
- <constraint firstAttribute="bottom" secondItem="uHv-0y-Afe" secondAttribute="bottom" id="dVN-YK-c9Y"/>
- <constraint firstItem="muN-Wj-ggZ" firstAttribute="leading" secondItem="Rda-vE-7T5" secondAttribute="leading" id="lZe-7J-ZjC"/>
- <constraint firstAttribute="bottom" secondItem="uHv-0y-Afe" secondAttribute="bottom" id="rxm-bY-9yW"/>
- </constraints>
- </stackView>
- </subviews>
+ <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="mnN-Gu-0lw"/>
<color key="backgroundColor" systemColor="systemBackgroundColor"/>
- <constraints>
- <constraint firstItem="Rda-vE-7T5" firstAttribute="top" secondItem="mnN-Gu-0lw" secondAttribute="top" id="8Jx-Gx-TIM"/>
- <constraint firstItem="Rda-vE-7T5" firstAttribute="bottom" secondItem="mnN-Gu-0lw" secondAttribute="bottom" id="duf-iA-FhS"/>
- <constraint firstItem="mnN-Gu-0lw" firstAttribute="trailing" secondItem="Rda-vE-7T5" secondAttribute="trailing" id="luu-Xu-KbJ"/>
- <constraint firstItem="Rda-vE-7T5" firstAttribute="leading" secondItem="mnN-Gu-0lw" secondAttribute="leading" id="sVq-4t-ORv"/>
- </constraints>
</view>
<extendedEdge key="edgesForExtendedLayout" top="YES"/>
<navigationItem key="navigationItem" id="zLl-0A-Dht"/>
- <connections>
- <outlet property="cellularAlertLabel" destination="wJ3-e7-CMi" id="ScO-Xe-kfT"/>
- <outlet property="containerView" destination="uHv-0y-Afe" id="qh0-9W-oy5"/>
- <outlet property="conversationsTableView" destination="HFM-G6-hMN" id="M97-IB-NUZ"/>
- <outlet property="disableDonationButton" destination="Nki-qc-gWk" id="AkZ-rQ-tts"/>
- <outlet property="donateButton" destination="riP-u7-388" id="Eel-UJ-XoZ"/>
- <outlet property="donationBaner" destination="muN-Wj-ggZ" id="nfE-FU-7q9"/>
- <outlet property="donationLabel" destination="zuu-Bz-eqP" id="zaU-I2-Crd"/>
- <outlet property="networkAlertLabel" destination="gFu-6Z-dl9" id="NJF-MK-pWj"/>
- <outlet property="networkAlertView" destination="fK7-Ou-K2i" id="amM-wV-HA8"/>
- <outlet property="noConversationLabel" destination="uHt-ds-l8p" id="tYK-67-lg5"/>
- <outlet property="searchView" destination="Y4B-5f-ij4" id="FtS-9R-atZ"/>
- <outlet property="settingsButton" destination="aUA-Rj-5gi" id="CeY-yv-WaH"/>
- <outlet property="widgetsTopConstraint" destination="8Jx-Gx-TIM" id="Zil-5y-R8j"/>
- </connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="JSt-CJ-9Vq" userLabel="First Responder" sceneMemberID="firstResponder"/>
- <customObject id="MBy-7P-V3E" customClass="JamiSearchView" customModule="Ring" customModuleProvider="target"/>
- <customObject id="Y4B-5f-ij4" customClass="JamiSearchView" customModule="Ring" customModuleProvider="target">
- <connections>
- <outlet property="searchResultsTableView" destination="opE-y7-3Rm" id="cMo-3b-5FA"/>
- <outlet property="searchingLabel" destination="sy4-4D-ah4" id="lMx-Yi-EwV"/>
- </connections>
- </customObject>
</objects>
<point key="canvasLocation" x="-108" y="-1208.5457271364319"/>
</scene>
</scenes>
<resources>
- <image name="donation" width="130" height="160"/>
- <image name="gearshape" catalog="system" width="128" height="123"/>
- <namedColor name="donationBanner">
- <color red="0.83529411764705885" green="0.89411764705882346" blue="0.93725490196078431" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
- </namedColor>
<systemColor name="systemBackgroundColor">
<color white="1" alpha="1" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</systemColor>
- <systemColor name="systemGrayColor">
- <color red="0.5568627451" green="0.5568627451" blue="0.57647058819999997" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
- </systemColor>
</resources>
</document>
diff --git a/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.swift b/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.swift
index e4dee30..4370c04 100644
--- a/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.swift
+++ b/Ring/Ring/Features/Conversations/SmartList/SmartlistViewController.swift
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017-2023 Savoir-faire Linux Inc.
+ * Copyright (C) 2017-2024 Savoir-faire Linux Inc.
*
* Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
* Author: Quentin Muret <quentin.muret@savoirfairelinux.com>
@@ -21,13 +21,8 @@
*/
import UIKit
-import RxSwift
-import RxDataSources
-import RxCocoa
import Reusable
-import SwiftyBeaver
-import ContactsUI
-import QuartzCore
+import SwiftUI
// Constants
struct SmartlistConstants {
@@ -35,737 +30,42 @@
static let tableHeaderViewHeight: CGFloat = 30.0
}
-// swiftlint:disable type_body_length
-class SmartlistViewController: UIViewController, StoryboardBased, ViewModelBased, UISearchControllerDelegate {
+class SmartlistViewController: UIViewController, StoryboardBased, ViewModelBased {
- private let log = SwiftyBeaver.self
-
- // MARK: outlets
- @IBOutlet weak var conversationsTableView: UITableView!
- @IBOutlet weak var containerView: UIView!
- @IBOutlet weak var noConversationLabel: UILabel!
- @IBOutlet weak var networkAlertLabel: UILabel!
- @IBOutlet weak var cellularAlertLabel: UILabel!
- @IBOutlet weak var settingsButton: UIButton!
- @IBOutlet weak var widgetsTopConstraint: NSLayoutConstraint!
- @IBOutlet weak var networkAlertView: UIView!
- @IBOutlet weak var searchView: JamiSearchView!
- @IBOutlet weak var donationBaner: UIView!
- @IBOutlet weak var donateButton: UIButton!
- @IBOutlet weak var disableDonationButton: UIButton!
- @IBOutlet weak var donationLabel: UILabel!
-
- // account selection
- private var accounPicker = UIPickerView()
- private let accountPickerTextView = UITextField(frame: CGRect.zero)
- private let accountsAdapter = AccountPickerAdapter()
- private var accountsDismissTapRecognizer: UITapGestureRecognizer!
-
- private var selectedSegmentIndex = BehaviorRelay<Int>(value: 0)
var viewModel: SmartlistViewModel!
- private let disposeBag = DisposeBag()
-
- private let contactPicker = CNContactPickerViewController()
- private var headerView: SmartListHeaderView?
-
- private var contactRequestVC: ContactRequestsViewController?
override func viewDidLoad() {
super.viewDidLoad()
- self.setupDataSources()
- self.setupTableView()
- self.setupUI()
- self.applyL10n()
- self.configureNavigationBar()
- self.confugureAccountPicker()
- accountsDismissTapRecognizer = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard))
- self.setupSearchBar()
- searchView.configure(with: viewModel.injectionBag, source: viewModel, isIncognito: false, delegate: viewModel)
- if !self.viewModel.isSipAccount() {
- self.setUpContactRequest()
- }
+ self.addSwiftUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
self.viewModel.closeAllPlayers()
self.navigationController?.setNavigationBarHidden(false, animated: false)
- configureCustomNavBar(usingCustomSize: true)
- self.viewModel.updateDonationBunnerVisiblity()
+ self.navigationController?.setNavigationBarHidden(true, animated: animated)
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ self.navigationController?.setNavigationBarHidden(true, animated: animated)
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
- configureCustomNavBar(usingCustomSize: false)
+ self.navigationController?.setNavigationBarHidden(false, animated: animated)
}
- func setupTableViewHeader(for tableView: UITableView) {
- guard let headerView = loadHeaderView() else { return }
-
- setupHeaderViewConstraints(headerView, in: tableView)
- bindSegmentControlActions(headerView)
- bindViewModelUpdates(to: headerView, in: tableView)
- }
-
- private func loadHeaderView() -> SmartListHeaderView? {
- let nib = UINib(nibName: "SmartListHeaderView", bundle: nil)
- return nib.instantiate(withOwner: nil, options: nil).first as? SmartListHeaderView
- }
-
- private func setupHeaderViewConstraints(_ headerView: SmartListHeaderView, in tableView: UITableView) {
- tableView.tableHeaderView = headerView
- NSLayoutConstraint.activate([
- headerView.widthAnchor.constraint(equalTo: tableView.widthAnchor, constant: -30),
- headerView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor)
- ])
- }
-
- private func bindSegmentControlActions(_ headerView: SmartListHeaderView) {
- headerView.conversationsSegmentControl.addTarget(self, action: #selector(segmentAction), for: .valueChanged)
-
- self.selectedSegmentIndex.subscribe { [weak headerView] index in
- headerView?.conversationsSegmentControl.selectedSegmentIndex = index
- }
- .disposed(by: self.disposeBag)
- }
-
- private func bindViewModelUpdates(to headerView: SmartListHeaderView, in tableView: UITableView) {
- self.viewModel.updateSegmentControl
- .subscribe { [weak headerView, weak tableView, weak self] (messages, requests) in
- guard let headerView = headerView, let tableView = tableView else { return }
-
- let height: CGFloat = requests == 0 ? 0 : 32
- var frame = headerView.frame
- frame.size.height = height
- headerView.frame = frame
-
- headerView.setUnread(messages: messages, requests: requests)
-
- if requests == 0 {
- headerView.conversationsSegmentControl.selectedSegmentIndex = 0
- self?.navigationItem.title = L10n.Smartlist.conversations
- }
-
- // Resetting the header after adjusting its frame.
- tableView.tableHeaderView = headerView
- }
- .disposed(by: self.disposeBag)
- }
-
- @objc
- func segmentAction(_ segmentedControl: UISegmentedControl) {
- switch segmentedControl.selectedSegmentIndex {
- case 0:
- contactRequestVC?.view.isHidden = true
- searchView.showSearchResult = true
- self.navigationItem.title = L10n.Smartlist.conversations
- case 1:
- contactRequestVC?.view.isHidden = false
- searchView.showSearchResult = false
- self.navigationItem.title = L10n.Smartlist.invitations
- default:
- break
- }
- self.selectedSegmentIndex.accept(segmentedControl.selectedSegmentIndex)
- }
-
- func addContactRequestVC(controller: ContactRequestsViewController) {
- contactRequestVC = controller
- }
-
- private func setUpContactRequest() {
- guard let controller = contactRequestVC else { return }
- addChild(controller)
-
- // make sure that the child view controller's view is the right size
- controller.view.frame = containerView.bounds
- containerView.addSubview(controller.view)
-
- // you must call this at the end per Apple's documentation
- controller.didMove(toParent: self)
- controller.view.isHidden = true
- self.setupTableViewHeader(for: controller.tableView)
- self.searchView.searchBar.rx.text.orEmpty
- .debounce(Durations.textFieldThrottlingDuration.toTimeInterval(), scheduler: MainScheduler.instance)
- .bind(to: (self.contactRequestVC?.viewModel.filter)!)
- .disposed(by: disposeBag)
- }
-
- @objc
- func dismissKeyboard() {
- accountPickerTextView.resignFirstResponder()
- view.removeGestureRecognizer(accountsDismissTapRecognizer)
- }
-
- private func configureCustomNavBar(usingCustomSize: Bool) {
- guard let customNavBar = navigationController?.navigationBar as? SmartListNavigationBar else { return }
-
- if usingCustomSize {
- self.updateSearchBarIfActive()
- customNavBar.usingCustomSize = true
- } else {
- customNavBar.removeTopView()
- customNavBar.usingCustomSize = false
- }
- }
-
- private func applyL10n() {
- self.navigationItem.title = L10n.Smartlist.conversations
- self.noConversationLabel.text = L10n.Smartlist.noConversation
- self.networkAlertLabel.text = L10n.Smartlist.noNetworkConnectivity
- self.cellularAlertLabel.text = L10n.Smartlist.cellularAccess
-
- let attributes: [NSAttributedString.Key: Any] = [
- .foregroundColor: UIColor.jamiButtonDark,
- .font: UIFont.systemFont(ofSize: 14)]
- let disableDonationTitle = NSAttributedString(string: L10n.Smartlist.disableDonation, attributes: attributes)
- let donateTitle = NSAttributedString(string: L10n.Global.donate, attributes: attributes)
-
- self.disableDonationButton.setAttributedTitle(disableDonationTitle, for: .normal)
- self.donateButton.setAttributedTitle(donateTitle, for: .normal)
- self.donationLabel.text = L10n.Smartlist.donationExplanation
- }
-
- private func setupUI() {
- self.viewModel.hideNoConversationsMessage
- .bind(to: self.noConversationLabel.rx.isHidden)
- .disposed(by: disposeBag)
- self.viewModel.connectionState
- .observe(on: MainScheduler.instance)
- .startWith(self.viewModel.networkConnectionState())
- .subscribe(onNext: { [weak self] _ in
- self?.updateNetworkUI()
- })
- .disposed(by: self.disposeBag)
-
- self.settingsButton.backgroundColor = nil
- self.settingsButton.setTitle("", for: .normal)
- self.settingsButton.rx.tap
- .subscribe(onNext: { _ in
- if let url = URL(string: UIApplication.openSettingsURLString) {
- UIApplication.shared.open(url, completionHandler: nil)
- }
- })
- .disposed(by: self.disposeBag)
- self.viewModel.currentAccountChanged
- .observe(on: MainScheduler.instance)
- .subscribe(onNext: { [weak self] _ in
- self?.searchBarNotActive()
- })
- .disposed(by: disposeBag)
- // create account button
- let accountButton = UIButton(type: .custom)
- self.viewModel.profileImage.bind(to: accountButton.rx.image(for: .normal))
- .disposed(by: disposeBag)
- accountButton.roundedCorners = true
- accountButton.clipsToBounds = true
- accountButton.imageView?.contentMode = .scaleAspectFill
- accountButton.cornerRadius = smartListAccountSize * 0.5
- accountButton.frame = CGRect(x: 0, y: 0, width: smartListAccountSize, height: smartListAccountSize)
- accountButton.imageEdgeInsets = UIEdgeInsets(top: -smartListAccountMargin, left: -smartListAccountMargin, bottom: -smartListAccountMargin, right: -smartListAccountMargin)
- let accountButtonItem = UIBarButtonItem(customView: accountButton)
- accountButtonItem
- .customView?
- .translatesAutoresizingMaskIntoConstraints = false
- accountButtonItem.customView?
- .heightAnchor
- .constraint(equalToConstant: smartListAccountSize).isActive = true
- accountButtonItem.customView?
- .widthAnchor
- .constraint(equalToConstant: smartListAccountSize).isActive = true
- accountButton.rx.tap
- .throttle(Durations.halfSecond.toTimeInterval(), scheduler: MainScheduler.instance)
- .subscribe(onNext: { [weak self] in
- self?.openAccountsList()
- })
- .disposed(by: self.disposeBag)
- self.navigationItem.leftBarButtonItem = accountButtonItem
- let space = UIBarButtonItem(barButtonSystemItem: .fixedSpace, target: nil, action: nil)
- space.width = 20
- self.navigationItem.rightBarButtonItems = [createSearchButton(), space, createMenuButton()]
- self.conversationsTableView.tableFooterView = UIView()
- self.viewModel.donationBannerVisible
- .observe(on: MainScheduler.instance)
- .startWith(self.viewModel.donationBannerVisible.value)
- .map { !$0 }
- .bind(to: self.donationBaner.rx.isHidden)
- .disposed(by: disposeBag)
- self.donateButton.rx.tap
- .subscribe(onNext: { [weak self] _ in
- guard let self = self else { return }
- SharedActionsPresenter.openDonationLink()
- self.viewModel.temporaryDisableDonationCampaign()
- })
- .disposed(by: self.disposeBag)
- self.disableDonationButton.rx.tap
- .subscribe(onNext: { [weak self] _ in
- guard let self = self else { return }
- self.viewModel.temporaryDisableDonationCampaign()
- })
- .disposed(by: self.disposeBag)
- }
-
- private func createSearchButton() -> UIBarButtonItem {
- let imageSettings = UIImage(systemName: "square.and.pencil") as UIImage?
- let generalSettingsButton = UIButton(type: UIButton.ButtonType.system) as UIButton
- generalSettingsButton.frame = CGRect(x: 0, y: 0, width: 30, height: 30)
- generalSettingsButton.setImage(imageSettings, for: .normal)
- generalSettingsButton.tintColor = .jamiButtonDark
- generalSettingsButton.rx.tap
- .subscribe(onNext: { [weak self] in
- self?.searchController.isActive = true
- })
- .disposed(by: self.disposeBag)
- return UIBarButtonItem(customView: generalSettingsButton)
- }
-
- private func createMenuButton() -> UIBarButtonItem {
- let imageSettings = UIImage(systemName: "ellipsis.circle") as UIImage?
- let generalSettingsButton = UIButton(type: UIButton.ButtonType.system) as UIButton
- generalSettingsButton.frame = CGRect(x: 0, y: 0, width: 30, height: 30)
- generalSettingsButton.setImage(imageSettings, for: .normal)
- generalSettingsButton.menu = createMenu()
- generalSettingsButton.tintColor = .jamiButtonDark
- generalSettingsButton.showsMenuAsPrimaryAction = true
- return UIBarButtonItem(customView: generalSettingsButton)
- }
-
- private func shareAccountInfo() {
- guard let content = self.viewModel.accountInfoToShare else { return }
-
- let sourceView: UIView
- if UIDevice.current.userInterfaceIdiom == .phone {
- sourceView = self.view
- } else if let navigationBar = self.navigationController?.navigationBar {
- sourceView = navigationBar
- } else {
- sourceView = self.view
- }
-
- SharedActionsPresenter.shareAccountInfo(onViewController: self, sourceView: sourceView, content: content)
- }
-
- func confugureAccountPicker() {
- accountPickerTextView.inputView = accounPicker
- view.addSubview(accountPickerTextView)
-
- accounPicker.backgroundColor = .jamiBackgroundSecondaryColor
- self.viewModel.accounts
- .observe(on: MainScheduler.instance)
- .bind(to: accounPicker.rx.items(adapter: accountsAdapter))
- .disposed(by: disposeBag)
- if let account = self.viewModel.currentAccount,
- let row = accountsAdapter.rowForAccountId(account: account) {
- accounPicker.selectRow(row, inComponent: 0, animated: true)
- }
- self.viewModel.currentAccountChanged
- .observe(on: MainScheduler.instance)
- .subscribe(onNext: { [weak self] currentAccount in
- guard let self = self else { return }
- if let account = currentAccount,
- let row = self.accountsAdapter.rowForAccountId(account: account) {
- self.accounPicker.selectRow(row, inComponent: 0, animated: true)
- }
- })
- .disposed(by: disposeBag)
- accounPicker.rx.modelSelected(AccountItem.self)
- .subscribe(onNext: { [weak self] model in
- let account = model[0].account
- self?.viewModel.changeCurrentAccount(accountId: account.id)
- })
- .disposed(by: disposeBag)
- let accountsLabel = UILabel(frame: CGRect(x: 0, y: 20, width: self.view.frame.width, height: 40))
- accountsLabel.text = L10n.Smartlist.accountsTitle
- accountsLabel.font = UIFont.systemFont(ofSize: 25, weight: .light)
- accountsLabel.textColor = .jamiSecondary
- accountsLabel.textAlignment = .center
- let addAccountButton = UIButton(type: .custom)
- addAccountButton.frame = CGRect(x: 0, y: 0, width: 250, height: 40)
- addAccountButton.contentHorizontalAlignment = .right
- addAccountButton.setTitle(L10n.Smartlist.addAccountButton, for: .normal)
- addAccountButton.setTitleColor(.jamiButtonDark, for: .normal)
- addAccountButton.titleLabel?.font = UIFont(name: "HelveticaNeue-Light", size: 23)
-
- // Enable auto-shrink
- addAccountButton.titleLabel?.adjustsFontSizeToFitWidth = true
- addAccountButton.titleLabel?.minimumScaleFactor = 0.5 // The minimum scale factor for the font size
- addAccountButton.sizeToFit()
-
- let flexibleBarButton = UIBarButtonItem(barButtonSystemItem: UIBarButtonItem.SystemItem.flexibleSpace, target: self, action: nil)
- let addBarButton = UIBarButtonItem(customView: addAccountButton)
- let toolbar = UIToolbar()
- toolbar.barTintColor = .jamiBackgroundSecondaryColor
- toolbar.isTranslucent = false
-
- toolbar.sizeToFit()
- toolbar.center = CGPoint(x: self.view.frame.width * 0.5, y: 200)
-
- toolbar.items = [flexibleBarButton, addBarButton]
- accountPickerTextView.inputAccessoryView = toolbar
- addAccountButton.rx.tap
- .throttle(Durations.halfSecond.toTimeInterval(), scheduler: MainScheduler.instance)
- .subscribe(onNext: { [weak self] in
- self?.startAccountCreation()
- })
- .disposed(by: self.disposeBag)
- }
-
- func setupDataSources() {
- // Configure cells closure for the datasources
- let configureCell: (TableViewSectionedDataSource, UITableView, IndexPath, ConversationSection.Item)
- -> UITableViewCell = {
- ( _: TableViewSectionedDataSource<ConversationSection>,
- tableView: UITableView,
- indexPath: IndexPath,
- conversationItem: ConversationSection.Item) in
-
- let cell = tableView.dequeueReusableCell(for: indexPath, cellType: SmartListCell.self)
- cell.configureFromItem(conversationItem)
- return cell
- }
-
- // Create DataSources for conversations and filtered conversations
- let conversationsDataSource = RxTableViewSectionedReloadDataSource<ConversationSection>(configureCell: configureCell)
- // Allows to delete
- conversationsDataSource.canEditRowAtIndexPath = { _, _ in
- return true
- }
-
- // Bind TableViews to DataSources
- self.viewModel.conversations
- .bind(to: self.conversationsTableView.rx.items(dataSource: conversationsDataSource))
- .disposed(by: disposeBag)
- }
-
- func setupTableView() {
- // Set row height
- self.conversationsTableView.rowHeight = SmartlistConstants.smartlistRowHeight
-
- // Register Cell
- self.conversationsTableView.register(cellType: SmartListCell.self)
- // Deselect the rows
- self.conversationsTableView.rx.itemSelected
- .subscribe(onNext: { [weak self] indexPath in
- self?.conversationsTableView.deselectRow(at: indexPath, animated: true)
- })
- .disposed(by: disposeBag)
-
- self.conversationsTableView.rx.setDelegate(self).disposed(by: disposeBag)
-
- // table header
- setupTableViewHeader(for: self.conversationsTableView)
- }
-
- let searchController: UISearchController = {
- let searchController = UISearchController(searchResultsController: nil)
- searchController.obscuresBackgroundDuringPresentation = false
- searchController.definesPresentationContext = true
- searchController.hidesNavigationBarDuringPresentation = true
- return searchController
- }()
-
- func setupSearchBar() {
- searchController.delegate = self
- let navBar = SmartListNavigationBar()
- self.navigationController?.setValue(navBar, forKey: "navigationBar")
-
- navigationItem.searchController = searchController
- if #available(iOS 16.0, *) {
- navigationItem.preferredSearchBarPlacement = .stacked
- }
-
- navigationItem.hidesSearchBarWhenScrolling = false
- searchView.searchBar = searchController.searchBar
- self.searchView.editSearch
- .subscribe(onNext: {[weak self] (editing) in
- self?.viewModel.searching.onNext(editing)
- })
- .disposed(by: disposeBag)
- }
-
- func willPresentSearchController(_ searchController: UISearchController) {
- self.searchBarActive()
- }
-
- func updateSearchBarIfActive() {
- if searchController.isActive {
- searchBarActive()
- }
- }
-
- func updateNetworkUI() {
- let isHidden = self.viewModel.networkConnectionState() == .none ? false : true
- self.networkAlertView.isHidden = isHidden
- self.view.layoutIfNeeded()
- }
-
- func searchBarNotActive() {
- guard let customNavBar = self.navigationController?.navigationBar as? SmartListNavigationBar else { return }
- self.navigationItem.title = selectedSegmentIndex.value == 0 ?
- L10n.Smartlist.conversations : L10n.Smartlist.invitations
- self.widgetsTopConstraint.constant = 0
- updateNetworkUI()
- customNavBar.customHeight = 44
- customNavBar.searchActive = false
- customNavBar.removeTopView()
- }
-
- func searchBarActive() {
- guard let customNavBar = navigationController?.navigationBar as? SmartListNavigationBar else { return }
-
- setupCommonUI(customNavBar: customNavBar)
-
- if viewModel.isSipAccount() {
- setupUIForSipAccount(customNavBar: customNavBar)
- } else {
- setupUIForNonSipAccount(customNavBar: customNavBar)
- }
- }
-
- private func setupCommonUI(customNavBar: SmartListNavigationBar) {
- navigationItem.title = ""
- widgetsTopConstraint.constant = 42
- customNavBar.customHeight = 70
- customNavBar.searchActive = true
- }
-
- private func setupUIForSipAccount(customNavBar: SmartListNavigationBar) {
- let bookButton = createSearchBarButtonWithImage(named: "book.circle", weight: .regular, width: 27)
- bookButton.setImage(UIImage(asset: Asset.phoneBook), for: .normal)
- bookButton.rx.tap
- .subscribe(onNext: { [weak self] in
- self?.presentContactPicker()
- })
- .disposed(by: customNavBar.disposeBag)
-
- let dialpadCodeButton = createSearchBarButtonWithImage(named: "square.grid.3x3.topleft.filled", weight: .regular, width: 25)
- dialpadCodeButton.rx.tap
- .subscribe(onNext: { [weak self] in
- self?.viewModel.showDialpad()
- })
- .disposed(by: customNavBar.disposeBag)
-
- customNavBar.addTopView(with: [bookButton, dialpadCodeButton])
- }
-
- private func setupUIForNonSipAccount(customNavBar: SmartListNavigationBar) {
- let qrCodeButton = createSearchBarButtonWithImage(named: "qrcode", weight: .medium, width: 25)
- qrCodeButton.rx.tap
- .subscribe(onNext: { [weak self] in
- self?.viewModel.showQRCode()
- })
- .disposed(by: customNavBar.disposeBag)
-
- let swarmButton = createSearchBarButtonWithImage(named: "person.2", weight: .medium, width: 32)
- swarmButton.rx.tap
- .subscribe(onNext: { [weak self] in
- self?.viewModel.createGroup()
- })
- .disposed(by: customNavBar.disposeBag)
-
- customNavBar.addTopView(with: [qrCodeButton, swarmButton])
- }
-
- private func presentContactPicker() {
- contactPicker.delegate = self
- present(contactPicker, animated: true, completion: nil)
- }
-
- private func createSearchBarButtonWithImage(named imageName: String, weight: UIImage.SymbolWeight, width: CGFloat) -> UIButton {
- let button = UIButton()
- let configuration = UIImage.SymbolConfiguration(pointSize: 40, weight: weight, scale: .large)
- button.setImage(UIImage(systemName: imageName, withConfiguration: configuration), for: .normal)
- button.tintColor = .jamiButtonDark
- button.translatesAutoresizingMaskIntoConstraints = false
- button.widthAnchor.constraint(equalToConstant: width).isActive = true
- button.heightAnchor.constraint(equalToConstant: 23).isActive = true
- return button
- }
-
- func willDismissSearchController(_ searchController: UISearchController) {
- searchBarNotActive()
- }
-
- func startAccountCreation() {
- accountPickerTextView.resignFirstResponder()
- self.viewModel.createAccount()
- }
-
- func openAccountsList() {
- if searchView.searchBar.isFirstResponder {
- return
- }
- if accountPickerTextView.isFirstResponder {
- accountPickerTextView.resignFirstResponder()
- return
- }
- accountPickerTextView.becomeFirstResponder()
- self.view.addGestureRecognizer(accountsDismissTapRecognizer)
- }
-
- private func showRemoveConversationConfirmation(atIndex: IndexPath) {
- let alert = UIAlertController(title: L10n.Alerts.confirmDeleteConversationTitle, message: L10n.Alerts.confirmDeleteConversation, preferredStyle: .alert)
- let deleteAction = UIAlertAction(title: L10n.Actions.deleteAction, style: .destructive) { (_: UIAlertAction!) -> Void in
- if let convToDelete: ConversationViewModel = try? self.conversationsTableView.rx.model(at: atIndex) {
- self.viewModel.delete(conversationViewModel: convToDelete)
- }
- }
- let cancelAction = UIAlertAction(title: L10n.Global.cancel, style: .default) { (_: UIAlertAction!) -> Void in }
- alert.addAction(deleteAction)
- alert.addAction(cancelAction)
- self.present(alert, animated: true, completion: nil)
- }
-
- private func showBlockContactConfirmation(atIndex: IndexPath) {
- let alert = UIAlertController(title: L10n.Global.blockContact, message: L10n.Alerts.confirmBlockContact, preferredStyle: .alert)
- let blockAction = UIAlertAction(title: L10n.Global.block, style: .destructive) { (_: UIAlertAction!) -> Void in
- if let conversation: ConversationViewModel = try? self.conversationsTableView.rx.model(at: atIndex) {
- self.viewModel.blockConversationsContact(conversationViewModel: conversation)
- }
- }
- let cancelAction = UIAlertAction(title: L10n.Global.cancel, style: .default) { (_: UIAlertAction!) -> Void in }
- alert.addAction(blockAction)
- alert.addAction(cancelAction)
- self.present(alert, animated: true, completion: nil)
- }
-}
-
-extension SmartlistViewController: UITableViewDelegate {
-
- func tableView(_ tableView: UITableView, editActionsForRowAt: IndexPath) -> [UITableViewRowAction]? {
- let block = UITableViewRowAction(style: .normal, title: "Block") { _, index in
- self.showBlockContactConfirmation(atIndex: index)
- }
- block.backgroundColor = .red
-
- let delete = UITableViewRowAction(style: .normal, title: "Delete") { _, index in
- self.showRemoveConversationConfirmation(atIndex: index)
- }
- delete.backgroundColor = .orange
-
- return [delete, block]
- }
-
- private func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
- return true
- }
-
- func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
- if self.navigationController?.topViewController == self {
- if let convToShow: ConversationViewModel = try? tableView.rx.model(at: indexPath) {
- self.viewModel.showConversation(withConversationViewModel: convToShow)
- }
- }
- }
-}
-
-extension SmartlistViewController: CNContactPickerDelegate {
-
- func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
- let phoneNumberCount = contact.phoneNumbers.count
- guard phoneNumberCount > 0 else {
- dismiss(animated: true)
- let alert = UIAlertController(title: L10n.Smartlist.noNumber,
- message: nil,
- preferredStyle: .alert)
- let cancelAction = UIAlertAction(title: L10n.Global.ok,
- style: .default) { (_: UIAlertAction!) -> Void in }
- alert.addAction(cancelAction)
- self.present(alert, animated: true, completion: nil)
- return
- }
-
- if phoneNumberCount == 1 {
- setNumberFromContact(contactNumber: contact.phoneNumbers[0].value.stringValue)
- } else {
- let alert = UIAlertController(title: L10n.Smartlist.selectOneNumber, message: nil, preferredStyle: .alert)
- for contact in contact.phoneNumbers {
- let contactAction = UIAlertAction(title: contact.value.stringValue,
- style: .default) { [weak self](_: UIAlertAction!) -> Void in
- self?.setNumberFromContact(contactNumber: contact.value.stringValue)
- }
- alert.addAction(contactAction)
- }
- let cancelAction = UIAlertAction(title: L10n.Global.cancel,
- style: .default) { (_: UIAlertAction!) -> Void in }
- alert.addAction(cancelAction)
- dismiss(animated: true)
- self.present(alert, animated: true, completion: nil)
- }
- }
-
- func setNumberFromContact(contactNumber: String) {
- self.viewModel.showSipConversation(withNumber: contactNumber.trimmedSipNumber())
- }
-
- func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
-
- }
-}
-
-// MARK: - menu
-extension SmartlistViewController {
- private func createMenu() -> UIMenu {
- return UIMenu(title: "", children: [createSwarmAction(), inviteFriendsAction(), accountsAction(), openAccountAction(), openSettingsAction(), donateAction(), aboutJamiAction()])
- }
-
- private func createTintedImage(systemName: String, configuration: UIImage.SymbolConfiguration, tintColor: UIColor) -> UIImage? {
- let image = UIImage(systemName: systemName, withConfiguration: configuration)
- return image?.withTintColor(tintColor, renderingMode: .alwaysOriginal)
- }
-
- // MARK: - Action creation functions
-
- private var configuration: UIImage.SymbolConfiguration {
- return UIImage.SymbolConfiguration(scale: .medium)
- }
-
- private func createSwarmAction() -> UIAction {
- let image = createTintedImage(systemName: "person.2", configuration: configuration, tintColor: .jamiButtonDark)
- return UIAction(title: L10n.Swarm.newSwarm, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
- self?.viewModel.createGroup()
- }
- }
-
- private func inviteFriendsAction() -> UIAction {
- let image = createTintedImage(systemName: "envelope.open", configuration: configuration, tintColor: .jamiButtonDark)
- return UIAction(title: L10n.Smartlist.inviteFriends, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
- self?.shareAccountInfo()
- }
- }
-
- private func donateAction() -> UIAction {
- let image = createTintedImage(systemName: "heart", configuration: configuration, tintColor: .jamiDonation)
- return UIAction(title: L10n.Global.donate, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { _ in
- SharedActionsPresenter.openDonationLink()
- }
- }
-
- private func accountsAction() -> UIAction {
- let image = createTintedImage(systemName: "list.bullet", configuration: configuration, tintColor: .jamiButtonDark)
- return UIAction(title: L10n.Smartlist.accounts, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
- self?.openAccountsList()
- }
- }
-
- private func openAccountAction() -> UIAction {
- let image = createTintedImage(systemName: "person.circle", configuration: configuration, tintColor: .jamiButtonDark)
- return UIAction(title: L10n.Global.accountSettings, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
- self?.viewModel.showAccountSettings()
- }
- }
-
- private func openSettingsAction() -> UIAction {
- let image = createTintedImage(systemName: "gearshape", configuration: configuration, tintColor: .jamiButtonDark)
- return UIAction(title: L10n.Global.advancedSettings, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
- self?.viewModel.showGeneralSettings()
- }
- }
-
- private func aboutJamiAction() -> UIAction {
- let image = UIImage(asset: Asset.jamiIcon)?.resizeImageWith(newSize: CGSize(width: 22, height: 22), opaque: false)
- return UIAction(title: L10n.Smartlist.aboutJami, image: image, identifier: nil, discoverabilityTitle: nil, attributes: [], state: .off) { [weak self] _ in
- self?.viewModel.openAboutJami()
- }
+ func addSwiftUI() {
+ let contentView = UIHostingController(rootView: SmartListContainer(model: viewModel.conversationsModel))
+ addChild(contentView)
+ view.addSubview(contentView.view)
+ contentView.view.frame = self.view.bounds
+ contentView.view.translatesAutoresizingMaskIntoConstraints = false
+ contentView.view.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
+ contentView.view.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
+ contentView.view.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
+ contentView.view.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
+ contentView.didMove(toParent: self)
}
}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SmartlistViewModel.swift b/Ring/Ring/Features/Conversations/SmartList/SmartlistViewModel.swift
index 3796d9c..602dfc0 100644
--- a/Ring/Ring/Features/Conversations/SmartList/SmartlistViewModel.swift
+++ b/Ring/Ring/Features/Conversations/SmartList/SmartlistViewModel.swift
@@ -1,5 +1,5 @@
/*
- * Copyright (C) 2017-2023 Savoir-faire Linux Inc.
+ * Copyright (C) 2017-2024 Savoir-faire Linux Inc.
*
* Author: Silbino Gonçalves Matado <silbino.gmatado@savoirfairelinux.com>
* Author: Quentin Muret <quentin.muret@savoirfairelinux.com>
@@ -21,17 +21,10 @@
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
+import UIKit
import RxSwift
-import RxCocoa
-import SwiftyBeaver
-import RxRelay
-let smartListAccountSize: CGFloat = 28
-let smartListAccountMargin: CGFloat = 4
-
-class SmartlistViewModel: Stateable, ViewModel, FilterConversationDataSource {
-
- private let log = SwiftyBeaver.self
+class SmartlistViewModel: Stateable, ViewModel {
// MARK: - Rx Stateable
private let stateSubject = PublishSubject<State>()
@@ -39,407 +32,15 @@
return self.stateSubject.asObservable()
}()
- private let disposeBag = DisposeBag()
- private var tempBag = DisposeBag()
-
- // Services
- private let conversationsService: ConversationsService
- private let nameService: NameService
- private let accountsService: AccountsService
- private let contactsService: ContactsService
- private let networkService: NetworkService
- private let profileService: ProfilesService
- private let callService: CallsService
- private let requestsService: RequestsService
-
- var currentAccount: AccountModel? { self.accountsService.currentAccount }
-
- var searching = PublishSubject<Bool>()
-
- private var contactFoundConversation = BehaviorRelay<ConversationViewModel?>(value: nil)
-
- lazy var hideNoConversationsMessage: Observable<Bool> = {
- return Observable<Bool>
- .combineLatest(self.conversations,
- self.searching.asObservable().startWith(false),
- resultSelector: {(conversations, searching) -> Bool in
- if searching { return true }
- if let convf = conversations.first {
- return !convf.items.isEmpty
- }
- return false
- })
- .observe(on: MainScheduler.instance)
- }()
-
- var connectionState = PublishSubject<ConnectionType>()
- lazy var accounts: Observable<[AccountItem]> = {
- return self.accountsService
- .accountsObservable.asObservable()
- .map({ [weak self] accountsModels in
- var items = [AccountItem]()
- guard let self = self else { return items }
- for account in accountsModels {
- items.append(AccountItem(account: account,
- profileObservable: self.profileService.getAccountProfile(accountId: account.id)))
- }
- return items
- })
- }()
-
- var donationBannerVisible = BehaviorRelay(value: false)
-
- /// For FilterConversationDataSource protocol
- var conversationViewModels = [ConversationViewModel]()
-
- func networkConnectionState() -> ConnectionType {
- return self.networkService.connectionState.value
- }
-
let injectionBag: InjectionBag
- // Values need to be updated when selected account changed
- var profileImageForCurrentAccount = PublishSubject<Profile>()
-
- lazy var profileImage: Observable<UIImage> = { [weak self] in
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.01, execute: {
- if let self = self, let account = self.accountsService.currentAccount {
- self.profileService.getAccountProfile(accountId: account.id)
- .subscribe(onNext: { profile in
- self.profileImageForCurrentAccount.onNext(profile)
- })
- .disposed(by: self.tempBag)
- }
- })
- return profileImageForCurrentAccount.share()
- .map({ profile in
- let size = smartListAccountSize - (smartListAccountMargin * 3)
- if let photo = profile.photo,
- let data = NSData(base64Encoded: photo,
- options: NSData.Base64DecodingOptions.ignoreUnknownCharacters) as Data?,
- let image = UIImage(data: data) {
- return image
- }
- return UIImage.defaultJamiAvatarFor(profileName: profile.alias, account: self?.accountsService.currentAccount, size: size)
- })
- .startWith(UIImage(asset: Asset.icContactPicture)!)
- }()
- lazy var accountName: Observable<String> = { [weak self] in
- return profileImageForCurrentAccount.share()
- .map({ profile in
- if let alias = profile.alias {
- if !alias.isEmpty { return alias }
- }
- guard let account = self?.accountsService.currentAccount else {
- return ""
- }
- return account.registeredName.isEmpty ? account.jamiId : account.registeredName
- })
- .startWith("")
- }()
-
- lazy var conversations: Observable<[ConversationSection]> = { [weak self] in
- guard let self = self else { return Observable.empty() }
- return self.conversationsService
- .conversations
- .share()
- .startWith(self.conversationsService.conversations.value)
- .map({ (conversations) in
- if conversations.isEmpty {
- self.conversationViewModels = [ConversationViewModel]()
- }
- return conversations
- .compactMap({ conversationModel in
- var conversationViewModel: ConversationViewModel?
- if let foundConversationViewModel = self.conversationViewModels.filter({ conversationViewModel in
- return conversationViewModel.conversation == conversationModel
- }).first {
- conversationViewModel = foundConversationViewModel
- conversationViewModel?.conversation = conversationModel
- } else if let contactFound = self.contactFoundConversation.value, contactFound.conversation == conversationModel {
- conversationViewModel = contactFound
- conversationViewModel?.conversation = conversationModel
- conversationViewModel?.conversationCreated.accept(true)
- self.conversationViewModels.append(contactFound)
- } else {
- conversationViewModel = ConversationViewModel(with: self.injectionBag)
- conversationViewModel?.conversation = conversationModel
- if let conversation = conversationViewModel {
- self.conversationViewModels
- .append(conversation)
- }
- }
- return conversationViewModel
- })
- })
- .map({ conversationsViewModels in
- return [ConversationSection(header: "", items: conversationsViewModels)]
- })
- }()
-
- lazy var unreadMessages: Observable<Int> = {[weak self] in
- guard let self = self else {
- return Observable.just(0)
- }
- return self.conversationsService.conversations
- .share()
- .flatMap { conversations -> Observable<[Int]> in
- return Observable.combineLatest(conversations.map({ $0.numberOfUnreadMessages }))
- }
- .map { unreadMessages in
- return unreadMessages.reduce(0, +)
- }
- }()
-
- var accountInfoToShare: [Any]? {
- return self.accountsService.accountInfoToShare
- }
-
- lazy var unhandeledRequests: Observable<Int> = {[weak self] in
- guard let self = self else {
- return Observable.just(0)
- }
- let requestObservable = self.requestsService.requests.asObservable()
- let conversationObservable = self.conversationsService
- .conversations
- .share()
- .startWith(self.conversationsService.conversations.value)
-
- return Observable.combineLatest(requestObservable,
- conversationObservable) { [weak self] (requests, conversations) -> Int in
- guard let self = self,
- let account = self.accountsService.currentAccount else {
- return 0
- }
- // filter out existing conversations
- let conversationIds = conversations.map { $0.id }
- let filteredRequests = requests.filter {
- $0.accountId == account.id && !conversationIds.contains($0.conversationId)
- }
- return filteredRequests.count
- }
- }()
-
- typealias BageValues = (messages: Int, requests: Int)
-
- lazy var updateSegmentControl: ReplaySubject<BageValues> = {
- let subject = ReplaySubject<BageValues>.create(bufferSize: 1)
-
- Observable.combineLatest(self.unreadMessages, self.unhandeledRequests) { (messages, requests) -> BageValues in
- return (messages, requests)
- }
- .observe(on: MainScheduler.instance)
- .subscribe(onNext: { values in
- subject.onNext(values)
- }, onError: { error in
- subject.onError(error)
- }, onCompleted: {
- subject.onCompleted()
- })
- .disposed(by: self.disposeBag)
-
- return subject
- }()
-
- func reloadDataFor(accountId: String) {
- tempBag = DisposeBag()
- self.profileService.getAccountProfile(accountId: accountId)
- .subscribe(onNext: { [weak self] profile in
- self?.profileImageForCurrentAccount.onNext(profile)
- })
- .disposed(by: self.tempBag)
- }
-
- lazy var currentAccountChanged: Observable<AccountModel?> = {
- return self.accountsService.currentAccountChanged.asObservable()
- }()
+ let conversationsModel: ConversationsViewModel
required init(with injectionBag: InjectionBag) {
- self.conversationsService = injectionBag.conversationsService
- self.nameService = injectionBag.nameService
- self.accountsService = injectionBag.accountService
- self.contactsService = injectionBag.contactsService
- self.networkService = injectionBag.networkService
- self.profileService = injectionBag.profileService
- self.callService = injectionBag.callService
- self.requestsService = injectionBag.requestsService
self.injectionBag = injectionBag
- self.updateDonationBunnerVisiblity()
-
- self.callService.newCall
- .asObservable()
- .observe(on: MainScheduler.instance)
- .subscribe(onNext: { [weak self] _ in
- self?.closeAllPlayers()
- })
- .disposed(by: self.disposeBag)
-
- self.accountsService.currentAccountChanged
- .subscribe(onNext: { [weak self] account in
- if let currentAccount = account {
- self?.reloadDataFor(accountId: currentAccount.id)
- }
- })
- .disposed(by: self.disposeBag)
-
- // Observe connectivity changes
- self.networkService.connectionStateObservable
- .subscribe(onNext: { [weak self] value in
- self?.connectionState.onNext(value)
- })
- .disposed(by: self.disposeBag)
-
- // Observe conversation removed
- self.conversationsService.sharedResponseStream
- .filter({ event in
- event.eventType == .conversationRemoved && event.getEventInput(.accountId) == self.currentAccount?.id
- })
- .subscribe(onNext: { [weak self] event in
- guard let conversationId: String = event.getEventInput(.conversationId),
- let accountId: String = event.getEventInput(.accountId) else { return }
- guard let index = self?.conversationViewModels.firstIndex(where: { conversationModel in
- conversationModel.conversation.id == conversationId && conversationModel.conversation.accountId == accountId
- }) else { return }
- self?.conversationViewModels.remove(at: index)
- })
- .disposed(by: self.disposeBag)
- }
-
- func getDonationBunnerVisiblity() -> Bool {
- return PreferenceManager.isDateWithinCampaignPeriod() && PreferenceManager.isCampaignEnabled()
- }
-
- func updateDonationBunnerVisiblity() {
- self.donationBannerVisible.accept(getDonationBunnerVisiblity())
- }
-
- func temporaryDisableDonationCampaign() {
- PreferenceManager.temporarilyDisableDonationCampaign()
- self.donationBannerVisible.accept(getDonationBunnerVisiblity())
- }
-
- func delete(conversationViewModel: ConversationViewModel) {
- conversationViewModel.closeAllPlayers()
- let accountId = conversationViewModel.conversation.accountId
- let conversationId = conversationViewModel.conversation.id
- if conversationViewModel.conversation.isCoredialog(),
- let participantId = conversationViewModel.conversation.getParticipants().first?.jamiId {
- self.contactsService
- .removeContact(withId: participantId,
- ban: false,
- withAccountId: accountId)
- .asObservable()
- .subscribe(onCompleted: { [weak self, weak conversationViewModel] in
- guard let conversationViewModel = conversationViewModel else { return }
- self?.conversationsService
- .removeConversationFromDB(conversation: conversationViewModel.conversation,
- keepConversation: false)
- })
- .disposed(by: self.disposeBag)
- } else {
- self.conversationsService.removeConversation(conversationId: conversationId, accountId: accountId)
- }
- }
-
- func blockConversationsContact(conversationViewModel: ConversationViewModel) {
- conversationViewModel.closeAllPlayers()
- let accountId = conversationViewModel.conversation.accountId
- let conversationId = conversationViewModel.conversation.id
- if conversationViewModel.conversation.isCoredialog(),
- let participantId = conversationViewModel.conversation.getParticipants().first?.jamiId {
- self.contactsService
- .removeContact(withId: participantId,
- ban: true,
- withAccountId: accountId)
- .asObservable()
- .subscribe(onCompleted: { [weak self, weak conversationViewModel] in
- guard let conversationViewModel = conversationViewModel else { return }
- self?.conversationsService
- .removeConversationFromDB(conversation: conversationViewModel.conversation,
- keepConversation: false)
- })
- .disposed(by: self.disposeBag)
- } else {
- self.conversationsService.removeConversation(conversationId: conversationId, accountId: accountId)
- }
- }
-
- func showAccountSettings() {
- self.stateSubject.onNext(ConversationState.showAccountSettings)
+ self.conversationsModel = ConversationsViewModel(injectionBag: injectionBag, stateSubject: self.stateSubject)
}
func closeAllPlayers() {
- self.conversationViewModels.forEach { conversationModel in
- conversationModel.closeAllPlayers()
- }
- }
-
- func isSipAccount() -> Bool {
- guard let account = self.currentAccount else { return false }
- return account.type == .sip
- }
-
- func showSipConversation(withNumber number: String) {
- guard let account = self.accountsService
- .currentAccount else {
- return
- }
- let uri = JamiURI.init(schema: URIType.sip,
- infoHash: number,
- account: account)
- let conversation = ConversationModel(withParticipantUri: uri,
- accountId: account.id,
- hash: number)
- conversation.type = .sip
- let newConversation = ConversationViewModel(with: self.injectionBag)
- newConversation.conversation = conversation
- self.stateSubject
- .onNext(ConversationState
- .conversationDetail(conversationViewModel:
- newConversation))
- }
-
- func showQRCode() {
- self.stateSubject.onNext(ConversationState.qrCode)
- }
- func createGroup() {
- self.stateSubject.onNext(ConversationState.createSwarm)
- }
-
- func createAccount() {
- self.stateSubject.onNext(ConversationState.createNewAccount)
- }
-
- func changeCurrentAccount(accountId: String) {
- if let account = self.accountsService.getAccount(fromAccountId: accountId) {
- if accountsService.needAccountMigration(accountId: accountId) {
- self.stateSubject.onNext(ConversationState.needAccountMigration(accountId: accountId))
- return
- }
- self.accountsService.updateCurrentAccount(account: account)
- UserDefaults.standard.set(accountId, forKey: self.accountsService.selectedAccountID)
- }
- }
-
- func showDialpad() {
- self.stateSubject.onNext(ConversationState.showDialpad(inCall: false))
- }
-
- func showGeneralSettings() {
- self.stateSubject.onNext(ConversationState.showGeneralSettings)
- }
-
- func openAboutJami() {
- self.stateSubject.onNext(ConversationState.openAboutJami)
- }
-}
-
-extension SmartlistViewModel: FilterConversationDelegate {
- func temporaryConversationCreated(conversation: ConversationViewModel?) {
- self.contactFoundConversation.accept(conversation)
- }
-
- func showConversation(withConversationViewModel conversationViewModel: ConversationViewModel) {
- self.stateSubject.onNext(ConversationState.conversationDetail(conversationViewModel:
- conversationViewModel))
+ self.conversationsModel.closeAllPlayers()
}
}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/AccountsViewModel.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/AccountsViewModel.swift
new file mode 100644
index 0000000..b46b44b
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/AccountsViewModel.swift
@@ -0,0 +1,182 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import Foundation
+import SwiftUI
+import RxSwift
+
+protocol AccountProfileObserver: AnyObject {
+ var avatar: UIImage { get set }
+ var profileName: String { get set }
+ var registeredName: String { get set }
+ var bestName: String { get set }
+ var disposeBag: DisposeBag { get }
+ var profileService: ProfilesService { get }
+}
+
+extension AccountProfileObserver {
+ func updateProfileDetails(account: AccountModel) {
+ profileService.getAccountProfile(accountId: account.id)
+ .subscribe(onNext: { profile in
+ let avatar = profile.photo?.createImage() ?? UIImage.defaultJamiAvatarFor(profileName: profile.alias, account: account, size: 17)
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.avatar = avatar
+ self.profileName = profile.alias ?? ""
+ self.updateBestName()
+ }
+ })
+ .disposed(by: disposeBag)
+ }
+
+ func resolveAccountName(from account: AccountModel) -> String {
+ if !account.registeredName.isEmpty {
+ return account.registeredName
+ }
+ if let userNameData = UserDefaults.standard.dictionary(forKey: registeredNamesKey),
+ let accountName = userNameData[account.id] as? String,
+ !accountName.isEmpty {
+ return accountName
+ }
+ return account.jamiId
+ }
+
+ func updateBestName() {
+ self.bestName = profileName.isEmpty ? registeredName : profileName
+ }
+}
+
+struct AccountRowSizes {
+ let imageSize: CGFloat = 28
+ let spacing: CGFloat = 15
+}
+
+class AccountRow: ObservableObject, Hashable, Identifiable, AccountProfileObserver {
+ let id: String
+
+ @Published var avatar = UIImage()
+ @Published var profileName: String = ""
+ @Published var registeredName: String = ""
+ @Published var bestName: String = ""
+ @Published var needMigrate: String?
+
+ var dimensions = AccountRowSizes()
+
+ var disposeBag = DisposeBag()
+ var profileService: ProfilesService
+ var account: AccountModel
+
+ init(account: AccountModel, profileService: ProfilesService) {
+ self.id = account.id
+ self.profileService = profileService
+ self.account = account
+ if account.status == .errorNeedMigration {
+ needMigrate = L10n.Account.needMigration
+ }
+
+ self.registeredName = resolveAccountName(from: account)
+ updateProfileDetails(account: account)
+ }
+
+ func hash(into hasher: inout Hasher) {
+ return hasher.combine(id)
+ }
+
+ static func == (lhs: AccountRow, rhs: AccountRow) -> Bool {
+ return lhs.id == rhs.id
+ }
+}
+
+class AccountsViewModel: ObservableObject, AccountProfileObserver {
+ @Published var avatar = UIImage()
+ @Published var profileName: String = ""
+ @Published var registeredName: String = ""
+ @Published var bestName: String = ""
+ @Published var selectedAccount: String?
+ @Published var accountsRows: [AccountRow] = []
+
+ let headerTitle = L10n.Smartlist.accounts
+
+ var dimensions = AccountRowSizes()
+
+ let accountService: AccountsService
+ let profileService: ProfilesService
+ let nameService: NameService
+ var disposeBag = DisposeBag()
+ let stateSubject: PublishSubject<State>
+
+ init(accountService: AccountsService, profileService: ProfilesService, nameService: NameService, stateSubject: PublishSubject<State>) {
+ self.accountService = accountService
+ self.profileService = profileService
+ self.nameService = nameService
+ self.stateSubject = stateSubject
+ self.subscribeToCurrentAccountUpdates()
+ self.subscribeToRegisteredName()
+ }
+
+ func subscribeToCurrentAccountUpdates() {
+ accountService.currentAccountChanged
+ .startWith(accountService.currentAccount)
+ .compactMap { $0 }
+ .subscribe(onNext: { [weak self] account in
+ guard let self = self else { return }
+ self.selectedAccount = account.id
+ self.registeredName = self.resolveAccountName(from: account)
+ self.updateProfileDetails(account: account)
+ })
+ .disposed(by: disposeBag)
+ }
+
+ func subscribeToRegisteredName() {
+ self.nameService.sharedRegistrationStatus
+ .filter({ (serviceEvent) -> Bool in
+ guard let account = self.accountService.currentAccount else { return false }
+ guard serviceEvent.getEventInput(ServiceEventInput.accountId) == account.id,
+ serviceEvent.eventType == .nameRegistrationEnded,
+ let status: NameRegistrationState = serviceEvent.getEventInput(ServiceEventInput.state),
+ status == .success else {
+ return false
+ }
+ return true
+ })
+ .subscribe(onNext: { [weak self] _ in
+ guard let self = self else { return }
+ guard let account = self.accountService.currentAccount else { return }
+ self.updateProfileDetails(account: account)
+ })
+ .disposed(by: disposeBag)
+ }
+
+ func getAccountsRows() {
+ accountsRows = self.accountService.accounts.map { accountModel in
+ return AccountRow(account: accountModel, profileService: self.profileService)
+ }
+ }
+
+ func changeCurrentAccount(accountId: String) {
+ guard let account = self.accountService.getAccount(fromAccountId: accountId) else { return }
+ if accountService.needAccountMigration(accountId: accountId) {
+ self.stateSubject.onNext(ConversationState.needAccountMigration(accountId: accountId))
+ return
+ }
+ self.accountService.updateCurrentAccount(account: account)
+ UserDefaults.standard.set(accountId, forKey: self.accountService.selectedAccountID)
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/ConversationsViewModel.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/ConversationsViewModel.swift
new file mode 100644
index 0000000..b3637c4
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/ConversationsViewModel.swift
@@ -0,0 +1,486 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import Foundation
+import SwiftUI
+import RxSwift
+import RxRelay
+
+// swiftlint:disable type_body_length
+class ConversationsViewModel: ObservableObject, FilterConversationDataSource {
+ // filtered conversations to display
+ @Published var conversations = [ConversationViewModel]()
+ // temporary conversation for jami or sip
+ @Published var temporaryConversation: ConversationViewModel? {
+ didSet { updateSearchStatusIfNeeded() }
+ }
+ // jams search result
+ @Published var jamsSearchResult = [ConversationViewModel]() {
+ didSet { updateSearchStatusIfNeeded() }
+ }
+ // all conversations
+ var conversationViewModels = [ConversationViewModel]() {
+ didSet {
+ self.updateConversations()
+ }
+ }
+
+ @Published var publicDirectoryTitle = L10n.Smartlist.results
+
+ @Published var selectedSegment = 0
+ @Published var unreadMessages = 0
+ @Published var searchingLabel = ""
+ @Published var connectionState: ConnectionType = .none
+ var disposeBag = DisposeBag()
+ let conversationsService: ConversationsService
+ let requestsService: RequestsService
+ let accountsService: AccountsService
+ let contactsService: ContactsService
+ let stateSubject: PublishSubject<State>
+ let injectionBag: InjectionBag
+ var searchModel: JamiSearchViewModel?
+ var requestsModel: RequestsViewModel
+ @Published var searchQuery: String = ""
+ @Published var conversationCreated: String = ""
+ @Published var searchStatus: SearchStatus = .notSearching
+ let jamiImage = UIImage(asset: Asset.jamiIcon)!.resizeImageWith(newSize: CGSize(width: 20, height: 20), opaque: false)!
+
+ var accountsModel: AccountsViewModel
+
+ init(injectionBag: InjectionBag, stateSubject: PublishSubject<State>) {
+ self.conversationsService = injectionBag.conversationsService
+ self.requestsService = injectionBag.requestsService
+ self.accountsService = injectionBag.accountService
+ self.contactsService = injectionBag.contactsService
+ self.accountsModel =
+ AccountsViewModel(accountService: injectionBag.accountService,
+ profileService: injectionBag.profileService,
+ nameService: injectionBag.nameService,
+ stateSubject: stateSubject)
+ self.injectionBag = injectionBag
+ self.stateSubject = stateSubject
+ self.requestsModel = RequestsViewModel(injectionBag: injectionBag)
+ self.searchModel = JamiSearchViewModel(with: injectionBag, source: self, searchOnlyExistingConversations: false)
+ self.subscribeConversations()
+ self.subscribeSearch()
+ injectionBag.networkService.connectionState
+ .startWith(injectionBag.networkService.connectionState.value)
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] state in
+ self?.connectionState = state
+ })
+ .disposed(by: self.disposeBag)
+ self.accountsService.currentAccountChanged
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] account in
+ if let account = account {
+ if account.isJams {
+ self?.publicDirectoryTitle = L10n.Smartlist.jamsResults
+ } else {
+ self?.publicDirectoryTitle = L10n.Smartlist.results
+ }
+ }
+ })
+ .disposed(by: self.disposeBag)
+ if let account = self.accountsService.currentAccount, account.isJams {
+ publicDirectoryTitle = L10n.Smartlist.jamsResults
+ }
+ }
+
+ func conversationFromTemporaryCreated(conversation: ConversationModel) {
+ // If conversation created from temporary navigate back to smart list
+ if self.presentedConversation.isTemporaryPresented() {
+ navigationTarget = .smartList
+ self.presentedConversation.resetPresentedConversation()
+ }
+ // cleanup search
+ self.performSearch(query: "")
+ // disable search bar
+ conversationCreated = conversation.id
+ }
+
+ private func subscribeConversations() {
+ let conversationObservable = self.conversationsService.conversations
+ .share()
+ .startWith(self.conversationsService.conversations.value)
+ let conersationViewModels =
+ conversationObservable.map { [weak self] conversations -> [ConversationViewModel] in
+ guard let self = self else { return [] }
+
+ // Reset conversationViewModels if conversations are empty
+ if conversations.isEmpty {
+ self.conversationViewModels.removeAll()
+ return []
+ }
+
+ // Map conversations to view models, updating existing ones or creating new
+ return conversations.compactMap { conversationModel in
+ // Check for existing conversation view model
+ if let existing = self.conversationViewModels.first(where: { $0.conversation == conversationModel }) {
+ return existing
+ }
+ // Check for temporary conversation
+ else if let tempConversation = self.temporaryConversation, tempConversation.conversation == conversationModel {
+ tempConversation.conversation = conversationModel
+ self.conversationViewModels.append(tempConversation)
+ tempConversation.isTemporary.accept(false)
+ tempConversation.conversationCreated.accept(true)
+ self.conversationFromTemporaryCreated(conversation: conversationModel)
+ return tempConversation
+ } else if let jamsConversation = self.jamsSearchResult.first(where: { jams in
+ jams.conversation == conversationModel
+ }) {
+ jamsConversation.conversation = conversationModel
+ self.conversationViewModels.append(jamsConversation)
+ jamsConversation.isTemporary.accept(false)
+ jamsConversation.conversationCreated.accept(true)
+ self.conversationFromTemporaryCreated(conversation: conversationModel)
+ return jamsConversation
+ }
+ // Create new conversation view model
+ else {
+ let newViewModel = ConversationViewModel(with: self.injectionBag)
+ newViewModel.conversation = conversationModel
+ self.conversationViewModels.append(newViewModel)
+ return newViewModel
+ }
+ }
+ }
+
+ conersationViewModels
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] updatedViewModels in
+ self?.conversationViewModels = updatedViewModels
+ })
+ .disposed(by: self.disposeBag)
+
+ // Observe conversation removed
+ self.conversationsService.sharedResponseStream
+ .filter({ event in
+ event.eventType == .conversationRemoved && event.getEventInput(.accountId) == self.accountsService.currentAccount?.id
+ })
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] event in
+ guard let conversationId: String = event.getEventInput(.conversationId),
+ let accountId: String = event.getEventInput(.accountId) else { return }
+ guard let index = self?.conversationViewModels.firstIndex(where: { conversationModel in
+ conversationModel.conversation.id == conversationId && conversationModel.conversation.accountId == accountId
+ }) else { return }
+ self?.conversationViewModels.remove(at: index)
+ self?.updateConversations()
+ })
+ .disposed(by: self.disposeBag)
+ }
+
+ private func subscribeSearch() {
+ searchModel?
+ .filteredResults
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] conversations in
+ guard let self = self else { return }
+ let filteredConv = conversations.isEmpty && searchQuery.isEmpty ? nil : conversations
+ self.updateConversations(with: filteredConv)
+ })
+ .disposed(by: self.disposeBag)
+
+ searchModel?
+ .temporaryConversation
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] conversation in
+ guard let self = self else { return }
+ withAnimation {
+ self.temporaryConversation = conversation
+ }
+ })
+ .disposed(by: self.disposeBag)
+
+ searchModel?
+ .jamsTemporaryResults
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] conversations in
+ guard let self = self else { return }
+ withAnimation {
+ self.jamsSearchResult = conversations
+ }
+ })
+ .disposed(by: self.disposeBag)
+ searchModel?
+ .searchStatus
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] status in
+ guard let self = self else { return }
+ self.updateSearchStatus(with: status)
+ })
+ .disposed(by: self.disposeBag)
+ }
+
+ private func updateConversations(with filtered: [ConversationViewModel]? = nil) {
+ DispatchQueue.main.async {[weak self] in
+ guard let self = self else { return }
+ withAnimation {
+ // Use filtered conversations if provided; otherwise, fall back to all conversationViewModels
+ self.conversations = filtered ?? self.conversationViewModels
+ }
+ }
+ }
+
+ func showConversation(withConversationViewModel conversationViewModel: ConversationViewModel) {
+ presentedConversation.updatePresentedConversation(conversationViewModel: conversationViewModel)
+ self.stateSubject.onNext(ConversationState.conversationDetail(conversationViewModel:
+ conversationViewModel))
+ }
+
+ func showConversationFromQRCode(jamiId: String) {
+ // Ensure there is a current account available
+ guard let account = accountsService.currentAccount else { return }
+
+ // Attempt to find an existing one-to-one conversation with the specified jamiId
+ if let existingConversation = conversations.first(where: {
+ $0.conversation.type == .oneToOne && $0.conversation.getParticipants().first?.jamiId == jamiId
+ }) {
+ // Update and show the existing conversation
+ presentedConversation.updatePresentedConversation(conversationViewModel: existingConversation)
+ stateSubject.onNext(ConversationState.conversationDetail(conversationViewModel: existingConversation))
+ return
+ }
+
+ // Create a new temporary swarm conversation since no existing one matched
+ let tempConversation = createTemporarySwarmConversation(with: jamiId, accountId: account.id)
+ temporaryConversation = tempConversation
+ presentedConversation.updatePresentedConversation(conversationViewModel: tempConversation)
+ stateSubject.onNext(ConversationState.conversationDetail(conversationViewModel: tempConversation))
+ }
+
+ private func createTemporarySwarmConversation(with hash: String, accountId: String) -> ConversationViewModel {
+ let uri = JamiURI.init(schema: URIType.ring, infoHash: hash)
+ let conversation = ConversationModel(withParticipantUri: uri,
+ accountId: accountId)
+ conversation.type = .oneToOne
+ let newConversation = ConversationViewModel(with: self.injectionBag)
+ newConversation.userName.accept(hash)
+ newConversation.conversation = conversation
+ newConversation.isTemporary.accept(true)
+ return newConversation
+ }
+
+ func showConversationIfExists(conversationId: String) {
+ if let conversation = self.conversations.first(where: { conv in
+ conv.conversation.id == conversationId
+ }) {
+ self.stateSubject.onNext(ConversationState.conversationDetail(conversationViewModel: conversation))
+ }
+ }
+
+ func showDialpad() {
+ self.stateSubject.onNext(ConversationState.showDialpad(inCall: false))
+ }
+
+ func isSipAccount() -> Bool {
+ guard let account = self.accountsService.currentAccount else { return false }
+ return account.type == .sip
+ }
+
+ func showSipConversation(withNumber number: String) {
+ guard let account = self.accountsService
+ .currentAccount else {
+ return
+ }
+ let uri = JamiURI.init(schema: URIType.sip,
+ infoHash: number,
+ account: account)
+ let conversation = ConversationModel(withParticipantUri: uri,
+ accountId: account.id,
+ hash: number)
+ conversation.type = .sip
+ let newConversation = ConversationViewModel(with: self.injectionBag)
+ newConversation.conversation = conversation
+ self.stateSubject
+ .onNext(ConversationState
+ .conversationDetail(conversationViewModel:
+ newConversation))
+ }
+
+ func deleteConversation(conversationViewModel: ConversationViewModel) {
+ conversationViewModel.closeAllPlayers()
+ let accountId = conversationViewModel.conversation.accountId
+ let conversationId = conversationViewModel.conversation.id
+ if conversationViewModel.conversation.isCoredialog(),
+ let participantId = conversationViewModel.conversation.getParticipants().first?.jamiId {
+ self.contactsService
+ .removeContact(withId: participantId,
+ ban: false,
+ withAccountId: accountId)
+ .asObservable()
+ .subscribe(onCompleted: { [weak self, weak conversationViewModel] in
+ guard let conversationViewModel = conversationViewModel else { return }
+ self?.conversationsService
+ .removeConversationFromDB(conversation: conversationViewModel.conversation,
+ keepConversation: false)
+ })
+ .disposed(by: self.disposeBag)
+ } else {
+ self.conversationsService.removeConversation(conversationId: conversationId, accountId: accountId)
+ }
+ }
+
+ func blockConversation(conversationViewModel: ConversationViewModel) {
+ conversationViewModel.closeAllPlayers()
+ let accountId = conversationViewModel.conversation.accountId
+ let conversationId = conversationViewModel.conversation.id
+ if conversationViewModel.conversation.isCoredialog(),
+ let participantId = conversationViewModel.conversation.getParticipants().first?.jamiId {
+ self.contactsService
+ .removeContact(withId: participantId,
+ ban: true,
+ withAccountId: accountId)
+ .asObservable()
+ .subscribe(onCompleted: { [weak self, weak conversationViewModel] in
+ guard let conversationViewModel = conversationViewModel else { return }
+ self?.conversationsService
+ .removeConversationFromDB(conversation: conversationViewModel.conversation,
+ keepConversation: false)
+ })
+ .disposed(by: self.disposeBag)
+ } else {
+ self.conversationsService.removeConversation(conversationId: conversationId, accountId: accountId)
+ }
+ }
+
+ // MARK: - PresentedConversation
+ struct PresentedConversation {
+ let temporaryConversationId = "temporary"
+ var presentedId: String = ""
+
+ mutating func updatePresentedConversation(conversationViewModel: ConversationViewModel) {
+ if conversationViewModel.conversation.id.isEmpty {
+ presentedId = temporaryConversationId
+ } else {
+ presentedId = conversationViewModel.conversation.id
+ }
+ }
+
+ func isTemporaryPresented() -> Bool {
+ return self.presentedId == temporaryConversationId
+ }
+
+ func hasPresentedConversation() -> Bool {
+ return !presentedId.isEmpty
+ }
+
+ mutating func resetPresentedConversation() {
+ self.presentedId = ""
+ }
+ }
+
+ var presentedConversation = PresentedConversation()
+
+ // MARK: - Navigation
+ enum Target {
+ case smartList
+ case newMessage
+ }
+
+ @Published var slideDirectionUp: Bool = true
+
+ @Published var navigationTarget: Target = .smartList
+
+ // MARK: - Search
+ func performSearch(query: String) {
+ withAnimation {
+ self.searchQuery = query
+ }
+ if let searchModel = self.searchModel {
+ searchModel.searchBarText.accept(query)
+ }
+ }
+
+ private func updateSearchStatus(with status: SearchStatus? = nil) {
+ if let status = status {
+ switch status {
+ case .searching:
+ searchStatus = status
+ default:
+ evaluateSearchResults()
+ }
+ } else {
+ evaluateSearchResults()
+ }
+ }
+
+ private func updateSearchStatusIfNeeded() {
+ guard let account = self.accountsService.currentAccount else { return }
+ if searchQuery.count > 2 || account.isJams {
+ evaluateSearchResults()
+ } else {
+ searchStatus = .invalidId
+ }
+ }
+
+ private func evaluateSearchResults() {
+ if temporaryConversation != nil {
+ searchStatus = .foundTemporary
+ } else if !jamsSearchResult.isEmpty {
+ searchStatus = .foundJams
+ } else {
+ searchStatus = .noResult
+ }
+ }
+
+ // MARK: - menu settings
+
+ func openSettings() {
+ self.stateSubject.onNext(ConversationState.showAccountSettings)
+ }
+
+ func createSwarm() {
+ self.stateSubject.onNext(ConversationState.createSwarm)
+ }
+
+ func scanQRCode() {
+ self.stateSubject.onNext(ConversationState.qrCode)
+ }
+
+ func showGeneralSettings() {
+ self.stateSubject.onNext(ConversationState.showGeneralSettings)
+ }
+
+ func openAboutJami() {
+ self.stateSubject.onNext(ConversationState.openAboutJami)
+ }
+
+ func donate() {
+ SharedActionsPresenter.openDonationLink()
+ }
+
+ func createAccount() {
+ self.stateSubject.onNext(ConversationState.createNewAccount)
+ }
+
+ var accountInfoToShare: String {
+ return self.accountsService.accountInfoToShare?.joined(separator: "\n") ?? ""
+ }
+
+ func closeAllPlayers() {
+ self.conversationViewModels.forEach { conversationModel in
+ conversationModel.closeAllPlayers()
+ }
+ }
+}
+// swiftlint:enable type_body_length
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/RequestsViewModel.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/RequestsViewModel.swift
new file mode 100644
index 0000000..2586409
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Models/RequestsViewModel.swift
@@ -0,0 +1,431 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import Foundation
+import SwiftUI
+import Combine
+import RxSwift
+import RxRelay
+
+enum RequestStatus {
+ case pending
+ case accepted
+ case refused
+ case banned
+
+ func toString() -> String {
+ switch self {
+ case .pending:
+ return ""
+ case .accepted:
+ return L10n.Invitations.accepted
+ case .refused:
+ return L10n.Invitations.refused
+ case .banned:
+ return L10n.Invitations.banned
+ }
+ }
+
+ func color() -> Color {
+ switch self {
+ case .pending:
+ return Color(UIColor.white)
+ case .accepted:
+ return Color(UIColor.systemGreen)
+ case .refused:
+ return Color(UIColor.orange)
+ case .banned:
+ return Color(UIColor.systemRed)
+ }
+ }
+}
+
+enum RequestAction {
+ case accept, discard, block
+}
+
+class RequestNameResolver: ObservableObject, Identifiable, Hashable {
+ var bestName: String = "" // Name to be shown in the request list. It is either the swarm title or the names of every participant in the conversation.
+ let id: String
+ /*
+ Name to be shown in the requests widget title on the smartList.
+ It is either the swarm title or the name of the first participant.
+ The request widget title is a name for every request,
+ separated by commas.
+ */
+ var requestName = BehaviorRelay(value: "")
+ let request: RequestModel
+ var registeredNames = [String: String]() // Dictionary of jamiId and registered name
+ let nameService: NameService
+ let disposeBag = DisposeBag()
+ var nameResolved = BehaviorRelay(value: false)
+
+ init(request: RequestModel, nameService: NameService) {
+ self.request = request
+ self.id = request.getIdentifier()
+ self.nameService = nameService
+ self.setName()
+ }
+
+ private func setName() {
+ if !request.name.isEmpty {
+ updateNameOnMainThread(with: request.name)
+ requestName.accept(request.name)
+ self.nameResolved.accept(true)
+ } else {
+ handleNoInvitationName()
+ }
+ }
+
+ private func updateNameOnMainThread(with name: String) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.bestName = name
+ }
+ }
+
+ private func updateNameFromRegistered() {
+ let newBestName = constructNameFromRegisteredNames()
+ updateNameOnMainThread(with: newBestName)
+ if let name = registeredNames.first {
+ requestName.accept(name.value.isEmpty ? name.key : name.value)
+ }
+ }
+
+ private func constructNameFromRegisteredNames() -> String {
+ return registeredNames.enumerated()
+ .map { _, element in
+ let (jamiId, name) = element
+ return name.isEmpty ? jamiId : name
+ }
+ .joined(separator: ", ")
+ }
+
+ private func handleNoInvitationName() {
+ initializeParticipantEntries()
+ updateNameFromRegistered()
+ performLookup()
+ }
+
+ private func initializeParticipantEntries() {
+ // Create a dictionary of participant IDs and names, so names can be updated when lookup is finished.
+ for participant in request.participants {
+ registeredNames[participant.jamiId] = ""
+ }
+ }
+
+ private func performLookup() {
+ for jamiId in registeredNames.keys {
+ lookupUserName(jamiId: jamiId)
+ }
+ }
+
+ private func lookupUserName(jamiId: String) {
+ nameService.usernameLookupStatus.asObservable()
+ .filter { lookupNameResponse in
+ return lookupNameResponse.address == jamiId
+ }
+ .take(1)
+ .subscribe(onNext: { lookupNameResponse in
+ if lookupNameResponse.state == .found && !lookupNameResponse.name.isEmpty {
+ self.registeredNames[jamiId] = lookupNameResponse.name
+ self.updateNameFromRegistered()
+ self.nameResolved.accept(true)
+ }
+ })
+ .disposed(by: disposeBag)
+
+ nameService.lookupAddress(withAccount: request.accountId, nameserver: "", address: jamiId)
+ }
+
+ func getIdentifier() -> String {
+ return request.getIdentifier()
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+
+ static func == (lhs: RequestNameResolver, rhs: RequestNameResolver) -> Bool {
+ return lhs.id == rhs.id
+ }
+}
+
+class RequestRowViewModel: ObservableObject, Identifiable, Hashable {
+ @Published var avatar: UIImage?
+ @Published var receivedDate: String
+ @Published var status: RequestStatus = .pending
+ let avatarSize: CGFloat = 55
+ let request: RequestModel
+ let id: String
+ let nameResolver: RequestNameResolver
+ let disposeBag = DisposeBag()
+
+ init(request: RequestModel, nameResolver: RequestNameResolver) {
+ self.nameResolver = nameResolver
+ self.id = request.getIdentifier()
+ self.request = request
+ self.receivedDate = request.receivedDate.conversationTimestamp()
+ self.setAvatar()
+ self.nameResolver.nameResolved
+ .startWith(self.nameResolver.nameResolved.value)
+ .subscribe(onNext: { [weak self] resolved in
+ if resolved {
+ self?.setAvatar()
+ }
+ })
+ .disposed(by: disposeBag)
+ }
+
+ private func setAvatar() {
+ let newAvatar = createAvatar()
+ updateAvatarOnMainThread(with: newAvatar)
+ }
+
+ private func updateAvatarOnMainThread(with image: UIImage) {
+ DispatchQueue.main.async { [weak self] in
+ guard let self = self else { return }
+ self.avatar = image
+ }
+ }
+
+ private func createAvatar() -> UIImage {
+ if let avatarData = nameResolver.request.avatar, let image = UIImage(data: avatarData) {
+ return image
+ } else if request.type == .contact {
+ return UIImage.createContactAvatar(username: nameResolver.bestName, size: CGSize(width: avatarSize, height: avatarSize))
+ } else {
+ return UIImage.createSwarmAvatar(convId: nameResolver.request.conversationId, size: CGSize(width: avatarSize, height: avatarSize))
+ }
+ }
+
+ func requestAccepted() {
+ self.status = .accepted
+ }
+
+ func requestDiscarded() {
+ self.status = .refused
+ }
+
+ func requestBlocked() {
+ self.status = .banned
+ }
+
+ func hash(into hasher: inout Hasher) {
+ hasher.combine(id)
+ }
+
+ static func == (lhs: RequestRowViewModel, rhs: RequestRowViewModel) -> Bool {
+ return lhs.id == rhs.id
+ }
+
+}
+
+class RequestsViewModel: ObservableObject {
+ @Published var requestsRow = [RequestRowViewModel]()
+ @Published var requestNames = ""
+ @Published var unreadRequests = 0
+ @Published var requestViewOpened = false
+ var requestsNameResolvers = [RequestNameResolver]() // requests and resolved name
+ var title = L10n.Smartlist.invitationReceived
+
+ let requestsService: RequestsService
+ let conversationService: ConversationsService
+ let accountService: AccountsService
+ let contactsService: ContactsService
+ let presenceService: PresenceService
+ let injectionBar: InjectionBag
+ let nameService: NameService
+
+ let disposeBag = DisposeBag()
+ var titleDisposeBag = DisposeBag()
+
+ init(injectionBag: InjectionBag) {
+ self.requestsService = injectionBag.requestsService
+ self.conversationService = injectionBag.conversationsService
+ self.accountService = injectionBag.accountService
+ self.nameService = injectionBag.nameService
+ self.contactsService = injectionBag.contactsService
+ self.presenceService = injectionBag.presenceService
+ self.injectionBar = injectionBag
+ self.subscribeToNewRequests()
+ }
+
+ func subscribeToNewRequests() {
+ let conversationsStream = conversationService.conversations
+ .share()
+ .startWith(conversationService.conversations.value)
+
+ let requestsStream = requestsService.requests.asObservable()
+
+ let unhandledRequests = Observable.combineLatest(requestsStream, conversationsStream) {
+ [weak self] requests, conversations -> [RequestModel] in
+ guard let self = self, let account = self.accountService.currentAccount else {
+ return []
+ }
+ return self.filterRequestsNotInConversations(requests: requests, conversations: conversations, accountId: account.id)
+ }
+
+ unhandledRequests
+ .observe(on: MainScheduler.instance)
+ .subscribe(onNext: { [weak self] newRequests in
+ self?.processNewRequests(newRequests)
+ })
+ .disposed(by: disposeBag)
+ }
+
+ private func filterRequestsNotInConversations(requests: [RequestModel], conversations: [ConversationModel], accountId: String) -> [RequestModel] {
+ let conversationIds = Set(conversations.map { $0.id })
+ return requests.filter { $0.accountId == accountId && !conversationIds.contains($0.conversationId) }
+ }
+
+ private func processNewRequests(_ newRequests: [RequestModel]) {
+ let newItems = self.findNewRequests(from: newRequests)
+ let outdatedItems = self.findOutdatedRequests(comparedTo: newRequests)
+
+ self.removeOutdatedItems(outdatedItems)
+ self.addNewRequests(newItems)
+ self.sortRequestsByReceivedDate()
+
+ self.updateUnreadCount()
+ if newItems.isEmpty && outdatedItems.isEmpty {
+ // Skip updating requestNames if there are no new or outdated items.
+ return
+ }
+ observeRequestNames()
+ }
+
+ private func findNewRequests(from newRequests: [RequestModel]) -> [RequestModel] {
+ return newRequests.filter { newItem in
+ !self.requestsNameResolvers.contains { $0.request.getIdentifier() == newItem.getIdentifier() }
+ }
+ }
+
+ private func findOutdatedRequests(comparedTo newRequests: [RequestModel]) -> [RequestNameResolver] {
+ return self.requestsNameResolvers.filter { oldItem in
+ !newRequests.contains { $0.getIdentifier() == oldItem.request.getIdentifier() }
+ }
+ }
+
+ private func removeOutdatedItems(_ items: [RequestNameResolver]) {
+ let identifiers = Set(items.map { $0.request.getIdentifier() })
+ self.requestsNameResolvers.removeAll { identifiers.contains($0.request.getIdentifier()) }
+ }
+
+ private func addNewRequests(_ newRequests: [RequestModel]) {
+ let newViewModels = newRequests.map { RequestNameResolver(request: $0, nameService: self.nameService) }
+ self.requestsNameResolvers.append(contentsOf: newViewModels)
+ if requestViewOpened {
+ for nameResolver in newViewModels {
+ requestsRow.append(RequestRowViewModel(request: nameResolver.request, nameResolver: nameResolver))
+ }
+ }
+ }
+
+ private func sortRequestsByReceivedDate() {
+ self.requestsNameResolvers.sort(by: { $0.request.receivedDate < $1.request.receivedDate })
+ }
+
+ private func updateUnreadCount() {
+ self.unreadRequests = self.requestsNameResolvers.count
+ }
+
+ private func observeRequestNames() {
+ self.titleDisposeBag = DisposeBag()
+
+ // Create a combined observable for request names
+ Observable.combineLatest(requestsNameResolvers.map { $0.requestName.asObservable() })
+ .map { names in names.joined(separator: ", ") }
+ .subscribe(onNext: { combinedNames in
+ DispatchQueue.main.async { [weak self] in
+ self?.requestNames = combinedNames
+ }
+ })
+ .disposed(by: self.titleDisposeBag)
+ }
+
+ // MARK: - presenting requests list
+
+ func presentRequests() {
+ // When the list of requests is presented, maintain the same number of requests.
+ // Create rows from the current requests so that the list does not change as requests are processed.
+ generateRequestRows()
+ requestViewOpened.toggle()
+ }
+
+ func generateRequestRows() {
+ requestsRow = [RequestRowViewModel]()
+ for nameResolver in requestsNameResolvers {
+ requestsRow.append(RequestRowViewModel(request: nameResolver.request, nameResolver: nameResolver))
+ }
+ }
+
+ // MARK: - request actions
+
+ func accept(requestRow: RequestRowViewModel) {
+ processRequest(requestRow, action: .accept) {
+ if requestRow.request.isDialog(), let jamiId = requestRow.request.participants.first?.jamiId {
+ self.presenceService.subscribeBuddy(withAccountId: requestRow.request.accountId, withUri: jamiId, withFlag: true)
+ }
+ }
+ }
+
+ func discard(requestRow: RequestRowViewModel) {
+ processRequest(requestRow, action: .discard)
+ }
+
+ func block(requestRow: RequestRowViewModel) {
+ processRequest(requestRow, action: .block) {
+ guard let jamiId = requestRow.request.participants.first?.jamiId else { return }
+ self.removeContactAndBan(jamiId: jamiId, accountId: requestRow.request.accountId)
+ }
+ }
+
+ private func processRequest(_ requestRow: RequestRowViewModel, action: RequestAction, completion: (() -> Void)? = nil) {
+ let requestServiceAction = (action == .accept) ? requestsService.acceptConverversationRequest : requestsService.discardConverversationRequest
+
+ requestServiceAction(requestRow.request.conversationId, requestRow.request.accountId)
+ .subscribe(
+ onError: { error in
+ print("Error processing request: \(error.localizedDescription)")
+ },
+ onCompleted: { [weak requestRow] in
+ guard let requestRow = requestRow else { return }
+ switch action {
+ case .accept:
+ requestRow.requestAccepted()
+ completion?()
+ case .discard:
+ requestRow.requestDiscarded()
+ case .block:
+ requestRow.requestBlocked()
+ completion?()
+ }
+ }
+ )
+ .disposed(by: disposeBag)
+ }
+
+ private func removeContactAndBan(jamiId: String, accountId: String) {
+ contactsService.removeContact(withId: jamiId, ban: true, withAccountId: accountId)
+ .subscribe()
+ .disposed(by: disposeBag)
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/AccountLists.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/AccountLists.swift
new file mode 100644
index 0000000..f3689ea
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/AccountLists.swift
@@ -0,0 +1,128 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import SwiftUI
+
+struct AccountLists: View {
+ @ObservedObject var model: AccountsViewModel
+ var createAccountCallback: (() -> Void)
+ let verticalSpacing: CGFloat = 15
+ let maxHeight: CGFloat = 300
+ let cornerRadius: CGFloat = 16
+ let shadowRadius: CGFloat = 6
+ var body: some View {
+ VStack(spacing: 10) {
+ accountsView()
+ newAccountButton()
+ }
+ .padding(.horizontal, 5)
+ }
+
+ @ViewBuilder
+ private func accountsView() -> some View {
+ VStack {
+ Spacer()
+ .frame(height: verticalSpacing)
+ Text(model.headerTitle)
+ .fontWeight(.semibold)
+ Spacer()
+ .frame(height: verticalSpacing)
+ accountsList()
+ Spacer()
+ .frame(height: verticalSpacing)
+ }
+ .background(VisualEffect(style: .systemMaterial, withVibrancy: false))
+ .cornerRadius(cornerRadius)
+ .shadow(radius: shadowRadius)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+
+ @ViewBuilder
+ private func newAccountButton() -> some View {
+ Button(action: {
+ createAccountCallback()
+ }, label: {
+ Text(L10n.Smartlist.addAccountButton)
+ .lineLimit(1)
+ .padding()
+ .frame(maxWidth: .infinity)
+ .background(VisualEffect(style: .systemChromeMaterial, withVibrancy: false))
+ })
+ .frame(minWidth: 100, maxWidth: .infinity)
+ .cornerRadius(cornerRadius)
+ .shadow(radius: shadowRadius)
+ }
+
+ @ViewBuilder
+ private func accountsList() -> some View {
+ ScrollView {
+ VStack {
+ ForEach(model.accountsRows, id: \.id) { accountRow in
+ AccountRowView(accountRow: accountRow, model: model)
+ }
+ }
+ .frame(minHeight: 0, maxHeight: .infinity)
+ }
+ .frame(maxHeight: maxHeight)
+ }
+}
+
+struct AccountRowView: View {
+ @ObservedObject var accountRow: AccountRow
+ @ObservedObject var model: AccountsViewModel
+ let cornerRadius: CGFloat = 8
+ var body: some View {
+ HStack(spacing: 0) {
+ Image(uiImage: accountRow.avatar)
+ .resizable()
+ .frame(width: accountRow.dimensions.imageSize, height: accountRow.dimensions.imageSize)
+ .clipShape(Circle())
+ Spacer().frame(width: accountRow.dimensions.spacing)
+ VStack(alignment: .leading) {
+ Text(accountRow.bestName)
+ .lineLimit(1)
+ }
+ Spacer()
+ }
+ .padding(.horizontal)
+ .padding(.vertical, accountRow.dimensions.spacing)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ .background(backgroundForAccountRow())
+ .onTapGesture {
+ model.changeCurrentAccount(accountId: accountRow.id)
+ }
+ }
+
+ private var isSelectedAccount: Bool {
+ accountRow.id == model.selectedAccount
+ }
+
+ @ViewBuilder
+ private func backgroundForAccountRow() -> some View {
+ Group {
+ if isSelectedAccount {
+ Color(UIColor.secondarySystemFill)
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius))
+ .padding(.horizontal, 6)
+ }
+ }
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift
new file mode 100644
index 0000000..f187014
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/ConversationsView.swift
@@ -0,0 +1,222 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import SwiftUI
+
+@available(iOS 15.0, *)
+struct SwipeActionsModifier: ViewModifier {
+ enum ActiveAlert: Identifiable {
+ case block, delete
+ var id: Self { self }
+ }
+
+ let conversation: ConversationViewModel
+ let model: ConversationsViewModel
+ @SwiftUI.State private var activeAlert: ActiveAlert?
+
+ func body(content: Content) -> some View {
+ content
+ .swipeActions(edge: .trailing) {
+ swipeButton(for: .block, color: .red, title: L10n.Global.block)
+ swipeButton(for: .delete, color: .orange, title: L10n.Actions.deleteAction)
+ }
+ .alert(item: $activeAlert, content: alertForType)
+ }
+
+ private func swipeButton(for alertType: ActiveAlert, color: Color, title: String) -> some View {
+ Button {
+ activeAlert = alertType
+ } label: {
+ Text(title)
+ }
+ .tint(color)
+ }
+
+ private func alertForType(_ alertType: ActiveAlert) -> Alert {
+ switch alertType {
+ case .block:
+ return Alert(
+ title: Text(L10n.Global.blockContact),
+ message: Text(L10n.Alerts.confirmBlockContact),
+ primaryButton: .default(Text(L10n.Global.cancel)),
+ secondaryButton: .destructive(Text(L10n.Global.block), action: { model.blockConversation(conversationViewModel: conversation) })
+ )
+ case .delete:
+ return Alert(
+ title: Text(L10n.Alerts.confirmDeleteConversationTitle),
+ message: Text(L10n.Alerts.confirmDeleteConversation),
+ primaryButton: .default(Text(L10n.Global.cancel)),
+ secondaryButton: .destructive(Text(L10n.Actions.deleteAction), action: { model.deleteConversation(conversationViewModel: conversation) })
+ )
+ }
+ }
+}
+
+extension View {
+ @ViewBuilder
+ func conditionalSmartListSwipeActions(conversation: ConversationViewModel, model: ConversationsViewModel) -> some View {
+ if #available(iOS 15.0, *), model.navigationTarget == .smartList {
+ self.modifier(SwipeActionsModifier(conversation: conversation, model: model))
+ } else {
+ self
+ }
+ }
+}
+
+struct ConversationsView: View {
+ @ObservedObject var model: ConversationsViewModel
+ @SwiftUI.State private var searchText = ""
+ var body: some View {
+ ForEach(model.conversations) { conversation in
+ Button(action: {
+ model.showConversation(withConversationViewModel: conversation)
+ }) {
+ ConversationRowView(model: conversation)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 15))
+ }
+ .hideRowSeparator()
+ .navigationBarBackButtonHidden(true)
+ }
+}
+
+struct TempConversationsView: View {
+ @ObservedObject var model: ConversationsViewModel
+ var body: some View {
+ if let conversation = model.temporaryConversation {
+ Button(action: {
+ model.showConversation(withConversationViewModel: conversation)
+ }) {
+ ConversationRowView(model: conversation, withSeparator: false)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .contentShape(Rectangle())
+ }
+ .listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 15))
+ }
+ }
+}
+
+struct JamsSearchResultView: View {
+ @ObservedObject var model: ConversationsViewModel
+ var body: some View {
+ ForEach(model.jamsSearchResult) { conversation in
+ Button(action: {
+ model.showConversation(withConversationViewModel: conversation)
+ }) {
+ ConversationRowView(model: conversation, withSeparator: true)
+ .contentShape(Rectangle())
+ }
+ .listRowInsets(EdgeInsets(top: 10, leading: 15, bottom: 0, trailing: 15))
+ }
+ }
+}
+
+struct ConversationRowView: View {
+ @ObservedObject var model: ConversationViewModel
+ var withSeparator: Bool = true
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack {
+ ZStack(alignment: .bottomTrailing) {
+ if let image = model.avatar {
+ Image(uiImage: image)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: 55, height: 55, alignment: .center)
+ .clipShape(Circle())
+ } else {
+ Image(uiImage: model.getDefaultAvatar())
+ .resizable()
+ .frame(width: 55, height: 55, alignment: .center)
+ .clipShape(Circle())
+ }
+ presenceIndicator
+ }
+ Spacer()
+ .frame(width: 15)
+ VStack(alignment: .leading) {
+ Text(model.name)
+ .fontWeight(model.unreadMessages > 0 ? .bold : .regular)
+ .lineLimit(1)
+ if !model.lastMessage.isEmpty {
+ Spacer()
+ .frame(height: 5)
+ HStack(alignment: .bottom, spacing: 4) {
+ Text(model.lastMessageDate + " -")
+ .fontWeight(.regular)
+ .font(.footnote)
+ .lineLimit(1)
+ Text( model.lastMessage)
+ .font(.footnote)
+ .lineLimit(1)
+ }
+ } else if model.isSynchronizing {
+ Spacer()
+ .frame(height: 5)
+ Text(L10n.Smartlist.inSynchronization)
+ .italic()
+ .font(.footnote)
+ .lineLimit(1)
+ }
+ }
+ Spacer()
+ if model.unreadMessages > 0 {
+ Text("\(model.unreadMessages)")
+ .fontWeight(.semibold)
+ .font(.footnote)
+ .padding(.vertical, 4)
+ .padding(.horizontal, 8)
+ .foregroundColor(Color.unreadMessageColorText)
+ .background(Color.unreadMessageBackground)
+ .clipShape(RoundedRectangle(cornerRadius: 5, style: .continuous))
+ }
+ }
+ if withSeparator {
+ Divider()
+ .padding(.leading, 55)
+ }
+ }
+ }
+
+ private var presenceIndicator: some View {
+ Group {
+ switch model.presence {
+ case .connected:
+ presenceCircle(color: Color.onlinePresenceColor)
+ case .available:
+ presenceCircle(color: Color.availablePresenceColor)
+ default:
+ EmptyView()
+ }
+ }
+ }
+
+ private func presenceCircle(color: Color) -> some View {
+ Circle()
+ .fill(color)
+ .frame(width: 14, height: 14)
+ .overlay(
+ Circle().stroke(Color(UIColor.systemBackground), lineWidth: 2)
+ )
+ .offset(x: -1, y: -1)
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift
new file mode 100644
index 0000000..f83f5a7
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/RequestsView.swift
@@ -0,0 +1,199 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import Foundation
+import SwiftUI
+
+struct RequestsIndicatorView: View {
+ @ObservedObject var model: RequestsViewModel
+ private let iconSize: CGFloat = 25
+ private let cornerRadius: CGFloat = 12
+ private let padding: CGFloat = 15
+ private let badgePadding: CGFloat = 8
+ private let badgeCornerRadius: CGFloat = 5
+ private let verticalPaddingForBadge: CGFloat = 20
+ private let horizontalPaddingForBody: CGFloat = 20
+ private let verticalTextPadding: CGFloat = 5
+ private let foregroundColor: Color = Color(UIColor.systemBackground)
+
+ var body: some View {
+ HStack(spacing: horizontalPaddingForBody) {
+ icon
+ description
+ Spacer()
+ unreadCounter
+ }
+ .frame(maxWidth: .infinity)
+ .background(Color.jamiRequestsColor)
+ .cornerRadius(cornerRadius)
+ }
+
+ private var icon: some View {
+ Image(systemName: "envelope.badge")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: iconSize, height: iconSize)
+ .foregroundColor(foregroundColor)
+ .padding(.leading, horizontalPaddingForBody)
+ }
+
+ private var description: some View {
+ VStack(alignment: .leading, spacing: verticalTextPadding) {
+ Text(model.title)
+ .lineLimit(1)
+ .foregroundColor(foregroundColor)
+ Text(model.requestNames)
+ .lineLimit(1)
+ .font(.footnote)
+ .foregroundColor(foregroundColor)
+ }
+ }
+
+ private var unreadCounter: some View {
+ Text("\(model.unreadRequests)")
+ .font(.footnote)
+ .fontWeight(.semibold)
+ .foregroundColor(Color.requestBadgeForeground)
+ .padding(.horizontal, badgePadding)
+ .padding(.vertical, 4)
+ .background(Color.requestsBadgeBackground)
+ .clipShape(RoundedRectangle(cornerRadius: badgeCornerRadius))
+ .padding(.vertical, verticalPaddingForBadge)
+ .padding(.trailing, horizontalPaddingForBody)
+ }
+}
+
+struct RequestsView: View {
+ @ObservedObject var model: RequestsViewModel
+
+ var body: some View {
+ NavigationView {
+ VStack(spacing: 0) {
+ Text(model.title)
+ .font(.headline)
+ .foregroundColor(Color(UIColor.systemBackground))
+ .padding(.vertical, 20)
+
+ requestsList
+ }
+ .background(Color.jamiRequestsColor.ignoresSafeArea())
+ }
+ }
+
+ private var requestsList: some View {
+ List(model.requestsRow) { request in
+ RequestsRowView(requestRow: request, nameResolver: request.nameResolver, listModel: model)
+ .listRowBackground(Color.jamiRequestsColor)
+ .hideRowSeparator()
+ }
+ .hideRowSeparator()
+ .listStyle(PlainListStyle())
+ .edgesIgnoringSafeArea(.all)
+ .background(Color.jamiRequestsColor)
+ }
+}
+
+struct RequestsRowView: View {
+ @ObservedObject var requestRow: RequestRowViewModel
+ @ObservedObject var nameResolver: RequestNameResolver
+ var listModel: RequestsViewModel
+
+ // Constants
+ private let actionIconSize: CGFloat = 20
+ private let spacerWidth: CGFloat = 15
+ private let spacerHeight: CGFloat = 20
+ private let buttonPadding: CGFloat = 10
+ private let dividerOpacity: Double = 0.1
+ private let cornerRadius: CGFloat = 12
+ private let foregroundColor: Color = Color(UIColor.systemBackground)
+
+ var body: some View {
+ VStack(spacing: 0) {
+ userInfoView
+ Spacer().frame(height: spacerHeight)
+ actionButtonsView
+ Spacer().frame(height: spacerHeight)
+ Divider()
+ .background(Color(UIColor.systemBackground).opacity(dividerOpacity))
+ }
+ }
+
+ private var userInfoView: some View {
+ HStack(alignment: .center) {
+ avatarView
+ Spacer().frame(width: spacerWidth)
+ VStack(alignment: .leading, spacing: 5) {
+ Text(nameResolver.bestName)
+ .foregroundColor(foregroundColor)
+ .lineLimit(1)
+ Text(requestRow.receivedDate)
+ .font(.footnote)
+ .foregroundColor(foregroundColor)
+ }
+ Spacer()
+ Text(requestRow.status.toString())
+ .font(.footnote)
+ .padding(.horizontal, buttonPadding)
+ .foregroundColor(requestRow.status.color())
+ }
+ }
+
+ private var avatarView: some View {
+ Group {
+ if let avatar = requestRow.avatar {
+ Image(uiImage: avatar)
+ .resizable()
+ .aspectRatio(contentMode: .fill)
+ .frame(width: requestRow.avatarSize, height: requestRow.avatarSize)
+ .clipShape(Circle())
+ }
+ }
+ }
+
+ private var actionButtonsView: some View {
+ HStack {
+ actionIcon("slash.circle") {
+ listModel.block(requestRow: requestRow)
+ }
+ Spacer().frame(width: spacerWidth)
+ actionIcon("xmark") {
+ listModel.discard(requestRow: requestRow)
+ }
+ Spacer().frame(width: spacerWidth)
+ actionIcon("checkmark") {
+ listModel.accept(requestRow: requestRow)
+ }
+ }
+ }
+
+ private func actionIcon(_ systemName: String, action: @escaping () -> Void) -> some View {
+ Image(systemName: systemName)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: actionIconSize, height: actionIconSize)
+ .foregroundColor(Color.requestBadgeForeground)
+ .padding(.horizontal, buttonPadding)
+ .padding(.vertical, buttonPadding)
+ .frame(maxWidth: .infinity)
+ .background(Color.requestsBadgeBackground)
+ .cornerRadius(cornerRadius)
+ .onTapGesture(perform: action)
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SearchBar.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SearchBar.swift
new file mode 100644
index 0000000..1aa6c6c
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SearchBar.swift
@@ -0,0 +1,129 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import SwiftUI
+import UIKit
+import Combine
+
+public extension View {
+ func navigationBarSearch(_ searchText: Binding<String>, isActive: Binding<Bool>, isSearchBarDisabled: Binding<Bool>) -> some View {
+ return overlay(SearchBar(text: searchText, isActive: isActive, isSearchBarDisabled: isSearchBarDisabled).frame(width: 0, height: 0))
+ }
+}
+
+private struct SearchBar: UIViewControllerRepresentable {
+ @Binding var text: String
+ @Binding var isActive: Bool
+ @Binding var isSearchBarDisabled: Bool // used to programaticly dismiss search controller
+
+ init(text: Binding<String>, isActive: Binding<Bool>, isSearchBarDisabled: Binding<Bool>) {
+ self._text = text
+ self._isActive = isActive
+ self._isSearchBarDisabled = isSearchBarDisabled
+ }
+
+ func makeUIViewController(context: Context) -> SearchBarWrapperController {
+ return SearchBarWrapperController()
+ }
+
+ func updateUIViewController(_ controller: SearchBarWrapperController, context: Context) {
+ controller.searchController = context.coordinator.searchController
+ if self.isSearchBarDisabled {
+ controller.searchController?.isActive = false
+ }
+ }
+
+ func makeCoordinator() -> Coordinator {
+ return Coordinator(text: $text, isActive: $isActive, isSearchBarDisabled: $isSearchBarDisabled)
+ }
+
+ class Coordinator: NSObject, UISearchResultsUpdating, UISearchControllerDelegate {
+ @Binding var text: String
+ @Binding var isActive: Bool
+ @Binding var isSearchBarDisabled: Bool
+ let searchController: UISearchController
+
+ init(text: Binding<String>, isActive: Binding<Bool>, isSearchBarDisabled: Binding<Bool>) {
+ self._text = text
+ self._isActive = isActive
+ self._isSearchBarDisabled = isSearchBarDisabled
+ self.searchController = UISearchController(searchResultsController: nil)
+
+ super.init()
+
+ searchController.searchResultsUpdater = self
+ searchController.searchBar.searchTextField.addTarget(self, action: #selector(searchBarTextDidBeginEditing(_:)), for: .editingDidBegin)
+ searchController.searchBar.searchTextField.addTarget(self, action: #selector(searchBarTextDidEndEditing(_:)), for: .editingDidEnd)
+ searchController.hidesNavigationBarDuringPresentation = true
+ searchController.obscuresBackgroundDuringPresentation = false
+
+ self.searchController.searchBar.text = self.text
+ searchController.delegate = self
+ }
+
+ @objc private func searchBarTextDidBeginEditing(_ textField: UITextField) {
+ DispatchQueue.main.async {
+ withAnimation {
+ self.isSearchBarDisabled = false
+ self.isActive = true
+ }
+ }
+ }
+
+ func didDismissSearchController(_ searchController: UISearchController) {
+ DispatchQueue.main.async {
+ withAnimation {
+ self.isActive = false
+ }
+ }
+ }
+
+ @objc private func searchBarTextDidEndEditing(_ textField: UITextField) {
+ DispatchQueue.main.async {
+ withAnimation {
+ self.isActive = false
+ }
+ }
+ }
+
+ func updateSearchResults(for searchController: UISearchController) {
+ // DispatchQueue.main.async is important to avoid "Modifying state during view update, this will cause undefined behavior." error
+ DispatchQueue.main.async {
+ guard let text = searchController.searchBar.text else { return }
+ self.text = text
+ }
+ }
+ }
+
+ class SearchBarWrapperController: UIViewController {
+ var searchController: UISearchController? // {
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ self.parent?.navigationItem.searchController = self.searchController
+ self.parent?.navigationItem.hidesSearchBarWhenScrolling = false
+ }
+
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ self.parent?.navigationItem.searchController = self.searchController
+ }
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContainer.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContainer.swift
new file mode 100644
index 0000000..41919a0
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContainer.swift
@@ -0,0 +1,312 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import SwiftUI
+import UIKit
+import Combine
+
+struct SmartListContainer: View {
+ @ObservedObject var model: ConversationsViewModel
+ var body: some View {
+ switch model.navigationTarget {
+ case .smartList:
+ SmartListView(model: model)
+ case .newMessage:
+ NewMessageView(model: model)
+ .applySlideTransition(directionUp: model.slideDirectionUp)
+ }
+ }
+}
+
+struct NewMessageView: View {
+ @ObservedObject var model: ConversationsViewModel
+ @SwiftUI.State private var isSearchBarActive = false // To track state initiated by the user
+ var body: some View {
+ PlatformAdaptiveNavView {
+ SearchableConversationsView(model: model, isSearchBarActive: $isSearchBarActive)
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationTitle(L10n.Smartlist.newMessage)
+ .navigationBarItems(leading: Button(L10n.Global.cancel) {
+ model.slideDirectionUp = false
+ withAnimation {
+ model.navigationTarget = .smartList
+ }
+ })
+
+ }
+ }
+}
+
+struct SmartListView: View {
+ @ObservedObject var model: ConversationsViewModel
+ // account list presentation
+ @SwiftUI.State private var showAccountList = false
+ @SwiftUI.State private var coverBackgroundOpacity: CGFloat = 0
+ @SwiftUI.State private var isSearchBarActive = false // To track state initiated by the user
+ let maxCoverBackgroundOpacity: CGFloat = 0.09
+ let minCoverBackgroundOpacity: CGFloat = 0
+ @SwiftUI.State var showingPicker = false
+ // share account info
+ @SwiftUI.State private var isSharing = false
+ var body: some View {
+ PlatformAdaptiveNavView {
+ ZStack(alignment: .bottom) {
+ SearchableConversationsView(model: model, isSearchBarActive: $isSearchBarActive)
+ .navigationBarTitleDisplayMode(.inline)
+ .navigationBarTitle("", displayMode: .inline)
+ .navigationBarItems(leading: leadingBarItems, trailing: trailingBarItems)
+ .zIndex(0)
+ if showAccountList {
+ backgroundCover()
+ accountListsView()
+ }
+ }
+ }
+ .sheet(isPresented: $showingPicker) {
+ ContactPicker { contact in
+ model.showSipConversation(withNumber: contact)
+ showingPicker = false
+ }
+ }
+ .onChange(of: isSearchBarActive) { _ in
+ showAccountList = false
+ }
+ }
+
+ private var leadingBarItems: some View {
+ Button(action: {
+ toggleAccountList()
+ }) {
+ CurrentAccountButton(model: model.accountsModel)
+ }
+ }
+
+ @ViewBuilder
+ private func backgroundCover() -> some View {
+ Color(UIColor.black).opacity(coverBackgroundOpacity)
+ .ignoresSafeArea(edges: [.top, .bottom])
+ .allowsHitTesting(true)
+ .onTapGesture {
+ toggleAccountList()
+ }
+ }
+
+ @ViewBuilder
+ private func accountListsView() -> some View {
+ AccountLists(model: model.accountsModel) {
+ toggleAccountList()
+ model.createAccount()
+ }
+ .zIndex(1)
+ .transition(.move(edge: .bottom))
+ .animation(.easeOut, value: showAccountList)
+ }
+
+ private func toggleAccountList() {
+ setupBeforeTogglingAccountList()
+ animateAccountListVisibility()
+ }
+
+ private func setupBeforeTogglingAccountList() {
+ prepareAccountsIfNeeded()
+ updateCoverBackgroundOpacity()
+ }
+
+ // Update accounts if the list is about to be shown.
+ private func prepareAccountsIfNeeded() {
+ guard !showAccountList else { return }
+ model.accountsModel.getAccountsRows()
+ }
+
+ private func updateCoverBackgroundOpacity() {
+ coverBackgroundOpacity = showAccountList ? minCoverBackgroundOpacity : maxCoverBackgroundOpacity
+ }
+
+ private func animateAccountListVisibility() {
+ withAnimation {
+ showAccountList.toggle()
+ }
+ }
+
+ private var trailingBarItems: some View {
+ HStack {
+ if model.isSipAccount() {
+ menuButton
+ bookButton
+ } else {
+ menuButton
+ composeButton
+ }
+ }
+ }
+
+ private var bookButton: some View {
+ Button(action: { showingPicker.toggle() }) {
+ if let uiImage = UIImage(asset: Asset.phoneBook) {
+ Image(uiImage: uiImage)
+ .foregroundColor(Color.jamiColor)
+ }
+ }
+ }
+
+ private var diapladButton: some View {
+ Button(action: { model.showDialpad() }) {
+ Image(systemName: "square.grid.3x3.topleft.filled")
+ .foregroundColor(Color.jamiColor)
+ }
+ }
+
+ private var menuButton: some View {
+ Menu {
+ if !model.isSipAccount() {
+ createSwarmButton
+ if #available(iOS 16.0, *) {
+ shareLinkButton
+ }
+ }
+ accountsButton
+ settingsButton
+ generalSettingsButton
+ donateButton
+ aboutJamiButton
+ } label: {
+ Image(systemName: "ellipsis.circle")
+ .foregroundColor(Color.jamiColor)
+ }
+ }
+
+ private var composeButton: some View {
+ Button(action: triggerNewMessageAnimation) {
+ Image(systemName: "square.and.pencil")
+ .foregroundColor(Color.jamiColor)
+ }
+ }
+
+ private var createSwarmButton: some View {
+ Button(action: model.createSwarm) {
+ Label(L10n.Swarm.newSwarm, systemImage: "person.2")
+ }
+ }
+
+ @available(iOS 16.0, *)
+ private var shareLinkButton: some View {
+ ShareLink(item: model.accountInfoToShare) {
+ Label(L10n.Smartlist.inviteFriends, systemImage: "envelope.open")
+ .padding()
+ .background(Color.blue)
+ .foregroundColor(.white)
+ .cornerRadius(8)
+ }
+ }
+
+ private var accountsButton: some View {
+ Button(action: toggleAccountList) {
+ Label(L10n.Smartlist.accounts, systemImage: "list.bullet")
+ }
+ }
+
+ private var settingsButton: some View {
+ Button(action: model.openSettings) {
+ Label(L10n.Global.accountSettings, systemImage: "person.circle")
+ }
+ }
+
+ private var generalSettingsButton: some View {
+ Button(action: model.showGeneralSettings) {
+ Label(L10n.Global.advancedSettings, systemImage: "gearshape")
+ }
+ }
+
+ private var donateButton: some View {
+ Button(action: model.donate) {
+ Label(L10n.Global.donate, systemImage: "heart")
+ }
+ }
+
+ private var aboutJamiButton: some View {
+ Button(action: model.openAboutJami) {
+ Label {
+ Text(L10n.Smartlist.aboutJami)
+ } icon: {
+ Image(uiImage: model.jamiImage)
+ }
+ }
+ }
+
+ private func triggerNewMessageAnimation() {
+ model.slideDirectionUp = true
+ withAnimation {
+ model.navigationTarget = .newMessage
+ }
+ }
+}
+
+struct SearchableConversationsView: View {
+ @ObservedObject var model: ConversationsViewModel
+ @Binding var isSearchBarActive: Bool
+ @SwiftUI.State private var searchText = ""
+ @SwiftUI.State private var isSearchBarDisabled = false // To programmatically disable the search bar
+ @SwiftUI.State private var scrollViewOffset: CGFloat = 0
+ var body: some View {
+ SmartListContentView(model: model, mode: model.navigationTarget, requestsModel: model.requestsModel, isSearchBarActive: $isSearchBarActive)
+ .navigationBarSearch(self.$searchText, isActive: $isSearchBarActive, isSearchBarDisabled: $isSearchBarDisabled)
+ .onChange(of: searchText) { _ in
+ model.performSearch(query: searchText)
+ }
+ .onChange(of: model.conversationCreated) { _ in
+ if model.conversationCreated.isEmpty { return }
+ isSearchBarDisabled = true
+ searchText = ""
+ }
+ }
+}
+
+struct CurrentAccountButton: View {
+ @ObservedObject var model: AccountsViewModel
+ var body: some View {
+ HStack(spacing: 0) {
+ Image(uiImage: model.avatar)
+ .resizable()
+ .frame(width: model.dimensions.imageSize, height: model.dimensions.imageSize)
+ .clipShape(Circle())
+ Spacer()
+ .frame(width: model.dimensions.spacing)
+ VStack(alignment: .leading) {
+ Text(model.bestName)
+ .bold()
+ .lineLimit(1)
+ .foregroundColor(Color.jamiColor)
+ .frame(maxWidth: 150, alignment: .leading)
+ }
+ Spacer()
+ }
+ .transaction { transaction in
+ transaction.animation = nil
+ }
+ }
+}
+
+class CustomHostingController: UIHostingController<SmartListContainer> {
+
+ // Override supported interface orientations
+ override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
+ return .all // Customize this based on your app's needs
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift
new file mode 100644
index 0000000..7b042d7
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/SmartListContentView.swift
@@ -0,0 +1,234 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import SwiftUI
+
+struct SmartListContentView: View {
+ @ObservedObject var model: ConversationsViewModel
+ @SwiftUI.State var mode: ConversationsViewModel.Target
+ @SwiftUI.State var hideTopView: Bool = true
+ @ObservedObject var requestsModel: RequestsViewModel
+ @Binding var isSearchBarActive: Bool
+ @SwiftUI.State var currentSearchBarStatus: Bool = false
+ @SwiftUI.State var isShowingScanner: Bool = false
+ @SwiftUI.State var isShowingNewMessageTop: Bool = true
+ var body: some View {
+ List {
+ publicDirectorySearchView
+ if !hideTopView {
+ if mode == .smartList {
+ smartListTopView
+ .transition(.opacity)
+ } else {
+ newMessageTopView
+ .transition(.opacity)
+ }
+ }
+ conversationsSearchHeaderView
+ .hideRowSeparator()
+ ConversationsView(model: model)
+ }
+ .onAppear {
+ // If there was an active search before presenting the conversation, the search results should remain the same upon returning to the page.
+ if model.presentedConversation.hasPresentedConversation() && !model.searchQuery.isEmpty {
+ isSearchBarActive = true
+ model.presentedConversation.resetPresentedConversation()
+ }
+ mode = model.navigationTarget
+ hideTopView = false
+ }
+ .onChange(of: isSearchBarActive) { _ in
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
+ withAnimation {
+ isShowingNewMessageTop = !isSearchBarActive
+ }
+ }
+ }
+ .listStyle(.plain)
+ .hideRowSeparator()
+ .sheet(isPresented: $requestsModel.requestViewOpened) {
+ RequestsView(model: requestsModel)
+ }
+ .sheet(isPresented: $isShowingScanner) {
+ ScanView(onCodeScanned: { code in
+ model.showConversationFromQRCode(jamiId: code)
+ isShowingScanner = false
+ }, injectionBag: model.injectionBag)
+ }
+ }
+
+ @ViewBuilder private var smartListTopView: some View {
+ if !isSearchBarActive && (requestsModel.unreadRequests > 0 || model.connectionState == .none) {
+ VStack {
+ if model.connectionState == .none {
+ networkSettingsButton()
+ }
+ if requestsModel.unreadRequests > 0 {
+ RequestsIndicatorView(model: requestsModel)
+ .onTapGesture {
+ requestsModel.presentRequests()
+ }
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 0, leading: 15, bottom: 5, trailing: 15))
+ .hideRowSeparator()
+ }
+ }
+
+ private func networkSettingsButton() -> some View {
+ HStack {
+ networkInfo()
+ Spacer()
+ .frame(width: 15)
+ Image(systemName: "gear")
+ .resizable()
+ .frame(width: 30, height: 30)
+ .foregroundColor(.white)
+ }
+ .padding(.horizontal, 15)
+ .padding(.vertical, 15)
+ .frame(maxWidth: .infinity)
+ .background(Color.networkAlertBackground)
+ .cornerRadius(12)
+ .onTapGesture {
+ openSettings()
+ }
+ }
+
+ private func networkInfo() -> some View {
+ VStack(spacing: 5) {
+ Text(L10n.Smartlist.noNetworkConnectivity)
+ .multilineTextAlignment(.center)
+ .foregroundColor(.white)
+ Text(L10n.Smartlist.cellularAccess)
+ .font(.footnote)
+ .multilineTextAlignment(.center)
+ .foregroundColor(.white)
+ }
+ }
+
+ private func openSettings() {
+ if let url = URL(string: UIApplication.openSettingsURLString) {
+ UIApplication.shared.open(url, completionHandler: nil)
+ }
+ }
+
+ @ViewBuilder private var newMessageTopView: some View {
+ if !isSearchBarActive {
+ VStack {
+ if isShowingNewMessageTop {
+ HStack {
+ actionItem(icon: "qrcode", title: L10n.Smartlist.newContact, action: { isShowingScanner.toggle() })
+ Spacer()
+ actionItem(icon: "person.2", title: L10n.Smartlist.newSwarm, action: model.createSwarm)
+ }
+ .hideRowSeparator()
+ }
+ }
+ .listRowInsets(EdgeInsets(top: 0, leading: 15, bottom: 5, trailing: 15))
+ .hideRowSeparator()
+ }
+ }
+
+ private func actionItem(icon: String, title: String, action: @escaping () -> Void) -> some View {
+ HStack {
+ Image(systemName: icon)
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 18, height: 18)
+ .foregroundColor(.jamiColor)
+ Text(title)
+ .font(.callout)
+ }
+ .padding(.horizontal)
+ .padding(.vertical, 10)
+ .frame(maxWidth: .infinity)
+ .background(Color.jamiTertiaryControl)
+ .cornerRadius(12)
+ .onTapGesture(perform: action)
+ }
+
+ @ViewBuilder private var conversationsSearchHeaderView: some View {
+ if !model.searchQuery.isEmpty {
+ Text(L10n.Smartlist.conversations)
+ .fontWeight(.semibold)
+ .hideRowSeparator()
+ if model.conversations.isEmpty {
+ Text(L10n.Smartlist.noConversationsFound)
+ .font(.callout)
+ .hideRowSeparator()
+ }
+ }
+ }
+
+ @ViewBuilder private var publicDirectorySearchView: some View {
+ if isSearchBarActive && !model.searchQuery.isEmpty {
+ Text(model.publicDirectoryTitle)
+ .fontWeight(.semibold)
+ .hideRowSeparator()
+ searchResultView
+ .hideRowSeparator()
+ }
+ }
+
+ @ViewBuilder private var searchResultView: some View {
+ switch model.searchStatus {
+ case .foundTemporary:
+ tempConversationsView
+ .hideRowSeparator()
+ case .foundJams:
+ jamsSearchResultContainerView
+ case .searching:
+ searchingView
+ case .noResult, .invalidId:
+ noResultView
+ .hideRowSeparator()
+ case .notSearching:
+ EmptyView()
+ }
+ }
+
+ private var searchingView: some View {
+ VStack {
+ HStack {
+ Spacer()
+ SwiftUI.ProgressView()
+ Spacer()
+ }
+ }
+ }
+
+ private var tempConversationsView: some View {
+ VStack(alignment: .leading) {
+ TempConversationsView(model: model)
+ }
+ }
+
+ private var jamsSearchResultContainerView: some View {
+ JamsSearchResultView(model: model)
+ }
+
+ private var noResultView: some View {
+ VStack(alignment: .leading) {
+ Text(model.searchStatus.toString())
+ .font(.callout)
+ }
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/UIControllersWrappers.swift b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/UIControllersWrappers.swift
new file mode 100644
index 0000000..a118121
--- /dev/null
+++ b/Ring/Ring/Features/Conversations/SmartList/SwiftUI/Views/UIControllersWrappers.swift
@@ -0,0 +1,117 @@
+/*
+ * Copyright (C) 2024 Savoir-faire Linux Inc.
+ *
+ * Author: Kateryna Kostiuk <kateryna.kostiuk@savoirfairelinux.com>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+import SwiftUI
+import AVFoundation
+import ContactsUI
+
+struct ScanView: UIViewControllerRepresentable {
+ var onCodeScanned: (String) -> Void
+ let injectionBag: InjectionBag
+
+ typealias UIViewControllerType = ScanViewController
+
+ func makeUIViewController(context: Context) -> ScanViewController {
+ let viewController = ScanViewController.instantiate(with: self.injectionBag)
+ viewController.onCodeScanned = onCodeScanned
+ return viewController
+ }
+
+ func updateUIViewController(_ uiViewController: ScanViewController, context: Context) {
+ }
+
+ static func dismantleUIViewController(_ uiViewController: ScanViewController, coordinator: ()) {
+ }
+}
+
+struct ContactPicker: UIViewControllerRepresentable {
+ @Environment(\.presentationMode) var presentationMode
+ var onSelectContact: (String) -> Void
+
+ func makeUIViewController(context: Context) -> CNContactPickerViewController {
+ let picker = CNContactPickerViewController()
+ picker.delegate = context.coordinator
+ return picker
+ }
+
+ func updateUIViewController(_ uiViewController: CNContactPickerViewController, context: Context) {}
+
+ func makeCoordinator() -> Coordinator {
+ Coordinator(self, onSelectContact: onSelectContact)
+ }
+
+ class Coordinator: NSObject, CNContactPickerDelegate {
+ var parent: ContactPicker
+ var onSelectContact: (String) -> Void
+
+ init(_ parent: ContactPicker, onSelectContact: @escaping (String) -> Void) {
+ self.parent = parent
+ self.onSelectContact = onSelectContact
+ }
+
+ func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
+ let phoneNumbers = contact.phoneNumbers.map { $0.value.stringValue }
+ if phoneNumbers.isEmpty {
+ DispatchQueue.main.async { [weak self] in
+ // No numbers available
+ let alert = UIAlertController(title: L10n.Smartlist.noNumber,
+ message: nil,
+ preferredStyle: .alert)
+ let cancelAction = UIAlertAction(title: L10n.Global.ok,
+ style: .default) { (_: UIAlertAction!) -> Void in }
+ alert.addAction(cancelAction)
+ if let rootViewController = UIApplication.shared.windows.first?.rootViewController {
+ rootViewController.present(alert, animated: true, completion: nil)
+ }
+ self?.parent.presentationMode.wrappedValue.dismiss()
+ }
+ } else if phoneNumbers.count == 1 {
+ DispatchQueue.main.async { [weak self] in
+ self?.onSelectContact(phoneNumbers[0])
+ }
+ } else {
+ self.presentNumberSelection(from: picker, with: phoneNumbers)
+ }
+ }
+
+ private func presentNumberSelection(from picker: UIViewController, with numbers: [String]) {
+ DispatchQueue.main.async {
+ let alert = UIAlertController(title: L10n.Smartlist.selectOneNumber, message: nil, preferredStyle: .alert)
+ numbers.forEach { number in
+ alert.addAction(UIAlertAction(title: number, style: .default, handler: { _ in
+ DispatchQueue.main.async {
+ self.onSelectContact(number)
+ }
+ }))
+ }
+ alert.addAction(UIAlertAction(title: L10n.Global.cancel, style: .cancel, handler: nil))
+ if let rootViewController = UIApplication.shared.windows.first?.rootViewController {
+ rootViewController.present(alert, animated: true, completion: nil)
+ }
+ }
+ }
+
+ func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
+ DispatchQueue.main.async { [weak self] in
+ self?.parent.presentationMode.wrappedValue.dismiss()
+ }
+ }
+ }
+}
diff --git a/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchView.swift b/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchView.swift
index 8a74b41..8c173de 100644
--- a/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchView.swift
+++ b/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchView.swift
@@ -41,7 +41,7 @@
var showSearchResult: Bool = true
func configure(with injectionBag: InjectionBag, source: FilterConversationDataSource, isIncognito: Bool, delegate: FilterConversationDelegate?) {
- self.viewModel = JamiSearchViewModel(with: injectionBag, source: source)
+ self.viewModel = JamiSearchViewModel(with: injectionBag, source: source, searchOnlyExistingConversations: true)
self.viewModel.setDelegate(delegate: delegate)
self.isIncognito = isIncognito
self.setUpView()
diff --git a/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchViewModel.swift b/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchViewModel.swift
index 4250270..155f7aa 100644
--- a/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchViewModel.swift
+++ b/Ring/Ring/Features/Conversations/views/JamiSearchView/JamiSearchViewModel.swift
@@ -26,8 +26,11 @@
enum SearchStatus {
case notSearching
+ case foundTemporary
+ case foundJams
case searching
case noResult
+ case invalidId
func toString() -> String {
switch self {
@@ -36,7 +39,13 @@
case .searching:
return L10n.Global.search
case .noResult:
- return L10n.Smartlist.noResults
+ return "Username not found"
+ case .invalidId:
+ return "Invalid id"
+ case .foundTemporary:
+ return ""
+ case .foundJams:
+ return ""
}
}
}
@@ -92,22 +101,25 @@
Existing conversations with the title containing search result or one of
the participant's name containing search result.
*/
- private var filteredResults = BehaviorRelay(value: [ConversationViewModel]())
+ var filteredResults = BehaviorRelay(value: [ConversationViewModel]())
// Jams temporary conversations created when perform search for a new contact
- private let jamsTemporaryResults = BehaviorRelay<[ConversationViewModel]>(value: [])
+ let jamsTemporaryResults = BehaviorRelay<[ConversationViewModel]>(value: [])
let searchBarText = BehaviorRelay<String>(value: "")
var isSearching: Observable<Bool>!
var searchStatus = PublishSubject<SearchStatus>()
private let dataSource: FilterConversationDataSource
+ // Indicates if the search should be limited to only existing conversations.
+ private let searchOnlyExistingConversations: Bool
private weak var delegate: FilterConversationDelegate?
- init(with injectionBag: InjectionBag, source: FilterConversationDataSource) {
+ init(with injectionBag: InjectionBag, source: FilterConversationDataSource, searchOnlyExistingConversations: Bool) {
self.nameService = injectionBag.nameService
self.accountsService = injectionBag.accountService
self.injectionBag = injectionBag
self.dataSource = source
+ self.searchOnlyExistingConversations = searchOnlyExistingConversations
// Observes if the user is searching.
self.isSearching = searchBarText.asObservable()
@@ -121,6 +133,13 @@
.observe(on: MainScheduler.instance)
.distinctUntilChanged()
.subscribe(onNext: { [weak self] text in
+ guard let account = self?.accountsService.currentAccount else { return }
+ if text.isEmpty {
+ self?.searchStatus.onNext(.notSearching)
+ }
+ if text.count < 3 && !account.isJams {
+ self?.searchStatus.onNext(.invalidId)
+ }
self?.search(withText: text)
})
.disposed(by: disposeBag)
@@ -133,7 +152,7 @@
}
func updateSearchStatus() {
- if self.filteredResults.value.isEmpty && self.jamsTemporaryResults.value.isEmpty && self.temporaryConversation.value == nil {
+ if self.jamsTemporaryResults.value.isEmpty && self.temporaryConversation.value == nil {
self.searchStatus.onNext(.noResult)
} else {
self.searchStatus.onNext(.notSearching)
@@ -227,6 +246,10 @@
if let filteredConversations = getFilteredConversations(for: searchQuery) {
self.filteredResults.accept(filteredConversations)
}
+ // not need to searh on network
+ if searchOnlyExistingConversations {
+ return
+ }
self.addTemporaryConversationsIfNeed(searchQuery: searchQuery)
}
@@ -347,6 +370,7 @@
newConversation.userName.accept(hash)
}
newConversation.conversation = conversation
+ newConversation.isTemporary.accept(true)
return newConversation
}
@@ -370,6 +394,7 @@
let newConversation = ConversationViewModel(with: injectionBag,
conversation: conversation,
user: user)
+ newConversation.isTemporary.accept(true)
return newConversation
}
@@ -378,4 +403,5 @@
delegate.showConversation(withConversationViewModel: conversation)
}
}
+
}
diff --git a/Ring/Ring/Models/RequestModel.swift b/Ring/Ring/Models/RequestModel.swift
index bd27e80..c6ea820 100644
--- a/Ring/Ring/Models/RequestModel.swift
+++ b/Ring/Ring/Models/RequestModel.swift
@@ -66,8 +66,6 @@
}
if let name = profile.alias {
self.name = name
- } else {
- self.name = jamiId
}
}
}
@@ -87,8 +85,6 @@
}
if let name = profile.alias {
self.name = name
- } else {
- self.name = jamiId
}
}
if let receivedDateString = dictionary[RequestKey.received.rawValue],
@@ -109,6 +105,10 @@
let conversationType = ConversationType(rawValue: typeInt) {
self.conversationType = conversationType
}
+
+ if self.conversationType == .nonSwarm {
+ self.type = .contact
+ }
if let conversationId = dictionary[RequestKey.conversationId.rawValue] {
self.conversationId = conversationId
}
@@ -134,6 +134,14 @@
self.conversationId = conversationId
}
+ func getIdentifier() -> String {
+ if self.type == .conversation {
+ return conversationId
+ } else {
+ return self.participants.first?.jamiId ?? ""
+ }
+ }
+
func isCoredialog() -> Bool {
return self.conversationType == .nonSwarm || self.conversationType == .oneToOne
}
diff --git a/Ring/Ring/Protocols/ConversationNavigation.swift b/Ring/Ring/Protocols/ConversationNavigation.swift
index b1394f7..6ffaa7d 100644
--- a/Ring/Ring/Protocols/ConversationNavigation.swift
+++ b/Ring/Ring/Protocols/ConversationNavigation.swift
@@ -217,7 +217,7 @@
conversationViewController.viewModel = conversationViewModel
self.present(viewController: conversationViewController,
withStyle: .show,
- withAnimation: true,
+ withAnimation: false,
withStateable: conversationViewController.viewModel,
lockWhilePresenting: VCType.conversation.rawValue)
}
diff --git a/Ring/Ring/QRCode/ScanViewController.swift b/Ring/Ring/QRCode/ScanViewController.swift
index a6550d4..af3799a 100644
--- a/Ring/Ring/QRCode/ScanViewController.swift
+++ b/Ring/Ring/QRCode/ScanViewController.swift
@@ -32,6 +32,7 @@
@IBOutlet weak var bottomMarginTitleConstraint: NSLayoutConstraint!
@IBOutlet weak var bottomCloseButtonConstraint: NSLayoutConstraint!
let disposeBag = DisposeBag()
+ var onCodeScanned: ((String) -> Void)?
// MARK: variables
let systemSoundId: SystemSoundID = 1016
@@ -180,8 +181,7 @@
if jamiId.isSHA1() {
AudioServicesPlayAlertSound(systemSoundId)
print("jamiId : " + jamiId)
- self.dismiss(animated: true, completion: nil)
- self.viewModel.openConversation(jamiId: jamiId)
+ onCodeScanned?(jamiId)
self.scannedQrCode = true
} else {
let alert = UIAlertController(title: L10n.Scan.badQrCode, message: "", preferredStyle: .alert)
diff --git a/Ring/Ring/Resources/Colors.xcassets/jamiMain.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/jami.colorset/Contents.json
similarity index 76%
rename from Ring/Ring/Resources/Colors.xcassets/jamiMain.colorset/Contents.json
rename to Ring/Ring/Resources/Colors.xcassets/jami.colorset/Contents.json
index 7cc755c..2c2a1a1 100644
--- a/Ring/Ring/Resources/Colors.xcassets/jamiMain.colorset/Contents.json
+++ b/Ring/Ring/Resources/Colors.xcassets/jami.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "153",
- "green" : "86",
- "red" : "0"
+ "blue" : "0x5F",
+ "green" : "0x34",
+ "red" : "0x00"
}
},
"idiom" : "universal"
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "153",
- "green" : "86",
- "red" : "0"
+ "blue" : "0.769",
+ "green" : "0.612",
+ "red" : "0.012"
}
},
"idiom" : "universal"
diff --git a/Ring/Ring/Resources/Colors.xcassets/jamiButtonDark.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/jamiButtonDark.colorset/Contents.json
index 91035b5..a252ba7 100644
--- a/Ring/Ring/Resources/Colors.xcassets/jamiButtonDark.colorset/Contents.json
+++ b/Ring/Ring/Resources/Colors.xcassets/jamiButtonDark.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0.600",
- "green" : "0.337",
- "red" : "0.000"
+ "blue" : "0x99",
+ "green" : "0x55",
+ "red" : "0x00"
}
},
"idiom" : "universal"
@@ -20,12 +20,12 @@
}
],
"color" : {
- "color-space" : "srgb",
+ "color-space" : "extended-linear-srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0.914",
- "green" : "0.725",
- "red" : "0.012"
+ "blue" : "0xCF",
+ "green" : "0x7B",
+ "red" : "0x00"
}
},
"idiom" : "universal"
diff --git a/Ring/Ring/Resources/Colors.xcassets/jamiButtonLight.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/jamiButtonLight.colorset/Contents.json
index 9d46483..15fb9e6 100644
--- a/Ring/Ring/Resources/Colors.xcassets/jamiButtonLight.colorset/Contents.json
+++ b/Ring/Ring/Resources/Colors.xcassets/jamiButtonLight.colorset/Contents.json
@@ -5,9 +5,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0.788",
- "green" : "0.443",
- "red" : "0.000"
+ "blue" : "0xC8",
+ "green" : "0x70",
+ "red" : "0x00"
}
},
"idiom" : "universal"
@@ -23,9 +23,9 @@
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
- "blue" : "0.769",
- "green" : "0.612",
- "red" : "0.012"
+ "blue" : "0xC4",
+ "green" : "0x9C",
+ "red" : "0x03"
}
},
"idiom" : "universal"
diff --git a/Ring/Ring/Resources/Colors.xcassets/jamiPrimaryControl.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/jamiPrimaryControl.colorset/Contents.json
new file mode 100644
index 0000000..083927f
--- /dev/null
+++ b/Ring/Ring/Resources/Colors.xcassets/jamiPrimaryControl.colorset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "153",
+ "green" : "86",
+ "red" : "0"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "light"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x29",
+ "green" : "0x17",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xCF",
+ "green" : "0x7B",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Ring/Ring/Resources/Colors.xcassets/jamiRequestsColor.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/jamiRequestsColor.colorset/Contents.json
new file mode 100644
index 0000000..775615d
--- /dev/null
+++ b/Ring/Ring/Resources/Colors.xcassets/jamiRequestsColor.colorset/Contents.json
@@ -0,0 +1,54 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "gray-gamma-22",
+ "components" : {
+ "alpha" : "1.000",
+ "white" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "light"
+ }
+ ],
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x3A",
+ "green" : "0x21",
+ "red" : "0x08"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "extended-srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0.942",
+ "green" : "0.896",
+ "red" : "0.826"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Ring/Ring/Resources/Colors.xcassets/jamiSecondaryControl.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/jamiSecondaryControl.colorset/Contents.json
new file mode 100644
index 0000000..2805f62
--- /dev/null
+++ b/Ring/Ring/Resources/Colors.xcassets/jamiSecondaryControl.colorset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xC8",
+ "green" : "0x70",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "light"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xC8",
+ "green" : "0x70",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xC4",
+ "green" : "0x9C",
+ "red" : "0x03"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Ring/Ring/Resources/Colors.xcassets/donationBanner.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/jamiTertiaryControl.colorset/Contents.json
similarity index 100%
rename from Ring/Ring/Resources/Colors.xcassets/donationBanner.colorset/Contents.json
rename to Ring/Ring/Resources/Colors.xcassets/jamiTertiaryControl.colorset/Contents.json
diff --git a/Ring/Ring/Resources/Colors.xcassets/requestBadgeForeground.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/requestBadgeForeground.colorset/Contents.json
new file mode 100644
index 0000000..e01a411
--- /dev/null
+++ b/Ring/Ring/Resources/Colors.xcassets/requestBadgeForeground.colorset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "light"
+ }
+ ],
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xFF",
+ "green" : "0xFF",
+ "red" : "0xFF"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x99",
+ "green" : "0x56",
+ "red" : "0x00"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Ring/Ring/Resources/Colors.xcassets/requestsBadgeBackground.colorset/Contents.json b/Ring/Ring/Resources/Colors.xcassets/requestsBadgeBackground.colorset/Contents.json
new file mode 100644
index 0000000..bcbc05c
--- /dev/null
+++ b/Ring/Ring/Resources/Colors.xcassets/requestsBadgeBackground.colorset/Contents.json
@@ -0,0 +1,56 @@
+{
+ "colors" : [
+ {
+ "color" : {
+ "color-space" : "extended-srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "1.000",
+ "green" : "1.000",
+ "red" : "1.000"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "light"
+ }
+ ],
+ "color" : {
+ "color-space" : "display-p3",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0x94",
+ "green" : "0x55",
+ "red" : "0x23"
+ }
+ },
+ "idiom" : "universal"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "color" : {
+ "color-space" : "extended-srgb",
+ "components" : {
+ "alpha" : "1.000",
+ "blue" : "0xDA",
+ "green" : "0xC2",
+ "red" : "0xA3"
+ }
+ },
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Ring/Ring/Resources/en.lproj/Localizable.strings b/Ring/Ring/Resources/en.lproj/Localizable.strings
index 8686759..353b3f6 100644
--- a/Ring/Ring/Resources/en.lproj/Localizable.strings
+++ b/Ring/Ring/Resources/en.lproj/Localizable.strings
@@ -84,7 +84,13 @@
// Smartlist
"smartlist.yesterday" = "Yesterday";
"smartlist.results" = "Public Directory";
+"smartlist.jamsResults" = "Search Result";
"smartlist.conversations" = "Conversations";
+"smartlist.noConversationsFound" = "No conversations match your search";
+"smartlist.newContact" = "New Contact";
+"smartlist.newSwarm" = "New Swarm";
+"smartlist.accounts" = "Accounts";
+"smartlist.invitationReceived" = "Invitations received";
"smartlist.noResults" = "No results";
"smartlist.noConversation" = "No conversations";
"smartlist.searchBarPlaceholder" = "Enter name...";
@@ -101,8 +107,13 @@
"smartlist.accounts" = "Account list";
"smartlist.disableDonation" = "Not now";
"smartlist.donationExplanation" = "If you enjoy using Jami and believe in our mission, would you make a donation?";
+"smartlist.inSynchronization" = "conversation in synchronization";
+"smartlist.newMessage" = "New Message";
//Conversation
+"conversation.addToContactsButton" = "Add to Contacts";
+"conversation.addToContactsLabel" = "Add to contacts?";
+"conversation.notContactLabel" = "is not in your contact list";
"conversation.messagePlaceholder" = "Write to";
"conversation.explanationSendingLocationTo" = "You are currently sharing your location with ";
"conversation.explanationReceivingLocationFrom" = "You are currently receiving a live location from ";
@@ -123,6 +134,11 @@
//Invitations
"invitations.noInvitations" = "No invitations";
+"invitations.pending" = "pending";
+"invitations.accepted" = "accepted";
+"invitations.refused" = "refused";
+"invitations.banned" = "banned";
+"invitations.list" = "Invitations received";
// Walkthrough
diff --git a/Ring/Ring/Services/AccountsService.swift b/Ring/Ring/Services/AccountsService.swift
index 1193fd6..22a95ed 100644
--- a/Ring/Ring/Services/AccountsService.swift
+++ b/Ring/Ring/Services/AccountsService.swift
@@ -163,7 +163,7 @@
}
}
- var accountInfoToShare: [Any]? {
+ var accountInfoToShare: [String]? {
var info = [String]()
guard let account = self.currentAccount else { return nil }
var nameToContact = ""
diff --git a/Ring/Ring/Services/ConversationsService.swift b/Ring/Ring/Services/ConversationsService.swift
index f45ef7c..e95517f 100644
--- a/Ring/Ring/Services/ConversationsService.swift
+++ b/Ring/Ring/Services/ConversationsService.swift
@@ -110,12 +110,12 @@
self?.sortAndUpdate(conversations: ¤tConversations)
// load one message for each swarm conversation
for swarmId in conversationToLoad {
- self?.conversationsAdapter.loadConversationMessages(accountId, conversationId: swarmId, from: "", size: 1)
+ self?.loadConversationMessages(conversationId: swarmId, accountId: accountId, from: "", size: 1)
}
}, onError: { [weak self] _ in
self?.conversations.accept(currentConversations)
for swarmId in conversationToLoad {
- self?.conversationsAdapter.loadConversationMessages(accountId, conversationId: swarmId, from: "", size: 1)
+ self?.loadConversationMessages(conversationId: swarmId, accountId: accountId, from: "", size: 1)
}
})
.disposed(by: self.disposeBag)
@@ -173,10 +173,6 @@
conversation.updatePreferences(preferences: prefsInfo)
}
conversation.addParticipantsFromArray(participantsInfo: participantsInfo, accountURI: accountURI)
- if let lastRead = conversation.getLastReadMessage() {
- let unreadInteractions = conversationsAdapter.countInteractions(accountId, conversationId: conversationId, from: lastRead, to: "", authorUri: accountURI)
- conversation.numberOfUnreadMessages.accept(Int(unreadInteractions))
- }
conversations.append(conversation)
}
}
@@ -226,8 +222,10 @@
// MARK: swarm interactions management
- func loadConversationMessages(conversationId: String, accountId: String, from: String) {
- self.conversationsAdapter.loadConversationMessages(accountId, conversationId: conversationId, from: from, size: 40)
+ func loadConversationMessages(conversationId: String, accountId: String, from: String, size: Int = 40) {
+ DispatchQueue.global(qos: .background).async {
+ self.conversationsAdapter.loadConversationMessages(accountId, conversationId: conversationId, from: from, size: size)
+ }
}
func loadMessagesUntil(messageId: String, conversationId: String, accountId: String, from: String) {
@@ -376,7 +374,7 @@
data[ConversationNotificationsKeys.conversationId.rawValue] = conversationId
data[ConversationNotificationsKeys.accountId.rawValue] = accountId
NotificationCenter.default.post(name: NSNotification.Name(ConversationNotifications.conversationReady.rawValue), object: nil, userInfo: data)
- self.conversationsAdapter.loadConversationMessages(accountId, conversationId: conversationId, from: "", size: 2)
+ self.loadConversationMessages(conversationId: conversationId, accountId: accountId, from: "", size: 2)
self.sortIfNeeded()
self.conversationReady.accept(conversationId)
return
@@ -392,7 +390,7 @@
let unreadInteractions = conversationsAdapter.countInteractions(accountId, conversationId: conversationId, from: lastRead, to: "", authorUri: accountURI)
conversation.numberOfUnreadMessages.accept(Int(unreadInteractions))
}
- self.conversationsAdapter.loadConversationMessages(accountId, conversationId: conversationId, from: "", size: 1)
+ self.loadConversationMessages(conversationId: conversationId, accountId: accountId, from: "", size: 2)
self.sortIfNeeded()
}
self.conversationReady.accept(conversationId)
diff --git a/Ring/RingTests/JamiSearchViewModelTests.swift b/Ring/RingTests/JamiSearchViewModelTests.swift
index 77ede1f..c5e9645 100644
--- a/Ring/RingTests/JamiSearchViewModelTests.swift
+++ b/Ring/RingTests/JamiSearchViewModelTests.swift
@@ -77,7 +77,7 @@
conversationVM = ConversationViewModel(with: injectionBag)
conversationVM.conversation = ConversationModel()
dataSource = TestableFilteredDataSource(conversations: [conversationVM])
- searchViewModel = JamiSearchViewModel(with: injectionBag, source: dataSource)
+ searchViewModel = JamiSearchViewModel(with: injectionBag, source: dataSource, searchOnlyExistingConversations: false)
}
override func tearDownWithError() throws {