Set story hidden state from stories view controller context menu

* Set hidden state from stories view controller context menu

* fix toasts when shown on controllers with UIScrollViews as their root views

* delete when hiding the onboarding story

* run translations script

* respond to pr feedback

* add hidden state to systemStoryManager

* use hidden state from SystemStoryManager for onboarding story

* remove unused method
This commit is contained in:
Harry 2022-09-06 09:48:06 -07:00 committed by GitHub
parent 9158e44d66
commit 87d9e5df2a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 351 additions and 27 deletions

View File

@ -773,6 +773,7 @@
661396AF28BE881E00E0C4DF /* ChainedPromiseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 661396AE28BE881E00E0C4DF /* ChainedPromiseTest.swift */; };
668CAB3E289983520085A2C3 /* AudioMessagePlaybackRateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668CAB3D289983520085A2C3 /* AudioMessagePlaybackRateView.swift */; };
668FE09B28B923A4008B9071 /* Bool+SSK.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668FE09A28B923A4008B9071 /* Bool+SSK.swift */; };
668FE09F28B947ED008B9071 /* StoryHidingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 668FE09E28B947ED008B9071 /* StoryHidingManager.swift */; };
669E8FE828B4153C00043D28 /* OWSURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669E8FE728B4153B00043D28 /* OWSURLSession.swift */; };
669E8FE928B415C000043D28 /* OWSURLBuilderUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669E8FDB28B02CC400043D28 /* OWSURLBuilderUtil.swift */; };
669E8FED28B4177900043D28 /* OWSSignalServiceMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 669E8FEC28B4177800043D28 /* OWSSignalServiceMock.swift */; };
@ -3026,6 +3027,7 @@
668AB0CB28AD610600B31984 /* StoryAuthorUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryAuthorUtil.swift; sourceTree = "<group>"; };
668CAB3D289983520085A2C3 /* AudioMessagePlaybackRateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioMessagePlaybackRateView.swift; sourceTree = "<group>"; };
668FE09A28B923A4008B9071 /* Bool+SSK.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bool+SSK.swift"; sourceTree = "<group>"; };
668FE09E28B947ED008B9071 /* StoryHidingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoryHidingManager.swift; sourceTree = "<group>"; };
669E8FDB28B02CC400043D28 /* OWSURLBuilderUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OWSURLBuilderUtil.swift; sourceTree = "<group>"; };
669E8FE528B4149200043D28 /* OWSURLSessionMock.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSURLSessionMock.swift; sourceTree = "<group>"; };
669E8FE728B4153B00043D28 /* OWSURLSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = OWSURLSession.swift; sourceTree = "<group>"; };
@ -6051,6 +6053,7 @@
children = (
8864072F27F21AA7009916B6 /* Group Reply Sheet */,
88423A51280A171E007D2918 /* StoryDirectReplySheet.swift */,
668FE09E28B947ED008B9071 /* StoryHidingManager.swift */,
88B00D4E28A33B5800BC9CA0 /* StoryPrivateViewsSheet.swift */,
8864073027F21AD7009916B6 /* StoryReplyInputToolbar.swift */,
88423A53280A2675007D2918 /* StoryReplyPreviewView.swift */,
@ -10367,6 +10370,7 @@
8864072827EEA658009916B6 /* StoryGroupReplySheet.swift in Sources */,
88B00D4B28A32DB600BC9CA0 /* StoryGroupReplyViewController.swift in Sources */,
8864072C27F0DA38009916B6 /* StoryGroupReplyViewItem.swift in Sources */,
668FE09F28B947ED008B9071 /* StoryHidingManager.swift in Sources */,
88863A52280CAE6A00977F69 /* StoryInteractiveTransitionCoordinator.swift in Sources */,
884DB95227DE67D900C6A309 /* StoryItemMediaView.swift in Sources */,
884DB94F27DE67BB00C6A309 /* StoryPageViewController.swift in Sources */,

View File

