Add support for sending group replies to stories

This commit is contained in:
Nora Trapp 2022-03-28 13:29:45 -07:00
parent 49d4a6d89f
commit 9494498d70
18 changed files with 691 additions and 123 deletions

View File

@ -865,6 +865,7 @@
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 */; };
8864073127F21AD7009916B6 /* StoryReplyInputToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8864073027F21AD7009916B6 /* StoryReplyInputToolbar.swift */; };
886A58CA276A760600A1099B /* SubscriptionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 886A58C8276A760600A1099B /* SubscriptionManager.swift */; };
886A58CB276A760600A1099B /* SubscriptionReceiptCredentialRedemptionJob.swift in Sources */ = {isa = PBXBuildFile; fileRef = 886A58C9276A760600A1099B /* SubscriptionReceiptCredentialRedemptionJob.swift */; };
886BB3D225BA0C9D00079781 /* PreviewWallpaperViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ABAB8E25B8BE3F0008C78A /* PreviewWallpaperViewController.swift */; };
@ -2095,6 +2096,7 @@
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>"; };
8864073027F21AD7009916B6 /* StoryReplyInputToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReplyInputToolbar.swift; sourceTree = "<group>"; };
886A58C8276A760600A1099B /* SubscriptionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = "<group>"; };
886A58C9276A760600A1099B /* SubscriptionReceiptCredentialRedemptionJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionReceiptCredentialRedemptionJob.swift; sourceTree = "<group>"; };
886CB07724E77E5B00753909 /* silence.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = silence.aiff; sourceTree = "<group>"; };
@ -4055,10 +4057,6 @@
884DB94D27DE67BB00C6A309 /* StoryPageViewController.swift */,
884DB95127DE67D900C6A309 /* StoryItemMediaView.swift */,
884DB95327DEB9E900C6A309 /* StoryPlaybackProgressView.swift */,
8864072727EEA658009916B6 /* StoryGroupReplySheet.swift */,
8864072927F0D426009916B6 /* StoryGroupReplyLoader.swift */,
8864072B27F0DA37009916B6 /* StoryGroupReplyViewItem.swift */,
8864072D27F0E8DF009916B6 /* StoryGroupReplyCell.swift */,
);
path = "Context View";
sourceTree = "<group>";
@ -4070,6 +4068,7 @@
884DB94427DD70F700C6A309 /* IncomingStoryViewModel.swift */,
884DB94627DD754700C6A309 /* StoryCell.swift */,
885275BF27E26775003F2F9B /* TextAttachmentView.swift */,
8864072F27F21AA7009916B6 /* Group Reply Sheet */,
884DB94A27DE66E000C6A309 /* Context View */,
);
path = Stories;
@ -4147,6 +4146,18 @@
path = Individual;
sourceTree = "<group>";
};
8864072F27F21AA7009916B6 /* Group Reply Sheet */ = {
isa = PBXGroup;
children = (
8864072727EEA658009916B6 /* StoryGroupReplySheet.swift */,
8864072927F0D426009916B6 /* StoryGroupReplyLoader.swift */,
8864072B27F0DA37009916B6 /* StoryGroupReplyViewItem.swift */,
8864072D27F0E8DF009916B6 /* StoryGroupReplyCell.swift */,
8864073027F21AD7009916B6 /* StoryReplyInputToolbar.swift */,
);
path = "Group Reply Sheet";
sourceTree = "<group>";
};
8871B16D25F007CE00D4E070 /* Profile */ = {
isa = PBXGroup;
children = (
@ -6158,6 +6169,7 @@
8855DF87238F1E0C0066D96F /* EmojiReactorsTableView.swift in Sources */,
3496744F2076ACD000080B5F /* LongTextViewController.swift in Sources */,
34F308A21ECB469700BB7697 /* OWSBezierPathView.m in Sources */,
8864073127F21AD7009916B6 /* StoryReplyInputToolbar.swift in Sources */,
326DF2612739F4D90017B789 /* FeaturedBadgeViewController.swift in Sources */,
34ACA7D92733159600E47AD4 /* OnboardingController.swift in Sources */,
88A9729422FB4D02004B4FBF /* LocationPicker.swift in Sources */,

View File

@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "add-reaction-outline-24.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -1,103 +0,0 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import SignalServiceKit
class StoryGroupReplySheet: InteractiveSheetViewController {
private lazy var tableView = UITableView()
private let storyMessage: StoryMessage
var dismissHandler: (() -> Void)?
init(storyMessage: StoryMessage) {
self.storyMessage = storyMessage
}
public required init() {
fatalError("init() has not been implemented")
}
fileprivate var replyLoader: StoryGroupReplyLoader?
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
contentView.addSubview(tableView)
tableView.autoPinEdgesToSuperviewEdges()
contentView.backgroundColor = .ows_gray90
tableView.backgroundColor = .ows_gray90
for type in StoryGroupReplyCell.CellType.allCases {
tableView.register(StoryGroupReplyCell.self, forCellReuseIdentifier: type.rawValue)
}
replyLoader = StoryGroupReplyLoader(storyMessage: storyMessage, tableView: tableView)
}
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
super.dismiss(animated: flag) { [dismissHandler] in
completion?()
dismissHandler?()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
replyLoader?.scrollToBottomOfLoadWindow(animated: true)
}
}
extension StoryGroupReplySheet: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let visibleRows = tableView.indexPathsForVisibleRows?.map({ $0.row }),
!visibleRows.isEmpty,
let oldestLoadedRow = replyLoader?.oldestLoadedRow,
let newestLoadedRow = replyLoader?.newestLoadedRow else { return }
let rowsFromTop = (visibleRows.min() ?? oldestLoadedRow) - oldestLoadedRow
let rowsFromBottom = newestLoadedRow - (visibleRows.max() ?? newestLoadedRow)
if rowsFromTop <= 30 {
replyLoader?.loadOlderPageIfNecessary()
}
if rowsFromBottom <= 30 {
replyLoader?.loadNewerPageIfNecessary()
}
}
}
extension StoryGroupReplySheet: UITableViewDelegate {
}
extension StoryGroupReplySheet: 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)")
return UITableViewCell()
}
let cell = tableView.dequeueReusableCell(withIdentifier: item.cellType.rawValue, for: indexPath) as! StoryGroupReplyCell
cell.configure(with: item)
return cell
}
func numberOfSections(in tableView: UITableView) -> Int {
1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
replyLoader?.numberOfRows ?? 0
}
}

