Update story reply UI for iOS 26.

• 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.
This commit is contained in:
Igor Solomennikov 2026-05-19 23:10:25 -07:00 committed by GitHub
parent f1335b65d3
commit 6560f22eac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 811 additions and 593 deletions

View File

@ -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 = "<group>"; };
886292112835606D00AA0C3B /* MyStoryCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyStoryCell.swift; sourceTree = "<group>"; };
8862A55825F090C5005D65DB /* InternalSettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InternalSettingsViewController.swift; sourceTree = "<group>"; };
8864072727EEA658009916B6 /* StoryGroupReplySheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplySheet.swift; sourceTree = "<group>"; };
8864072927F0D426009916B6 /* StoryGroupReplyLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyLoader.swift; sourceTree = "<group>"; };
8864072B27F0DA37009916B6 /* StoryGroupReplyViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyViewItem.swift; sourceTree = "<group>"; };
8864072D27F0E8DF009916B6 /* StoryGroupReplyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyCell.swift; sourceTree = "<group>"; };
@ -5929,8 +5926,7 @@
88ABAB8E25B8BE3F0008C78A /* PreviewWallpaperViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewWallpaperViewController.swift; sourceTree = "<group>"; };
88B00D4A28A32DB600BC9CA0 /* StoryGroupReplyViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyViewController.swift; sourceTree = "<group>"; };
88B00D4C28A3346000BC9CA0 /* StoryViewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryViewsViewController.swift; sourceTree = "<group>"; };
88B00D4E28A33B5800BC9CA0 /* StoryPrivateViewsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryPrivateViewsSheet.swift; sourceTree = "<group>"; };
88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupRepliesAndViewsSheet.swift; sourceTree = "<group>"; };
88B00D5028A341CF00BC9CA0 /* StoryGroupRepliesAndViewsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupRepliesAndViewsViewController.swift; sourceTree = "<group>"; };
88B22349283F290400A25048 /* StoryPrivacySettingsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryPrivacySettingsViewController.swift; sourceTree = "<group>"; };
88B2234B284FABE600A25048 /* StoryThumbnailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryThumbnailView.swift; sourceTree = "<group>"; };
88B688AF238F0D1000286F82 /* ReactionsDetailSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReactionsDetailSheet.swift; sourceTree = "<group>"; };
@ -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 */,

View File

@ -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)

View File

@ -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")
}

View File

@ -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
}
}
}

View File

@ -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?()
}
}

View File

@ -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()
}

View File

@ -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?()
}
}
}

View File

@ -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?()
}
}

View File

@ -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`

View File

@ -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?()
}
}
}

View File

@ -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) {}

View File

@ -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 }

View File

@ -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
}()