@ -0,0 +1,190 @@
//
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
//
import Foundation
import UIKit
class StoryHidingManager: Dependencies {
private let model: StoryViewModel
init(model: StoryViewModel) {
self.model = model
}
public func contextMenuAction(
forPresentingController presentingController: UIViewController
) -> ContextMenuAction {
let isHidden = model.isHidden
let title: String
let image: UIImage
if isHidden {
title = NSLocalizedString(
"STORIES_UNHIDE_STORY_ACTION",
comment: "Context menu action to unhide the selected story"
)
image = Theme.iconImage(.checkCircle)
} else {
title = NSLocalizedString(
"STORIES_HIDE_STORY_ACTION",
comment: "Context menu action to hide the selected story"
)
image = Theme.iconImage(.xCircle24)
}
return .init(
title: title,
image: image,
handler: { [self, weak presentingController] _ in
guard let presentingController = presentingController else {
return
}
if isHidden {
self.setHideStateAndShowToast(shouldHide: !isHidden, presentingController: presentingController)
} else {
self.showActionSheetIfNeeded(shouldHide: !isHidden, on: presentingController)
}
}
)
}
private func showActionSheetIfNeeded(shouldHide: Bool, on presentingController: UIViewController) {
guard shouldHide else {
// No need to show anything if unhiding, hide right away
setHideStateAndShowToast(shouldHide: shouldHide, presentingController: presentingController)
return
}
let actionSheet = createHidingActionSheetWithSneakyTransaction()
let actionTitle: String
if shouldHide {
actionTitle = NSLocalizedString(
"STORIES_HIDE_STORY_ACTION",
comment: "Context menu action to hide the selected story"
)
} else {
actionTitle = NSLocalizedString(
"STORIES_UNHIDE_STORY_ACTION",
comment: "Context menu action to unhide the selected story"
)
}
actionSheet.addAction(ActionSheetAction(
title: actionTitle,
style: .default,
handler: { [self, weak presentingController] _ in
guard let presentingController = presentingController else {
return
}
self.setHideStateAndShowToast(shouldHide: shouldHide, presentingController: presentingController)
}
))
actionSheet.addAction(OWSActionSheets.cancelAction)
presentingController.presentActionSheet(actionSheet)
}
// MARK: - Loading data
private func loadThread(_ transaction: SDSAnyReadTransaction) -> TSThread? {
switch model.context {
case .groupId(let groupId):
return TSGroupThread.fetch(groupId: groupId, transaction: transaction)
case .authorUuid(let authorUuid):
return TSContactThread.getWithContactAddress(
authorUuid.asSignalServiceAddress(),
transaction: transaction
)
case .privateStory:
owsFailDebug("Unexpectedly had private story when hiding")
return nil
case .none:
owsFailDebug("Unexpectedly missing context for story when hiding")
return nil
}
}
// MARK: - Header configuration
private func createHidingActionSheetWithSneakyTransaction() -> ActionSheetController {
return ActionSheetController(
title: NSLocalizedString(
"STORIES_HIDE_STORY_ACTION_SHEET_TITLE",
comment: "Title asking the user if they are sure they want to hide stories from another user"
),
message: loadThreadDisplayNameWithSneakyTransaction().map {
String(
format: NSLocalizedString(
"STORIES_HIDE_STORY_ACTION_SHEET_MESSAGE",
comment: "Message asking the user if they are sure they want to hide stories from {{other user's name}}"
),
$0
)
}
)
}
private func loadThreadDisplayNameWithSneakyTransaction() -> String? {
return Self.databaseStorage.read { transaction -> String? in
switch self.model.context {
case .groupId(let groupId):
return TSGroupThread.fetch(groupId: groupId, transaction: transaction)?.groupNameOrDefault
case .authorUuid(let authorUuid):
if authorUuid.asSignalServiceAddress().isSystemStoryAddress {
return NSLocalizedString(
"SYSTEM_ADDRESS_NAME",
comment: "Name to display for the 'system' sender, e.g. for release notes and the onboarding story"
)
}
return Self.contactsManager.shortDisplayName(
for: authorUuid.asSignalServiceAddress(),
transaction: transaction
)
case .privateStory:
owsFailDebug("Unexpectedly had private story when hiding")
return nil
case .none:
owsFailDebug("Unexpectedly missing context for story when hiding")
return nil
}
}
}
// MARK: - Issuing changes
private func setHideStateAndShowToast(
shouldHide: Bool,
presentingController: UIViewController
) {
Self.databaseStorage.write { transaction in
guard self.model.messages.first?.authorAddress.isSystemStoryAddress != true else {
// System stories go through SystemStoryManager
Self.systemStoryManager.setSystemStoriesHidden(shouldHide, transaction: transaction)
return
}
guard let thread = self.model.context.thread(transaction: transaction) else {
owsFailDebug("Hiding a story without a thread")
return
}
let threadAssociatedData = ThreadAssociatedData.fetchOrDefault(for: thread, transaction: transaction)
threadAssociatedData.updateWith(
hideStory: shouldHide,
updateStorageService: true,
transaction: transaction
)
}
let toastText: String
if shouldHide {
toastText = NSLocalizedString(
"STORIES_HIDE_STORY_CONFIRMATION_TOAST",
comment: "Toast shown when a story is successfuly hidden"
)
} else {
toastText = NSLocalizedString(
"STORIES_UNHIDE_STORY_CONFIRMATION_TOAST",
comment: "Toast shown when a story is successfuly unhidden"
)
}
presentingController.presentToast(text: toastText)
}
}