View File

@ -8,7 +8,7 @@ import SignalCoreKit
class StoryGroupReplyLoader: Dependencies {
private let loadingLock = UnfairLock()
private var messageMapping: CVMessageMapping!
private var threadUniqueId: String!
private let threadUniqueId: String
private let storyMessage: StoryMessage
private weak var tableView: UITableView?
private var replyUniqueIds = [String]() {
@ -37,18 +37,18 @@ class StoryGroupReplyLoader: Dependencies {
return tableView?.indexPathsForVisibleRows?.lazy.map { $0.row }.contains(replyUniqueIds.count - 1) ?? false
}
init?(storyMessage: StoryMessage, tableView: UITableView) {
guard case .groupId(let groupId) = storyMessage.context else {
owsFailDebug("Unexpected story message context")
init?(storyMessage: StoryMessage, threadUniqueId: String?, tableView: UITableView) {
guard let threadUniqueId = threadUniqueId else {
owsFailDebug("Unexpectedly missing threadUniqueId")
return nil
}
self.threadUniqueId = threadUniqueId
self.storyMessage = storyMessage
self.tableView = tableView
// Load the first page synchronously.
databaseStorage.read { transaction in
threadUniqueId = TSGroupThread.threadId(forGroupId: groupId, transaction: transaction)
messageMapping = CVMessageMapping(
threadUniqueId: threadUniqueId,
storyReplyQueryMode: .onlyGroupReplies(storyTimestamp: storyMessage.timestamp)

View File

@ -0,0 +1,277 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import SignalServiceKit
class StoryGroupReplySheet: InteractiveSheetViewController {
override var renderExternalHandle: Bool { false }
private lazy var tableView = UITableView()
private lazy var inputToolbar = StoryReplyInputToolbar()
private lazy var inputToolbarBottomConstraint = inputToolbar.autoPinEdge(toSuperviewEdge: .bottom)
private lazy var inputAccessoryPlaceholder: InputAccessoryViewPlaceholder = {
let placeholder = InputAccessoryViewPlaceholder()
placeholder.delegate = self
placeholder.referenceView = view
return placeholder
}()
private let storyMessage: StoryMessage
private let thread: TSThread?
var dismissHandler: (() -> Void)?
init(storyMessage: StoryMessage) {
self.storyMessage = storyMessage
self.thread = Self.databaseStorage.read { transaction in
if let groupId = storyMessage.groupId {
return TSGroupThread.fetch(groupId: groupId, transaction: transaction)
} else {
owsFailDebug("Unexpectedly received non-group thread for story reply sheet.")
return TSContactThread.getWithContactAddress(storyMessage.authorAddress, transaction: transaction)
}
}
}
public required init() {
fatalError("init() has not been implemented")
}
fileprivate var replyLoader: StoryGroupReplyLoader?
override func viewDidLoad() {
super.viewDidLoad()
tableView.delegate = self
tableView.dataSource = self
tableView.separatorStyle = .none
tableView.rowHeight = UITableView.automaticDimension
tableView.keyboardDismissMode = .interactive
tableView.contentInset = UIEdgeInsets(top: 30, left: 0, bottom: 0, right: 0)
contentView.addSubview(tableView)
tableView.autoPinEdgesToSuperviewEdges()
// We add the handle directly to the content view,
// so that it doesn't scroll with the table.
let handleContainer = UIView()
contentView.addSubview(handleContainer)
handleContainer.autoPinWidthToSuperview()
handleContainer.autoPinEdge(toSuperviewEdge: .top)
let handle = UIView()
handle.backgroundColor = .ows_gray65
handle.autoSetDimensions(to: CGSize(width: 36, height: 5))
handle.layer.cornerRadius = 5 / 2
handleContainer.addSubview(handle)
handle.autoPinHeightToSuperview(withMargin: 12)
handle.autoHCenterInSuperview()
inputToolbar.delegate = self
contentView.addSubview(inputToolbar)
inputToolbar.autoPinWidthToSuperview()
inputToolbarBottomConstraint.isActive = true
contentView.backgroundColor = .ows_gray90
tableView.backgroundColor = .ows_gray90
handleContainer.backgroundColor = .ows_gray90
for type in StoryGroupReplyCell.CellType.allCases {
tableView.register(StoryGroupReplyCell.self, forCellReuseIdentifier: type.rawValue)
}
replyLoader = StoryGroupReplyLoader(storyMessage: storyMessage, threadUniqueId: thread?.uniqueId, tableView: tableView)
}
public override var canBecomeFirstResponder: Bool { true }
public override var inputAccessoryView: UIView? { inputAccessoryPlaceholder }
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
super.dismiss(animated: flag) { [dismissHandler] in
completion?()
dismissHandler?()
}
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
replyLoader?.scrollToBottomOfLoadWindow(animated: true)
}
}
extension StoryGroupReplySheet: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
guard let visibleRows = tableView.indexPathsForVisibleRows?.map({ $0.row }),
!visibleRows.isEmpty,
let oldestLoadedRow = replyLoader?.oldestLoadedRow,
let newestLoadedRow = replyLoader?.newestLoadedRow else { return }
let rowsFromTop = (visibleRows.min() ?? oldestLoadedRow) - oldestLoadedRow
let rowsFromBottom = newestLoadedRow - (visibleRows.max() ?? newestLoadedRow)
if rowsFromTop <= 30 {
replyLoader?.loadOlderPageIfNecessary()
}
if rowsFromBottom <= 30 {
replyLoader?.loadNewerPageIfNecessary()
}
}
}
extension StoryGroupReplySheet: UITableViewDelegate {
}
extension StoryGroupReplySheet: 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)")
return UITableViewCell()
}
let cell = tableView.dequeueReusableCell(withIdentifier: item.cellType.rawValue, for: indexPath) as! StoryGroupReplyCell
cell.configure(with: item)
return cell
}
func numberOfSections(in tableView: UITableView) -> Int {
1
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
replyLoader?.numberOfRows ?? 0
}
}
extension StoryGroupReplySheet: InputAccessoryViewPlaceholderDelegate {
public func inputAccessoryPlaceholderKeyboardIsPresenting(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
handleKeyboardStateChange(animationDuration: animationDuration, animationCurve: animationCurve)
}
public func inputAccessoryPlaceholderKeyboardDidPresent() {
updateInputToolbarPosition()
updateContentInsets(animated: false)
}
public func inputAccessoryPlaceholderKeyboardIsDismissing(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
handleKeyboardStateChange(animationDuration: animationDuration, animationCurve: animationCurve)
}
public func inputAccessoryPlaceholderKeyboardDidDismiss() {
updateInputToolbarPosition()
updateContentInsets(animated: false)
}
public func inputAccessoryPlaceholderKeyboardIsDismissingInteractively() {
updateInputToolbarPosition()
}
func handleKeyboardStateChange(animationDuration: TimeInterval, animationCurve: UIView.AnimationCurve) {
guard animationDuration > 0 else {
updateInputToolbarPosition()
updateContentInsets(animated: false)
return
}
UIView.beginAnimations("keyboardStateChange", context: nil)
UIView.setAnimationBeginsFromCurrentState(true)
UIView.setAnimationCurve(animationCurve)
UIView.setAnimationDuration(animationDuration)
updateInputToolbarPosition()
updateContentInsets(animated: true)
UIView.commitAnimations()
}
func updateInputToolbarPosition() {
inputToolbarBottomConstraint.constant = -inputAccessoryPlaceholder.keyboardOverlap
// We always want to apply the new bottom bar position immediately,
// as this only happens during animations (interactive or otherwise)
inputToolbar.superview?.layoutIfNeeded()
}
func updateContentInsets(animated: Bool) {
let wasScrolledToBottom = replyLoader?.isScrolledToBottom ?? false
tableView.contentInset.bottom = inputAccessoryPlaceholder.keyboardOverlap + inputToolbar.height - view.safeAreaInsets.bottom
if wasScrolledToBottom {
replyLoader?.scrollToBottomOfLoadWindow(animated: animated)
}
}
}
extension StoryGroupReplySheet: StoryReplyInputToolbarDelegate {
func storyReplyInputToolbarDidTapReact(_ storyReplyInputToolbar: StoryReplyInputToolbar) {
}
func storyReplyInputToolbarDidTapSend(_ storyReplyInputToolbar: StoryReplyInputToolbar) {
guard let messageBody = storyReplyInputToolbar.messageBody, !messageBody.text.isEmpty else {
return owsFailDebug("Unexpectedly missing message body")
}
tryToSendTextMessage(messageBody)
}
func tryToSendTextMessage(_ messageBody: MessageBody) {
owsAssertDebug(!messageBody.text.isEmpty)
guard let thread = thread else {
return owsFailDebug("Unexpectedly missing thread")
}
guard !blockingManager.isThreadBlocked(thread) else {
BlockListUIUtils.showUnblockThreadActionSheet(thread, from: self) { [weak self] isBlocked in
guard !isBlocked else { return }
self?.tryToSendTextMessage(messageBody)
}
return
}
guard !SafetyNumberConfirmationSheet.presentIfNecessary(
addresses: thread.recipientAddresses,
confirmationText: SafetyNumberStrings.confirmSendButton,
completion: { [weak self] didConfirmIdentity in
guard didConfirmIdentity else { return }
self?.tryToSendTextMessage(messageBody)
}
) else { return }
let builder = TSOutgoingMessageBuilder(thread: thread)
builder.messageBody = messageBody.text
builder.bodyRanges = messageBody.ranges
builder.storyTimestamp = NSNumber(value: storyMessage.timestamp)
builder.storyAuthorAddress = storyMessage.authorAddress
let message = builder.build()
ThreadUtil.enqueueSendAsyncWrite { [weak self] transaction in
ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequest(thread: thread, setDefaultTimerIfNecessary: false, transaction: transaction)
message.anyInsert(transaction: transaction)
Self.messageSenderJobQueue.add(message: message.asPreparer, transaction: transaction)
transaction.addAsyncCompletionOnMain {
self?.replyLoader?.reload()
self?.inputToolbar.messageBody = nil
}
}
}
func storyReplyInputToolbarDidBeginEditing(_ storyReplyInputToolbar: StoryReplyInputToolbar) {
maximizeHeight()
}
func storyReplyInputToolbarHeightDidChange(_ storyReplyInputToolbar: StoryReplyInputToolbar) {
updateContentInsets(animated: false)
}
func storyReplyInputToolbarMentionPickerPossibleAddresses(_ storyReplyInputToolbar: StoryReplyInputToolbar) -> [SignalServiceAddress] {
return thread?.recipientAddresses ?? []
}
}

