diff --git a/Session.xcodeproj/project.pbxproj b/Session.xcodeproj/project.pbxproj index d36be785dc..eed38048bd 100644 --- a/Session.xcodeproj/project.pbxproj +++ b/Session.xcodeproj/project.pbxproj @@ -154,6 +154,7 @@ 7BFD1A972747689000FB91B9 /* Session-Turn-Server in Resources */ = {isa = PBXBuildFile; fileRef = 7BFD1A962747689000FB91B9 /* Session-Turn-Server */; }; 9409433E2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433D2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift */; }; 940943402C7ED62300D9D2E0 /* StartupError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9409433F2C7ED62300D9D2E0 /* StartupError.swift */; }; + 941BE62F2C1BF888005A880A /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941BE62E2C1BF887005A880A /* HomeScreen.swift */; }; 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */; }; 942256812C23F8BB00C0FDBF /* NewMessageScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */; }; 942256822C23F8BB00C0FDBF /* InviteAFriendScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */; }; @@ -174,14 +175,17 @@ 942256A12C23F90700C0FDBF /* CustomTopTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 942256A02C23F90700C0FDBF /* CustomTopTabBar.swift */; }; 9422EE2B2B8C3A97004C740D /* String+Utilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9422EE2A2B8C3A97004C740D /* String+Utilities.swift */; }; 94367C432C6C828500814252 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; - 94367C442C6C828500814252 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; - 94367C452C6C828500814252 /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 94367C422C6C828500814252 /* Localizable.xcstrings */; }; 943C6D822B75E061004ACE64 /* Message+DisappearingMessages.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D812B75E061004ACE64 /* Message+DisappearingMessages.swift */; }; 943C6D842B86B5F1004ACE64 /* Localization.swift in Sources */ = {isa = PBXBuildFile; fileRef = 943C6D832B86B5F1004ACE64 /* Localization.swift */; }; 9473386E2BDF5F3E00B9E169 /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 9473386D2BDF5F3E00B9E169 /* InfoPlist.xcstrings */; }; 947AD6902C8968FF000B2730 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 947AD68F2C8968FF000B2730 /* Constants.swift */; }; 94B3DC172AF8592200C88531 /* QuoteView_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */; }; 94C58AC92D2E037200609195 /* Permissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C58AC82D2E036E00609195 /* Permissions.swift */; }; + 94C5DCB02BE88170003AA8C5 /* BezierPathView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94C5DCAF2BE88170003AA8C5 /* BezierPathView.swift */; }; + 94E12DBC2C24063A00D28EE0 /* SessionButton_SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E12DBB2C24063A00D28EE0 /* SessionButton_SwiftUI.swift */; }; + 94E5EDA52C8ECBFC0084ED63 /* HomeScreen+DataModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E5EDA42C8ECBFC0084ED63 /* HomeScreen+DataModel.swift */; }; + 94E5EDA72C8ECC150084ED63 /* HomeScreen+ViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E5EDA62C8ECC150084ED63 /* HomeScreen+ViewModel.swift */; }; + 94E89A9B2C2A997B00FB18E1 /* HomeScreen+ConversationList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E89A9A2C2A997B00FB18E1 /* HomeScreen+ConversationList.swift */; }; 94E9BC0D2C7BFBDA006984EA /* Localization+Style.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */; }; A11CD70D17FA230600A2D1B1 /* QuartzCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */; }; A163E8AB16F3F6AA0094D68B /* Security.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A163E8AA16F3F6A90094D68B /* Security.framework */; }; @@ -1427,6 +1431,8 @@ 7BFD1A962747689000FB91B9 /* Session-Turn-Server */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = "Session-Turn-Server"; sourceTree = ""; }; 9409433D2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WebRTCSession+Constants.swift"; sourceTree = ""; }; 9409433F2C7ED62300D9D2E0 /* StartupError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupError.swift; sourceTree = ""; }; + 9410613F2BBE45EB0056C084 /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; + 941BE62E2C1BF887005A880A /* HomeScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreen.swift; sourceTree = ""; }; 9422567D2C23F8BB00C0FDBF /* StartConversationScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StartConversationScreen.swift; sourceTree = ""; }; 9422567E2C23F8BB00C0FDBF /* NewMessageScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NewMessageScreen.swift; sourceTree = ""; }; 9422567F2C23F8BB00C0FDBF /* InviteAFriendScreen.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = InviteAFriendScreen.swift; sourceTree = ""; }; @@ -1454,6 +1460,11 @@ 947AD68F2C8968FF000B2730 /* Constants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Constants.swift; sourceTree = ""; }; 94B3DC162AF8592200C88531 /* QuoteView_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuoteView_SwiftUI.swift; sourceTree = ""; }; 94C58AC82D2E036E00609195 /* Permissions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Permissions.swift; sourceTree = ""; }; + 94C5DCAF2BE88170003AA8C5 /* BezierPathView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BezierPathView.swift; sourceTree = ""; }; + 94E12DBB2C24063A00D28EE0 /* SessionButton_SwiftUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionButton_SwiftUI.swift; sourceTree = ""; }; + 94E5EDA42C8ECBFC0084ED63 /* HomeScreen+DataModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeScreen+DataModel.swift"; sourceTree = ""; }; + 94E5EDA62C8ECC150084ED63 /* HomeScreen+ViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HomeScreen+ViewModel.swift"; sourceTree = ""; }; + 94E89A9A2C2A997B00FB18E1 /* HomeScreen+ConversationList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = "HomeScreen+ConversationList.swift"; path = "Session/Home/HomeScreen+ConversationList.swift"; sourceTree = SOURCE_ROOT; }; 94E9BC0C2C7BFBDA006984EA /* Localization+Style.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Localization+Style.swift"; sourceTree = ""; }; A11CD70C17FA230600A2D1B1 /* QuartzCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuartzCore.framework; path = System/Library/Frameworks/QuartzCore.framework; sourceTree = SDKROOT; }; A163E8AA16F3F6A90094D68B /* Security.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Security.framework; path = System/Library/Frameworks/Security.framework; sourceTree = SDKROOT; }; @@ -2655,6 +2666,8 @@ 942256932C23F8DD00C0FDBF /* SwiftUI */ = { isa = PBXGroup; children = ( + 9410613F2BBE45EB0056C084 /* ActivityView.swift */, + 94E12DBB2C24063A00D28EE0 /* SessionButton_SwiftUI.swift */, 9422568D2C23F8DD00C0FDBF /* ActivityView.swift */, 9422568E2C23F8DD00C0FDBF /* AttributedText.swift */, 9422568F2C23F8DD00C0FDBF /* CompatibleScrollingVStack.swift */, @@ -3194,6 +3207,10 @@ 7BAF54CA27ACCEEC003D12F8 /* GlobalSearch */, FDCDB8DF2811007F00352A0C /* HomeViewModel.swift */, B8BB82A4238F627000BA5194 /* HomeVC.swift */, + 941BE62E2C1BF887005A880A /* HomeScreen.swift */, + 94E89A9A2C2A997B00FB18E1 /* HomeScreen+ConversationList.swift */, + 94E5EDA42C8ECBFC0084ED63 /* HomeScreen+DataModel.swift */, + 94E5EDA62C8ECC150084ED63 /* HomeScreen+ViewModel.swift */, ); path = Home; sourceTree = ""; @@ -5276,7 +5293,6 @@ buildActionMask = 2147483647; files = ( 4535186E1FC635DD00210559 /* MainInterface.storyboard in Resources */, - 94367C442C6C828500814252 /* Localizable.xcstrings in Resources */, B8D07406265C683A00F77E07 /* ElegantIcons.ttf in Resources */, FD86FDA42BC51C5400EC251B /* PrivacyInfo.xcprivacy in Resources */, 3478504C1FD7496D007B8332 /* Images.xcassets in Resources */, @@ -5288,7 +5304,6 @@ buildActionMask = 2147483647; files = ( FD86FDA52BC51C5500EC251B /* PrivacyInfo.xcprivacy in Resources */, - 94367C452C6C828500814252 /* Localizable.xcstrings in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -5795,6 +5810,7 @@ C331FFE32558FB0000070591 /* TabBar.swift in Sources */, FD37E9D528A1FCE8003AE748 /* Theme+OceanLight.swift in Sources */, FDF848F129406A30007DCAE5 /* Format.swift in Sources */, + 94E12DBC2C24063A00D28EE0 /* SessionButton_SwiftUI.swift in Sources */, FDB348632BE3774000B716C2 /* BezierPathView.swift in Sources */, 7BA1E0E82A8087DB00123D0D /* SwiftUI+Utilities.swift in Sources */, FD37E9C828A1D73F003AE748 /* Theme+Colors.swift in Sources */, @@ -6482,6 +6498,7 @@ 942256802C23F8BB00C0FDBF /* StartConversationScreen.swift in Sources */, 7B9F71C928470667006DFE7B /* ReactionListSheet.swift in Sources */, FD12A8412AD63BEA00EEBA0D /* NavigatableState.swift in Sources */, + 94E5EDA52C8ECBFC0084ED63 /* HomeScreen+DataModel.swift in Sources */, 7B7037452834BCC0000DCF35 /* ReactionView.swift in Sources */, FD7115F428C71EB200B47552 /* ThreadDisappearingMessagesSettingsViewModel.swift in Sources */, B8D84ECF25E3108A005A043E /* ExpandingAttachmentsButton.swift in Sources */, @@ -6511,6 +6528,7 @@ FDEF57212C3CF03A00131302 /* (null) in Sources */, 7B9F71D32852EEE2006DFE7B /* Emoji.swift in Sources */, FDC498BB2AC1606C00EDD897 /* AppNotificationUserInfoKey.swift in Sources */, + 94E89A9B2C2A997B00FB18E1 /* HomeScreen+ConversationList.swift in Sources */, C328250F25CA06020062D0A7 /* VoiceMessageView.swift in Sources */, 3488F9362191CC4000E524CC /* MediaView.swift in Sources */, B8569AC325CB5D2900DBA3DB /* ConversationVC+Interaction.swift in Sources */, @@ -6559,10 +6577,13 @@ FD71163828E2C50700B47552 /* SessionTableViewModel.swift in Sources */, FD71164A28E3EA5B00B47552 /* DismissType.swift in Sources */, C328251F25CA3A900062D0A7 /* QuoteView.swift in Sources */, + 94E5EDA72C8ECC150084ED63 /* HomeScreen+ViewModel.swift in Sources */, 7B3A39322980D02B002FE4AC /* SessionCarouselView.swift in Sources */, FD37E9CC28A1E578003AE748 /* AppearanceViewController.swift in Sources */, B8EB20F02640F7F000773E52 /* OpenGroupInvitationView.swift in Sources */, C328254025CA55880062D0A7 /* ContextMenuVC.swift in Sources */, + 941BE62F2C1BF888005A880A /* HomeScreen.swift in Sources */, + 7BD687D12A5D0D1200D8E455 /* MessageInfoScreen.swift in Sources */, 9409433E2C7EB81800D9D2E0 /* WebRTCSession+Constants.swift in Sources */, FDE754B12C9B96B4002A2623 /* TurnServerInfo.swift in Sources */, 7BD687D12A5D0D1200D8E455 /* MessageInfoScreen.swift in Sources */, diff --git a/Session/Home/HomeScreen+ConversationList.swift b/Session/Home/HomeScreen+ConversationList.swift new file mode 100644 index 0000000000..27c0d9ec9d --- /dev/null +++ b/Session/Home/HomeScreen+ConversationList.swift @@ -0,0 +1,423 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import GRDB +import DifferenceKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit +import SignalUtilitiesKit + +extension HomeScreen { + struct ConversationList: View { + private var threadData: [DataModel.SectionModel] + + public static let mutePrefix: String = "\u{e067} " // stringlint:disable + public static let unreadCountViewSize: CGFloat = 20 + public static let statusIndicatorSize: CGFloat = 14 + + public init(threadData: [DataModel.SectionModel]) { + self.threadData = threadData + } + + var body: some View { + List { + ForEach(self.threadData) { sectionModel in + switch sectionModel.model { + case .messageRequests: + Section { + ForEach(sectionModel.elements) { threadViewModel in + MessageRequestItemRow(threadViewModel: threadViewModel) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + .swipeActions(edge: .trailing) { + Button { + + } label: { + VStack { + Image(systemName: "eye.slash") + .foregroundColor(themeColor: .white) + + Text("noteToSelfHide".localized()) + .foregroundColor(themeColor: .white) + } + .backgroundColor(themeColor: .danger) + } + } + } + } + case .threads: + Section { + ForEach(sectionModel.elements) { threadViewModel in + ConversationItemRow(threadViewModel: threadViewModel) + .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets()) + .swipeActions(edge: .leading) { + Button { + + } label: { + + } + } + } + } + default: preconditionFailure("Other sections should have no content") + } + } + } + .listStyle(.plain) + .transparentListBackground() + } + } + + // MARK: MessageRequestItemRow + + struct MessageRequestItemRow: View { + + private var threadViewModel: SessionThreadViewModel + + init(threadViewModel: SessionThreadViewModel) { + self.threadViewModel = threadViewModel + } + + var body: some View { + HStack( + alignment: .center, + content: { + Image("icon_msg_req") + .renderingMode(.template) + .resizable() + .foregroundColor(themeColor: .conversationButton_unreadBubbleText) + .background( + Circle() + .fill(themeColor: .conversationButton_unreadBubbleBackground) + .frame( + width: ProfilePictureView.Size.list.viewSize, + height: ProfilePictureView.Size.list.viewSize + ) + ) + + Text("sessionMessageRequests".localized()) + .bold() + .font(.system(size: Values.mediumFontSize)) + .foregroundColor(themeColor: .textPrimary) + .padding(.leading, Values.mediumSpacing) + .padding(.trailing, Values.verySmallSpacing) + + Text("\(threadViewModel.threadUnreadCount ?? 0)") + .bold() + .font(.system(size: Values.veryLargeFontSize)) + .foregroundColor(themeColor: .conversationButton_unreadBubbleText) + .background( + Circle() + .fill(themeColor: .conversationButton_unreadBubbleBackground) + .frame( + width: ConversationList.unreadCountViewSize, + height: ConversationList.unreadCountViewSize + ) + ) + } + ) + .backgroundColor(themeColor: .conversationButton_unreadBackground) + .frame( + width: .infinity, + height: 68 + ) + } + } + + // MARK: ConversationItemRow info + + struct Info { + let displayName: String + let unreadCount: UInt + let threadIsUnread: Bool + let themeBackgroundColor: ThemeValue + let isBlocked: Bool + let isPinned: Bool + let shouldShowUnreadCount: Bool + let unreadCountString: String + let unreadCountFontSize: CGFloat + let shouldShowUnreadIcon: Bool + let shouldShowMentionIcon: Bool + let timeString: String + let shouldShowTypingIndicator: Bool + let snippet: NSAttributedString + + init(threadViewModel: SessionThreadViewModel) { + self.displayName = threadViewModel.displayName + self.unreadCount = (threadViewModel.threadUnreadCount ?? 0) + self.threadIsUnread = ( + self.unreadCount > 0 || + threadViewModel.threadWasMarkedUnread == true + ) + self.themeBackgroundColor = (self.threadIsUnread ? + .conversationButton_unreadBackground : + .conversationButton_background + ) + self.isBlocked = (threadViewModel.threadIsBlocked == true) + self.isPinned = threadViewModel.threadPinnedPriority > 0 + self.shouldShowUnreadCount = (threadIsUnread && unreadCount > 0) + self.unreadCountString = (unreadCount < 10000 ? "\(unreadCount)" : "9999+") // stringlint:disable + self.unreadCountFontSize = (unreadCount < 10000 ? Values.verySmallFontSize : 8) + self.shouldShowUnreadIcon = (threadIsUnread && !self.shouldShowUnreadCount) + self.shouldShowMentionIcon = ( + (threadViewModel.threadUnreadMentionCount ?? 0) > 0 && + threadViewModel.threadVariant != .contact + ) + self.timeString = threadViewModel.lastInteractionDate.formattedForDisplay + self.shouldShowTypingIndicator = (threadViewModel.threadContactIsTyping == true) + self.snippet = Self.getSnippet(threadViewModel: threadViewModel) + } + + private static func getSnippet(threadViewModel: SessionThreadViewModel) -> NSMutableAttributedString { + // If we don't have an interaction then do nothing + guard threadViewModel.interactionId != nil else { return NSMutableAttributedString() } + + var maybeTextColor: UIColor? { + switch threadViewModel.interactionVariant { + case .infoClosedGroupCurrentUserErrorLeaving: + return ThemeManager.currentTheme.color(for: .danger) + case .infoClosedGroupCurrentUserLeaving: + return ThemeManager.currentTheme.color(for: .textSecondary) + default: + return ThemeManager.currentTheme.color(for: .textPrimary) + } + } + + guard let textColor = maybeTextColor else { return NSMutableAttributedString() } + + let result = NSMutableAttributedString() + + if Date().timeIntervalSince1970 < (threadViewModel.threadMutedUntilTimestamp ?? 0) { + result.append(NSAttributedString( + string: FullConversationCell.mutePrefix, + attributes: [ + .font: UIFont(name: "ElegantIcons", size: 10) as Any, + .foregroundColor: textColor + ] + )) + } + else if threadViewModel.threadOnlyNotifyForMentions == true { + let imageAttachment = NSTextAttachment() + imageAttachment.image = UIImage(named: "NotifyMentions.png")?.withTint(textColor) + imageAttachment.bounds = CGRect(x: 0, y: -2, width: Values.smallFontSize, height: Values.smallFontSize) + + let imageString = NSAttributedString(attachment: imageAttachment) + result.append(imageString) + result.append(NSAttributedString( + string: " ", + attributes: [ + .font: UIFont(name: "ElegantIcons", size: 10) as Any, + .foregroundColor: textColor + ] + )) + } + + if + (threadViewModel.threadVariant == .legacyGroup || threadViewModel.threadVariant == .group || threadViewModel.threadVariant == .community) && + (threadViewModel.interactionVariant?.isGroupControlMessage == false) + { + let authorName: String = threadViewModel.authorName(for: threadViewModel.threadVariant) + + result.append(NSAttributedString( + string: "\(authorName): ", // stringlint:disable + attributes: [ .foregroundColor: textColor ] + )) + } + + let previewText: String = { + if threadViewModel.interactionVariant == .infoClosedGroupCurrentUserErrorLeaving { + return "groupLeaveErrorFailed" + .put(key: "group_name", value: threadViewModel.displayName) + .localized() + } + return Interaction.previewText( + variant: (threadViewModel.interactionVariant ?? .standardIncoming), + body: threadViewModel.interactionBody, + threadContactDisplayName: threadViewModel.threadContactName(), + authorDisplayName: threadViewModel.authorName(for: threadViewModel.threadVariant), + attachmentDescriptionInfo: threadViewModel.interactionAttachmentDescriptionInfo, + attachmentCount: threadViewModel.interactionAttachmentCount, + isOpenGroupInvitation: (threadViewModel.interactionIsOpenGroupInvitation == true) + ) + }() + + result.append(NSAttributedString( + string: MentionUtilities.highlightMentionsNoAttributes( + in: previewText, + threadVariant: threadViewModel.threadVariant, + currentUserPublicKey: threadViewModel.currentUserPublicKey, + currentUserBlinded15PublicKey: threadViewModel.currentUserBlinded15PublicKey, + currentUserBlinded25PublicKey: threadViewModel.currentUserBlinded25PublicKey + ), + attributes: [ .foregroundColor: textColor ] + )) + + return result + } + } + + // MARK: ConversationItemRow + + struct ConversationItemRow: View { + + private var threadViewModel: SessionThreadViewModel + private var info: Info + + init(threadViewModel: SessionThreadViewModel) { + self.threadViewModel = threadViewModel + self.info = Info(threadViewModel: threadViewModel) + } + + var body: some View { + ZStack(alignment: .leading) { + if info.isBlocked { + Rectangle() + .fill(themeColor: .danger) + .frame( + width: Values.accentLineThickness, + height: .infinity + ) + } else if info.unreadCount > 0 { + Rectangle() + .fill(themeColor: .conversationButton_unreadStripBackground) + .frame(width: Values.accentLineThickness) + .frame(maxHeight: .infinity) + } + + HStack( + alignment: .center, + content: { + ProfilePictureSwiftUI( + size: .list, + publicKey: threadViewModel.threadId, + threadVariant: threadViewModel.threadVariant, + customImageData: threadViewModel.openGroupProfilePictureData, + profile: threadViewModel.profile, + additionalProfile: threadViewModel.additionalProfile + ) + .frame( + width: ProfilePictureView.Size.list.viewSize, + height: ProfilePictureView.Size.list.viewSize + ) + .padding(.leading, Values.mediumSpacing) + .padding(.trailing, Values.smallSpacing) + + VStack( + alignment: .leading, + spacing: Values.verySmallSpacing, + content: { + HStack( + spacing: Values.verySmallSpacing, + content: { + // Display name + Text(info.displayName) + .bold() + .font(.system(size: Values.mediumFontSize)) + .foregroundColor(themeColor: .textPrimary) + .fixedSize() + + if info.isPinned { + Image("Pin") + .resizable() + .renderingMode(.template) + .foregroundColor(themeColor: .textSecondary) + .scaledToFit() + .frame( + width: ConversationList.unreadCountViewSize, + height: ConversationList.unreadCountViewSize + ) + } + + // Unread count + if info.shouldShowUnreadCount { + Text(info.unreadCountString) + .bold() + .font(.system(size: info.unreadCountFontSize)) + .foregroundColor(themeColor: .conversationButton_unreadBubbleText) + .background( + Capsule() + .fill(themeColor: .conversationButton_unreadBubbleBackground) + .frame(minWidth: ConversationList.unreadCountViewSize) + .frame(height: ConversationList.unreadCountViewSize) + ) + .padding(.horizontal, Values.verySmallSpacing) + } + + // Unread icon + if info.shouldShowUnreadIcon { + ZStack( + alignment: .topTrailing, + content: { + Image(systemName: "envelope") + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: .textPrimary) + .padding(.top, 2) + + Circle() + .fill(themeColor: .conversationButton_unreadBackground) + .frame( + width: 6, + height: 6 + ) + .padding(.top, 1) + .padding(.trailing, 1) + } + ) + } + + // Mention icon + if info.shouldShowMentionIcon { + Text("@") // stringlint:disable + .bold() + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: .conversationButton_unreadBubbleText) + .background( + Circle() + .fill(themeColor: .conversationButton_unreadBubbleBackground) + .frame( + width: ConversationList.unreadCountViewSize, + height: ConversationList.unreadCountViewSize + ) + ) + } + + Spacer(minLength: 0) + + // Interaction time + Text(info.timeString) + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: .textSecondary) + .opacity(Values.lowOpacity) + .padding(.horizontal, Values.mediumSpacing) + .fixedSize() + } + ) + + HStack( + spacing: Values.verySmallSpacing, + content: { + if info.shouldShowTypingIndicator { + + } else { + AttributedText(info.snippet) + .font(.system(size: Values.smallFontSize)) + } + + Spacer() + + + } + ) + } + ) + } + ) + } + .frame(maxWidth: .infinity) + .frame(height: 68, alignment: .center) + .backgroundColor(themeColor: info.themeBackgroundColor) + } + } +} diff --git a/Session/Home/HomeScreen+DataModel.swift b/Session/Home/HomeScreen+DataModel.swift new file mode 100644 index 0000000000..a999e71b81 --- /dev/null +++ b/Session/Home/HomeScreen+DataModel.swift @@ -0,0 +1,50 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import GRDB +import DifferenceKit +import SignalUtilitiesKit +import SessionMessagingKit +import SessionUtilitiesKit + +extension HomeScreen { + public class DataModel { + public typealias SectionModel = ArraySection + + // MARK: - Section + + public enum Section: Differentiable { + case messageRequests + case threads + case loadMore + } + + // MARK: - Variables + + public static let pageSize: Int = (UIDevice.current.isIPad ? 20 : 15) + + public struct State: Equatable { + let showViewedSeedBanner: Bool + let hasHiddenMessageRequests: Bool + let unreadMessageRequestThreadCount: Int + let userProfile: Profile + } + + public static func retrieveState(_ db: Database) throws -> State { + let hasViewedSeed: Bool = db[.hasViewedSeed] + let hasHiddenMessageRequests: Bool = db[.hasHiddenMessageRequests] + let userProfile: Profile = Profile.fetchOrCreateCurrentUser(db) + let unreadMessageRequestThreadCount: Int = try SessionThread + .unreadMessageRequestsCountQuery(userPublicKey: userProfile.id) + .fetchOne(db) + .defaulting(to: 0) + + return State( + showViewedSeedBanner: !hasViewedSeed, + hasHiddenMessageRequests: hasHiddenMessageRequests, + unreadMessageRequestThreadCount: unreadMessageRequestThreadCount, + userProfile: userProfile + ) + } + } +} diff --git a/Session/Home/HomeScreen+ViewModel.swift b/Session/Home/HomeScreen+ViewModel.swift new file mode 100644 index 0000000000..4647b992ae --- /dev/null +++ b/Session/Home/HomeScreen+ViewModel.swift @@ -0,0 +1,505 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import SwiftUI +import GRDB +import DifferenceKit +import SignalUtilitiesKit +import SessionMessagingKit +import SessionUtilitiesKit + +extension HomeScreen { + public protocol ViewModelDelegate: AnyObject { + func ensureRootViewController() + } + public class ViewModel: ObservableObject { + public let dependencies: Dependencies + public var onReceivedInitialChange: (() -> ())? = nil + private var dataChangeObservable: DatabaseCancellable? { + didSet { oldValue?.cancel() } // Cancel the old observable if there was one + } + private var hasLoadedInitialStateData: Bool = false + private var hasLoadedInitialThreadData: Bool = false + private var isLoadingMore: Bool = false + private var isAutoLoadingNextPage: Bool = false + private var viewHasAppeared: Bool = false + @State var shouldLoadMore: Bool = false + + // MARK: - Initialization + + init(using dependencies: Dependencies, onReceivedInitialChange: (() -> ())? = nil) { + typealias InitialData = ( + showViewedSeedBanner: Bool, + hasHiddenMessageRequests: Bool, + profile: Profile + ) + + let initialData: InitialData? = dependencies.storage.read { db -> InitialData in + ( + !db[.hasViewedSeed], + db[.hasHiddenMessageRequests], + Profile.fetchOrCreateCurrentUser(db) + ) + } + + self.dependencies = dependencies + self.onReceivedInitialChange = onReceivedInitialChange + + self.state = DataModel.State( + showViewedSeedBanner: (initialData?.showViewedSeedBanner ?? true), + hasHiddenMessageRequests: (initialData?.hasHiddenMessageRequests ?? false), + unreadMessageRequestThreadCount: 0, + userProfile: (initialData?.profile ?? Profile.fetchOrCreateCurrentUser()) + ) + self.pagedDataObserver = nil + + // Note: Since this references self we need to finish initializing before setting it, we + // also want to skip the initial query and trigger it async so that the push animation + // doesn't stutter (it should load basically immediately but without this there is a + // distinct stutter) + let userPublicKey: String = self.state.userProfile.id + let thread: TypedTableAlias = TypedTableAlias() + self.pagedDataObserver = PagedDatabaseObserver( + pagedTable: SessionThread.self, + pageSize: HomeViewModel.pageSize, + idColumn: .id, + observedChanges: [ + PagedData.ObservedChanges( + table: SessionThread.self, + columns: [ + .id, + .shouldBeVisible, + .pinnedPriority, + .mutedUntilTimestamp, + .onlyNotifyForMentions, + .markedAsUnread + ] + ), + PagedData.ObservedChanges( + table: Interaction.self, + columns: [ + .body, + .wasRead + ], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + + return SQL("JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: Contact.self, + columns: [.isBlocked], + joinToPagedType: { + let contact: TypedTableAlias = TypedTableAlias() + + return SQL("JOIN \(Contact.self) ON \(contact[.id]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: Profile.self, + columns: [.name, .nickname, .profilePictureFileName], + joinToPagedType: { + let profile: TypedTableAlias = TypedTableAlias() + let groupMember: TypedTableAlias = TypedTableAlias() + let threadVariants: [SessionThread.Variant] = [.legacyGroup, .group] + let targetRole: GroupMember.Role = GroupMember.Role.standard + + return SQL(""" + JOIN \(Profile.self) ON ( + ( -- Contact profile change + \(profile[.id]) = \(thread[.id]) AND + \(SQL("\(thread[.variant]) = \(SessionThread.Variant.contact)")) + ) OR ( -- Closed group profile change + \(SQL("\(thread[.variant]) IN \(threadVariants)")) AND ( + profile.id = ( -- Front profile + SELECT MIN(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(thread[.id]) AND + \(SQL("\(groupMember[.role]) = \(targetRole)")) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) OR + profile.id = ( -- Back profile + SELECT MAX(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(thread[.id]) AND + \(SQL("\(groupMember[.role]) = \(targetRole)")) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) OR ( -- Fallback profile + profile.id = \(userPublicKey) AND + ( + SELECT COUNT(\(groupMember[.profileId])) + FROM \(GroupMember.self) + JOIN \(Profile.self) ON \(profile[.id]) = \(groupMember[.profileId]) + WHERE ( + \(groupMember[.groupId]) = \(thread[.id]) AND + \(SQL("\(groupMember[.role]) = \(targetRole)")) AND + \(groupMember[.profileId]) != \(userPublicKey) + ) + ) = 1 + ) + ) + ) + ) + """) + }() + ), + PagedData.ObservedChanges( + table: ClosedGroup.self, + columns: [.name], + joinToPagedType: { + let closedGroup: TypedTableAlias = TypedTableAlias() + + return SQL("JOIN \(ClosedGroup.self) ON \(closedGroup[.threadId]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: OpenGroup.self, + columns: [.name, .imageData], + joinToPagedType: { + let openGroup: TypedTableAlias = TypedTableAlias() + + return SQL("JOIN \(OpenGroup.self) ON \(openGroup[.threadId]) = \(thread[.id])") + }() + ), + PagedData.ObservedChanges( + table: RecipientState.self, + columns: [.state], + joinToPagedType: { + let interaction: TypedTableAlias = TypedTableAlias() + let recipientState: TypedTableAlias = TypedTableAlias() + + return """ + JOIN \(Interaction.self) ON \(interaction[.threadId]) = \(thread[.id]) + JOIN \(RecipientState.self) ON \(recipientState[.interactionId]) = \(interaction[.id]) + """ + }() + ), + PagedData.ObservedChanges( + table: ThreadTypingIndicator.self, + columns: [.threadId], + joinToPagedType: { + let typingIndicator: TypedTableAlias = TypedTableAlias() + + return SQL("JOIN \(ThreadTypingIndicator.self) ON \(typingIndicator[.threadId]) = \(thread[.id])") + }() + ) + ], + /// **Note:** This `optimisedJoinSQL` value includes the required minimum joins needed for the query but differs + /// from the JOINs that are actually used for performance reasons as the basic logic can be simpler for where it's used + joinSQL: SessionThreadViewModel.optimisedJoinSQL, + filterSQL: SessionThreadViewModel.homeFilterSQL(userPublicKey: userPublicKey), + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.homeOrderSQL, + dataQuery: SessionThreadViewModel.baseQuery( + userPublicKey: userPublicKey, + groupSQL: SessionThreadViewModel.groupSQL, + orderSQL: SessionThreadViewModel.homeOrderSQL + ), + onChangeUnsorted: { [weak self] updatedData, updatedPageInfo in + PagedData.processAndTriggerUpdates( + updatedData: self?.process(data: updatedData, for: updatedPageInfo), + currentDataRetriever: { self?.threadData }, + onDataChangeRetriever: { self?.onThreadChange }, + onUnobservedDataChange: { updatedData in + self?.unobservedThreadDataChanges = updatedData + } + ) + + self?.hasReceivedInitialThreadData = true + } + ) + + dependencies.storage.addObserver(self.pagedDataObserver) + + self.registerForNotifications() + + // Run the initial query on a background thread so we don't block the main thread + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.startObservingChanges(onReceivedInitialChange: self?.onReceivedInitialChange) + // The `.pageBefore` will query from a `0` offset loading the first page + self?.pagedDataObserver?.load(.pageBefore) + } + } + + // MARK: - State + + /// This value is the current state of the view + @Published public private(set) var state: DataModel.State + + /// This is all the data the screen needs to populate itself, please see the following link for tips to help optimise + /// performance https://github.com/groue/GRDB.swift#valueobservation-performance + /// + /// **Note:** This observation will be triggered twice immediately (and be de-duped by the `removeDuplicates`) + /// this is due to the behaviour of `ValueConcurrentObserver.asyncStartObservation` which triggers it's own + /// fetch (after the ones in `ValueConcurrentObserver.asyncStart`/`ValueConcurrentObserver.syncStart`) + /// just in case the database has changed between the two reads - unfortunately it doesn't look like there is a way to prevent this + public lazy var observableState = ValueObservation + .trackingConstantRegion { db -> DataModel.State in try DataModel.retrieveState(db) } + .removeDuplicates() + .handleEvents(didFail: { SNLog("[HomeViewModel] Observation failed with error: \($0)") }) + + public func updateState(_ updatedState: DataModel.State) { + let oldState: DataModel.State = self.state + self.state = updatedState + + // If the messageRequest content changed then we need to re-process the thread data (assuming + // we've received the initial thread data) + guard + self.hasReceivedInitialThreadData, + ( + oldState.hasHiddenMessageRequests != updatedState.hasHiddenMessageRequests || + oldState.unreadMessageRequestThreadCount != updatedState.unreadMessageRequestThreadCount + ), + let currentPageInfo: PagedData.PageInfo = self.pagedDataObserver?.pageInfo.wrappedValue + else { return } + + /// **MUST** have the same logic as in the 'PagedDataObserver.onChangeUnsorted' above + let currentData: [DataModel.SectionModel] = (self.unobservedThreadDataChanges ?? self.threadData) + let updatedThreadData: [DataModel.SectionModel] = self.process( + data: (currentData.first(where: { $0.model == .threads })?.elements ?? []), + for: currentPageInfo + ) + + PagedData.processAndTriggerUpdates( + updatedData: updatedThreadData, + currentDataRetriever: { [weak self] in (self?.unobservedThreadDataChanges ?? self?.threadData) }, + onDataChangeRetriever: { [weak self] in self?.onThreadChange }, + onUnobservedDataChange: { [weak self] updatedData in + self?.unobservedThreadDataChanges = updatedData + } + ) + } + + // MARK: - Thread Data + + private var hasReceivedInitialThreadData: Bool = false + public private(set) var unobservedThreadDataChanges: [DataModel.SectionModel]? + @Published public private(set) var threadData: [DataModel.SectionModel] = [] + public private(set) var pagedDataObserver: PagedDatabaseObserver? + + public var onThreadChange: (([DataModel.SectionModel], StagedChangeset<[DataModel.SectionModel]>) -> ())? { + didSet { + guard onThreadChange != nil else { return } + + // When starting to observe interaction changes we want to trigger a UI update just in case the + // data was changed while we weren't observing + if let changes: [DataModel.SectionModel] = self.unobservedThreadDataChanges { + PagedData.processAndTriggerUpdates( + updatedData: changes, + currentDataRetriever: { [weak self] in self?.threadData }, + onDataChangeRetriever: { [weak self] in self?.onThreadChange }, + onUnobservedDataChange: { [weak self] updatedData in + self?.unobservedThreadDataChanges = updatedData + } + ) + self.unobservedThreadDataChanges = nil + } + } + } + + private func process(data: [SessionThreadViewModel], for pageInfo: PagedData.PageInfo) -> [DataModel.SectionModel] { + let finalUnreadMessageRequestCount: Int = (self.state.hasHiddenMessageRequests ? + 0 : + self.state.unreadMessageRequestThreadCount + ) + let groupedOldData: [String: [SessionThreadViewModel]] = (self.threadData + .first(where: { $0.model == .threads })? + .elements) + .defaulting(to: []) + .grouped(by: \.threadId) + + return [ + // If there are no unread message requests then hide the message request banner + (finalUnreadMessageRequestCount == 0 ? + [] : + [DataModel.SectionModel( + section: .messageRequests, + elements: [ + SessionThreadViewModel( + threadId: SessionThreadViewModel.messageRequestsSectionId, + unreadCount: UInt(finalUnreadMessageRequestCount) + ) + ] + )] + ), + [ + DataModel.SectionModel( + section: .threads, + elements: data + .filter { threadViewModel in + threadViewModel.id != SessionThreadViewModel.invalidId && + threadViewModel.id != SessionThreadViewModel.messageRequestsSectionId + } + .sorted { lhs, rhs -> Bool in + guard lhs.threadPinnedPriority == rhs.threadPinnedPriority else { + return lhs.threadPinnedPriority > rhs.threadPinnedPriority + } + + return lhs.lastInteractionDate > rhs.lastInteractionDate + } + .map { viewModel -> SessionThreadViewModel in + viewModel.populatingCurrentUserBlindedKeys( + currentUserBlinded15PublicKeyForThisThread: groupedOldData[viewModel.threadId]? + .first? + .currentUserBlinded15PublicKey, + currentUserBlinded25PublicKeyForThisThread: groupedOldData[viewModel.threadId]? + .first? + .currentUserBlinded25PublicKey + ) + } + ) + ], + (!data.isEmpty && (pageInfo.pageOffset + pageInfo.currentCount) < pageInfo.totalCount ? + [DataModel.SectionModel(section: .loadMore)] : + [] + ) + ].flatMap { $0 } + } + + public func updateThreadData(_ updatedData: [DataModel.SectionModel]) { + self.threadData = updatedData + } + + // MARK: - Updating + + public func startObservingChanges(didReturnFromBackground: Bool = false, onReceivedInitialChange: (() -> ())? = nil) { + guard dataChangeObservable == nil else { return } + + var runAndClearInitialChangeCallback: (() -> ())? = nil + + runAndClearInitialChangeCallback = { [weak self] in + guard self?.hasLoadedInitialStateData == true && self?.hasLoadedInitialThreadData == true else { return } + + onReceivedInitialChange?() + runAndClearInitialChangeCallback = nil + } + + dataChangeObservable = dependencies.storage.start( + self.observableState, + onError: { _ in print("Error observing data") }, + onChange: { [weak self] state in + // The default scheduler emits changes on the main thread + self?.handleStateUpdates(state) + runAndClearInitialChangeCallback?() + } + ) + + self.onThreadChange = { [weak self] updatedThreadData, changeset in + self?.handleThreadUpdates(updatedThreadData) + runAndClearInitialChangeCallback?() + } + + // Note: When returning from the background we could have received notifications but the + // PagedDatabaseObserver won't have them so we need to force a re-fetch of the current + // data to ensure everything is up to date + if didReturnFromBackground { + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.pagedDataObserver?.reload() + } + } + } + + private func stopObservingChanges() { + // Stop observing database changes + self.dataChangeObservable = nil + self.onThreadChange = nil + } + + private func handleStateUpdates(_ updatedState: DataModel.State, animated: Bool = true) { + // Ensure the first load runs without animations (if we don't do this the cells will animate + // in from a frame of CGRect.zero) + guard hasLoadedInitialStateData else { + hasLoadedInitialStateData = true + handleStateUpdates(updatedState, animated: false) + return + } + + if animated { + withAnimation(.easeInOut) { + self.updateState(updatedState) + } + } else { + self.updateState(updatedState) + } + } + + private func handleThreadUpdates(_ updatedData: [DataModel.SectionModel]) { + // Ensure the first load runs without animations (if we don't do this the cells will animate + // in from a frame of CGRect.zero) + guard hasLoadedInitialThreadData else { + self.updateThreadData(updatedData) + self.hasLoadedInitialThreadData = true + return + } + + withAnimation(.easeInOut) { + self.updateThreadData(updatedData) + self.isLoadingMore = false + self.autoLoadNextPageIfNeeded() + } + } + + private func autoLoadNextPageIfNeeded() { + guard + self.hasLoadedInitialThreadData && + !self.isAutoLoadingNextPage && + !self.isLoadingMore + else { return } + + self.isAutoLoadingNextPage = true + + DispatchQueue.main.asyncAfter(deadline: .now() + PagedData.autoLoadNextPageDelay) { [weak self] in + self?.isAutoLoadingNextPage = false + + // Note: We sort the headers as we want to prioritise loading newer pages over older ones + let sections: [DataModel.Section] = (self?.threadData + .enumerated() + .map { _, section in section.model }) + .defaulting(to: []) + + guard sections.contains(.loadMore) && (self?.shouldLoadMore == true) else { return } + + self?.isLoadingMore = true + + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + self?.pagedDataObserver?.load(.pageAfter) + } + } + } + + // MARK: Notification + + func registerForNotifications() { + // Notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidBecomeActive(_:)), + name: UIApplication.didBecomeActiveNotification, + object: nil + ) + NotificationCenter.default.addObserver( + self, + selector: #selector(applicationDidResignActive(_:)), + name: UIApplication.didEnterBackgroundNotification, object: nil + ) + } + + @objc func applicationDidBecomeActive(_ notification: Notification) { + /// Need to dispatch to the next run loop to prevent a possible crash caused by the database resuming mid-query + DispatchQueue.main.async { [weak self] in + self?.startObservingChanges(didReturnFromBackground: true) + } + } + + @objc func applicationDidResignActive(_ notification: Notification) { + self.stopObservingChanges() + } + } +} diff --git a/Session/Home/HomeScreen.swift b/Session/Home/HomeScreen.swift new file mode 100644 index 0000000000..b372115698 --- /dev/null +++ b/Session/Home/HomeScreen.swift @@ -0,0 +1,422 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI +import Combine +import GRDB +import DifferenceKit +import SessionUIKit +import SessionMessagingKit +import SessionUtilitiesKit +import SignalUtilitiesKit + +struct HomeScreen: View { + @EnvironmentObject var host: HostWrapper + @StateObject private var viewModel: ViewModel + private var flow: Onboarding.Flow? + + init(flow: Onboarding.Flow? = nil, using dependencies: Dependencies, rootViewControllerSetupComplete: (() -> ())? = nil) { + self.flow = flow + _viewModel = StateObject(wrappedValue: ViewModel(using: dependencies, onReceivedInitialChange: rootViewControllerSetupComplete)) + self.initialize() + } + + private func initialize() { + // Note: This is a hack to ensure `isRTL` is initially gets run on the main thread so the value + // is cached (it gets called on background threads and if it hasn't cached the value then it can + // cause odd performance issues since it accesses UIKit) + if Singleton.hasAppContext { _ = Singleton.appContext.isRTL } + + // Preparation + // TODO: [HomeScreen Refactoring] +// SessionApp.homeViewController.mutate { $0 = self } + + // Start polling if needed (i.e. if the user just created or restored their Session ID) + if Identity.userExists(), let appDelegate: AppDelegate = UIApplication.shared.delegate as? AppDelegate { + appDelegate.startPollersIfNeeded() + } + + // Onion request path countries cache + IP2Country.populateCacheIfNeededAsync() + } + + var body: some View { + ZStack( + alignment: .top, + content: { + if viewModel.threadData.isEmpty { + ZStack { + EmptyStateView(flow: self.flow) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .center + ) + } + + VStack(spacing: 0) { + if viewModel.state.showViewedSeedBanner { + SeedBanner(action: handleContinueButtonTapped) + } + + ConversationList(threadData: viewModel.threadData) + } + + NewConversationButton(action: createNewConversation) + } + ) + .backgroundColor(themeColor: .backgroundPrimary) + .onReceive(Just(viewModel.state), perform: { updatedState in + (self.host.controller as? SessionHostingViewController)?.setUpNavBarButton( + leftItem: .profile(profile: updatedState.userProfile), + rightItem: .search, + leftAction: openSettings, + rightAction: showSearchUI + ) + }) + } + + // MARK: - Interaction + + func handleContinueButtonTapped() { + if let recoveryPasswordScreen: RecoveryPasswordScreen = try? RecoveryPasswordScreen() { + let viewController: SessionHostingViewController = SessionHostingViewController(rootView: recoveryPasswordScreen) + viewController.setNavBarTitle("sessionRecoveryPassword".localized()) + self.host.controller?.navigationController?.pushViewController(viewController, animated: true) + } else { + let targetViewController: UIViewController = ConfirmationModal( + info: ConfirmationModal.Info( + title: "theError".localized(), + body: .text("recoveryPasswordErrorLoad".localized()), + cancelTitle: "okay".localized(), + cancelStyle: .alert_text + ) + ) + self.host.controller?.present(targetViewController, animated: true, completion: nil) + } + } + + func show( + _ threadId: String, + variant: SessionThread.Variant, + isMessageRequest: Bool, + with action: ConversationViewModel.Action, + focusedInteractionInfo: Interaction.TimestampInfo?, + animated: Bool + ) { + if let presentedVC = self.host.controller?.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) + } + + let finalViewControllers: [UIViewController] = [ + self.host.controller, + ( + (isMessageRequest && action != .compose) ? + SessionTableViewController( + viewModel: MessageRequestsViewModel( + using: viewModel.dependencies) + ) : nil + ), + ConversationVC( + threadId: threadId, + threadVariant: variant, + focusedInteractionInfo: focusedInteractionInfo, + using: viewModel.dependencies + ) + ].compactMap { $0 } + + self.host.controller?.navigationController?.setViewControllers(finalViewControllers, animated: animated) + } + + private func openSettings() { + let settingsViewController: SessionTableViewController = SessionTableViewController( + viewModel: SettingsViewModel() + ) + let navigationController = StyledNavigationController(rootViewController: settingsViewController) + navigationController.modalPresentationStyle = .fullScreen + self.host.controller?.present(navigationController, animated: true, completion: nil) + } + + private func showSearchUI() { + if let presentedVC = self.host.controller?.presentedViewController { + presentedVC.dismiss(animated: false, completion: nil) + } + let searchController = GlobalSearchViewController(using: viewModel.dependencies) + self.host.controller?.navigationController?.setViewControllers( + [ self.host.controller, searchController ].compactMap{ $0 }, + animated: true + ) + } + + func createNewConversation() { + let viewController = SessionHostingViewController( + rootView: StartConversationScreen(), + customizedNavigationBackground: .backgroundSecondary + ) + viewController.setNavBarTitle("conversationsStart".localized()) + viewController.setUpNavBarButton(rightItem: .close) + + let navigationController = StyledNavigationController(rootViewController: viewController) + if UIDevice.current.isIPad { + navigationController.modalPresentationStyle = .fullScreen + } + navigationController.modalPresentationCapturesStatusBarAppearance = true + self.host.controller?.present(navigationController, animated: true, completion: nil) + } + + func createNewDMFromDeepLink(sessionId: String) { + let viewController: SessionHostingViewController = SessionHostingViewController(rootView: NewMessageScreen(accountId: sessionId)) + viewController.setNavBarTitle("messageNew".localized()) + let navigationController = StyledNavigationController(rootViewController: viewController) + if UIDevice.current.isIPad { + navigationController.modalPresentationStyle = .fullScreen + } + navigationController.modalPresentationCapturesStatusBarAppearance = true + self.host.controller?.present(navigationController, animated: true, completion: nil) + } +} + +// MARK: NewConversationButton + +extension HomeScreen { + struct NewConversationButton: View { + + struct NewConversationButtonStyle: ButtonStyle { + func makeBody(configuration: Self.Configuration) -> some View { + configuration.label + .background( + configuration.isPressed ? + Circle() + .fill(themeColor: .highlighted(.menuButton_background, alwaysDarken: true)) + .frame( + width: NewConversationButton.size, + height: NewConversationButton.size + ) + .shadow( + themeColor: .menuButton_outerShadow, + opacity: 0.3, + radius: 15 + ) : + Circle() + .fill(themeColor: .menuButton_background) + .frame( + width: NewConversationButton.size, + height: NewConversationButton.size + ) + .shadow( + themeColor: .menuButton_outerShadow, + opacity: 0.3, + radius: 15 + ) + ) + } + } + + private static let size: CGFloat = 60 + private var action: () -> () + + init(action: @escaping () -> Void) { + self.action = action + } + + var body: some View { + ZStack { + Button { + action() + } label: { + Image("Plus") + .renderingMode(.template) + .foregroundColor(themeColor: .menuButton_icon) + } + .buttonStyle(NewConversationButtonStyle()) + .accessibility( + Accessibility( + identifier: "New conversation button", + label: "New conversation button" + ) + ) + .padding(.bottom, Values.smallSpacing) + } + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .bottom + ) + } + } +} + +// MARK: EmptyStateView + +extension HomeScreen { + struct EmptyStateView: View { + var flow: Onboarding.Flow? + var body: some View { + VStack( + alignment: .center, + spacing: Values.smallSpacing, + content: { + if flow == .register { + // Welcome state after account creation + Image("Hooray") + .frame( + height: 96, + alignment: .center + ) + + Text("onboardingAccountCreated".localized()) + .bold() + .font(.system(size: Values.veryLargeFontSize)) + .foregroundColor(themeColor: .textPrimary) + + Text( + "onboardingBubbleWelcomeToSession" + .put(key: "emoji", value: "") + .localized() + ) + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: .sessionButton_text) + + } else { + // Normal empty state + Image("SessionGreen64") + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + height: 103, + alignment: .center + ) + .padding(.bottom, Values.mediumSpacing) + + Image("SessionHeading") + .resizable() + .renderingMode(.template) + .aspectRatio(contentMode: .fit) + .foregroundColor(themeColor: .textPrimary) + .frame( + height: 22, + alignment: .center + ) + .padding(.bottom, Values.smallSpacing) + } + + Line(color: .borderSeparator) + .padding(.vertical, Values.smallSpacing) + + Text("conversationsNone".localized()) + .bold() + .font(.system(size: Values.mediumFontSize)) + .foregroundColor(themeColor: .textPrimary) + + Text("onboardingHitThePlusButton".localized()) + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: .textPrimary) + .multilineTextAlignment(.center) + } + ) + .frame( + width: 300, + alignment: .center + ) + } + } +} + +// MARK: SeedBanner + +extension HomeScreen { + struct SeedBanner: View { + private var action: () -> () + + init(action: @escaping () -> Void) { + self.action = action + } + + var body: some View { + ZStack( + alignment: .topLeading, + content: { + Rectangle() + .fill(themeColor: .primary) + .frame(height: 2) + .frame(maxWidth: .infinity) + + HStack( + alignment: .center, + spacing: 0, + content: { + VStack( + alignment: .leading, + spacing: Values.smallSpacing, + content: { + HStack( + alignment: .center, + spacing: Values.verySmallSpacing, + content: { + Text("recoveryPasswordBannerTitle".localized()) + .font(.system(size: Values.smallFontSize)) + .bold() + .foregroundColor(themeColor: .textPrimary) + + Image("SessionShieldFilled") + .resizable() + .renderingMode(.template) + .foregroundColor(themeColor: .textPrimary) + .scaledToFit() + .frame( + width: 14, + height: 16 + ) + } + ) + + Text("recoveryPasswordBannerDescription".localized()) + .font(.system(size: Values.verySmallFontSize)) + .foregroundColor(themeColor: .textSecondary) + .lineLimit(2) + } + ) + + Spacer() + + Button { + action() + } label: { + Text("theContinue".localized()) + .bold() + .font(.system(size: Values.smallFontSize)) + .foregroundColor(themeColor: .sessionButton_text) + .frame( + minWidth: 80, + maxHeight: Values.smallButtonHeight, + alignment: .center + ) + .overlay( + Capsule() + .stroke(themeColor: .sessionButton_border) + ) + } + .accessibility( + Accessibility( + identifier: "Reveal recovery phrase button", + label: "Reveal recovery phrase button" + ) + ) + } + ) + .padding(isIPhone5OrSmaller ? Values.smallSpacing : Values.mediumSpacing) + } + ) + .backgroundColor(themeColor: .conversationButton_background) + .border( + width: Values.separatorThickness, + edges: [.bottom], + color: .borderSeparator + ) + } + } +} + +//#Preview { +// HomeScreen(flow: .register, using: Dependencies()) +//} diff --git a/Session/Home/New Conversation/StartConversationScreen.swift b/Session/Home/New Conversation/StartConversationScreen.swift index 5937053c4e..22399a3528 100644 --- a/Session/Home/New Conversation/StartConversationScreen.swift +++ b/Session/Home/New Conversation/StartConversationScreen.swift @@ -35,7 +35,7 @@ struct StartConversationScreen: View { rootView: NewMessageScreen(using: dependencies) ) viewController.setNavBarTitle(title) - viewController.setUpDismissingButton(on: .right) + viewController.setUpNavBarButton(rightItem: .close) self.host.controller?.navigationController?.pushViewController(viewController, animated: true) } .accessibility( @@ -93,7 +93,7 @@ struct StartConversationScreen: View { rootView: InviteAFriendScreen(accountId: dependencies[cache: .general].sessionId.hexString) ) viewController.setNavBarTitle("sessionInviteAFriend".localized()) - viewController.setUpDismissingButton(on: .right) + viewController.setUpNavBarButton(rightItem: .close) self.host.controller?.navigationController?.pushViewController(viewController, animated: true) } .accessibility( diff --git a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist index b3bc8e8f69..a813a2b72a 100644 --- a/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist +++ b/Session/Meta/Settings.bundle/ThirdPartyLicenses.plist @@ -1656,6 +1656,88 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI License The MIT License (MIT) +Copyright (c) 2016 swiftlyfalling (https://github.com/swiftlyfalling) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + Title + session-grdb-swift + + + License + The author disclaims copyright to this source code. In place of +a legal notice, here is a blessing: + + * May you do good and not evil. + * May you find forgiveness for yourself and forgive others. + * May you share freely, never taking more than you give. + + Title + session-grdb-swift + + + License + /* +** LICENSE for the sqlite3 WebAssembly/JavaScript APIs. +** +** This bundle (typically released as sqlite3.js or sqlite3.mjs) +** is an amalgamation of JavaScript source code from two projects: +** +** 1) https://emscripten.org: the Emscripten "glue code" is covered by +** the terms of the MIT license and University of Illinois/NCSA +** Open Source License, as described at: +** +** https://emscripten.org/docs/introducing_emscripten/emscripten_license.html +** +** 2) https://sqlite.org: all code and documentation labeled as being +** from this source are released under the same terms as the sqlite3 +** C library: +** +** 2022-10-16 +** +** The author disclaims copyright to this source code. In place of a +** legal notice, here is a blessing: +** +** * May you do good and not evil. +** * May you find forgiveness for yourself and forgive others. +** * May you share freely, never taking more than you give. +*/ + + Title + session-grdb-swift + + + License + The author disclaims copyright to this source code. In place of +a legal notice, here is a blessing: + + May you do good and not evil. + May you find forgiveness for yourself and forgive others. + May you share freely, never taking more than you give. + + Title + session-grdb-swift + + + License + The MIT License (MIT) + Copyright (c) 2015 ibireme <ibireme@gmail.com> Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/Session/Settings/RecoveryPasswordScreen.swift b/Session/Settings/RecoveryPasswordScreen.swift index 680cf2feb9..fc224e6065 100644 --- a/Session/Settings/RecoveryPasswordScreen.swift +++ b/Session/Settings/RecoveryPasswordScreen.swift @@ -43,7 +43,7 @@ struct RecoveryPasswordScreen: View { VStack( alignment: .leading, - spacing: 0 + spacing: 4 ) { HStack( alignment: .center, diff --git a/Session/Shared/SessionHostingViewController.swift b/Session/Shared/SessionHostingViewController.swift index bb9fde27f7..3391080909 100644 --- a/Session/Shared/SessionHostingViewController.swift +++ b/Session/Shared/SessionHostingViewController.swift @@ -8,9 +8,10 @@ public class HostWrapper: ObservableObject { public weak var controller: UIViewController? } -public enum NavigationItemPosition { - case left - case right +public enum NavigationItem { + case profile(profile: Profile) + case search + case close } public class SessionHostingViewController: UIHostingController>>, ThemedNavigation where Content : View { @@ -21,6 +22,9 @@ public class SessionHostingViewController: UIHostingController ())? + private var rightBarButtonItemAction: (() -> ())? lazy var navBarTitleLabel: UILabel = { let result = UILabel() @@ -78,6 +82,8 @@ public class SessionHostingViewController: UIHostingController: UIHostingController ())? = nil, rightAction: (() -> ())? = nil) { + self.leftBarButtonItemAction = leftAction + self.rightBarButtonItemAction = rightAction + navigationItem.leftBarButtonItem = generateBarButtonItem(item: leftItem, action: #selector(leftBarButtonAction)) + navigationItem.rightBarButtonItem = generateBarButtonItem(item: rightItem, action: #selector(rightBarButtonAction)) + } + + private func generateBarButtonItem(item: NavigationItem?, action: Selector?) -> UIBarButtonItem? { + guard let navigationItem: NavigationItem = item else { return nil } + switch navigationItem { + case .profile(let profile): + // Profile picture view + let profilePictureView = ProfilePictureView(size: .navigation) + profilePictureView.accessibilityIdentifier = "User settings" + profilePictureView.accessibilityLabel = "User settings" + profilePictureView.isAccessibilityElement = true + profilePictureView.update( + publicKey: profile.id, + threadVariant: .contact, + customImageData: nil, + profile: profile, + additionalProfile: nil + ) + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: action) + profilePictureView.addGestureRecognizer(tapGestureRecognizer) + + // Path status indicator + let pathStatusView = PathStatusView() + pathStatusView.accessibilityLabel = "Current onion routing path indicator" + + // Container view + let profilePictureViewContainer = UIView() + profilePictureViewContainer.addSubview(profilePictureView) + profilePictureView.pin(to: profilePictureViewContainer) + profilePictureViewContainer.addSubview(pathStatusView) + pathStatusView.pin(.trailing, to: .trailing, of: profilePictureViewContainer) + pathStatusView.pin(.bottom, to: .bottom, of: profilePictureViewContainer) + + let result = UIBarButtonItem(customView: profilePictureViewContainer) + result.isAccessibilityElement = true + return result + case .search: + let result = UIBarButtonItem(barButtonSystemItem: .search, target: self, action: action) + result.accessibilityLabel = "Search button" + result.isAccessibilityElement = true + return result + case .close: + let result = UIBarButtonItem(image: #imageLiteral(resourceName: "X"), style: .plain, target: self, action: #selector(close)) + result.themeTintColor = .textPrimary + result.isAccessibilityElement = true + return result + } + } + + @objc private func leftBarButtonAction() { + self.leftBarButtonItemAction?() + } + + @objc private func rightBarButtonAction() { + self.rightBarButtonItemAction?() } @objc private func close() { diff --git a/SessionUIKit/Components/SwiftUI/SessionButton_SwiftUI.swift b/SessionUIKit/Components/SwiftUI/SessionButton_SwiftUI.swift new file mode 100644 index 0000000000..29067e9307 --- /dev/null +++ b/SessionUIKit/Components/SwiftUI/SessionButton_SwiftUI.swift @@ -0,0 +1,74 @@ +// Copyright © 2024 Rangeproof Pty Ltd. All rights reserved. + +import SwiftUI + +struct SessionButton_SwiftUI: View { + public enum Style { + case bordered + case borderless + case destructive + case destructiveBorderless + case filled + } + + public enum Size { + case small + case medium + case large + } + + public struct Info: Equatable { + public let style: Style + public let title: String + public let isEnabled: Bool + public let accessibility: Accessibility? + public let minWidth: CGFloat + public let onTap: () -> () + + public init( + style: Style, + title: String, + isEnabled: Bool = true, + accessibility: Accessibility? = nil, + minWidth: CGFloat = 0, + onTap: @escaping () -> () + ) { + self.style = style + self.title = title + self.isEnabled = isEnabled + self.accessibility = accessibility + self.onTap = onTap + self.minWidth = minWidth + } + + public static func == (lhs: SessionButton_SwiftUI.Info, rhs: SessionButton_SwiftUI.Info) -> Bool { + return ( + lhs.style == rhs.style && + lhs.title == rhs.title && + lhs.isEnabled == rhs.isEnabled && + lhs.accessibility == rhs.accessibility && + lhs.minWidth == rhs.minWidth + ) + } + } + + private let info: SessionButton_SwiftUI.Info + + init(info: SessionButton_SwiftUI.Info) { + self.info = info + } + + var body: some View { + Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + } +} + +#Preview { + SessionButton_SwiftUI( + info: .init( + style: .bordered, + title: "Test", + onTap: {} + ) + ) +} diff --git a/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift b/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift index 37b02cc9a1..ffc59daea8 100644 --- a/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift +++ b/SessionUIKit/Style Guide/Themes/SwiftUI+Theme.swift @@ -20,6 +20,15 @@ public extension View { ) } } + + func shadow(themeColor: ThemeValue, opacity: CGFloat, radius: CGFloat, x: CGFloat = 0, y: CGFloat = 0) -> some View { + return self.shadow( + color: ThemeManager.currentTheme.colorSwiftUI(for: themeColor)?.opacity(opacity) ?? Color(.sRGBLinear, white: 0, opacity: opacity), + radius: radius, + x: x, + y: y + ) + } } public extension Shape { diff --git a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift index d8ab095909..dea8e542af 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+ClassicDark.swift @@ -207,11 +207,11 @@ internal enum Theme_ClassicDark: ThemeColors { .toast_background: .classicDark2, // ConversationButton - .conversationButton_background: .classicDark1, - .conversationButton_unreadBackground: .classicDark2, + .conversationButton_background: .classicDark0, + .conversationButton_unreadBackground: .classicDark1, .conversationButton_unreadStripBackground: .primary, - .conversationButton_unreadBubbleBackground: .classicDark3, - .conversationButton_unreadBubbleText: .classicDark6, + .conversationButton_unreadBubbleBackground: .primary, + .conversationButton_unreadBubbleText: .classicDark0, .conversationButton_swipeDestructive: .dangerDark, .conversationButton_swipeSecondary: .classicDark2, .conversationButton_swipeTertiary: Theme.PrimaryColor.orange.colorSwiftUI, diff --git a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift index 724a12fe6c..a3d10e6656 100644 --- a/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift +++ b/SessionUIKit/Style Guide/Themes/Theme+OceanDark.swift @@ -207,8 +207,8 @@ internal enum Theme_OceanDark: ThemeColors { .toast_background: .oceanDark4, // ConversationButton - .conversationButton_background: .oceanDark3, - .conversationButton_unreadBackground: .oceanDark4, + .conversationButton_background: .oceanDark2, + .conversationButton_unreadBackground: .oceanDark3, .conversationButton_unreadStripBackground: .primary, .conversationButton_unreadBubbleBackground: .primary, .conversationButton_unreadBubbleText: .oceanDark0, diff --git a/SessionUtilitiesKit/Utilities/DifferenceKit+Utilities.swift b/SessionUtilitiesKit/Utilities/DifferenceKit+Utilities.swift new file mode 100644 index 0000000000..47e01ddacd --- /dev/null +++ b/SessionUtilitiesKit/Utilities/DifferenceKit+Utilities.swift @@ -0,0 +1,12 @@ +// Copyright © 2023 Rangeproof Pty Ltd. All rights reserved. + +import Foundation +import DifferenceKit + +extension String: Differentiable {} + +extension ArraySection: Identifiable { + public var id: String { + "\(model.differenceIdentifier)\(elements.count)" + } +}