View File

@ -507,21 +507,18 @@ extension StoriesViewController: StoryPageViewControllerDataSource {
extension StoriesViewController: ContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: ContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> ContextMenuConfiguration? {
guard let indexPath = tableView.indexPathForRow(at: location),
let model = model(for: indexPath) else { return nil }
guard
let indexPath = tableView.indexPathForRow(at: location),
let model = model(for: indexPath)
else {
return nil
}
return .init(identifier: indexPath as NSCopying) { _ in
var actions = [ContextMenuAction]()
actions.append(.init(
title: NSLocalizedString(
"STORIES_HIDE_STORY_ACTION",
comment: "Context menu action to hide the selected story"),
image: Theme.iconImage(.xCircle24),
handler: { _ in
OWSActionSheets.showActionSheet(title: LocalizationNotNeeded("Hiding stories is not yet implemented."))
}))
actions.append(StoryHidingManager(model: model).contextMenuAction(forPresentingController: self))
func appendForwardAction() {
actions.append(.init(

View File

@ -11,6 +11,10 @@ struct StoryViewModel: Dependencies {
let messages: [StoryMessage]
let hasUnviewedMessages: Bool
// NOTE: "hidden" stories are still shown,
// just in a separate section thats collapsed by default.
let isHidden: Bool
let latestMessageAttachment: StoryThumbnailView.Attachment
let hasReplies: Bool
let latestMessageName: String
@ -44,6 +48,14 @@ struct StoryViewModel: Dependencies {
latestMessageAttachment = .from(latestMessage.attachment, transaction: transaction)
latestMessageTimestamp = latestMessage.timestamp
latestMessageViewedTimestamp = latestMessage.localUserViewedTimestamp
if latestMessage.authorAddress.isSystemStoryAddress {
self.isHidden = Self.systemStoryManager.areSystemStoriesHidden(transaction: transaction)
} else {
self.isHidden = context.thread(transaction: transaction).map {
return ThreadAssociatedData.fetchOrDefault(for: $0, transaction: transaction).hideStory
} ?? false
}
}
func copy(updatedMessages: [StoryMessage], deletedMessageRowIds: [Int64], transaction: SDSAnyReadTransaction) throws -> Self? {

View File

@ -6160,6 +6160,15 @@
/* Context menu action to hide the selected story */
"STORIES_HIDE_STORY_ACTION" = "Hide Story";
/* Message asking the user if they are sure they want to hide stories from {{other user's name}} */
"STORIES_HIDE_STORY_ACTION_SHEET_MESSAGE" = "New story updates from %@ won't appear at the top of the stories list anymore.";
/* Title asking the user if they are sure they want to hide stories from another user */
"STORIES_HIDE_STORY_ACTION_SHEET_TITLE" = "Hide Story?";
/* Toast shown when a story is successfuly hidden */
"STORIES_HIDE_STORY_CONFIRMATION_TOAST" = "Story Hidden";
/* Indicates that there are no recent stories to render */
"STORIES_NO_RECENT_MESSAGES" = "No updates to show right now.\nTap + to add to your story.";
@ -6205,6 +6214,12 @@
/* Title for the stories view. */
"STORIES_TITLE" = "Stories";
/* Context menu action to unhide the selected story */
"STORIES_UNHIDE_STORY_ACTION" = "Unhide Story";
/* Toast shown when a story is successfuly unhidden */
"STORIES_UNHIDE_STORY_CONFIRMATION_TOAST" = "Story Unhidden";
/* Title text for the 'views' tab on the stories views & replies sheet */
"STORIES_VIEWS_TAB" = "Views";

View File

@ -85,6 +85,53 @@ public class SystemStoryManager: NSObject, Dependencies, SystemStoryManagerProto
}.map(on: queue) { _ in () }
}
// MARK: Hidden State
private var stateChangeObservers = [SystemStoryStateChangeObserver]()
public func addStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
stateChangeObservers.append(observer)
}
public func removeStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
stateChangeObservers.removeAll(where: { $0 == observer })
}
public func areSystemStoriesHidden(transaction: SDSAnyReadTransaction) -> Bool {
// No need to make this serial with the other calls, db transactions cover us.
kvStore.getBool(Constants.kvStoreHiddenStateKey, defaultValue: false, transaction: transaction)
}
public func setSystemStoriesHidden(_ hidden: Bool, transaction: SDSAnyWriteTransaction) {
var changedRowIds = [Int64]()
defer {
DispatchQueue.main.async {
self.stateChangeObservers.forEach { $0.systemStoryHiddenStateDidChange(rowIds: changedRowIds) }
}
}
// No need to make this serial with the other calls, db transactions cover us.
kvStore.setBool(hidden, key: Constants.kvStoreHiddenStateKey, transaction: transaction)
guard
let rawStatus = kvStore.getData(Constants.kvStoreOnboardingStoryStatusKey, transaction: transaction),
let onboardingStatus = try? JSONDecoder().decode(DownloadStatus.self, from: rawStatus),
let messageUniqueIds = onboardingStatus.messageUniqueIds,
!messageUniqueIds.isEmpty
else {
return
}
let stories = StoryFinder.listStoriesWithUniqueIds(messageUniqueIds, transaction: transaction)
stories.forEach {
if hidden {
$0.markAsViewed(at: Date().ows_millisecondsSince1970, circumstance: .onThisDevice, transaction: transaction)
}
if let rowId = $0.id {
changedRowIds.append(rowId)
}
}
}
// MARK: - Event Observation
private var isObservingBackgrounding = false
@ -159,26 +206,38 @@ public class SystemStoryManager: NSObject, Dependencies, SystemStoryManagerProto
}
private func checkDownloadStatus() -> Promise<DownloadStatus> {
return databaseStorage.write(.promise) { [kvStore] transaction in
guard
let rawStatus = kvStore.getData(Constants.kvStoreStatusKey, transaction: transaction),
let status = try? JSONDecoder().decode(DownloadStatus.self, from: rawStatus)
else {
return .requiresDownload
return databaseStorage.write(.promise) { [weak self] transaction in
guard let strongSelf = self else {
throw OWSAssertionError("SystemStoryManager unretained")
}
if status.isDownloaded {
// clean up opportunistically.
try? self.cleanUpStoriesIfNeeded(
messageUniqueIds: status.messageUniqueIds,
transaction: transaction
)
}
return status
return strongSelf.checkDownloadStatus(forceDeleteIfDownloaded: false, transaction: transaction)
}
}
private func checkDownloadStatus(
forceDeleteIfDownloaded: Bool,
transaction: SDSAnyWriteTransaction
) -> DownloadStatus {
guard
let rawStatus = kvStore.getData(Constants.kvStoreOnboardingStoryStatusKey, transaction: transaction),
let status = try? JSONDecoder().decode(DownloadStatus.self, from: rawStatus)
else {
return .requiresDownload
}
if status.isDownloaded {
// clean up opportunistically.
try? self.cleanUpStoriesIfNeeded(
messageUniqueIds: status.messageUniqueIds,
forceDeleteIfDownloaded: forceDeleteIfDownloaded,
transaction: transaction
)
}
return status
}
private func cleanUpStoriesIfNeeded(
messageUniqueIds: [String]?,
forceDeleteIfDownloaded: Bool,
transaction: SDSAnyWriteTransaction
) throws {
guard let messageUniqueIds = messageUniqueIds, !messageUniqueIds.isEmpty else {
@ -189,11 +248,18 @@ public class SystemStoryManager: NSObject, Dependencies, SystemStoryManagerProto
guard !stories.isEmpty else {
return
}
var shouldDelete = forceDeleteIfDownloaded
// If they exist and are expired, delete them.
if
let minViewTime = stories.lazy.compactMap(\.localUserViewedTimestamp).min(),
Date().timeIntervalSince(Date(millisecondsSince1970: minViewTime)) >= Constants.postViewingTimeout
{
shouldDelete = true
}
if shouldDelete {
stories.forEach {
$0.sdsRemove(transaction: transaction)
}
@ -305,13 +371,14 @@ public class SystemStoryManager: NSObject, Dependencies, SystemStoryManagerProto
) throws {
try kvStore.setData(
JSONEncoder().encode(DownloadStatus(messageUniqueIds: messageUniqueIds)),
key: Constants.kvStoreStatusKey,
key: Constants.kvStoreOnboardingStoryStatusKey,
transaction: transaction
)
}
internal enum Constants {
static let kvStoreStatusKey = "OnboardingStoryStatus"
static let kvStoreOnboardingStoryStatusKey = "OnboardingStoryStatus"
static let kvStoreHiddenStateKey = "SystemStoriesAreHidden"
static let manifestPath = "dynamic/ios/stories/onboarding/manifest.json"
static let manifestVersionKey = "version"

View File

@ -24,6 +24,22 @@ public class SystemStoryManagerMock: NSObject, SystemStoryManagerProtocol {
public func cleanUpOnboardingStoryIfNeeded() -> Promise<Void> {
return cleanUpOnboardingStoryHandler()
}
public func addStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
fatalError("Unimplemented for tests")
}
public func removeStateChangedObserver(_ observer: SystemStoryStateChangeObserver) {
fatalError("Unimplemented for tests")
}
public func areSystemStoriesHidden(transaction: SDSAnyReadTransaction) -> Bool {
fatalError("Unimplemented for tests")
}
public func setSystemStoriesHidden(_ hidden: Bool, transaction: SDSAnyWriteTransaction) {
fatalError("Unimplemented for tests")
}
}
public class OnboardingStoryManagerFilesystemMock: OnboardingStoryManagerFilesystem {

View File

@ -19,4 +19,20 @@ public protocol SystemStoryManagerProtocol: SystemStoryManagerProtocolObjc {
/// to be expired, deletes it and cleans up references.
/// Called on its own when the app is backgrounded.
func cleanUpOnboardingStoryIfNeeded() -> Promise<Void>
// MARK: Hidden State
func addStateChangedObserver(_ observer: SystemStoryStateChangeObserver)
func removeStateChangedObserver(_ observer: SystemStoryStateChangeObserver)
func areSystemStoriesHidden(transaction: SDSAnyReadTransaction) -> Bool
/// Sets system stories hidden state. If hiding, marks the onboarding story as viewed.
func setSystemStoriesHidden(_ hidden: Bool, transaction: SDSAnyWriteTransaction)
}
public protocol SystemStoryStateChangeObserver: NSObject {
func systemStoryHiddenStateDidChange(rowIds: [Int64])
}

View File

@ -35,9 +35,16 @@ public class ToastController: NSObject, ToastViewDelegate {
owsAssertDebug(edge == .bottom || edge == .top)
let offset = (edge == .top) ? inset : -inset
// Add to the first non-scrollview in the hierarchy, but still pin to the original view.
// We don't want the toast to be a subview of any scrollview or it will be subject to scrolling.
var parentView = view
while parentView is UIScrollView, let superview = view.superview {
parentView = superview
}
Logger.debug("")
toastView.alpha = 0
view.addSubview(toastView)
parentView.addSubview(toastView)
toastView.setCompressionResistanceHigh()
toastView.autoPinEdge(edge, to: edge, of: view, withOffset: offset)
toastView.autoPinWidthToSuperview(withMargin: 8)