View File

@ -0,0 +1,302 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
import SignalUI
// Coincides with Android's max text message length
let kMaxMessageBodyCharacterCount = 2000
protocol StoryReplyInputToolbarDelegate: AnyObject {
func storyReplyInputToolbarDidTapSend(_ storyReplyInputToolbar: StoryReplyInputToolbar)
func storyReplyInputToolbarDidTapReact(_ storyReplyInputToolbar: StoryReplyInputToolbar)
func storyReplyInputToolbarDidBeginEditing(_ storyReplyInputToolbar: StoryReplyInputToolbar)
func storyReplyInputToolbarHeightDidChange(_ storyReplyInputToolbar: StoryReplyInputToolbar)
func storyReplyInputToolbarMentionPickerPossibleAddresses(_ storyReplyInputToolbar: StoryReplyInputToolbar) -> [SignalServiceAddress]
}
// MARK: -
class StoryReplyInputToolbar: UIView {
weak var delegate: StoryReplyInputToolbarDelegate?
var messageBody: MessageBody? {
get { textView.messageBody }
set {
textView.messageBody = newValue
updateContent()
}
}
override var bounds: CGRect {
didSet {
guard oldValue.height != bounds.height else { return }
delegate?.storyReplyInputToolbarHeightDidChange(self)
}
}
let minTextViewHeight: CGFloat = 36
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
}
var textViewHeightConstraint: NSLayoutConstraint?
// MARK: - Initializers
init() {
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
if UIAccessibility.isReduceTransparencyEnabled {
self.backgroundColor = .ows_black
let extendedBackground = UIView()
addSubview(extendedBackground)
extendedBackground.autoPinWidthToSuperview()
extendedBackground.autoPinEdge(.top, to: .bottom, of: self)
extendedBackground.autoSetDimension(.height, toSize: backgroundExtension)
} else {
self.backgroundColor = .clear
let blurEffectView = UIVisualEffectView(effect: Theme.darkThemeBarBlurEffect)
blurEffectView.layer.zPosition = -1
addSubview(blurEffectView)
blurEffectView.autoPinWidthToSuperview()
blurEffectView.autoPinEdge(toSuperviewEdge: .top)
blurEffectView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -backgroundExtension)
}
textView.mentionDelegate = self
let sendButton = OWSButton.sendButton(imageName: "send-solid-24") { [weak self] in
self?.didTapSend()
}
sendButtonContainer.addSubview(sendButton)
let reactButton = OWSButton(imageName: "add-reaction-outline-24", tintColor: Theme.darkThemePrimaryColor) { [weak self] in
self?.didTapReact()
}
reactButton.autoSetDimensions(to: CGSize(square: 40))
reactButtonContainer.addSubview(reactButton)
for button in [sendButton, reactButton] {
button.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top)
button.autoPinEdge(toSuperviewEdge: .top, withInset: 0, relation: .greaterThanOrEqual)
NSLayoutConstraint.autoSetPriority(.defaultLow) {
button.autoPinEdge(toSuperviewEdge: .top)
}
button.setContentHuggingHigh()
button.setCompressionResistanceHigh()
}
// 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.
let hStackView = UIStackView(arrangedSubviews: [ textContainer, sendButtonContainer, reactButtonContainer ])
hStackView.isLayoutMarginsRelativeArrangement = true
hStackView.layoutMargins = UIEdgeInsets(margin: 12)
hStackView.axis = .horizontal
hStackView.alignment = .bottom
hStackView.spacing = 12
hStackView.semanticContentAttribute = .forceLeftToRight
addSubview(hStackView)
hStackView.autoPinEdgesToSuperviewEdges()
textViewHeightConstraint = textView.autoSetDimension(.height, toSize: minTextViewHeight)
textContainer.autoPinEdge(toSuperviewMargin: .top)
textContainer.autoPinEdge(toSuperviewMargin: .bottom)
updateContent()
}
required init?(coder aDecoder: NSCoder) {
notImplemented()
}
// MARK: - UIView Overrides
override var intrinsicContentSize: CGSize {
get {
// Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify
// an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout.
return CGSize.zero
}
}
// MARK: - Subviews
private lazy var sendButtonContainer = UIView()
private lazy var reactButtonContainer = UIView()
lazy var textView: MentionTextView = {
let textView = buildTextView()
textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3)
textView.mentionDelegate = self
return textView
}()
private let placeholderText = OWSLocalizedString(
"MESSAGE_TEXT_FIELD_PLACEHOLDER",
comment: "placeholder text for the editable message field"
)
private lazy var placeholderTextView: UITextView = {
let placeholderTextView = buildTextView()
placeholderTextView.text = placeholderText
placeholderTextView.isEditable = false
placeholderTextView.textContainer.maximumNumberOfLines = 1
placeholderTextView.textContainer.lineBreakMode = .byTruncatingTail
placeholderTextView.textColor = .ows_whiteAlpha60
return placeholderTextView
}()
private lazy var textContainer: UIView = {
let textContainer = UIView()
let textBubble = UIView()
textContainer.addSubview(textBubble)
let inset = (40 - minTextViewHeight) / 2
textBubble.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 0, leading: 0, bottom: inset, trailing: 0))
textBubble.layer.cornerRadius = minTextViewHeight / 2
textBubble.clipsToBounds = true
textBubble.backgroundColor = .ows_gray75
textBubble.addSubview(placeholderTextView)
placeholderTextView.autoPinEdgesToSuperviewEdges()
textBubble.addSubview(textView)
textView.autoPinEdgesToSuperviewEdges()
return textContainer
}()
private func buildTextView() -> MentionTextView {
let textView = MentionTextView()
textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance
textView.backgroundColor = .clear
textView.tintColor = Theme.darkThemePrimaryColor
let textViewFont = UIFont.ows_dynamicTypeBody
textView.font = textViewFont
textView.textColor = Theme.darkThemePrimaryColor
// Check the system font size and increase text inset accordingly
// to keep the text vertically centered
textView.updateVerticalInsetsForDynamicBodyType(defaultInsets: 7)
textView.textContainerInset.left = 7
textView.textContainerInset.right = 7
return textView
}
// MARK: - Actions
func didTapSend() {
textView.acceptAutocorrectSuggestion()
delegate?.storyReplyInputToolbarDidTapSend(self)
}
func didTapReact() {
delegate?.storyReplyInputToolbarDidTapReact(self)
}
// MARK: - Helpers
private func updateContent() {
AssertIsOnMainThread()
updateHeight(textView: textView)
let isTextViewEmpty = textView.text.isEmptyOrNil
reactButtonContainer.isHidden = !isTextViewEmpty
sendButtonContainer.isHidden = isTextViewEmpty
placeholderTextView.isHidden = !isTextViewEmpty
}
private func updateHeight(textView: UITextView) {
guard let textViewHeightConstraint = textViewHeightConstraint else {
owsFailDebug("Missing constraint.")
return
}
// compute new height assuming width is unchanged
let currentSize = textView.frame.size
let textViewHeight = clampedTextViewHeight(fixedWidth: currentSize.width)
if textViewHeightConstraint.constant != textViewHeight {
Logger.debug("TextView height changed: \(textViewHeightConstraint.constant) -> \(textViewHeight)")
textViewHeightConstraint.constant = textViewHeight
invalidateIntrinsicContentSize()
}
}
private func clampedTextViewHeight(fixedWidth: CGFloat) -> CGFloat {
let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))
return CGFloatClamp(contentSize.height, minTextViewHeight, maxTextViewHeight)
}
}
extension StoryReplyInputToolbar: MentionTextViewDelegate {
func textViewDidBeginTypingMention(_ textView: MentionTextView) {}
func textViewDidEndTypingMention(_ textView: MentionTextView) {}
func textViewMentionPickerParentView(_ textView: MentionTextView) -> UIView? {
return superview
}
func textViewMentionPickerReferenceView(_ textView: MentionTextView) -> UIView? {
return self
}
func textViewMentionPickerPossibleAddresses(_ textView: MentionTextView) -> [SignalServiceAddress] {
return delegate?.storyReplyInputToolbarMentionPickerPossibleAddresses(self) ?? []
}
func textView(_ textView: MentionTextView, didDeleteMention mention: Mention) {}
func textView(_ textView: MentionTextView, shouldResolveMentionForAddress address: SignalServiceAddress) -> Bool {
return textViewMentionPickerPossibleAddresses(textView).contains(address)
}
func textViewMentionStyle(_ textView: MentionTextView) -> Mention.Style {
return .groupReply
}
public func textViewDidChange(_ textView: UITextView) {
updateHeight(textView: textView)
updateContent()
}
public func textViewDidBeginEditing(_ textView: UITextView) {
delegate?.storyReplyInputToolbarDidBeginEditing(self)
updateContent()
}
public func textViewDidEndEditing(_ textView: UITextView) {
updateContent()
}
}

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
NS_ASSUME_NONNULL_BEGIN
@ -47,6 +47,11 @@ NS_ASSUME_NONNULL_BEGIN
transaction:(SDSAnyWriteTransaction *)transaction
NS_SWIFT_NAME(addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimer(thread:transaction:));
+ (BOOL)addThreadToProfileWhitelistIfEmptyOrPendingRequest:(TSThread *)thread
setDefaultTimerIfNecessary:(BOOL)setDefaultTimerIfNecessary
transaction:(SDSAnyWriteTransaction *)transaction
NS_SWIFT_NAME(addThreadToProfileWhitelistIfEmptyOrPendingRequest(thread:setDefaultTimerIfNecessary:transaction:));
#pragma mark - Delete Content
+ (void)deleteAllContent;

