From 6560f22eac366c550c15f5cc54adcec374571d52 Mon Sep 17 00:00:00 2001 From: Igor Solomennikov Date: Tue, 19 May 2026 23:10:25 -0700 Subject: [PATCH] Update story reply UI for iOS 26. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • use UISheetPresentationController for group story views / replies screen. • use UISheetPresentationController for private story views screen. • liquid glass backgrounds for reply input field and reactions panel. • move away from Theme and hardcoded colors to UIColor.Signal palette. • use modern UIButton configuration APIs. --- Signal.xcodeproj/project.pbxproj | 16 +- .../ConversationInputToolbar.swift | 5 +- .../StoryContextViewController.swift | 38 +- .../StoryGroupRepliesAndViewsSheet.swift | 208 --------- ...ryGroupRepliesAndViewsViewController.swift | 245 ++++++++++ .../StoryGroupReplyCell.swift | 18 +- .../StoryGroupReplySheet.swift | 49 -- .../StoryGroupReplyViewController.swift | 210 +++++---- .../StoryDirectReplySheet.swift | 13 +- .../StoryPrivateViewsSheet.swift | 38 -- .../StoryReplyInputToolbar.swift | 425 +++++++++++++----- .../StoryReplySheet.swift | 2 - .../StoryViewsViewController.swift | 137 ++++-- 13 files changed, 811 insertions(+), 593 deletions(-) delete mode 100644 Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsSheet.swift create mode 100644 Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsViewController.swift delete mode 100644 Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplySheet.swift delete mode 100644 Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryPrivateViewsSheet.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index daa2a47343..1b66955225 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -1613,7 +1613,6 @@ 8862921028355B8000AA0C3B /* MyStoryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8862920F28355B8000AA0C3B /* MyStoryViewModel.swift */; }; 886292122835606D00AA0C3B /* MyStoryCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 886292112835606D00AA0C3B /* MyStoryCell.swift */; }; 8862A55925F090C5005D65DB /* InternalSettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8862A55825F090C5005D65DB /* InternalSettingsViewController.swift */; }; - 8864072827EEA658009916B6 /* StoryGroupReplySheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8864072727EEA658009916B6 /* StoryGroupReplySheet.swift */; }; 8864072A27F0D426009916B6 /* StoryGroupReplyLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8864072927F0D426009916B6 /* StoryGroupReplyLoader.swift */; }; 8864072C27F0DA38009916B6 /* StoryGroupReplyViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8864072B27F0DA37009916B6 /* StoryGroupReplyViewItem.swift */; }; 8864072E27F0E8DF009916B6 /* StoryGroupReplyCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8864072D27F0E8DF009916B6 /* StoryGroupReplyCell.swift */; }; @@ -1677,8 +1676,7 @@ 88A9729422FB4D02004B4FBF /* LocationPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88A9729322FB4D02004B4FBF /* LocationPicker.swift */; }; 88B00D4B28A32DB600BC9CA0 /* StoryGroupReplyViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B00D4A28A32DB600BC9CA0 /* StoryGroupReplyViewController.swift */; }; 88B00D4D28A3346100BC9CA0 /* StoryViewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B00D4C28A3346000BC9CA0 /* StoryViewsViewController.swift */; }; - 88B00D4F28A33B5800BC9CA0 /* StoryPrivateViewsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B00D4E28A33B5800BC9CA0 /* StoryPrivateViewsSheet.swift */; }; - 88B00D5128A341D000BC9CA0 /* StoryGroupRepliesAndViewsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsSheet.swift */; }; + 88B00D5128A341D000BC9CA0 /* StoryGroupRepliesAndViewsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsViewController.swift */; }; 88B2234A283F290400A25048 /* StoryPrivacySettingsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B22349283F290400A25048 /* StoryPrivacySettingsViewController.swift */; }; 88B2234C284FABE600A25048 /* StoryThumbnailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B2234B284FABE600A25048 /* StoryThumbnailView.swift */; }; 88B688B0238F0D1000286F82 /* ReactionsDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88B688AF238F0D1000286F82 /* ReactionsDetailSheet.swift */; }; @@ -5821,7 +5819,6 @@ 8862920F28355B8000AA0C3B /* MyStoryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyStoryViewModel.swift; sourceTree = ""; }; 886292112835606D00AA0C3B /* MyStoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyStoryCell.swift; sourceTree = ""; }; 8862A55825F090C5005D65DB /* InternalSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalSettingsViewController.swift; sourceTree = ""; }; - 8864072727EEA658009916B6 /* StoryGroupReplySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplySheet.swift; sourceTree = ""; }; 8864072927F0D426009916B6 /* StoryGroupReplyLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyLoader.swift; sourceTree = ""; }; 8864072B27F0DA37009916B6 /* StoryGroupReplyViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyViewItem.swift; sourceTree = ""; }; 8864072D27F0E8DF009916B6 /* StoryGroupReplyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyCell.swift; sourceTree = ""; }; @@ -5929,8 +5926,7 @@ 88ABAB8E25B8BE3F0008C78A /* PreviewWallpaperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWallpaperViewController.swift; sourceTree = ""; }; 88B00D4A28A32DB600BC9CA0 /* StoryGroupReplyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyViewController.swift; sourceTree = ""; }; 88B00D4C28A3346000BC9CA0 /* StoryViewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryViewsViewController.swift; sourceTree = ""; }; - 88B00D4E28A33B5800BC9CA0 /* StoryPrivateViewsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryPrivateViewsSheet.swift; sourceTree = ""; }; - 88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupRepliesAndViewsSheet.swift; sourceTree = ""; }; + 88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupRepliesAndViewsViewController.swift; sourceTree = ""; }; 88B22349283F290400A25048 /* StoryPrivacySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryPrivacySettingsViewController.swift; sourceTree = ""; }; 88B2234B284FABE600A25048 /* StoryThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryThumbnailView.swift; sourceTree = ""; }; 88B688AF238F0D1000286F82 /* ReactionsDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsDetailSheet.swift; sourceTree = ""; }; @@ -11214,7 +11210,6 @@ 8864072F27F21AA7009916B6 /* Group Reply Sheet */, 668FE09E28B947ED008B9071 /* StoryContextMenuGenerator.swift */, 88423A51280A171E007D2918 /* StoryDirectReplySheet.swift */, - 88B00D4E28A33B5800BC9CA0 /* StoryPrivateViewsSheet.swift */, 8864073027F21AD7009916B6 /* StoryReplyInputToolbar.swift */, 88423A53280A2675007D2918 /* StoryReplyPreviewView.swift */, 88423A55280A373C007D2918 /* StoryReplySheet.swift */, @@ -11316,10 +11311,9 @@ 8864072F27F21AA7009916B6 /* Group Reply Sheet */ = { isa = PBXGroup; children = ( - 88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsSheet.swift */, + 88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsViewController.swift */, 8864072D27F0E8DF009916B6 /* StoryGroupReplyCell.swift */, 8864072927F0D426009916B6 /* StoryGroupReplyLoader.swift */, - 8864072727EEA658009916B6 /* StoryGroupReplySheet.swift */, 88B00D4A28A32DB600BC9CA0 /* StoryGroupReplyViewController.swift */, 8864072B27F0DA37009916B6 /* StoryGroupReplyViewItem.swift */, ); @@ -18744,10 +18738,9 @@ 66BE544D28CA4EC10021AFF1 /* StoryContextOnboardingOverlayView.swift in Sources */, 884DB95027DE67BB00C6A309 /* StoryContextViewController.swift in Sources */, 88423A52280A171E007D2918 /* StoryDirectReplySheet.swift in Sources */, - 88B00D5128A341D000BC9CA0 /* StoryGroupRepliesAndViewsSheet.swift in Sources */, + 88B00D5128A341D000BC9CA0 /* StoryGroupRepliesAndViewsViewController.swift in Sources */, 8864072E27F0E8DF009916B6 /* StoryGroupReplyCell.swift in Sources */, 8864072A27F0D426009916B6 /* StoryGroupReplyLoader.swift in Sources */, - 8864072827EEA658009916B6 /* StoryGroupReplySheet.swift in Sources */, 88B00D4B28A32DB600BC9CA0 /* StoryGroupReplyViewController.swift in Sources */, 8864072C27F0DA38009916B6 /* StoryGroupReplyViewItem.swift in Sources */, 880FB40828CD437600FA1C10 /* StoryInfoSheet.swift in Sources */, @@ -18757,7 +18750,6 @@ 884DB94F27DE67BB00C6A309 /* StoryPageViewController.swift in Sources */, 884DB95427DEB9E900C6A309 /* StoryPlaybackProgressView.swift in Sources */, 88B2234A283F290400A25048 /* StoryPrivacySettingsViewController.swift in Sources */, - 88B00D4F28A33B5800BC9CA0 /* StoryPrivateViewsSheet.swift in Sources */, 8864073127F21AD7009916B6 /* StoryReplyInputToolbar.swift in Sources */, 88423A54280A2675007D2918 /* StoryReplyPreviewView.swift in Sources */, 88423A56280A373C007D2918 /* StoryReplySheet.swift in Sources */, diff --git a/Signal/ConversationView/ConversationInputToolbar.swift b/Signal/ConversationView/ConversationInputToolbar.swift index 9d0f48ade0..9d4b54308e 100644 --- a/Signal/ConversationView/ConversationInputToolbar.swift +++ b/Signal/ConversationView/ConversationInputToolbar.swift @@ -707,9 +707,10 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { // Rounded rect background for the text input field: // Liquid Glass on iOS 26, gray-ish on earlier iOS versions. let backgroundView: UIView + let cornerRadius = LayoutMetrics.initialTextBoxHeight / 2 if #available(iOS 26, *) { let glassEffectView = UIVisualEffectView(effect: Style.glassEffect(isInteractive: true)) - glassEffectView.cornerConfiguration = .uniformCorners(radius: 20) + glassEffectView.cornerConfiguration = .uniformCorners(radius: .fixed(cornerRadius)) glassEffectView.contentView.addSubview(messageComponentsView) backgroundView = glassEffectView @@ -717,7 +718,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { } else { backgroundView = UIView() backgroundView.backgroundColor = UIColor.Signal.tertiaryFill - backgroundView.layer.cornerRadius = 20 + backgroundView.layer.cornerRadius = cornerRadius messageContentView.addSubview(backgroundView) messageContentView.addSubview(messageComponentsView) diff --git a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift index bb3e177b81..bd2ac1425a 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryContextViewController.swift @@ -982,38 +982,52 @@ class StoryContextViewController: OWSViewController, DatabaseChangeDelegate, case .groupId: switch currentItem.message.direction { case .outgoing: - let groupRepliesAndViewsVC = StoryGroupRepliesAndViewsSheet( + pause() + + let groupRepliesAndViewsVC = StoryGroupRepliesAndViewsViewController( storyMessage: currentItem.message, context: context, spoilerState: spoilerState, ) groupRepliesAndViewsVC.dismissHandler = { [weak self] in self?.play() } groupRepliesAndViewsVC.focusedTab = currentItem.numberOfReplies > 0 ? .replies : .views - self.pause() - self.present(groupRepliesAndViewsVC, animated: true) + present(groupRepliesAndViewsVC, animated: true) + case .incoming: - let groupReplyVC = StoryGroupReplySheet( + pause() + + let groupReplyVC = StoryGroupReplyViewController( storyMessage: currentItem.message, spoilerState: spoilerState, + isStandaloneVC: true, ) groupReplyVC.dismissHandler = { [weak self] in self?.play() } - self.pause() - self.present(groupReplyVC, animated: true) + present(groupReplyVC, animated: true) } + case .authorAci: + pause() + owsAssertDebug( !currentItem.message.authorAddress.isSystemStoryAddress, "Should be impossible to reply to system stories", ) + let directReplyVC = StoryDirectReplySheet(storyMessage: currentItem.message, spoilerState: spoilerState) directReplyVC.dismissHandler = { [weak self] in self?.play() } - self.pause() - self.present(directReplyVC, animated: true) + present(directReplyVC, animated: true) + case .privateStory: - let privateViewsVC = StoryPrivateViewsSheet(storyMessage: currentItem.message, context: context) - privateViewsVC.dismissHandler = { [weak self] in self?.play() } - self.pause() - self.present(privateViewsVC, animated: true) + pause() + + let viewController = StoryViewsViewController( + storyMessage: currentItem.message, + context: context, + isStandaloneVC: true, + ) + viewController.dismissHandler = { [weak self] in self?.play() } + present(viewController, animated: true) + case .none: owsFailDebug("Unexpected context") } diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsSheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsSheet.swift deleted file mode 100644 index 6bc01bd357..0000000000 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsSheet.swift +++ /dev/null @@ -1,208 +0,0 @@ -// -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import Foundation -import SignalServiceKit -import SignalUI -import UIKit - -class StoryGroupRepliesAndViewsSheet: InteractiveSheetViewController, StoryGroupReplier { - override var interactiveScrollViews: [UIScrollView] { [groupReplyViewController.tableView, viewsViewController.tableView] } - - override var sheetBackgroundColor: UIColor { .ows_gray90 } - - private let groupReplyViewController: StoryGroupReplyViewController - private let viewsViewController: StoryViewsViewController - private let pagingScrollView = UIScrollView() - - var storyMessage: StoryMessage { groupReplyViewController.storyMessage } - var threadUniqueId: String? { groupReplyViewController.thread?.uniqueId } - - private lazy var viewsButton = createToggleButton( - title: OWSLocalizedString("STORIES_VIEWS_TAB", comment: "Title text for the 'views' tab on the stories views & replies sheet"), - ) { [weak self] in - self?.switchToViewsTab(animated: true) - } - - private lazy var repliesButton = createToggleButton( - title: OWSLocalizedString("STORIES_REPLIES_TAB", comment: "Title text for the 'replies' tab on the stories views & replies sheet"), - ) { [weak self] in - self?.switchToRepliesTab(animated: true) - } - - var dismissHandler: (() -> Void)? - - enum Tab: Int { - case views = 0 - case replies = 1 - } - - var focusedTab: Tab = .views - - init(storyMessage: StoryMessage, context: StoryContext, spoilerState: SpoilerRenderState) { - self.groupReplyViewController = StoryGroupReplyViewController(storyMessage: storyMessage, spoilerState: spoilerState) - self.viewsViewController = StoryViewsViewController(storyMessage: storyMessage, context: context) - - super.init() - - self.allowsExpansion = true - minimizedHeight = CurrentAppContext().frame.height * 0.6 - } - - override func viewDidLoad() { - super.viewDidLoad() - - let vStack = UIStackView() - vStack.axis = .vertical - vStack.alignment = .center - vStack.spacing = 20 - contentView.addSubview(vStack) - vStack.autoPinEdgesToSuperviewEdges() - - let hStack = UIStackView(arrangedSubviews: [viewsButton, repliesButton]) - hStack.axis = .horizontal - hStack.spacing = 12 - vStack.addArrangedSubview(hStack) - - pagingScrollView.isPagingEnabled = true - pagingScrollView.showsHorizontalScrollIndicator = false - pagingScrollView.isDirectionalLockEnabled = true - pagingScrollView.delegate = self - vStack.addArrangedSubview(pagingScrollView) - pagingScrollView.autoPinEdge(toSuperviewSafeArea: .left) - pagingScrollView.autoPinEdge(toSuperviewSafeArea: .right) - - let pagesContainer = UIView() - pagingScrollView.addSubview(pagesContainer) - pagesContainer.autoPinEdgesToSuperviewEdges() - pagesContainer.autoMatch(.height, to: .height, of: pagingScrollView) - pagesContainer.autoMatch(.width, to: .width, of: pagingScrollView, withMultiplier: 2) - - addChild(viewsViewController) - pagesContainer.addSubview(viewsViewController.view) - viewsViewController.view.autoMatch(.width, to: .width, of: pagesContainer, withMultiplier: 0.5) - viewsViewController.view.autoPinHeightToSuperview() - viewsViewController.view.autoPinEdge(toSuperviewEdge: .leading) - - groupReplyViewController.delegate = self - addChild(groupReplyViewController) - pagesContainer.addSubview(groupReplyViewController.view) - groupReplyViewController.view.autoMatch(.width, to: .width, of: pagesContainer, withMultiplier: 0.5) - groupReplyViewController.view.autoPinHeightToSuperview() - groupReplyViewController.view.autoPinEdge(.leading, to: .trailing, of: viewsViewController.view) - groupReplyViewController.view.autoPinEdge(toSuperviewEdge: .trailing) - - pagingScrollViewObservation = pagingScrollView.observe(\.contentSize, changeHandler: { [weak self] _, _ in - self?.didUpdatePagingScrollViewContentSize() - }) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - switch focusedTab { - case .views: - break - case .replies: - maximizeHeight { - // Once we maximize, don't let you minimize again or things look weird. - self.minimizedHeight = super.maxHeight - } - } - } - - private var pagingScrollViewObservation: NSKeyValueObservation? - - private func didUpdatePagingScrollViewContentSize() { - guard view.frame != .zero, self.pagingScrollView.contentSize.width > 0 else { return } - // Only need to trigger once. - pagingScrollViewObservation = nil - - // Once we have a frame, we need to re-switch to the tab - switch focusedTab { - case .views: switchToViewsTab(animated: false) - case .replies: - switchToRepliesTab(animated: false) - groupReplyViewController.inputToolbar.becomeFirstResponder() - } - } - - override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - super.dismiss(animated: flag) { [dismissHandler] in - completion?() - dismissHandler?() - } - } - - private var isManuallySwitchingTabs = false - func switchToRepliesTab(animated: Bool) { - if animated { - isManuallySwitchingTabs = true - } - focusedTab = .replies - repliesButton.isSelected = true - viewsButton.isSelected = false - view.layoutIfNeeded() - pagingScrollView.setContentOffset(CGPoint(x: pagingScrollView.width, y: 0), animated: animated) - } - - func switchToViewsTab(animated: Bool) { - if animated { - isManuallySwitchingTabs = true - } - focusedTab = .views - repliesButton.isSelected = false - viewsButton.isSelected = true - pagingScrollView.setContentOffset(.zero, animated: animated) - } - - func createToggleButton(title: String, block: @escaping () -> Void) -> UIButton { - let button = OWSButton() - button.block = { [unowned button] in - guard !button.isSelected else { return } - block() - } - button.autoSetDimension(.height, toSize: 28) - button.layer.cornerRadius = 14 - button.clipsToBounds = true - button.titleLabel?.font = UIFont.semiboldFont(ofSize: 15) - button.ows_contentEdgeInsets = UIEdgeInsets(hMargin: 12, vMargin: 4) - button.setTitle(title, for: .normal) - button.setTitleColor(Theme.darkThemePrimaryColor, for: .normal) - button.setBackgroundImage(UIImage.image(color: .ows_gray65), for: .selected) - return button - } -} - -extension StoryGroupRepliesAndViewsSheet: UIScrollViewDelegate { - func scrollViewDidScroll(_ scrollView: UIScrollView) { - groupReplyViewController.inputToolbar.resignFirstResponder() - - guard !isManuallySwitchingTabs else { return } - - if scrollView.contentOffset.x < scrollView.width / 2 { - repliesButton.isSelected = false - viewsButton.isSelected = true - focusedTab = .views - } else { - repliesButton.isSelected = true - viewsButton.isSelected = false - focusedTab = .replies - } - } - - func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - isManuallySwitchingTabs = false - } -} - -extension StoryGroupRepliesAndViewsSheet: StoryGroupReplyDelegate { - func storyGroupReplyViewControllerDidBeginEditing(_ storyGroupReplyViewController: StoryGroupReplyViewController) { - maximizeHeight { - // Once we maximize, don't let you minimize again or things look weird. - self.minimizedHeight = super.maxHeight - } - } -} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsViewController.swift new file mode 100644 index 0000000000..c639571a1b --- /dev/null +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupRepliesAndViewsViewController.swift @@ -0,0 +1,245 @@ +// +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalServiceKit +import SignalUI + +class StoryGroupRepliesAndViewsViewController: OWSViewController, StoryGroupReplier, StoryGroupReplyDelegate, + UIAdaptivePresentationControllerDelegate, UIScrollViewDelegate +{ + private let groupReplyViewController: StoryGroupReplyViewController + private let viewsViewController: StoryViewsViewController + private lazy var pagingScrollView: UIScrollView = { + let scrollView = UIScrollView() + scrollView.isPagingEnabled = true + scrollView.showsHorizontalScrollIndicator = false + scrollView.isDirectionalLockEnabled = true + scrollView.delegate = self + return scrollView + }() + + var storyMessage: StoryMessage { groupReplyViewController.storyMessage } + var threadUniqueId: String? { groupReplyViewController.thread?.uniqueId } + + private lazy var viewsAndRepliesControl: UISegmentedControl = { + let control = UISegmentedControl(items: [ + OWSLocalizedString( + "STORIES_VIEWS_TAB", + comment: "Title text for the 'views' tab on the stories views & replies sheet", + ), + OWSLocalizedString( + "STORIES_REPLIES_TAB", + comment: "Title text for the 'replies' tab on the stories views & replies sheet", + ), + ]) + control.addAction( + UIAction { [weak self] _ in + self?.switchBetweenRepliesAndViews() + }, + for: .primaryActionTriggered, + ) + return control + }() + + var dismissHandler: (() -> Void)? + + init(storyMessage: StoryMessage, context: StoryContext, spoilerState: SpoilerRenderState) { + self.groupReplyViewController = StoryGroupReplyViewController( + storyMessage: storyMessage, + spoilerState: spoilerState, + isStandaloneVC: false, + ) + self.viewsViewController = StoryViewsViewController( + storyMessage: storyMessage, + context: context, + isStandaloneVC: false, + ) + + super.init() + + groupReplyViewController.delegate = self + + overrideUserInterfaceStyle = .dark + modalPresentationStyle = .pageSheet + presentationController?.delegate = self + + if let sheetPresentationController { + if #available(iOS 17.0, *) { + sheetPresentationController.traitOverrides.userInterfaceStyle = .dark + } else { + sheetPresentationController.overrideTraitCollection = UITraitCollection(userInterfaceStyle: .dark) + } + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.prefersGrabberVisible = true + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.preservesSuperviewLayoutMargins = true + + let segmentedControlContainer = UIView() + segmentedControlContainer.addSubview(viewsAndRepliesControl) + viewsAndRepliesControl.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + viewsAndRepliesControl.topAnchor.constraint(equalTo: segmentedControlContainer.topAnchor, constant: 8), + viewsAndRepliesControl.leadingAnchor.constraint(greaterThanOrEqualTo: segmentedControlContainer.leadingAnchor), + viewsAndRepliesControl.centerXAnchor.constraint(equalTo: segmentedControlContainer.centerXAnchor), + viewsAndRepliesControl.bottomAnchor.constraint(equalTo: segmentedControlContainer.bottomAnchor), + ]) + + let vStack = UIStackView(arrangedSubviews: [segmentedControlContainer, pagingScrollView]) + vStack.axis = .vertical + vStack.alignment = .fill + vStack.spacing = 20 + view.addSubview(vStack) + vStack.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + vStack.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + vStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + vStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + vStack.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + + // Page container: same height as scroll view, twice the width (two full pages of content). + let pageContainer = UIView() + pagingScrollView.addSubview(pageContainer) + pageContainer.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + pageContainer.topAnchor.constraint(equalTo: pagingScrollView.contentLayoutGuide.topAnchor), + pageContainer.leadingAnchor.constraint(equalTo: pagingScrollView.contentLayoutGuide.leadingAnchor), + pageContainer.trailingAnchor.constraint(equalTo: pagingScrollView.contentLayoutGuide.trailingAnchor), + pageContainer.bottomAnchor.constraint(equalTo: pagingScrollView.contentLayoutGuide.bottomAnchor), + + pageContainer.heightAnchor.constraint(equalTo: pagingScrollView.frameLayoutGuide.heightAnchor), + pageContainer.widthAnchor.constraint(equalTo: pagingScrollView.frameLayoutGuide.widthAnchor, multiplier: 2), + ]) + + // Page 1: "Views" + addChild(viewsViewController) + pageContainer.addSubview(viewsViewController.view) + viewsViewController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + viewsViewController.view.topAnchor.constraint(equalTo: pageContainer.topAnchor), + viewsViewController.view.leadingAnchor.constraint(equalTo: pageContainer.leadingAnchor), + viewsViewController.view.widthAnchor.constraint(equalTo: pageContainer.widthAnchor, multiplier: 0.5), + viewsViewController.view.bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor), + ]) + + // Page 2: "Replies" + addChild(groupReplyViewController) + pageContainer.addSubview(groupReplyViewController.view) + groupReplyViewController.view.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + groupReplyViewController.view.topAnchor.constraint(equalTo: pageContainer.topAnchor), + groupReplyViewController.view.trailingAnchor.constraint(equalTo: pageContainer.trailingAnchor), + groupReplyViewController.view.widthAnchor.constraint(equalTo: pageContainer.widthAnchor, multiplier: 0.5), + groupReplyViewController.view.bottomAnchor.constraint(equalTo: pageContainer.bottomAnchor), + ]) + + pagingScrollViewObservation = pagingScrollView.observe(\.contentSize, changeHandler: { [weak self] _, _ in + self?.didUpdatePagingScrollViewContentSize() + }) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + if case .replies = focusedTab { + sheetPresentationController?.animateChanges { + sheetPresentationController?.selectedDetentIdentifier = .large + } + } + } + + private var pagingScrollViewObservation: NSKeyValueObservation? + + private func didUpdatePagingScrollViewContentSize() { + guard view.frame != .zero, pagingScrollView.contentSize.width > 0 else { return } + + // Only need to trigger once. + pagingScrollViewObservation = nil + + // Once we have a frame, we need to re-switch to the tab + switch focusedTab { + case .views: + switchToTab(.views, animated: false) + + case .replies: + switchToTab(.replies, animated: false) + groupReplyViewController.inputToolbar.becomeFirstResponder() + } + } + + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + super.dismiss(animated: flag) { [dismissHandler] in + completion?() + dismissHandler?() + } + } + + // MARK: - Tabs + + private var ignoreScrollViewDidScroll = false + + enum Tab: Int { + case views = 0 + case replies = 1 + } + + var focusedTab: Tab = .views + + private func switchBetweenRepliesAndViews() { + guard let newTab = Tab(rawValue: viewsAndRepliesControl.selectedSegmentIndex) else { return } + switchToTab(newTab, animated: true) + } + + private func switchToTab(_ tab: Tab, animated: Bool) { + if animated { + ignoreScrollViewDidScroll = true + } + focusedTab = tab + viewsAndRepliesControl.selectedSegmentIndex = tab.rawValue + + let xOffset: CGFloat = switch tab { + case .views: 0 + case .replies: pagingScrollView.width + } + pagingScrollView.setContentOffset(CGPoint(x: xOffset, y: 0), animated: animated) + } + + // MARK: - UIScrollViewDelegate + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + groupReplyViewController.inputToolbar.resignFirstResponder() + + guard ignoreScrollViewDidScroll == false else { return } + + let newTab: Tab = if scrollView.contentOffset.x < scrollView.width / 2 { .views } else { .replies } + guard focusedTab != newTab else { return } + + focusedTab = newTab + viewsAndRepliesControl.selectedSegmentIndex = focusedTab.rawValue + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + ignoreScrollViewDidScroll = false + } + + // MARK: - StoryGroupReplyDelegate + + func storyGroupReplyViewControllerDidBeginEditing(_ storyGroupReplyViewController: StoryGroupReplyViewController) { + sheetPresentationController?.animateChanges { + sheetPresentationController?.selectedDetentIdentifier = .large + } + } + + // MARK: - UIAdaptivePresentationControllerDelegate + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + dismissHandler?() + } +} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift index 6b1c752b31..e877010535 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyCell.swift @@ -47,8 +47,7 @@ class StoryGroupReplyCell: UITableViewCell { let imageView = UIImageView() imageView.contentMode = .scaleAspectFit imageView.image = UIImage(imageLiteralResourceName: "error-circle-20") - imageView.tintColor = .ows_accentRed - imageView.autoSetDimensions(to: .square(20)) + imageView.tintColor = .Signal.red let container = UIView() container.layoutMargins = UIEdgeInsets(hMargin: 12, vMargin: 0) @@ -254,7 +253,7 @@ class StoryGroupReplyCell: UITableViewCell { sendingSpinner.isHiddenInStackView = true sendingSpinner.autoSetDimension(.width, toSize: 12) - sendingSpinner.tintColor = Theme.darkThemePrimaryColor + sendingSpinner.tintColor = .Signal.label vStack.addArrangedSubview(internalHStack) @@ -305,7 +304,7 @@ class StoryGroupReplyCell: UITableViewCell { if item.wasRemotelyDeleted { return .attributedText(OWSLocalizedString("THIS_MESSAGE_WAS_DELETED", comment: "text indicating the message was remotely deleted").styled( with: .font(UIFont.dynamicTypeBodyClamped.italic()), - .color(.ows_gray05), + .color(.Signal.label), )) } else if cellType.isReaction { let reactionString: String @@ -316,7 +315,7 @@ class StoryGroupReplyCell: UITableViewCell { } return .attributedText(reactionString.styled( with: .font(.dynamicTypeBodyClamped), - .color(.ows_gray05), + .color(.Signal.label), .alignment(.natural), )) } else if let displayableText = item.displayableText { @@ -377,7 +376,7 @@ class StoryGroupReplyCell: UITableViewCell { // Style footer footerText.addAttributesToEntireString([ .font: UIFont.dynamicTypeCaption1Clamped, - .foregroundColor: UIColor.ows_gray25, + .foregroundColor: UIColor.Signal.secondaryLabel, ]) // Render footer inline if possible @@ -467,7 +466,7 @@ class StoryGroupReplyCell: UITableViewCell { let paragraphStyle = NSMutableParagraphStyle() paragraphStyle.alignment = textValue.naturalTextAligment let baseFont = UIFont.dynamicTypeBodyClamped - let baseTextColor = UIColor.ows_gray05 + let baseTextColor = UIColor.Signal.label let baseAttrs: [NSAttributedString.Key: Any] = [ .font: baseFont, .foregroundColor: baseTextColor, @@ -621,7 +620,10 @@ class StoryGroupReplyCell: UITableViewCell { private class SendingSpinner: UIImageView { init() { - super.init(image: #imageLiteral(resourceName: "message_status_sending").withRenderingMode(.alwaysTemplate).withAlignmentRectInsets(.init(hMargin: 0, vMargin: -2))) + super.init( + image: UIImage(imageLiteralResourceName: "message_status_sending") + .withAlignmentRectInsets(.init(hMargin: 0, vMargin: -2)), + ) startAnimating() } diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplySheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplySheet.swift deleted file mode 100644 index 1337bebbd6..0000000000 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplySheet.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import SignalServiceKit -import SignalUI - -class StoryGroupReplySheet: InteractiveSheetViewController, StoryGroupReplier { - override var interactiveScrollViews: [UIScrollView] { [groupReplyViewController.tableView] } - override var sheetBackgroundColor: UIColor { .ows_gray90 } - - private let groupReplyViewController: StoryGroupReplyViewController - - var dismissHandler: (() -> Void)? - - var storyMessage: StoryMessage { groupReplyViewController.storyMessage } - var threadUniqueId: String? { groupReplyViewController.thread?.uniqueId } - - init(storyMessage: StoryMessage, spoilerState: SpoilerRenderState) { - self.groupReplyViewController = StoryGroupReplyViewController(storyMessage: storyMessage, spoilerState: spoilerState) - - super.init() - - self.allowsExpansion = true - } - - override func viewDidLoad() { - super.viewDidLoad() - - minimizedHeight = super.maxHeight - - addChild(groupReplyViewController) - contentView.addSubview(groupReplyViewController.view) - groupReplyViewController.view.autoPinEdgesToSuperviewEdges() - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - groupReplyViewController.inputToolbar.becomeFirstResponder() - } - - override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - super.dismiss(animated: flag) { [dismissHandler] in - completion?() - dismissHandler?() - } - } -} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift index bf3844e072..9f51e35997 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/Group Reply Sheet/StoryGroupReplyViewController.swift @@ -10,13 +10,18 @@ protocol StoryGroupReplyDelegate: AnyObject { func storyGroupReplyViewControllerDidBeginEditing(_ storyGroupReplyViewController: StoryGroupReplyViewController) } -class StoryGroupReplyViewController: OWSViewController, StoryReplySheet, StoryGroupReplyMessageResendDelegate { +class StoryGroupReplyViewController: OWSViewController, ContextMenuInteractionDelegate, DatabaseChangeDelegate, + StoryGroupReplyMessageResendDelegate, StoryReplyInputToolbarDelegate, StoryReplySheet, + UIAdaptivePresentationControllerDelegate, UIScrollViewDelegate, UITableViewDelegate, UITableViewDataSource +{ weak var delegate: StoryGroupReplyDelegate? private(set) lazy var tableView = UITableView() private let spoilerState: SpoilerRenderState + var dismissHandler: (() -> Void)? + let bottomBar = UIView() private(set) lazy var inputToolbar = StoryReplyInputToolbar(isGroupStory: true, spoilerState: spoilerState) private lazy var contextMenu = ContextMenuInteraction(delegate: self) @@ -31,80 +36,122 @@ class StoryGroupReplyViewController: OWSViewController, StoryReplySheet, StoryGr private lazy var emptyStateView: UIView = { let label = UILabel() - label.textColor = .ows_gray45 + label.textColor = .Signal.secondaryLabel label.textAlignment = .center label.numberOfLines = 2 label.attributedText = NSAttributedString( string: OWSLocalizedString("STORIES_NO_REPLIES_YET", comment: "Indicates that this story has no replies yet"), - attributes: [NSAttributedString.Key.font: UIFont.dynamicTypeHeadline], + attributes: [.font: UIFont.dynamicTypeHeadline], ).stringByAppendingString( "\n", ).stringByAppendingString( OWSLocalizedString("STORIES_NO_REPLIES_SUBTITLE", comment: "The subtitle when this story has no replies"), - attributes: [NSAttributedString.Key.font: UIFont.dynamicTypeSubheadline], + attributes: [.font: UIFont.dynamicTypeSubheadline], ) - label.isHidden = true label.isUserInteractionEnabled = false - return label + + let view = UIView() + view.isHidden = true + + label.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(label) + NSLayoutConstraint.activate([ + label.topAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.topAnchor), + label.centerYAnchor.constraint(equalTo: view.layoutMarginsGuide.centerYAnchor), + label.leadingAnchor.constraint(equalTo: view.leadingAnchor), + label.trailingAnchor.constraint(equalTo: view.trailingAnchor), + ]) + + return view }() let storyMessage: StoryMessage + + // This VC also gets embedded as a child VC into StoryGroupRepliesAndViewsViewController. + // Distinguish that vs when this VC is presented on its own. + private let isStandaloneVC: Bool + lazy var thread: TSThread? = SSKEnvironment.shared.databaseStorageRef.read { storyMessage.context.thread(transaction: $0) } - init(storyMessage: StoryMessage, spoilerState: SpoilerRenderState) { + init(storyMessage: StoryMessage, spoilerState: SpoilerRenderState, isStandaloneVC: Bool) { self.storyMessage = storyMessage self.spoilerState = spoilerState + self.isStandaloneVC = isStandaloneVC super.init() DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self) + + overrideUserInterfaceStyle = .dark + + if isStandaloneVC { + modalPresentationStyle = .pageSheet + presentationController?.delegate = self + + if let sheetPresentationController { + if #available(iOS 17.0, *) { + sheetPresentationController.traitOverrides.userInterfaceStyle = .dark + } else { + sheetPresentationController.overrideTraitCollection = UITraitCollection(userInterfaceStyle: .dark) + } + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.prefersGrabberVisible = true + } + } } fileprivate var replyLoader: StoryGroupReplyLoader? + override func viewDidLoad() { super.viewDidLoad() + view.preservesSuperviewLayoutMargins = true + tableView.delegate = self tableView.dataSource = self tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension tableView.keyboardDismissMode = .interactive - tableView.backgroundColor = .ows_gray90 + tableView.backgroundColor = .clear tableView.addInteraction(contextMenu) + for type in StoryGroupReplyCell.CellType.all { + tableView.register(StoryGroupReplyCell.self, forCellReuseIdentifier: type.rawValue) + } + + inputToolbar.delegate = self view.addSubview(tableView) tableView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - tableView.topAnchor.constraint(equalTo: view.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - ]) - inputToolbar.delegate = self + bottomBar.preservesSuperviewLayoutMargins = true view.addSubview(bottomBar) bottomBar.translatesAutoresizingMaskIntoConstraints = false + + view.insertSubview(emptyStateView, belowSubview: bottomBar) + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.layoutMarginsGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + bottomBar.topAnchor.constraint(equalTo: tableView.bottomAnchor), bottomBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), bottomBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), bottomBar.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor), + + emptyStateView.topAnchor.constraint(equalTo: view.topAnchor), + emptyStateView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor), + emptyStateView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor), + emptyStateView.bottomAnchor.constraint(equalTo: bottomBar.topAnchor), ]) // Its a bit silly but this is the easiest way to capture touches // and not let them pass up to any parent scrollviews. pans inside the // bottom bar shouldn't scroll anything. bottomBar.addGestureRecognizer(UIPanGestureRecognizer()) - for type in StoryGroupReplyCell.CellType.all { - tableView.register(StoryGroupReplyCell.self, forCellReuseIdentifier: type.rawValue) - } - replyLoader = StoryGroupReplyLoader(storyMessage: storyMessage, threadUniqueId: thread?.uniqueId, tableView: tableView) - view.insertSubview(emptyStateView, belowSubview: bottomBar) - emptyStateView.autoPinWidthToSuperview() - emptyStateView.autoPinEdge(toSuperviewEdge: .top) - emptyStateView.autoPinEdge(.bottom, to: .top, of: bottomBar) - updateBottomBarContents() } @@ -117,61 +164,50 @@ class StoryGroupReplyViewController: OWSViewController, StoryReplySheet, StoryGr return owsFailDebug("Unexpectedly missing group thread") } - if groupThread.canSendChatMessagesToThread() { - switch bottomBarMode { - case .member: - // Nothing to do, we're already in the right state - break - case .nonMember, .blockedByAnnouncementOnly, .none: - bottomBar.removeAllSubviews() - bottomBar.addSubview(inputToolbar) - inputToolbar.autoPinEdgesToSuperviewEdges() - } - - bottomBarMode = .member + let newBottomBarMode: BottomBarMode = if groupThread.canSendChatMessagesToThread() { + .member } else if groupThread.isBlockedByAnnouncementOnly { - switch bottomBarMode { - case .blockedByAnnouncementOnly: - // Nothing to do, we're already in the right state - break - case .member, .nonMember, .none: - bottomBar.removeAllSubviews() - - let view = BlockingAnnouncementOnlyView(thread: groupThread, fromViewController: self, forceDarkMode: true) - bottomBar.addSubview(view) - view.autoPinWidthToSuperview() - view.autoPinEdge(toSuperviewEdge: .top, withInset: 8) - view.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 8) - } - - bottomBarMode = .blockedByAnnouncementOnly + .blockedByAnnouncementOnly } else { - switch bottomBarMode { - case .nonMember: - // Nothing to do, we're already in the right state - break - case .member, .blockedByAnnouncementOnly, .none: - bottomBar.removeAllSubviews() + .nonMember + } - let label = UILabel() - label.font = .dynamicTypeSubheadline - label.text = OWSLocalizedString( - "STORIES_GROUP_REPLY_NOT_A_MEMBER", - comment: "Text indicating you can't reply to a group story because you're not a member of the group", - ) - label.textColor = .ows_gray05 - label.textAlignment = .center - label.numberOfLines = 0 - label.alpha = 0.7 - label.setContentHuggingVerticalHigh() + guard bottomBarMode != newBottomBarMode else { return } - bottomBar.addSubview(label) - label.autoPinWidthToSuperview(withMargin: 37) - label.autoPinEdge(toSuperviewEdge: .top, withInset: 8) - label.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 8) - } + bottomBarMode = newBottomBarMode + bottomBar.removeAllSubviews() - bottomBarMode = .nonMember + switch bottomBarMode { + case .member: + bottomBar.addSubview(inputToolbar) + inputToolbar.autoPinEdgesToSuperviewEdges() + + case .nonMember: + let label = UILabel() + label.font = .dynamicTypeSubheadline + label.text = OWSLocalizedString( + "STORIES_GROUP_REPLY_NOT_A_MEMBER", + comment: "Text indicating you can't reply to a group story because you're not a member of the group", + ) + label.textColor = .Signal.secondaryLabel + label.textAlignment = .center + label.numberOfLines = 0 + label.setContentHuggingVerticalHigh() + + bottomBar.addSubview(label) + label.autoPinWidthToSuperviewMargins() + label.autoPinEdge(toSuperviewEdge: .top, withInset: 8) + label.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 8) + + case .blockedByAnnouncementOnly: + let view = BlockingAnnouncementOnlyView(thread: groupThread, fromViewController: self, forceDarkMode: true) + bottomBar.addSubview(view) + view.autoPinWidthToSuperview() + view.autoPinEdge(toSuperviewEdge: .top, withInset: 8) + view.autoPinEdge(toSuperviewSafeArea: .bottom, withInset: 8) + + case .none: + owsFailDebug("Invalid state") } } @@ -199,9 +235,9 @@ class StoryGroupReplyViewController: OWSViewController, StoryReplySheet, StoryGr self.present(promptBuilder.build(for: message, isTerminatedGroup: isTerminatedGroupThread), animated: true) } -} -extension StoryGroupReplyViewController: UIScrollViewDelegate { + // MARK: - UIScrollViewDelegate + func scrollViewDidScroll(_ scrollView: UIScrollView) { guard let visibleRows = tableView.indexPathsForVisibleRows?.map({ $0.row }), @@ -220,9 +256,9 @@ extension StoryGroupReplyViewController: UIScrollViewDelegate { replyLoader?.loadNewerPageIfNecessary() } } -} -extension StoryGroupReplyViewController: UITableViewDelegate { + // MARK: - UITableView + func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { guard let cell = cell as? StoryGroupReplyCell else { return @@ -236,9 +272,7 @@ extension StoryGroupReplyViewController: UITableViewDelegate { } cell.setIsCellVisible(false) } -} -extension StoryGroupReplyViewController: UITableViewDataSource { func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { guard let item = replyLoader?.replyItem(for: indexPath) else { owsFailDebug("Missing item for cell at indexPath \(indexPath)") @@ -260,15 +294,15 @@ extension StoryGroupReplyViewController: UITableViewDataSource { emptyStateView.isHidden = numberOfRows > 0 return numberOfRows } -} -extension StoryGroupReplyViewController: StoryReplyInputToolbarDelegate { + // MARK: - StoryReplyInputToolbarDelegate + func storyReplyInputToolbarDidBeginEditing(_ storyReplyInputToolbar: StoryReplyInputToolbar) { delegate?.storyGroupReplyViewControllerDidBeginEditing(self) } -} -extension StoryGroupReplyViewController: ContextMenuInteractionDelegate { + // MARK: - ContextMenuInteractionDelegate + func contextMenuInteraction(_ interaction: ContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> ContextMenuConfiguration? { guard let indexPath = tableView.indexPathForRow(at: location), @@ -333,9 +367,9 @@ extension StoryGroupReplyViewController: ContextMenuInteractionDelegate { func contextMenuInteraction(_ interaction: ContextMenuInteraction, willEndForConfiguration: ContextMenuConfiguration) {} func contextMenuInteraction(_ interaction: ContextMenuInteraction, didEndForConfiguration configuration: ContextMenuConfiguration) {} -} -extension StoryGroupReplyViewController: DatabaseChangeDelegate { + // MARK: - DatabaseChangeDelegate + func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) { guard let thread, databaseChanges.didUpdate(thread: thread) else { return } updateBottomBarContents() @@ -348,4 +382,10 @@ extension StoryGroupReplyViewController: DatabaseChangeDelegate { func databaseChangesDidReset() { updateBottomBarContents() } + + // MARK: - UIAdaptivePresentationControllerDelegate + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + dismissHandler?() + } } diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryDirectReplySheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryDirectReplySheet.swift index 9b908adb8f..80b0683c81 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryDirectReplySheet.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryDirectReplySheet.swift @@ -4,9 +4,9 @@ // import SignalServiceKit -public import SignalUI +import SignalUI -public class StoryDirectReplySheet: OWSViewController, StoryReplySheet { +class StoryDirectReplySheet: OWSViewController, StoryReplySheet { var dismissHandler: (() -> Void)? @@ -33,19 +33,20 @@ public class StoryDirectReplySheet: OWSViewController, StoryReplySheet { self.spoilerState = spoilerState super.init() modalPresentationStyle = .custom + overrideUserInterfaceStyle = .dark } - override public func viewWillAppear(_ animated: Bool) { + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) inputToolbar.becomeFirstResponder() } - override public func viewWillDisappear(_ animated: Bool) { + override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) inputToolbar.resignFirstResponder() } - override public func viewDidLoad() { + override func viewDidLoad() { super.viewDidLoad() let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) @@ -68,7 +69,7 @@ public class StoryDirectReplySheet: OWSViewController, StoryReplySheet { dismiss(animated: true) } - override public func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { + override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { // We don't want `inputToolbar` to stay attached to the keyboard's layout guide during dismiss animation // as this creates unpleasant animations where the bar flies across the screen. // To workaround that we freeze vertical position of the `inputToolbar` diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryPrivateViewsSheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryPrivateViewsSheet.swift deleted file mode 100644 index 77d7605b0a..0000000000 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryPrivateViewsSheet.swift +++ /dev/null @@ -1,38 +0,0 @@ -// -// Copyright 2022 Signal Messenger, LLC -// SPDX-License-Identifier: AGPL-3.0-only -// - -import SignalServiceKit -import SignalUI - -class StoryPrivateViewsSheet: InteractiveSheetViewController { - override var interactiveScrollViews: [UIScrollView] { [viewsViewController.tableView] } - override var sheetBackgroundColor: UIColor { .ows_gray90 } - - var dismissHandler: (() -> Void)? - - let viewsViewController: StoryViewsViewController - - init(storyMessage: StoryMessage, context: StoryContext) { - viewsViewController = StoryViewsViewController(storyMessage: storyMessage, context: context) - super.init() - } - - override func viewDidLoad() { - super.viewDidLoad() - - minimizedHeight = CurrentAppContext().frame.height * 0.6 - - addChild(viewsViewController) - contentView.addSubview(viewsViewController.view) - viewsViewController.view.autoPinEdgesToSuperviewEdges() - } - - override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) { - super.dismiss(animated: flag) { [dismissHandler] in - completion?() - dismissHandler?() - } - } -} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift index b78396e0fe..baebac5399 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplyInputToolbar.swift @@ -6,7 +6,6 @@ import LibSignalClient import SignalServiceKit import SignalUI -import UIKit // Coincides with Android's max text message length let kMaxMessageBodyCharacterCount = 2000 @@ -21,9 +20,9 @@ protocol StoryReplyInputToolbarDelegate: MessageReactionPickerDelegate { func storyReplyInputToolbarMentionPickerParentView(_ storyReplyInputToolbar: StoryReplyInputToolbar) -> UIView? } -// MARK: - +class StoryReplyInputToolbar: UIView, BodyRangesTextViewDelegate { -class StoryReplyInputToolbar: UIView { + // MARK: - Public weak var delegate: StoryReplyInputToolbarDelegate? { didSet { @@ -32,6 +31,7 @@ class StoryReplyInputToolbar: UIView { } let isGroupStory: Bool + let quotedReplyModel: QuotedReplyModel? let spoilerState: SpoilerRenderState @@ -45,23 +45,7 @@ class StoryReplyInputToolbar: UIView { updateContent(animated: true) } - override var bounds: CGRect { - didSet { - guard oldValue.height != bounds.height else { return } - delegate?.storyReplyInputToolbarHeightDidChange(self) - } - } - - private let minTextViewHeight: CGFloat = 36 - private var maxTextViewHeight: CGFloat { - // About ~4 lines in portrait and ~3 lines in landscape. - // Otherwise we risk obscuring too much of the content. - return UIDevice.current.orientation.isPortrait ? 160 : 100 - } - - private var textViewHeightConstraint: NSLayoutConstraint? - - // MARK: - Initializers + // MARK: - UIView init( isGroupStory: Bool, @@ -71,70 +55,129 @@ class StoryReplyInputToolbar: UIView { self.isGroupStory = isGroupStory self.quotedReplyModel = quotedReplyModel self.spoilerState = spoilerState + super.init(frame: CGRect.zero) - // When presenting or dismissing the keyboard, there may be a slight - // gap between the keyboard and the bottom of the input bar during - // the animation. Extend the background below the toolbar's bounds - // by this much to mask that extra space. - let backgroundExtension: CGFloat = 500 + // Blur background on legacy (pre-iOS 26 iOS versions). + if #unavailable(iOS 26) { + // When presenting or dismissing the keyboard, there may be a slight + // gap between the keyboard and the bottom of the input bar during + // the animation. Extend the background below the toolbar's bounds + // by this much to mask that extra space. + let backgroundExtension: CGFloat = 500 - if UIAccessibility.isReduceTransparencyEnabled { - backgroundColor = .ows_black + if UIAccessibility.isReduceTransparencyEnabled { + backgroundColor = .Signal.background - let extendedBackground = UIView() - addSubview(extendedBackground) - extendedBackground.autoPinWidthToSuperview() - extendedBackground.autoPinEdge(.top, to: .bottom, of: self) - extendedBackground.autoSetDimension(.height, toSize: backgroundExtension) - } else { - backgroundColor = .clear - - let blurEffect: UIBlurEffect - if quotedReplyModel != nil { - blurEffect = UIBlurEffect(style: .systemThickMaterialDark) + let extendedBackground = UIView() + addSubview(extendedBackground) + extendedBackground.autoPinWidthToSuperview() + extendedBackground.autoPinEdge(.top, to: .bottom, of: self) + extendedBackground.autoSetDimension(.height, toSize: backgroundExtension) } else { - blurEffect = Theme.darkThemeBarBlurEffect + backgroundColor = .clear + + let blurEffect: UIBlurEffect + if quotedReplyModel != nil { + blurEffect = UIBlurEffect(style: .systemThickMaterialDark) + } else { + blurEffect = Theme.darkThemeBarBlurEffect + } + let blurEffectView = UIVisualEffectView(effect: blurEffect) + blurEffectView.layer.zPosition = -1 + addSubview(blurEffectView) + blurEffectView.autoPinWidthToSuperview() + blurEffectView.autoPinEdge(toSuperviewEdge: .top) + blurEffectView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -backgroundExtension) } - let blurEffectView = UIVisualEffectView(effect: blurEffect) - blurEffectView.layer.zPosition = -1 - addSubview(blurEffectView) - blurEffectView.autoPinWidthToSuperview() - blurEffectView.autoPinEdge(toSuperviewEdge: .top) - blurEffectView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -backgroundExtension) } - textView.bodyRangesDelegate = self + let containerView: UIView + let contentView: UIView + if #available(iOS 26, *) { + let glassContainer = UIVisualEffectView(effect: UIGlassContainerEffect()) - // The input toolbar should *always* be laid out left-to-right, even when using - // a right-to-left language. The convention for messaging apps is for the send - // button to always be to the right of the input field, even in RTL layouts. - // This means, in most places you'll want to pin deliberately to left/right - // instead of leading/trailing. You'll also want to the semanticContentAttribute - // to ensure horizontal stack views layout left-to-right. + containerView = glassContainer + contentView = glassContainer.contentView + } else { + containerView = UIView() + contentView = containerView + } + contentView.semanticContentAttribute = .forceLeftToRight - let containerView = UIView.container() + containerView.translatesAutoresizingMaskIntoConstraints = false addSubview(containerView) - containerView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) - containerView.autoPinEdge(toSuperviewSafeArea: .bottom) + NSLayoutConstraint.activate([ + containerView.topAnchor.constraint(equalTo: topAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor), + containerView.trailingAnchor.constraint(equalTo: trailingAnchor), + containerView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor), + ]) - containerView.addSubview(reactionPicker) - reactionPicker.overrideUserInterfaceStyle = .dark - reactionPicker.autoPinEdges(toSuperviewEdgesExcludingEdge: .bottom) + // On iOS 26 and later reaction picker must be wrapped into a glass panel. + let reactionPickerHMargin: CGFloat + let reactionPickerBottomPadding: CGFloat + let reactionPickerView: UIView + if #available(iOS 26, *) { + let glassEffect = ConversationInputToolbar.Style.glassEffect(isInteractive: true) + let reactionPickerPanel = UIVisualEffectView(effect: glassEffect) + reactionPickerPanel.directionalLayoutMargins = .zero + reactionPickerPanel.cornerConfiguration = .capsule() + reactionPickerPanel.contentView.addSubview(reactionPicker) + reactionPicker.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + reactionPicker.topAnchor.constraint(equalTo: reactionPickerPanel.layoutMarginsGuide.topAnchor), + reactionPicker.leadingAnchor.constraint(equalTo: reactionPickerPanel.layoutMarginsGuide.leadingAnchor), + reactionPicker.trailingAnchor.constraint(equalTo: reactionPickerPanel.layoutMarginsGuide.trailingAnchor), + reactionPicker.bottomAnchor.constraint(equalTo: reactionPickerPanel.layoutMarginsGuide.bottomAnchor), + ]) - containerView.addSubview(textContainer) - textContainer.autoPinEdge(toSuperviewMargin: .left, withInset: OWSTableViewController2.defaultHOuterMargin) - textContainer.autoPinEdge(.top, to: .bottom, of: reactionPicker) - textContainer.autoPinEdge(toSuperviewMargin: .bottom, withInset: 8) - textContainer.autoPinEdge(toSuperviewEdge: .right, withInset: OWSTableViewController2.defaultHOuterMargin, relation: .greaterThanOrEqual) + reactionPickerView = reactionPickerPanel + reactionPickerHMargin = OWSTableViewController2.defaultHOuterMargin + reactionPickerBottomPadding = 8 + } else { + reactionPickerView = reactionPicker + reactionPickerHMargin = 0 + reactionPickerBottomPadding = 0 + } - containerView.addSubview(rightEdgeControlsView) - rightEdgeControlsView.autoPinEdge(toSuperviewEdge: .right, withInset: 2) - rightEdgeControlsView.autoPinEdge(toSuperviewEdge: .bottom) - rightEdgeControlsView.autoPinEdge(.left, to: .right, of: textContainer, withOffset: 2) - rightEdgeControlsView.autoAlignAxis(.horizontal, toSameAxisOf: textContainer) + reactionPickerView.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(reactionPickerView) - textViewHeightConstraint = textView.autoSetDimension(.height, toSize: minTextViewHeight) + textContainer.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(textContainer) + + sendButtonWrapper.translatesAutoresizingMaskIntoConstraints = false + contentView.addSubview(sendButtonWrapper) + + // No Send button visible: text view's trailing edge is pinned to containerView's trailing edge. + textViewContainerTrailingEdgeConstraintNoSendButton = textContainer.trailingAnchor.constraint( + equalTo: containerView.trailingAnchor, + ) + // Send button visible: trailing edge of text view's background (which is defined by textContainer.layoutMarginsGuide) + // is pinned to the leading edge of the `rightEdgeControlsView`. + // RightEdgeControlsView has a leading margin that defines spacing between send button and text view. + textViewContainerTrailingEdgeConstraintSendButton = textContainer.layoutMarginsGuide.trailingAnchor.constraint( + equalTo: sendButtonWrapper.leadingAnchor, + ) + + NSLayoutConstraint.activate([ + // Reaction picker: full width, above text view. + reactionPickerView.topAnchor.constraint(equalTo: containerView.topAnchor), + reactionPickerView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: reactionPickerHMargin), + reactionPickerView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -reactionPickerHMargin), + + // Text container: + // under reaction picker, pinned to the left edge, with Send button on the right. + textContainer.topAnchor.constraint(equalTo: reactionPickerView.bottomAnchor, constant: reactionPickerBottomPadding), + textContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + textContainer.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + sendButtonWrapper.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + sendButtonWrapper.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + + textViewContainerTrailingEdgeConstraintNoSendButton, + ]) updateContent(animated: false) } @@ -143,21 +186,56 @@ class StoryReplyInputToolbar: UIView { fatalError("init(coder:) has not been implemented") } - // MARK: - UIView Overrides + override var bounds: CGRect { + didSet { + guard oldValue.height != bounds.height else { return } + delegate?.storyReplyInputToolbarHeightDidChange(self) + } + } // Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify // an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout. override var intrinsicContentSize: CGSize { .zero } + @discardableResult + override func becomeFirstResponder() -> Bool { + textView.becomeFirstResponder() + } + + @discardableResult + override func resignFirstResponder() -> Bool { + textView.resignFirstResponder() + } + // MARK: - Subviews - private lazy var rightEdgeControlsView: RightEdgeControlsView = { - let view = RightEdgeControlsView() - view.sendButton.addTarget(self, action: #selector(didTapSend), for: .touchUpInside) + // Copied from ConversationInputToolbar + private enum LayoutMetrics { + static let initialTextBoxHeight: CGFloat = 40 + static let minTextViewHeight: CGFloat = 35 + static var maxTextViewHeight: CGFloat { + // About ~4 lines in portrait and ~3 lines in landscape. + // Otherwise we risk obscuring too much of the content. + UIDevice.current.orientation.isPortrait ? 160 : 100 + } + } + + private var textViewHeightConstraint: NSLayoutConstraint! + private var textViewContainerTrailingEdgeConstraintNoSendButton: NSLayoutConstraint! + private var textViewContainerTrailingEdgeConstraintSendButton: NSLayoutConstraint! + + private lazy var sendButtonWrapper: SendButtonWrapper = { + let view = SendButtonWrapper() + view.sendButton.addAction( + UIAction { [weak self] _ in + self?.didTapSend() + }, + for: .primaryActionTriggered, + ) return view }() - private class RightEdgeControlsView: UIView { + private class SendButtonWrapper: UIView { var sendButtonHidden = true { didSet { sendButton.alpha = sendButtonHidden ? 0 : 1 @@ -166,24 +244,75 @@ class StoryReplyInputToolbar: UIView { } } - lazy var sendButton: UIButton = { - let button = UIButton(type: .system) + private static let legacySendButtonInnerHMargin: CGFloat = 8 // 48 dp button width + private static let legacySendButtonInnerVMargin: CGFloat = 4 // 40 dp (LayoutMetrics.initialTextBoxHeight) button height + + @available(iOS, deprecated: 26) + private func buildSendButtonLegacy() -> UIButton { + let button = UIButton(configuration: .plain()) button.accessibilityLabel = MessageStrings.sendButton button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "sendButton") - button.setImage(UIImage(imageLiteralResourceName: "send-blue-32"), for: .normal) - button.bounds.size = .init(width: 48, height: 48) + button.configurationUpdateHandler = { button in + button.alpha = button.isHighlighted ? 0.5 : 1 + } + button.configuration?.image = UIImage(named: "send-blue-32") + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + hMargin: SendButtonWrapper.legacySendButtonInnerHMargin, + vMargin: SendButtonWrapper.legacySendButtonInnerVMargin, + ) return button - }() + } + + @available(iOS 26, *) + private func buildSendButton() -> UIButton { + let buttonSize = LayoutMetrics.initialTextBoxHeight + let buttonImage = Theme.iconImage(.arrowUp) + + let button = UIButton(configuration: .prominentGlass()) + button.tintColor = .Signal.accent + button.configuration?.image = buttonImage + button.configuration?.baseForegroundColor = .white + button.configuration?.cornerStyle = .capsule + button.configuration?.contentInsets = NSDirectionalEdgeInsets( + hMargin: 0.5 * (buttonSize - buttonImage.size.width), + vMargin: 0.5 * (buttonSize - buttonImage.size.height), + ) + button.accessibilityLabel = MessageStrings.sendButton + return button + } + + lazy var sendButton: UIButton = if #available(iOS 26, *) { buildSendButton() } else { buildSendButtonLegacy() } override init(frame: CGRect) { super.init(frame: frame) + + directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 0, + // Spacing between text view and send button. + leading: 12, + // Same as in `textContainer` + bottom: 8, + // Spacing between Send button and trailing edge of the screen. + trailing: OWSTableViewController2.defaultHOuterMargin, + ) + + // Legacy button has 8 dp margins around circular icon. + // Subtract that amount from leading and trailing margings to compensate for it. + if #unavailable(iOS 26) { + directionalLayoutMargins.leading -= SendButtonWrapper.legacySendButtonInnerHMargin + directionalLayoutMargins.trailing -= SendButtonWrapper.legacySendButtonInnerHMargin + } + sendButton.setContentHuggingHorizontalHigh() sendButton.setCompressionResistanceHorizontalHigh() addSubview(sendButton) - sendButton.autoCenterInSuperview() - - setContentHuggingHigh() - setCompressionResistanceHigh() + sendButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + sendButton.topAnchor.constraint(greaterThanOrEqualTo: layoutMarginsGuide.topAnchor), + sendButton.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor), + sendButton.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + sendButton.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + ]) } required init?(coder: NSCoder) { @@ -197,21 +326,11 @@ class StoryReplyInputToolbar: UIView { private lazy var textView: BodyRangesTextView = { let textView = buildTextView() - textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3) + textView.verticalScrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3) textView.bodyRangesDelegate = self return textView }() - @discardableResult - override func becomeFirstResponder() -> Bool { - textView.becomeFirstResponder() - } - - @discardableResult - override func resignFirstResponder() -> Bool { - textView.resignFirstResponder() - } - private lazy var reactionPicker: MessageReactionPicker = MessageReactionPicker(selectedEmoji: nil, delegate: delegate, style: .inline) private lazy var placeholderTextView: UITextView = { @@ -244,48 +363,105 @@ class StoryReplyInputToolbar: UIView { placeholderTextView.isEditable = false placeholderTextView.textContainer.maximumNumberOfLines = 1 placeholderTextView.textContainer.lineBreakMode = .byTruncatingTail - placeholderTextView.textColor = .ows_whiteAlpha60 + placeholderTextView.textColor = .Signal.secondaryLabel return placeholderTextView }() private lazy var textContainer: UIView = { - let textContainer = UIStackView() - textContainer.axis = .vertical + let textContainer = UIView() - let bubbleView = UIStackView() - bubbleView.axis = .vertical - bubbleView.addBackgroundView(withBackgroundColor: .ows_gray75, cornerRadius: minTextViewHeight / 2) - textContainer.addArrangedSubview(bubbleView) + // Controls padding around the text view background. + textContainer.directionalLayoutMargins = NSDirectionalEdgeInsets( + top: 0, + leading: OWSTableViewController2.defaultHOuterMargin, + bottom: 8, // spacing to keyboard + trailing: OWSTableViewController2.defaultHOuterMargin, + ) - let textAndPlaceholderContainer = UIView() - bubbleView.addArrangedSubview(textAndPlaceholderContainer) + let backgroundView: UIView + if #available(iOS 26, *) { + let glassEffect = ConversationInputToolbar.Style.glassEffect(isInteractive: true) + let glassEffectView = UIVisualEffectView(effect: glassEffect) + glassEffectView.cornerConfiguration = .uniformCorners(radius: .fixed(LayoutMetrics.initialTextBoxHeight / 2)) - textAndPlaceholderContainer.addSubview(placeholderTextView) - textAndPlaceholderContainer.addSubview(textView) + glassEffectView.translatesAutoresizingMaskIntoConstraints = false + textContainer.addSubview(glassEffectView) - textView.autoPinEdgesToSuperviewEdges() - placeholderTextView.autoPinEdges(toEdgesOf: textView) + placeholderTextView.translatesAutoresizingMaskIntoConstraints = false + glassEffectView.contentView.addSubview(placeholderTextView) + + textView.translatesAutoresizingMaskIntoConstraints = false + glassEffectView.contentView.addSubview(textView) + + backgroundView = glassEffectView + } else { + backgroundView = UIView() + backgroundView.backgroundColor = UIColor.Signal.tertiaryFill + backgroundView.layer.cornerRadius = LayoutMetrics.initialTextBoxHeight / 2 + + backgroundView.translatesAutoresizingMaskIntoConstraints = false + textContainer.addSubview(backgroundView) + + placeholderTextView.translatesAutoresizingMaskIntoConstraints = false + textContainer.addSubview(placeholderTextView) + + textView.translatesAutoresizingMaskIntoConstraints = false + textContainer.addSubview(textView) + } + + backgroundView.directionalLayoutMargins = .zero + + textView.translatesAutoresizingMaskIntoConstraints = false + textViewHeightConstraint = textView.heightAnchor.constraint(equalToConstant: LayoutMetrics.minTextViewHeight) + + NSLayoutConstraint.activate([ + // Background view is constrained to container's layout margins. + // Change those to adjust outer padding around the background. + backgroundView.topAnchor.constraint(equalTo: textContainer.layoutMarginsGuide.topAnchor), + backgroundView.leadingAnchor.constraint(equalTo: textContainer.layoutMarginsGuide.leadingAnchor), + backgroundView.trailingAnchor.constraint(equalTo: textContainer.layoutMarginsGuide.trailingAnchor), + backgroundView.bottomAnchor.constraint(equalTo: textContainer.layoutMarginsGuide.bottomAnchor), + + // This sets minimum height on visual text view box. This height can exceed height of an empty inputTextView. + // We don't want `textView` to grow above it's content size because that causes + // incorrect (top) alignment of text when there's just a single line of it. + backgroundView.heightAnchor.constraint(greaterThanOrEqualToConstant: LayoutMetrics.initialTextBoxHeight), + + // This defines height of `textView` which is always set to content size. Calculated in `updateHeight(textView:)` + textViewHeightConstraint, + + // This lets `textContainer` grow with `textView` when height of the latter increases with text. + // Working in conjuction with the next constraint they center `textView` vertically + // when it's height is below the minimum height of `backgroundView`. + textView.topAnchor.constraint(greaterThanOrEqualTo: backgroundView.topAnchor), + textView.centerYAnchor.constraint(equalTo: backgroundView.centerYAnchor), + + // Adjust trailing and leading margins on the backgroundView to control inner horizontal padding. + textView.leadingAnchor.constraint(equalTo: backgroundView.layoutMarginsGuide.leadingAnchor), + textView.trailingAnchor.constraint(equalTo: backgroundView.layoutMarginsGuide.trailingAnchor), + + // Placeholder text view is always same frame as active text view. + placeholderTextView.topAnchor.constraint(equalTo: textView.topAnchor), + placeholderTextView.leadingAnchor.constraint(equalTo: textView.leadingAnchor), + placeholderTextView.trailingAnchor.constraint(equalTo: textView.trailingAnchor), + placeholderTextView.bottomAnchor.constraint(equalTo: textView.bottomAnchor), + ]) return textContainer }() private func buildTextView() -> BodyRangesTextView { let textView = BodyRangesTextView() - - textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance + textView.textColor = .Signal.label + textView.tintColor = .Signal.label // cursor color textView.backgroundColor = .clear - textView.tintColor = Theme.darkThemePrimaryColor - - let textViewFont = UIFont.dynamicTypeBody - textView.font = textViewFont - textView.textColor = Theme.darkThemePrimaryColor + textView.font = .dynamicTypeBody return textView } // MARK: - Actions - @objc private func didTapSend() { textView.acceptAutocorrectSuggestion() Task { @@ -296,8 +472,6 @@ class StoryReplyInputToolbar: UIView { // MARK: - Helpers private func updateContent(animated: Bool) { - AssertIsOnMainThread() - updateHeight(textView: textView) let hasAnyText = !textView.isEmpty @@ -315,13 +489,17 @@ class StoryReplyInputToolbar: UIView { isSendButtonHidden = isHidden guard animated else { - self.rightEdgeControlsView.sendButtonHidden = isHidden + sendButtonWrapper.sendButtonHidden = isHidden + textViewContainerTrailingEdgeConstraintSendButton.isActive = isHidden == false + textViewContainerTrailingEdgeConstraintNoSendButton.isActive = isHidden == true return } let animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 0.645, springResponse: 0.25) animator.addAnimations { - self.rightEdgeControlsView.sendButtonHidden = isHidden + self.sendButtonWrapper.sendButtonHidden = isHidden + self.textViewContainerTrailingEdgeConstraintSendButton.isActive = isHidden == false + self.textViewContainerTrailingEdgeConstraintNoSendButton.isActive = isHidden == true self.layoutIfNeeded() } animator.startAnimation() @@ -334,7 +512,11 @@ class StoryReplyInputToolbar: UIView { } let contentSize = textView.sizeThatFits(CGSize(width: textView.frame.width, height: .greatestFiniteMagnitude)) - let newHeight = CGFloat.clamp(contentSize.height, min: minTextViewHeight, max: maxTextViewHeight) + let newHeight = CGFloat.clamp( + contentSize.height, + min: LayoutMetrics.minTextViewHeight, + max: LayoutMetrics.maxTextViewHeight, + ) guard textViewHeightConstraint.constant != newHeight else { return } if let superview { @@ -353,9 +535,8 @@ class StoryReplyInputToolbar: UIView { textViewHeightConstraint.constant = newHeight } } -} -extension StoryReplyInputToolbar: BodyRangesTextViewDelegate { + // MARK: - BodyRangesTextViewDelegate func textViewDidBeginTypingMention(_ textView: BodyRangesTextView) {} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift index 2796ca8233..7f0ae8ba8e 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryReplySheet.swift @@ -3,11 +3,9 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import Foundation import LibSignalClient import SignalServiceKit import SignalUI -import UIKit protocol StoryReplySheet: OWSViewController, StoryReplyInputToolbarDelegate, MessageReactionPickerDelegate { var bottomBar: UIView { get } diff --git a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryViewsViewController.swift b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryViewsViewController.swift index ec2da1ce99..68f9808ab6 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryViewsViewController.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Replies & Views Sheets/StoryViewsViewController.swift @@ -3,10 +3,8 @@ // SPDX-License-Identifier: AGPL-3.0-only // -import Foundation import SignalServiceKit import SignalUI -import UIKit private struct Viewer { let address: SignalServiceAddress @@ -14,19 +12,48 @@ private struct Viewer { let viewedTimestamp: UInt64 } -class StoryViewsViewController: OWSViewController { +class StoryViewsViewController: OWSViewController, DatabaseChangeDelegate, UIAdaptivePresentationControllerDelegate, + UITableViewDelegate, UITableViewDataSource +{ private(set) var storyMessage: StoryMessage + let context: StoryContext + // This VC also gets embedded as a child VC into StoryGroupRepliesAndViewsViewController. + // Distinguish that vs when this VC is presented on its own. + private let isStandaloneVC: Bool + + var dismissHandler: (() -> Void)? + let tableView = UITableView(frame: .zero, style: .grouped) private let emptyStateView = UIView() - init(storyMessage: StoryMessage, context: StoryContext) { + init(storyMessage: StoryMessage, context: StoryContext, isStandaloneVC: Bool) { self.storyMessage = storyMessage self.context = context + self.isStandaloneVC = isStandaloneVC + super.init() + DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self) + + overrideUserInterfaceStyle = .dark + + if isStandaloneVC { + modalPresentationStyle = .pageSheet + presentationController?.delegate = self + + if let sheetPresentationController { + if #available(iOS 17.0, *) { + sheetPresentationController.traitOverrides.userInterfaceStyle = .dark + } else { + sheetPresentationController.overrideTraitCollection = UITraitCollection(userInterfaceStyle: .dark) + } + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.prefersGrabberVisible = true + } + } } override func viewDidLoad() { @@ -43,15 +70,23 @@ class StoryViewsViewController: OWSViewController { tableView.register(StoryViewCell.self, forCellReuseIdentifier: StoryViewCell.reuseIdentifier) view.addSubview(emptyStateView) + emptyStateView.preservesSuperviewLayoutMargins = true emptyStateView.autoPinEdgesToSuperviewEdges() updateViewers() } + // MARK: - Data + private var viewers = [Viewer]() + private func updateViewers(reloadStoryMessage: Bool = false) { defer { tableView.reloadData() + // If it's a personal story, only allow half-screen sheet only if no views. + if isStandaloneVC { + sheetPresentationController?.detents = if viewers.isEmpty { [.medium()] } else { [.medium()] } + } updateEmptyStateView() } @@ -106,80 +141,77 @@ class StoryViewsViewController: OWSViewController { emptyStateView.removeAllSubviews() emptyStateView.isHidden = viewers.count > 0 - let label = UILabel() - label.textAlignment = .center - if StoryManager.areViewReceiptsEnabled { + let label = UILabel() + label.textAlignment = .center label.font = .dynamicTypeHeadline - label.textColor = .ows_gray45 + label.textColor = .Signal.secondaryLabel label.text = OWSLocalizedString( "STORIES_NO_VIEWS_YET", comment: "Indicates that this story has no views yet", ) + emptyStateView.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + label.topAnchor.constraint(greaterThanOrEqualTo: emptyStateView.topAnchor), + label.centerYAnchor.constraint(equalTo: emptyStateView.centerYAnchor), + + label.leadingAnchor.constraint(equalTo: emptyStateView.layoutMarginsGuide.leadingAnchor), + label.trailingAnchor.constraint(equalTo: emptyStateView.layoutMarginsGuide.trailingAnchor), + ]) emptyStateView.isUserInteractionEnabled = false - emptyStateView.addSubview(label) - label.autoPinEdgesToSuperviewEdges() } else { - label.font = .dynamicTypeCallout - label.textColor = .ows_gray25 + let label = UILabel() + label.textAlignment = .center + label.font = .dynamicTypeSubheadline + label.textColor = .Signal.secondaryLabel label.text = OWSLocalizedString( "STORIES_VIEWS_OFF_DESCRIPTION", comment: "Text explaining that you will not see any views for your story because you have view receipts turned off", ) label.numberOfLines = 0 + label.lineBreakMode = .byWordWrapping label.setContentHuggingVerticalHigh() - let settingsButton = OWSButton { [weak self] in - let privacySettings = OWSNavigationController(rootViewController: StoryPrivacySettingsViewController()) + let settingsButton = UIButton( + configuration: .smallSecondary(title: CommonStrings.goToSettingsButton), + primaryAction: UIAction { [weak self] _ in + guard let self else { return } - // Dismiss the story view and present the privacy settings screen - owsAssertDebug(self?.presentingViewController?.presentingViewController is ConversationSplitViewController) - self?.presentingViewController?.presentingViewController?.dismiss(animated: true, completion: { - CurrentAppContext().frontmostViewController()?.present(privacySettings, animated: true) - }) - } - settingsButton.setTitle(CommonStrings.goToSettingsButton, for: .normal) - settingsButton.titleLabel?.font = UIFont.dynamicTypeCaption1.semibold() - settingsButton.setTitleColor(.ows_gray25, for: .normal) - settingsButton.ows_contentEdgeInsets = UIEdgeInsets(hMargin: 14, vMargin: 6) - settingsButton.layer.borderWidth = 1.5 - settingsButton.layer.borderColor = UIColor.ows_gray25.cgColor + let privacySettings = OWSNavigationController(rootViewController: StoryPrivacySettingsViewController()) - let settingsButtonPillWrapper = ManualLayoutView(name: "SettingsButton") - settingsButtonPillWrapper.shouldDeactivateConstraints = false - settingsButtonPillWrapper.addSubview(settingsButton) { view in - settingsButton.layer.cornerRadius = settingsButton.height / 2 - } - settingsButton.autoPinEdgesToSuperviewEdges() - - let topSpacer = UIView.vStretchingSpacer() - let bottomSpacer = UIView.vStretchingSpacer() + // Dismiss the story view and present the privacy settings screen + owsAssertDebug(self.presentingViewController?.presentingViewController is ConversationSplitViewController) + self.presentingViewController?.presentingViewController?.dismiss(animated: true, completion: { + CurrentAppContext().frontmostViewController()?.present(privacySettings, animated: true) + }) + }, + ) let stackView = UIStackView(arrangedSubviews: [ - topSpacer, label, - settingsButtonPillWrapper, - bottomSpacer, + settingsButton, ]) - stackView.isLayoutMarginsRelativeArrangement = true - stackView.layoutMargins = UIEdgeInsets(hMargin: 65, vMargin: 0) stackView.axis = .vertical stackView.spacing = 20 stackView.alignment = .center emptyStateView.addSubview(stackView) - stackView.autoPinEdgesToSuperviewEdges() + stackView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(greaterThanOrEqualTo: emptyStateView.topAnchor), + stackView.centerYAnchor.constraint(equalTo: emptyStateView.centerYAnchor), - topSpacer.autoMatch(.height, to: .height, of: bottomSpacer) + stackView.leadingAnchor.constraint(equalTo: emptyStateView.layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: emptyStateView.layoutMarginsGuide.trailingAnchor), + ]) emptyStateView.isUserInteractionEnabled = true } } -} -extension StoryViewsViewController: UITableViewDelegate {} + // MARK: - UITableView -extension StoryViewsViewController: UITableViewDataSource { func numberOfSections(in tableView: UITableView) -> Int { 1 } @@ -197,9 +229,9 @@ extension StoryViewsViewController: UITableViewDataSource { cell.configure(with: viewer) return cell } -} -extension StoryViewsViewController: DatabaseChangeDelegate { + // MARK: - DatabaseChangeDelegate + func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) { if databaseChanges.storyMessageRowIds.contains(storyMessage.id!) { updateViewers(reloadStoryMessage: true) @@ -213,9 +245,16 @@ extension StoryViewsViewController: DatabaseChangeDelegate { func databaseChangesDidReset() { updateViewers(reloadStoryMessage: true) } + + // MARK: - UIAdaptivePresentationControllerDelegate + + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + dismissHandler?() + } } private class StoryViewCell: UITableViewCell { + static let reuseIdentifier = "StoryViewCell" let avatarView = ConversationAvatarView(sizeClass: .thirtySix, localUserDisplayMode: .asUser, badged: true) @@ -223,14 +262,14 @@ private class StoryViewCell: UITableViewCell { lazy var nameLabel: UILabel = { let label = UILabel() label.font = .dynamicTypeBodyClamped - label.textColor = Theme.darkThemePrimaryColor + label.textColor = .Signal.label return label }() lazy var timestampLabel: UILabel = { let label = UILabel() label.font = .dynamicTypeFootnoteClamped - label.textColor = Theme.darkThemeSecondaryTextAndIconColor + label.textColor = .Signal.secondaryLabel return label }()