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:
parent
f1335b65d3
commit
6560f22eac
@ -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 */,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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`
|
||||
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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) {}
|
||||
|
||||
|
||||
@ -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 }
|
||||
|
||||
@ -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
|
||||
}()
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user