View File

@ -182,6 +182,15 @@ NS_ASSUME_NONNULL_BEGIN
+ (BOOL)addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimer:(TSThread *)thread
transaction:(SDSAnyWriteTransaction *)transaction
{
return [self addThreadToProfileWhitelistIfEmptyOrPendingRequest:thread
setDefaultTimerIfNecessary:YES
transaction:transaction];
}
+ (BOOL)addThreadToProfileWhitelistIfEmptyOrPendingRequest:(TSThread *)thread
setDefaultTimerIfNecessary:(BOOL)setDefaultTimerIfNecessary
transaction:(SDSAnyWriteTransaction *)transaction
{
OWSAssertDebug(thread);
@ -192,7 +201,7 @@ NS_ASSUME_NONNULL_BEGIN
[GRDBThreadFinder shouldSetDefaultDisappearingMessageTimerWithThread:thread
transaction:transaction.unwrapGrdbRead];
if (needsDefaultTimerSet) {
if (needsDefaultTimerSet && setDefaultTimerIfNecessary) {
OWSDisappearingMessagesConfiguration *configuration =
[OWSDisappearingMessagesConfiguration applyToken:defaultTimerToken toThread:thread transaction:transaction];

View File

@ -116,7 +116,9 @@ NS_ASSUME_NONNULL_BEGIN
messageSticker:transcript.messageSticker
isViewOnceMessage:transcript.isViewOnceMessage
changeActionsProtoData:nil
additionalRecipients:nil] build];
additionalRecipients:nil
storyAuthorAddress:nil
storyTimestamp:nil] build];
SignalServiceAddress *_Nullable localAddress = self.tsAccountManager.localAddress;
if (localAddress == nil) {

View File

@ -1089,6 +1089,21 @@ NSUInteger const TSOutgoingMessageSchemaVersion = 1;
}
}
// Story Context
if (self.storyTimestamp && self.storyAuthorUuidString) {
SSKProtoDataMessageStoryContextBuilder *storyContextBuilder = [SSKProtoDataMessageStoryContext builder];
[storyContextBuilder setAuthorUuid:self.storyAuthorUuidString];
[storyContextBuilder setSentTimestamp:self.storyTimestamp.longLongValue];
NSError *error;
SSKProtoDataMessageStoryContext *_Nullable storyContext = [storyContextBuilder buildAndReturnError:&error];
if (error || !storyContext) {
OWSFailDebug(@"Could not build storyContext protobuf: %@.", error);
} else {
[builder setStoryContext:storyContext];
}
}
[builder setExpireTimer:self.expiresInSeconds];
// Group Messages

