From 9494498d7086bbd9bd5a68443071c19c2db95564 Mon Sep 17 00:00:00 2001 From: Nora Trapp Date: Mon, 28 Mar 2022 13:29:45 -0700 Subject: [PATCH] Add support for sending group replies to stories --- Signal.xcodeproj/project.pbxproj | 20 +- .../Contents.json | 12 + .../add-reaction-outline-24.pdf | Bin 0 -> 4400 bytes .../Context View/StoryGroupReplySheet.swift | 103 ------ .../StoryGroupReplyCell.swift | 0 .../StoryGroupReplyLoader.swift | 10 +- .../StoryGroupReplySheet.swift | 277 ++++++++++++++++ .../StoryGroupReplyViewItem.swift | 0 .../StoryReplyInputToolbar.swift | 302 ++++++++++++++++++ SignalMessaging/utils/ThreadUtil.h | 7 +- SignalMessaging/utils/ThreadUtil.m | 11 +- .../src/Devices/OWSRecordTranscriptJob.m | 4 +- .../Messages/Interactions/TSOutgoingMessage.m | 15 + .../Interactions/TSOutgoingMessage.swift | 16 +- .../Database/Records/InteractionFinder.swift | 1 + .../src/TestUtils/Factories.swift | 14 +- .../Views/InputAccessoryViewPlaceholder.swift | 4 +- SignalUI/Views/Mentions/MentionPicker.swift | 18 +- 18 files changed, 691 insertions(+), 123 deletions(-) create mode 100644 Signal/Images.xcassets/add-reaction-outline-24.imageset/Contents.json create mode 100644 Signal/Images.xcassets/add-reaction-outline-24.imageset/add-reaction-outline-24.pdf delete mode 100644 Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplySheet.swift rename Signal/src/ViewControllers/HomeView/Stories/{Context View => Group Reply Sheet}/StoryGroupReplyCell.swift (100%) rename Signal/src/ViewControllers/HomeView/Stories/{Context View => Group Reply Sheet}/StoryGroupReplyLoader.swift (97%) create mode 100644 Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift rename Signal/src/ViewControllers/HomeView/Stories/{Context View => Group Reply Sheet}/StoryGroupReplyViewItem.swift (100%) create mode 100644 Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryReplyInputToolbar.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 6ffb97a33d..a91bb9d161 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -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 = ""; }; 8864072B27F0DA37009916B6 /* StoryGroupReplyViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyViewItem.swift; sourceTree = ""; }; 8864072D27F0E8DF009916B6 /* StoryGroupReplyCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryGroupReplyCell.swift; sourceTree = ""; }; + 8864073027F21AD7009916B6 /* StoryReplyInputToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryReplyInputToolbar.swift; sourceTree = ""; }; 886A58C8276A760600A1099B /* SubscriptionManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionManager.swift; sourceTree = ""; }; 886A58C9276A760600A1099B /* SubscriptionReceiptCredentialRedemptionJob.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionReceiptCredentialRedemptionJob.swift; sourceTree = ""; }; 886CB07724E77E5B00753909 /* silence.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = silence.aiff; sourceTree = ""; }; @@ -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 = ""; @@ -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 = ""; }; + 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 = ""; + }; 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 */, diff --git a/Signal/Images.xcassets/add-reaction-outline-24.imageset/Contents.json b/Signal/Images.xcassets/add-reaction-outline-24.imageset/Contents.json new file mode 100644 index 0000000000..5a929fd630 --- /dev/null +++ b/Signal/Images.xcassets/add-reaction-outline-24.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "add-reaction-outline-24.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Signal/Images.xcassets/add-reaction-outline-24.imageset/add-reaction-outline-24.pdf b/Signal/Images.xcassets/add-reaction-outline-24.imageset/add-reaction-outline-24.pdf new file mode 100644 index 0000000000000000000000000000000000000000..b41e82b2b7705e90a51518239cb2ce87e268b0af GIT binary patch literal 4400 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 literal 0 HcmV?d00001 diff --git a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplySheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplySheet.swift deleted file mode 100644 index 91d5c512cd..0000000000 --- a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplySheet.swift +++ /dev/null @@ -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 - } -} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplyCell.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyCell.swift similarity index 100% rename from Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplyCell.swift rename to Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyCell.swift diff --git a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplyLoader.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyLoader.swift similarity index 97% rename from Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplyLoader.swift rename to Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyLoader.swift index 5ead3037dc..55ac3f1a91 100644 --- a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplyLoader.swift +++ b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyLoader.swift @@ -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) diff --git a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift new file mode 100644 index 0000000000..c615fcb057 --- /dev/null +++ b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplySheet.swift @@ -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 ?? [] + } +} diff --git a/Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplyViewItem.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift similarity index 100% rename from Signal/src/ViewControllers/HomeView/Stories/Context View/StoryGroupReplyViewItem.swift rename to Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryGroupReplyViewItem.swift diff --git a/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryReplyInputToolbar.swift b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryReplyInputToolbar.swift new file mode 100644 index 0000000000..fb6572319f --- /dev/null +++ b/Signal/src/ViewControllers/HomeView/Stories/Group Reply Sheet/StoryReplyInputToolbar.swift @@ -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() + } +} diff --git a/SignalMessaging/utils/ThreadUtil.h b/SignalMessaging/utils/ThreadUtil.h index 7c078e6a12..ed79b73f6d 100644 --- a/SignalMessaging/utils/ThreadUtil.h +++ b/SignalMessaging/utils/ThreadUtil.h @@ -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; diff --git a/SignalMessaging/utils/ThreadUtil.m b/SignalMessaging/utils/ThreadUtil.m index e445a89bd3..22a8e43913 100644 --- a/SignalMessaging/utils/ThreadUtil.m +++ b/SignalMessaging/utils/ThreadUtil.m @@ -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]; diff --git a/SignalServiceKit/src/Devices/OWSRecordTranscriptJob.m b/SignalServiceKit/src/Devices/OWSRecordTranscriptJob.m index f5421c3d45..0d06331ac6 100644 --- a/SignalServiceKit/src/Devices/OWSRecordTranscriptJob.m +++ b/SignalServiceKit/src/Devices/OWSRecordTranscriptJob.m @@ -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) { diff --git a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m index bc8257ab8e..7d26f58464 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m +++ b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.m @@ -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 diff --git a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.swift b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.swift index 86adac63fb..43e2fa99f5 100644 --- a/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.swift +++ b/SignalServiceKit/src/Messages/Interactions/TSOutgoingMessage.swift @@ -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 diff --git a/SignalServiceKit/src/Storage/Database/Records/InteractionFinder.swift b/SignalServiceKit/src/Storage/Database/Records/InteractionFinder.swift index 21e44a0cfd..0a56c12ad5 100644 --- a/SignalServiceKit/src/Storage/Database/Records/InteractionFinder.swift +++ b/SignalServiceKit/src/Storage/Database/Records/InteractionFinder.swift @@ -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 ) """ diff --git a/SignalServiceKit/src/TestUtils/Factories.swift b/SignalServiceKit/src/TestUtils/Factories.swift index 81d3536e2c..b7a7a17c99 100644 --- a/SignalServiceKit/src/TestUtils/Factories.swift +++ b/SignalServiceKit/src/TestUtils/Factories.swift @@ -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 diff --git a/SignalUI/Views/InputAccessoryViewPlaceholder.swift b/SignalUI/Views/InputAccessoryViewPlaceholder.swift index 7cc0981db6..fbafa3e81b 100644 --- a/SignalUI/Views/InputAccessoryViewPlaceholder.swift +++ b/SignalUI/Views/InputAccessoryViewPlaceholder.swift @@ -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 } diff --git a/SignalUI/Views/Mentions/MentionPicker.swift b/SignalUI/Views/Mentions/MentionPicker.swift index 6db1b80105..b90ae57255 100644 --- a/SignalUI/Views/Mentions/MentionPicker.swift +++ b/SignalUI/Views/Mentions/MentionPicker.swift @@ -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)