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 0000000000..b41e82b2b7 Binary files /dev/null and b/Signal/Images.xcassets/add-reaction-outline-24.imageset/add-reaction-outline-24.pdf differ 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)