View File

@ -36,7 +36,9 @@ public class TSOutgoingMessageBuilder: TSMessageBuilder {
messageSticker: MessageSticker? = nil,
isViewOnceMessage: Bool = false,
changeActionsProtoData: Data? = nil,
additionalRecipients: [SignalServiceAddress]? = nil) {
additionalRecipients: [SignalServiceAddress]? = nil,
storyAuthorAddress: SignalServiceAddress? = nil,
storyTimestamp: UInt64? = nil) {
super.init(thread: thread,
timestamp: timestamp,
@ -49,7 +51,9 @@ public class TSOutgoingMessageBuilder: TSMessageBuilder {
contactShare: contactShare,
linkPreview: linkPreview,
messageSticker: messageSticker,
isViewOnceMessage: isViewOnceMessage)
isViewOnceMessage: isViewOnceMessage,
storyAuthorAddress: storyAuthorAddress,
storyTimestamp: storyTimestamp)
self.isVoiceMessage = isVoiceMessage
self.groupMetaMessage = groupMetaMessage
@ -88,7 +92,9 @@ public class TSOutgoingMessageBuilder: TSMessageBuilder {
messageSticker: MessageSticker?,
isViewOnceMessage: Bool,
changeActionsProtoData: Data?,
additionalRecipients: [SignalServiceAddress]?) -> TSOutgoingMessageBuilder {
additionalRecipients: [SignalServiceAddress]?,
storyAuthorAddress: SignalServiceAddress?,
storyTimestamp: NSNumber?) -> TSOutgoingMessageBuilder {
return TSOutgoingMessageBuilder(thread: thread,
timestamp: timestamp,
messageBody: messageBody,
@ -104,7 +110,9 @@ public class TSOutgoingMessageBuilder: TSMessageBuilder {
messageSticker: messageSticker,
isViewOnceMessage: isViewOnceMessage,
changeActionsProtoData: changeActionsProtoData,
additionalRecipients: additionalRecipients)
additionalRecipients: additionalRecipients,
storyAuthorAddress: storyAuthorAddress,
storyTimestamp: storyTimestamp?.uint64Value)
}
private var hasBuilt = false

View File

@ -1519,6 +1519,7 @@ public class GRDBInteractionFinder: NSObject, InteractionFinderAdapter {
AND \(interactionColumn: .errorType) IN (\(errorMessageTypes.map { "\($0.rawValue)" }.joined(separator: ",")))
) OR \(interactionColumn: .recordType) IN (\(interactionTypes.map { "\($0.rawValue)" }.joined(separator: ",")))
)
\(Self.filterStoryRepliesClause(for: .excludeGroupReplies))
LIMIT 1
)
"""

View File

@ -139,7 +139,9 @@ public class OutgoingMessageFactory: NSObject, Factory {
messageSticker: messageStickerBuilder(),
isViewOnceMessage: isViewOnceMessageBuilder(),
changeActionsProtoData: changeActionsProtoDataBuilder(),
additionalRecipients: additionalRecipientsBuilder()).build()
additionalRecipients: additionalRecipientsBuilder(),
storyAuthorAddress: storyAuthorAddressBuilder(),
storyTimestamp: storyTimestampBuilder()).build()
}
@objc
@ -234,6 +236,16 @@ public class OutgoingMessageFactory: NSObject, Factory {
return nil
}
@objc
public var storyAuthorAddressBuilder: () -> SignalServiceAddress? = {
return nil
}
@objc
public var storyTimestampBuilder: () -> NSNumber? = {
return nil
}
// MARK: Delivery Receipts
@objc

View File

@ -1,5 +1,5 @@
//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
@ -36,7 +36,7 @@ public class InputAccessoryViewPlaceholder: UIView {
}
@objc
weak var referenceView: UIView?
public weak var referenceView: UIView?
private var visibleKeyboardHeight: CGFloat {
guard var keyboardFrame = transitioningKeyboardFrame ?? superview?.frame else { return 0 }

View File

@ -159,6 +159,22 @@ class MentionPicker: UIView {
if style == .composingAttachment {
tableView.backgroundColor = UIColor.ows_gray95
hairlineView.backgroundColor = .ows_gray65
} else if style == .groupReply {
blurView?.removeFromSuperview()
blurView = nil
if UIAccessibility.isReduceTransparencyEnabled {
tableView.backgroundColor = Theme.darkThemeBackgroundColor
} else {
tableView.backgroundColor = .clear
let blurView = UIVisualEffectView(effect: Theme.darkThemeBarBlurEffect)
self.blurView = blurView
insertSubview(blurView, belowSubview: tableView)
blurView.autoPinEdgesToSuperviewEdges()
}
hairlineView.backgroundColor = .ows_gray75
} else {
blurView?.removeFromSuperview()
blurView = nil
@ -330,7 +346,7 @@ private class MentionableUserCell: UITableViewCell {
}
func configure(with mentionableUser: MentionPicker.MentionableUser, style: Mention.Style) {
if style == .composingAttachment {
if [.composingAttachment, .groupReply].contains(style) {
displayNameLabel.textColor = Theme.darkThemePrimaryColor
usernameLabel.textColor = Theme.darkThemeSecondaryTextAndIconColor
selectedBackgroundView?.backgroundColor = UIColor.white.withAlphaComponent(0.2)