From ceb89e65d2bcf65d37e2bc331fac4a199cf4ef09 Mon Sep 17 00:00:00 2001 From: faroukbmiled Date: Thu, 9 Apr 2026 18:46:21 +0100 Subject: [PATCH] feat: Per-user story seen-receipt exclusions feat: Story seen button mode (button / toggle) feat: Long-press menu on the story seen button (mark seen, exclude, settings) feat: Auto mark-seen on exclude for both stories and DM chats imp: Cleaner exclusion menu wording across stories and DMs imp: Tweak settings now update in real time for exclude ui imp: Ability to batch select in both stories and messages exclude UI --- README.md | 4 +- .../StoriesAndMessages/DisableStorySeen.x | 36 ++- .../StoriesAndMessages/ExcludeFromSeen.x | 3 +- .../StoriesAndMessages/ExcludeFromStorySeen.x | 251 +++++++++++++++++ .../StoriesAndMessages/OverlayButtons.xm | 258 ++++++++++++++---- .../SCIExcludedStoryUsers.h | 18 ++ .../SCIExcludedStoryUsers.m | 65 +++++ src/Features/StoriesAndMessages/SeenButtons.x | 10 +- src/Settings/SCIExcludedChatsViewController.m | 95 ++++++- .../SCIExcludedStoryUsersViewController.h | 4 + .../SCIExcludedStoryUsersViewController.m | 179 ++++++++++++ src/Settings/SCISetting.h | 2 + src/Settings/SCISettingsBackup.m | 1 + src/Settings/SCISettingsViewController.m | 7 +- src/Settings/TweakSettings.m | 80 +++++- src/Tweak.x | 9 +- src/Utils.m | 1 + 17 files changed, 923 insertions(+), 100 deletions(-) create mode 100644 src/Features/StoriesAndMessages/ExcludeFromStorySeen.x create mode 100644 src/Features/StoriesAndMessages/SCIExcludedStoryUsers.h create mode 100644 src/Features/StoriesAndMessages/SCIExcludedStoryUsers.m create mode 100644 src/Settings/SCIExcludedStoryUsersViewController.h create mode 100644 src/Settings/SCIExcludedStoryUsersViewController.m diff --git a/README.md b/README.md index 31e66ae..16b16bc 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,9 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Disable screenshot detection - Disable story seen receipt (blocks network upload, toggleable at runtime without restart) **\*** - Keep stories visually unseen — keeps the colorful ring in the tray after viewing **\*** -- Manual mark story as seen — button on story overlay to selectively mark stories as seen **\*** +- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\*** +- Long-press the story seen button for quick actions **\*** +- Per-user story seen-receipt exclusions — exclude specific users so their stories behave normally. Manage via 3-dot menu, eye button long-press, or settings list **\*** - Stop story auto-advance — stories won't auto-skip when the timer ends **\*** - Story download button — download directly from the story overlay **\*** - Download disappearing DM media (photos + videos) **\*** diff --git a/src/Features/StoriesAndMessages/DisableStorySeen.x b/src/Features/StoriesAndMessages/DisableStorySeen.x index 3ccf025..7b5f17e 100644 --- a/src/Features/StoriesAndMessages/DisableStorySeen.x +++ b/src/Features/StoriesAndMessages/DisableStorySeen.x @@ -6,8 +6,16 @@ BOOL sciSeenBypassActive = NO; BOOL sciAdvanceBypassActive = NO; +BOOL sciStorySeenToggleEnabled = NO; // toggle-mode session bypass NSMutableSet *sciAllowedSeenPKs = nil; +extern BOOL sciIsCurrentStoryOwnerExcluded(void); +extern BOOL sciIsObjectStoryOwnerExcluded(id obj); + +static BOOL sciStorySeenToggleBypass(void) { + return [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"] && sciStorySeenToggleEnabled; +} + void sciAllowSeenForPK(id media) { if (!media) return; id pk = sciCall(media, @selector(pk)); @@ -25,14 +33,28 @@ static BOOL sciIsPKAllowed(id media) { static BOOL sciShouldBlockSeenNetwork() { if (sciSeenBypassActive) return NO; + if (sciStorySeenToggleBypass()) return NO; + if (sciIsCurrentStoryOwnerExcluded()) return NO; return [SCIUtils getBoolPref:@"no_seen_receipt"]; } static BOOL sciShouldBlockSeenVisual() { if (sciSeenBypassActive) return NO; + if (sciStorySeenToggleBypass()) return NO; + if (sciIsCurrentStoryOwnerExcluded()) return NO; return [SCIUtils getBoolPref:@"no_seen_receipt"] && [SCIUtils getBoolPref:@"no_seen_visual"]; } +// Per-instance gating for tray/item/ring hooks where the "current" story +// VC may not be the owner of the model in question. +static BOOL sciShouldBlockSeenVisualForObj(id obj) { + if (sciSeenBypassActive) return NO; + if (sciStorySeenToggleBypass()) return NO; + if (![SCIUtils getBoolPref:@"no_seen_receipt"] || ![SCIUtils getBoolPref:@"no_seen_visual"]) return NO; + if (sciIsObjectStoryOwnerExcluded(obj)) return NO; + return YES; +} + // network seen blocking %hook IGStorySeenStateUploader - (void)uploadSeenStateWithMedia:(id)arg1 { @@ -79,16 +101,16 @@ static BOOL sciShouldBlockSeenVisual() { %end %hook IGStoryTrayViewModel -- (void)markAsSeen { if (sciShouldBlockSeenVisual()) return; %orig; } -- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(YES); return; } %orig; } -- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisual()) return YES; return %orig; } -- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; } -- (BOOL)isSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; } +- (void)markAsSeen { if (sciShouldBlockSeenVisualForObj(self)) return; %orig; } +- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(YES); return; } %orig; } +- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisualForObj(self)) return YES; return %orig; } +- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; } +- (BOOL)isSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; } %end %hook IGStoryItem -- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; } -- (BOOL)hasSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; } +- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; } +- (BOOL)hasSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; } %end %hook IGStoryGradientRingView diff --git a/src/Features/StoriesAndMessages/ExcludeFromSeen.x b/src/Features/StoriesAndMessages/ExcludeFromSeen.x index f3d8b8a..301590e 100644 --- a/src/Features/StoriesAndMessages/ExcludeFromSeen.x +++ b/src/Features/StoriesAndMessages/ExcludeFromSeen.x @@ -73,8 +73,7 @@ static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) { UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray *suggested) { UIMenu *base = origProvider ? origProvider(suggested) : [UIMenu menuWithChildren:suggested]; BOOL excluded = [SCIExcludedThreads isThreadIdExcluded:tid]; - NSString *title = excluded ? @"Remove from read-receipt exclusion" - : @"Add to read-receipt exclusion"; + NSString *title = excluded ? @"Un-exclude chat" : @"Exclude chat"; UIImage *img = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"]; UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil handler:^(__kindof UIAction *_) { diff --git a/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x b/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x new file mode 100644 index 0000000..29c721a --- /dev/null +++ b/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x @@ -0,0 +1,251 @@ +// Per-user story seen-receipt exclusions. Excluded users' stories behave +// normally (your view appears in their viewer list). Provides owner detection +// helpers, 3-dot menu injection, and overlay refresh utilities. + +#import "../../Utils.h" +#import "../../InstagramHeaders.h" +#import "StoryHelpers.h" +#import "SCIExcludedStoryUsers.h" +#import +#import +#import + +NSDictionary *sciOwnerInfoFromObject(id obj); + +// ============ Active story VC tracking ============ + +__weak UIViewController *sciActiveStoryViewerVC = nil; + +%hook IGStoryViewerViewController +- (void)viewDidAppear:(BOOL)animated { + %orig; + sciActiveStoryViewerVC = self; +} +- (void)viewWillDisappear:(BOOL)animated { + if (sciActiveStoryViewerVC == (UIViewController *)self) sciActiveStoryViewerVC = nil; + %orig; +} +%end + +// ============ Owner extraction ============ + +NSDictionary *sciOwnerInfoFromObject(id obj) { + if (!obj) return nil; + @try { + id pk = nil, un = nil, fn = nil; + if ([obj respondsToSelector:@selector(pk)]) + pk = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(pk)); + if ([obj respondsToSelector:@selector(username)]) + un = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(username)); + if ([obj respondsToSelector:@selector(fullName)]) + fn = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(fullName)); + if (pk && un) { + return @{ @"pk": [NSString stringWithFormat:@"%@", pk], + @"username": [NSString stringWithFormat:@"%@", un], + @"fullName": fn ? [NSString stringWithFormat:@"%@", fn] : @"" }; + } + NSArray *nestedKeys = @[@"user", @"owner", @"author", @"reelUser", @"reelOwner"]; + for (NSString *k in nestedKeys) { + @try { + id sub = [obj valueForKey:k]; + if (sub && sub != obj) { + NSDictionary *d = sciOwnerInfoFromObject(sub); + if (d) return d; + } + } @catch (__unused id e) {} + } + } @catch (__unused id e) {} + return nil; +} + +NSDictionary *sciOwnerInfoForStoryVC(UIViewController *vc) { + if (!vc) return nil; + @try { + id vm = ((id(*)(id, SEL))objc_msgSend)(vc, @selector(currentViewModel)); + if (!vm) return nil; + id owner = nil; + @try { owner = [vm valueForKey:@"owner"]; } @catch (__unused id e) {} + if (!owner) return nil; + return sciOwnerInfoFromObject(owner); + } @catch (__unused id e) { return nil; } +} + +NSDictionary *sciCurrentStoryOwnerInfo(void) { + return sciOwnerInfoForStoryVC(sciActiveStoryViewerVC); +} + +// Find the section controller for a specific cell via ivar scan. +static id sciFindSectionControllerForCell(UICollectionViewCell *cell) { + Class sectionClass = NSClassFromString(@"IGStoryFullscreenSectionController"); + if (!sectionClass || !cell) return nil; + unsigned int cCount = 0; + Ivar *cIvars = class_copyIvarList([cell class], &cCount); + for (unsigned int i = 0; i < cCount; i++) { + const char *type = ivar_getTypeEncoding(cIvars[i]); + if (!type || type[0] != '@') continue; + id val = object_getIvar(cell, cIvars[i]); + if (!val) continue; + if ([val isKindOfClass:sectionClass]) { free(cIvars); return val; } + unsigned int vCount = 0; + Ivar *vIvars = class_copyIvarList([val class], &vCount); + for (unsigned int j = 0; j < vCount; j++) { + const char *type2 = ivar_getTypeEncoding(vIvars[j]); + if (!type2 || type2[0] != '@') continue; + id val2 = object_getIvar(val, vIvars[j]); + if (val2 && [val2 isKindOfClass:sectionClass]) { free(vIvars); free(cIvars); return val2; } + } + if (vIvars) free(vIvars); + } + if (cIvars) free(cIvars); + return nil; +} + +static NSDictionary *sciOwnerInfoFromSectionController(id sc) { + if (!sc) return nil; + NSArray *tryKeys = @[@"viewModel", @"item", @"model", @"object"]; + for (NSString *k in tryKeys) { + @try { + id obj = [sc valueForKey:k]; + if (obj) { + NSDictionary *info = sciOwnerInfoFromObject(obj); + if (info) return info; + } + } @catch (__unused id e) {} + } + return sciOwnerInfoFromObject(sc); +} + +// Per-cell owner lookup: walks from the overlay to its IGStoryFullscreenCell, +// finds the cell's section controller, and reads the owner. Gives the correct +// owner even when multiple cells are alive (pre-loaded adjacent reels). +NSDictionary *sciOwnerInfoForView(UIView *view) { + if (!view) return nil; + Class cellClass = NSClassFromString(@"IGStoryFullscreenCell"); + UIView *cur = view; + UICollectionViewCell *cell = nil; + while (cur) { + if (cellClass && [cur isKindOfClass:cellClass]) { cell = (UICollectionViewCell *)cur; break; } + cur = cur.superview; + } + if (cell) { + id sc = sciFindSectionControllerForCell(cell); + NSDictionary *info = sciOwnerInfoFromSectionController(sc); + if (info) return info; + } + // Fallback: VC's currentViewModel + UIViewController *vc = sciFindVC(view, @"IGStoryViewerViewController"); + return sciOwnerInfoForStoryVC(vc); +} + +BOOL sciIsCurrentStoryOwnerExcluded(void) { + NSDictionary *info = sciCurrentStoryOwnerInfo(); + if (!info) return NO; + return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]]; +} + +BOOL sciIsObjectStoryOwnerExcluded(id obj) { + NSDictionary *info = sciOwnerInfoFromObject(obj); + if (!info) return NO; + return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]]; +} + +// ============ Overlay utilities ============ + +void sciTriggerStoryMarkSeen(UIViewController *storyVC) { + if (!storyVC) return; + Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); + if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView"); + if (!overlayCls) return; + SEL markSel = @selector(sciMarkSeenTapped:); + NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view]; + while (stack.count) { + UIView *v = stack.lastObject; [stack removeLastObject]; + if ([v isKindOfClass:overlayCls] && [v respondsToSelector:markSel]) { + ((void(*)(id, SEL, id))objc_msgSend)(v, markSel, nil); + return; + } + for (UIView *sub in v.subviews) [stack addObject:sub]; + } +} + +void sciRefreshAllVisibleOverlays(UIViewController *storyVC) { + if (!storyVC) return; + Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); + if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView"); + if (!overlayCls) return; + SEL refreshSel = @selector(sciRefreshSeenButton); + NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view]; + while (stack.count) { + UIView *v = stack.lastObject; [stack removeLastObject]; + if ([v isKindOfClass:overlayCls] && [v respondsToSelector:refreshSel]) { + ((void(*)(id, SEL))objc_msgSend)(v, refreshSel); + } + for (UIView *sub in v.subviews) [stack addObject:sub]; + } +} + +// ============ 3-dot menu injection ============ +// Hooks into the existing IGDSMenu hook in Tweak.x via sciMaybeAppendStoryExcludeMenuItem. +// Always present regardless of master toggle (fallback when eye affordance is hidden). + +NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) { + if (!sciActiveStoryViewerVC) return items; + BOOL looksLikeStoryHeader = NO; + for (id it in items) { + @try { + id title = [it valueForKey:@"title"]; + NSString *t = [NSString stringWithFormat:@"%@", title ?: @""]; + if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] || + [t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] || + [t isEqualToString:@"Hide"]) { + looksLikeStoryHeader = YES; break; + } + } @catch (__unused id e) {} + } + if (!looksLikeStoryHeader) return items; + + NSDictionary *ownerInfo = sciCurrentStoryOwnerInfo(); + if (!ownerInfo) return items; + + NSString *pk = ownerInfo[@"pk"]; + NSString *username = ownerInfo[@"username"] ?: @""; + NSString *fullName = ownerInfo[@"fullName"] ?: @""; + // Bypass master toggle so the 3-dot fallback always shows + BOOL excluded = NO; + for (NSDictionary *e in [SCIExcludedStoryUsers allEntries]) { + if ([e[@"pk"] isEqualToString:pk]) { excluded = YES; break; } + } + + Class menuItemCls = NSClassFromString(@"IGDSMenuItem"); + if (!menuItemCls) return items; + + NSString *title = excluded ? @"Un-exclude story seen" : @"Exclude story seen"; + + __weak UIViewController *weakVC = sciActiveStoryViewerVC; + void (^handler)(void) = ^{ + if (excluded) { + [SCIExcludedStoryUsers removePK:pk]; + [SCIUtils showToastForDuration:2.0 title:@"Un-excluded"]; + } else { + [SCIExcludedStoryUsers addOrUpdateEntry:@{ + @"pk": pk, @"username": username, @"fullName": fullName + }]; + [SCIUtils showToastForDuration:2.0 title:@"Excluded"]; + sciTriggerStoryMarkSeen(weakVC); + } + sciRefreshAllVisibleOverlays(weakVC); + }; + + id newItem = nil; + @try { + SEL initSel = @selector(initWithTitle:image:handler:); + typedef id (*Init)(id, SEL, id, id, id); + newItem = ((Init)objc_msgSend)([menuItemCls alloc], initSel, title, nil, handler); + } @catch (__unused id e) { newItem = nil; } + + if (!newItem) return items; + + NSMutableArray *newItems = [items mutableCopy] ?: [NSMutableArray array]; + [newItems addObject:newItem]; + return [newItems copy]; +} diff --git a/src/Features/StoriesAndMessages/OverlayButtons.xm b/src/Features/StoriesAndMessages/OverlayButtons.xm index 70e3b56..4c6461a 100644 --- a/src/Features/StoriesAndMessages/OverlayButtons.xm +++ b/src/Features/StoriesAndMessages/OverlayButtons.xm @@ -1,11 +1,19 @@ // Download + mark seen buttons on story/DM visual message overlay #import "StoryHelpers.h" #import "SCIExcludedThreads.h" +#import "SCIExcludedStoryUsers.h" extern "C" BOOL sciSeenBypassActive; extern "C" BOOL sciAdvanceBypassActive; extern "C" NSMutableSet *sciAllowedSeenPKs; extern "C" void sciAllowSeenForPK(id); +extern "C" BOOL sciIsCurrentStoryOwnerExcluded(void); +extern "C" NSDictionary *sciCurrentStoryOwnerInfo(void); +extern "C" NSDictionary *sciOwnerInfoForView(UIView *view); +extern "C" BOOL sciStorySeenToggleEnabled; +extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC); +extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC); +extern "C" __weak UIViewController *sciActiveStoryViewerVC; static SCIDownloadDelegate *sciStoryVideoDl = nil; static SCIDownloadDelegate *sciStoryImageDl = nil; @@ -41,7 +49,6 @@ static void sciDownloadWithConfirm(void(^block)(void)) { } } -// get media from DM visual message VC static void sciDownloadDMVisualMessage(UIViewController *dmVC) { Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource"); id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil; @@ -50,7 +57,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil; if (!msg) return; - // video id rawVideo = sciCall(msg, @selector(rawVideo)); if (rawVideo) { NSURL *url = [SCIUtils getVideoUrl:rawVideo]; @@ -61,7 +67,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { } } - // photo via rawPhoto id rawPhoto = sciCall(msg, @selector(rawPhoto)); if (rawPhoto) { NSURL *url = [SCIUtils getPhotoUrl:rawPhoto]; @@ -72,7 +77,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { } } - // photo via imageSpecifier id imgSpec = sciCall(msg, NSSelectorFromString(@"imageSpecifier")); if (imgSpec) { NSURL *url = sciCall(imgSpec, @selector(url)); @@ -83,7 +87,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { } } - // photo via _visualMediaInfo._media Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo"); id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil; if (vmi) { @@ -100,11 +103,14 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { } %hook IGStoryFullscreenOverlayView + +// ============ Button injection ============ + - (void)didMoveToSuperview { %orig; if (!self.superview) return; - // download button + // Download button if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) { UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; btn.tag = 1340; @@ -125,50 +131,111 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { ]]; } - // mark seen button (stories: mark as seen, DMs: mark as viewed + dismiss) - // Skip for DM visual messages inside an excluded thread — the button - // would be a no-op there since we don't block visual seen anyway. - if ([SCIUtils getBoolPref:@"no_seen_receipt"] && ![self viewWithTag:1339] - && ![SCIExcludedThreads isActiveThreadExcluded]) { - UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; - btn.tag = 1339; - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; - [btn setImage:[UIImage systemImageNamed:@"eye" withConfiguration:cfg] forState:UIControlStateNormal]; - btn.tintColor = [UIColor whiteColor]; - btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4]; - btn.layer.cornerRadius = 18; - btn.clipsToBounds = YES; - btn.translatesAutoresizingMaskIntoConstraints = NO; - [btn addTarget:self action:@selector(sciMarkSeenTapped:) forControlEvents:UIControlEventTouchUpInside]; - [self addSubview:btn]; - UIView *dlBtn = [self viewWithTag:1340]; - if (dlBtn) { - [NSLayoutConstraint activateConstraints:@[ - [btn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor], - [btn.trailingAnchor constraintEqualToAnchor:dlBtn.leadingAnchor constant:-10], - [btn.widthAnchor constraintEqualToConstant:36], - [btn.heightAnchor constraintEqualToConstant:36] - ]]; - } else { - [NSLayoutConstraint activateConstraints:@[ - [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], - [btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], - [btn.widthAnchor constraintEqualToConstant:36], - [btn.heightAnchor constraintEqualToConstant:36] - ]]; - } + // Seen button — deferred so the responder chain is wired up + __weak UIView *weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + UIView *s = weakSelf; + if (s && s.superview) ((void(*)(id, SEL))objc_msgSend)(s, @selector(sciRefreshSeenButton)); + }); +} + +// ============ Seen button lifecycle ============ + +// Rebuilds the eye button (tag 1339) based on current owner + prefs. Idempotent. +%new - (void)sciRefreshSeenButton { + if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return; + if ([SCIExcludedThreads isActiveThreadExcluded]) return; + + NSDictionary *ownerInfo = sciOwnerInfoForView(self); + NSString *ownerPK = ownerInfo[@"pk"] ?: @""; + BOOL ownerExcluded = ownerInfo && [SCIExcludedStoryUsers isUserPKExcluded:ownerPK]; + BOOL hideForExcludedOwner = ownerExcluded && ![SCIUtils getBoolPref:@"story_excluded_show_unexclude_eye"]; + BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]; + + NSString *symName; + UIColor *tint; + if (ownerExcluded) { + symName = @"eye.slash.fill"; tint = SCIUtils.SCIColor_Primary; + } else if (toggleMode) { + symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye"; + tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor]; + } else { + symName = @"eye"; tint = [UIColor whiteColor]; + } + + UIButton *existing = (UIButton *)[self viewWithTag:1339]; + + if (hideForExcludedOwner) { + [existing removeFromSuperview]; + return; + } + + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; + + if (existing) { + [existing setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal]; + existing.tintColor = tint; + return; + } + + UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; + btn.tag = 1339; + [btn setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal]; + btn.tintColor = tint; + btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4]; + btn.layer.cornerRadius = 18; + btn.clipsToBounds = YES; + btn.translatesAutoresizingMaskIntoConstraints = NO; + [btn addTarget:self action:@selector(sciSeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; + UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc] + initWithTarget:self action:@selector(sciSeenButtonLongPressed:)]; + lp.minimumPressDuration = 0.4; + [btn addGestureRecognizer:lp]; + [self addSubview:btn]; + UIView *dlBtn = [self viewWithTag:1340]; + if (dlBtn) { + [NSLayoutConstraint activateConstraints:@[ + [btn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor], + [btn.trailingAnchor constraintEqualToAnchor:dlBtn.leadingAnchor constant:-10], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; + } else { + [NSLayoutConstraint activateConstraints:@[ + [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], + [btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; } } +// Refresh when story owner changes (overlay reuse across reels) +- (void)layoutSubviews { + %orig; + if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return; + static char kLastPKKey; + static char kLastExclKey; + NSDictionary *info = sciOwnerInfoForView(self); + NSString *pk = info[@"pk"] ?: @""; + BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk]; + NSString *prev = objc_getAssociatedObject(self, &kLastPKKey); + NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey); + BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded); + if (!changed) return; + objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC); + objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + ((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton)); +} + +// ============ Download handler ============ -// download handler — works for both stories and DM visual messages %new - (void)sciDownloadTapped:(UIButton *)sender { UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; [haptic impactOccurred]; [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); } completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }]; @try { - // story path id item = sciGetCurrentStoryItem(self); IGMedia *media = sciExtractMediaFromItem(item); if (media) { @@ -176,7 +243,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { return; } - // DM visual message path UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController"); if (dmVC) { sciDownloadDMVisualMessage(dmVC); @@ -189,18 +255,101 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { } } -// mark seen handler — stories: allow-list approach, DMs: trigger viewed + dismiss +// ============ Seen button tap ============ + +%new - (void)sciSeenButtonTapped:(UIButton *)sender { + NSDictionary *ownerInfo = sciOwnerInfoForView(self); + BOOL excluded = ownerInfo && [SCIExcludedStoryUsers isUserPKExcluded:ownerInfo[@"pk"]]; + + // Excluded owner: tap to un-exclude + if (excluded) { + UIViewController *host = [SCIUtils nearestViewControllerForView:self]; + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:@"Un-exclude story seen?" + message:[NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""] + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Un-exclude" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { + [SCIExcludedStoryUsers removePK:ownerInfo[@"pk"]]; + [SCIUtils showToastForDuration:2.0 title:@"Un-excluded"]; + sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC); + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [host presentViewController:alert animated:YES completion:nil]; + return; + } + + // Toggle mode + if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) { + sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled; + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; + [sender setImage:[UIImage systemImageNamed:(sciStorySeenToggleEnabled ? @"eye.fill" : @"eye") withConfiguration:cfg] forState:UIControlStateNormal]; + sender.tintColor = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor]; + [SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? @"Story read receipts enabled" : @"Story read receipts disabled"]; + return; + } + + // Button mode: mark seen once + ((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender); +} + +// ============ Seen button long-press menu ============ + +%new - (void)sciSeenButtonLongPressed:(UILongPressGestureRecognizer *)gr { + if (gr.state != UIGestureRecognizerStateBegan) return; + UIView *btn = gr.view; + UIViewController *host = [SCIUtils nearestViewControllerForView:self]; + if (!host) return; + UIWindow *capturedWin = btn.window ?: self.window; + if (!capturedWin) { + for (UIWindow *w in [UIApplication sharedApplication].windows) { if (w.isKeyWindow) { capturedWin = w; break; } } + } + NSDictionary *ownerInfo = sciOwnerInfoForView(self); + NSString *pk = ownerInfo[@"pk"]; + NSString *username = ownerInfo[@"username"] ?: @""; + NSString *fullName = ownerInfo[@"fullName"] ?: @""; + BOOL excluded = pk && [SCIExcludedStoryUsers isUserPKExcluded:pk]; + + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Mark seen" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + ((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn); + }]]; + if (pk) { + NSString *t = excluded ? @"Un-exclude story seen" : @"Exclude story seen"; + [sheet addAction:[UIAlertAction actionWithTitle:t style:excluded ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + if (excluded) { + [SCIExcludedStoryUsers removePK:pk]; + [SCIUtils showToastForDuration:2.0 title:@"Un-excluded"]; + } else { + [SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }]; + [SCIUtils showToastForDuration:2.0 title:@"Excluded"]; + sciTriggerStoryMarkSeen(sciActiveStoryViewerVC); + } + sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC); + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Stories settings" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [SCIUtils showSettingsVC:capturedWin atTopLevelEntry:@"Stories"]; + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.sourceView = btn; + sheet.popoverPresentationController.sourceRect = btn.bounds; + [host presentViewController:sheet animated:YES completion:nil]; +} + +// ============ Mark seen handler ============ + %new - (void)sciMarkSeenTapped:(UIButton *)sender { UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; [haptic impactOccurred]; - [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; } - completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }]; + if (sender) { + [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; } + completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }]; + } @try { - // story path + // Story path UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController"); if (storyVC) { - // allow-list the current media PK for deferred upload id sectionCtrl = sciFindSectionController(storyVC); id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil; if (!storyItem) storyItem = sciGetCurrentStoryItem(self); @@ -238,28 +387,24 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { sciSeenBypassActive = NO; [SCIUtils showToastForDuration:2.0 title:@"Marked as seen" subtitle:@"Will sync when leaving stories"]; - // Advance to next story item if enabled — bypass the stop-auto-advance hook - if ([SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) { + // Advance to next story if enabled (skip when triggered programmatically via exclude) + if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) { __block id secCtrl = sectionCtrl; __weak __typeof(self) weakSelf = self; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ sciAdvanceBypassActive = YES; - SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:"); - if ([secCtrl respondsToSelector:advSel]) { + if ([secCtrl respondsToSelector:advSel]) ((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1); - } - // After advancing, kick playback on the new section controller dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ __strong __typeof(weakSelf) strongSelf = weakSelf; UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil; id sc2 = vc2 ? sciFindSectionController(vc2) : nil; if (sc2) { SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:"); - if ([sc2 respondsToSelector:resumeSel]) { + if ([sc2 respondsToSelector:resumeSel]) ((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0); - } } sciAdvanceBypassActive = NO; }); @@ -317,9 +462,8 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) { %end -// Mirror IG's chrome alpha onto our injected seen + download buttons so they -// fade in sync during hold/zoom. Walks up from a fading sibling to find the -// IGStoryFullscreenOverlayView and updates its tagged subviews. +// ============ Chrome alpha sync ============ + static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) { Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); if (!overlayCls) return; diff --git a/src/Features/StoriesAndMessages/SCIExcludedStoryUsers.h b/src/Features/StoriesAndMessages/SCIExcludedStoryUsers.h new file mode 100644 index 0000000..22b953b --- /dev/null +++ b/src/Features/StoriesAndMessages/SCIExcludedStoryUsers.h @@ -0,0 +1,18 @@ +// Persistent per-user exclusion list for story read-receipts. Lookup is by +// user pk (string). Excluded users get normal seen behavior — your view +// shows up in their viewer list as if RyukGram weren't installed. +#import + +@interface SCIExcludedStoryUsers : NSObject + ++ (BOOL)isFeatureEnabled; + ++ (BOOL)isUserPKExcluded:(NSString *)pk; ++ (NSDictionary *)entryForPK:(NSString *)pk; ++ (NSArray *)allEntries; ++ (NSUInteger)count; + ++ (void)addOrUpdateEntry:(NSDictionary *)entry; // {pk, username, fullName} ++ (void)removePK:(NSString *)pk; + +@end diff --git a/src/Features/StoriesAndMessages/SCIExcludedStoryUsers.m b/src/Features/StoriesAndMessages/SCIExcludedStoryUsers.m new file mode 100644 index 0000000..70d8d63 --- /dev/null +++ b/src/Features/StoriesAndMessages/SCIExcludedStoryUsers.m @@ -0,0 +1,65 @@ +#import "SCIExcludedStoryUsers.h" +#import "../../Utils.h" + +#define SCI_STORY_EXCL_KEY @"excluded_story_users" + +@implementation SCIExcludedStoryUsers + ++ (BOOL)isFeatureEnabled { + return [SCIUtils getBoolPref:@"enable_story_user_exclusions"]; +} + ++ (NSArray *)allEntries { + NSArray *raw = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_STORY_EXCL_KEY]; + return raw ?: @[]; +} + ++ (NSUInteger)count { return [self allEntries].count; } + ++ (void)saveAll:(NSArray *)entries { + [[NSUserDefaults standardUserDefaults] setObject:entries forKey:SCI_STORY_EXCL_KEY]; +} + ++ (NSDictionary *)entryForPK:(NSString *)pk { + if (pk.length == 0) return nil; + for (NSDictionary *e in [self allEntries]) { + if ([e[@"pk"] isEqualToString:pk]) return e; + } + return nil; +} + ++ (BOOL)isUserPKExcluded:(NSString *)pk { + if (![self isFeatureEnabled]) return NO; + return [self entryForPK:pk] != nil; +} + ++ (void)addOrUpdateEntry:(NSDictionary *)entry { + NSString *pk = entry[@"pk"]; + if (pk.length == 0) return; + NSMutableArray *all = [[self allEntries] mutableCopy]; + NSInteger existingIdx = -1; + for (NSInteger i = 0; i < (NSInteger)all.count; i++) { + if ([all[i][@"pk"] isEqualToString:pk]) { existingIdx = i; break; } + } + NSMutableDictionary *merged = [entry mutableCopy]; + if (existingIdx >= 0) { + NSDictionary *old = all[existingIdx]; + if (old[@"addedAt"]) merged[@"addedAt"] = old[@"addedAt"]; + all[existingIdx] = merged; + } else { + if (!merged[@"addedAt"]) merged[@"addedAt"] = @([[NSDate date] timeIntervalSince1970]); + [all addObject:merged]; + } + [self saveAll:all]; +} + ++ (void)removePK:(NSString *)pk { + if (pk.length == 0) return; + NSMutableArray *all = [[self allEntries] mutableCopy]; + [all filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) { + return ![e[@"pk"] isEqualToString:pk]; + }]]; + [self saveAll:all]; +} + +@end diff --git a/src/Features/StoriesAndMessages/SeenButtons.x b/src/Features/StoriesAndMessages/SeenButtons.x index 5e5ef16..48befc0 100644 --- a/src/Features/StoriesAndMessages/SeenButtons.x +++ b/src/Features/StoriesAndMessages/SeenButtons.x @@ -117,7 +117,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW [items addObject:seenAction]; } - NSString *toggleTitle = excluded ? @"Remove from exclusion" : @"Add to exclusion"; + NSString *toggleTitle = excluded ? @"Un-exclude chat" : @"Exclude chat"; UIImage *toggleImg = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"]; __weak UIView *weakAnchor = anchor; UIAction *toggle = [UIAction actionWithTitle:toggleTitle image:toggleImg identifier:nil @@ -125,13 +125,17 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW if (!threadId) return; if (excluded) { [SCIExcludedThreads removeThreadId:threadId]; - [SCIUtils showToastForDuration:2.0 title:@"Removed from exclusion"]; + [SCIUtils showToastForDuration:2.0 title:@"Un-excluded"]; } else { [SCIExcludedThreads addOrUpdateEntry:@{ @"threadId": threadId, @"threadName": @"", @"isGroup": @NO, @"users": @[] }]; - [SCIUtils showToastForDuration:2.0 title:@"Added to exclusion"]; + [SCIUtils showToastForDuration:2.0 title:@"Excluded"]; + // Immediately mark seen since exclusion means normal behavior. + UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor]; + if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) + [(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen]; } sciRefreshNavBarItems(weakAnchor); }]; diff --git a/src/Settings/SCIExcludedChatsViewController.m b/src/Settings/SCIExcludedChatsViewController.m index aa2ae18..1c44c5e 100644 --- a/src/Settings/SCIExcludedChatsViewController.m +++ b/src/Settings/SCIExcludedChatsViewController.m @@ -6,14 +6,17 @@ @property (nonatomic, strong) UISearchBar *searchBar; @property (nonatomic, copy) NSArray *filtered; @property (nonatomic, copy) NSString *query; -@property (nonatomic, assign) NSInteger sortMode; // 0=added desc, 1=name asc +@property (nonatomic, assign) NSInteger sortMode; +@property (nonatomic, strong) UIBarButtonItem *sortBtn; +@property (nonatomic, strong) UIBarButtonItem *editBtn; +@property (nonatomic, strong) UIToolbar *batchToolbar; @end @implementation SCIExcludedChatsViewController - (void)viewDidLoad { [super viewDidLoad]; - self.title = @"Excluded chats"; + self.title = @"Chats"; self.view.backgroundColor = [UIColor systemBackgroundColor]; self.searchBar = [[UISearchBar alloc] init]; @@ -25,23 +28,89 @@ self.tableView.dataSource = self; self.tableView.delegate = self; self.tableView.tableHeaderView = self.searchBar; + self.tableView.allowsMultipleSelectionDuringEditing = YES; self.tableView.translatesAutoresizingMaskIntoConstraints = NO; [self.view addSubview:self.tableView]; + + self.batchToolbar = [[UIToolbar alloc] init]; + self.batchToolbar.translatesAutoresizingMaskIntoConstraints = NO; + self.batchToolbar.hidden = YES; + [self.view addSubview:self.batchToolbar]; + [NSLayoutConstraint activateConstraints:@[ [self.tableView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], - [self.tableView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor], [self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], [self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [self.tableView.bottomAnchor constraintEqualToAnchor:self.batchToolbar.topAnchor], + [self.batchToolbar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [self.batchToolbar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [self.batchToolbar.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor], ]]; - UIBarButtonItem *sortBtn = [[UIBarButtonItem alloc] + self.sortBtn = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"] style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)]; - self.navigationItem.rightBarButtonItem = sortBtn; + self.editBtn = [[UIBarButtonItem alloc] + initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)]; + self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn]; [self reload]; } +- (void)toggleEdit { + BOOL entering = !self.tableView.isEditing; + [self.tableView setEditing:entering animated:YES]; + self.editBtn.title = entering ? @"Done" : @"Select"; + self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain; + self.batchToolbar.hidden = !entering; + if (entering) [self updateToolbar]; +} + +- (void)updateToolbar { + UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; + UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)]; + del.tintColor = [UIColor systemRedColor]; + UIBarButtonItem *kd = [[UIBarButtonItem alloc] initWithTitle:@"Keep-deleted" style:UIBarButtonItemStylePlain target:self action:@selector(batchKeepDeleted)]; + self.batchToolbar.items = @[del, flex, kd]; +} + +- (void)removeSelected { + NSArray *sel = self.tableView.indexPathsForSelectedRows; + if (!sel.count) return; + for (NSIndexPath *ip in sel) { + NSDictionary *e = self.filtered[ip.row]; + [SCIExcludedThreads removeThreadId:e[@"threadId"]]; + } + [self toggleEdit]; + [self reload]; +} + +- (void)batchKeepDeleted { + NSArray *sel = self.tableView.indexPathsForSelectedRows; + if (!sel.count) return; + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Set keep-deleted override" message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + void (^apply)(SCIKeepDeletedOverride) = ^(SCIKeepDeletedOverride mode) { + for (NSIndexPath *ip in sel) { + NSDictionary *e = self.filtered[ip.row]; + [SCIExcludedThreads setKeepDeletedOverride:mode forThreadId:e[@"threadId"]]; + } + [self toggleEdit]; + [self reload]; + }; + [sheet addAction:[UIAlertAction actionWithTitle:@"Follow default" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + apply(SCIKeepDeletedOverrideDefault); + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Force ON (preserve unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + apply(SCIKeepDeletedOverrideIncluded); + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Force OFF (allow unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + apply(SCIKeepDeletedOverrideExcluded); + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.barButtonItem = self.batchToolbar.items.lastObject; + [self presentViewController:sheet animated:YES completion:nil]; +} + - (void)toggleSort { UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by" message:nil @@ -58,7 +127,7 @@ [sheet addAction:a]; } [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; - sheet.popoverPresentationController.barButtonItem = self.navigationItem.rightBarButtonItem; + sheet.popoverPresentationController.barButtonItem = self.sortBtn; [self presentViewController:sheet animated:YES completion:nil]; } @@ -77,17 +146,15 @@ } if (self.sortMode == 0) { all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { - NSNumber *na = a[@"addedAt"] ?: @0, *nb = b[@"addedAt"] ?: @0; - return [nb compare:na]; + return [b[@"addedAt"] ?: @0 compare:a[@"addedAt"] ?: @0]; }]; } else { all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { - NSString *na = a[@"threadName"] ?: @"", *nb = b[@"threadName"] ?: @""; - return [na caseInsensitiveCompare:nb]; + return [a[@"threadName"] ?: @"" caseInsensitiveCompare:b[@"threadName"] ?: @""]; }]; } self.filtered = all; - self.title = [NSString stringWithFormat:@"Excluded chats (%lu)", (unsigned long)self.filtered.count]; + self.title = [NSString stringWithFormat:@"Chats (%lu)", (unsigned long)self.filtered.count]; [self.tableView reloadData]; } @@ -129,11 +196,12 @@ cell.textLabel.text = [NSString stringWithFormat:@"%@%@", isGroup ? @"👥 " : @"", name]; cell.detailTextLabel.text = subtitle; cell.detailTextLabel.numberOfLines = 2; - cell.accessoryType = isGroup ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator; + cell.accessoryType = (isGroup || tv.isEditing) ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator; return cell; } - (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + if (tv.isEditing) return; [tv deselectRowAtIndexPath:indexPath animated:YES]; NSDictionary *e = self.filtered[indexPath.row]; NSArray *users = e[@"users"]; @@ -141,9 +209,8 @@ NSString *username = users.firstObject[@"username"]; if (!username) return; NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]]; - if ([[UIApplication sharedApplication] canOpenURL:url]) { + if ([[UIApplication sharedApplication] canOpenURL:url]) [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; - } } - (UISwipeActionsConfiguration *)tableView:(UITableView *)tv trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath { diff --git a/src/Settings/SCIExcludedStoryUsersViewController.h b/src/Settings/SCIExcludedStoryUsersViewController.h new file mode 100644 index 0000000..ff900d8 --- /dev/null +++ b/src/Settings/SCIExcludedStoryUsersViewController.h @@ -0,0 +1,4 @@ +#import + +@interface SCIExcludedStoryUsersViewController : UIViewController +@end diff --git a/src/Settings/SCIExcludedStoryUsersViewController.m b/src/Settings/SCIExcludedStoryUsersViewController.m new file mode 100644 index 0000000..17f9a36 --- /dev/null +++ b/src/Settings/SCIExcludedStoryUsersViewController.m @@ -0,0 +1,179 @@ +#import "SCIExcludedStoryUsersViewController.h" +#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h" + +@interface SCIExcludedStoryUsersViewController () +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UISearchBar *searchBar; +@property (nonatomic, copy) NSArray *filtered; +@property (nonatomic, copy) NSString *query; +@property (nonatomic, assign) NSInteger sortMode; +@property (nonatomic, strong) UIBarButtonItem *sortBtn; +@property (nonatomic, strong) UIBarButtonItem *editBtn; +@property (nonatomic, strong) UIToolbar *batchToolbar; +@end + +@implementation SCIExcludedStoryUsersViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Story users"; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + + self.searchBar = [[UISearchBar alloc] init]; + self.searchBar.delegate = self; + self.searchBar.placeholder = @"Search by username or name"; + [self.searchBar sizeToFit]; + + self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.tableHeaderView = self.searchBar; + self.tableView.allowsMultipleSelectionDuringEditing = YES; + self.tableView.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.tableView]; + + self.batchToolbar = [[UIToolbar alloc] init]; + self.batchToolbar.translatesAutoresizingMaskIntoConstraints = NO; + self.batchToolbar.hidden = YES; + [self.view addSubview:self.batchToolbar]; + + [NSLayoutConstraint activateConstraints:@[ + [self.tableView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [self.tableView.bottomAnchor constraintEqualToAnchor:self.batchToolbar.topAnchor], + [self.batchToolbar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [self.batchToolbar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [self.batchToolbar.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor], + ]]; + + self.sortBtn = [[UIBarButtonItem alloc] + initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"] + style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)]; + self.editBtn = [[UIBarButtonItem alloc] + initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)]; + self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn]; + + [self reload]; +} + +- (void)toggleEdit { + BOOL entering = !self.tableView.isEditing; + [self.tableView setEditing:entering animated:YES]; + self.editBtn.title = entering ? @"Done" : @"Select"; + self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain; + self.batchToolbar.hidden = !entering; + if (entering) { + UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; + UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove Selected" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)]; + del.tintColor = [UIColor systemRedColor]; + self.batchToolbar.items = @[flex, del, flex]; + } +} + +- (void)removeSelected { + NSArray *sel = self.tableView.indexPathsForSelectedRows; + if (!sel.count) return; + for (NSIndexPath *ip in sel) { + NSDictionary *e = self.filtered[ip.row]; + [SCIExcludedStoryUsers removePK:e[@"pk"]]; + } + [self toggleEdit]; + [self reload]; +} + +- (void)toggleSort { + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by" + message:nil + preferredStyle:UIAlertControllerStyleActionSheet]; + NSArray *titles = @[@"Recently added", @"Username (A–Z)"]; + for (NSInteger i = 0; i < (NSInteger)titles.count; i++) { + UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i] + style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { + self.sortMode = i; + [self reload]; + }]; + if (i == self.sortMode) [a setValue:@YES forKey:@"checked"]; + [sheet addAction:a]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + sheet.popoverPresentationController.barButtonItem = self.sortBtn; + [self presentViewController:sheet animated:YES completion:nil]; +} + +- (void)reload { + NSArray *all = [SCIExcludedStoryUsers allEntries]; + NSString *q = [self.query lowercaseString]; + if (q.length > 0) { + all = [all filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) { + if ([[e[@"username"] lowercaseString] containsString:q]) return YES; + if ([[e[@"fullName"] lowercaseString] containsString:q]) return YES; + return NO; + }]]; + } + if (self.sortMode == 0) { + all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { + return [b[@"addedAt"] ?: @0 compare:a[@"addedAt"] ?: @0]; + }]; + } else { + all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { + return [a[@"username"] ?: @"" caseInsensitiveCompare:b[@"username"] ?: @""]; + }]; + } + self.filtered = all; + self.title = [NSString stringWithFormat:@"Story users (%lu)", (unsigned long)self.filtered.count]; + [self.tableView reloadData]; +} + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText { + self.query = searchText; + [self reload]; +} +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { [searchBar resignFirstResponder]; } + +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { + return self.filtered.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath { + static NSString *reuse = @"sciStoryExclCell"; + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:reuse]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuse]; + + NSDictionary *e = self.filtered[indexPath.row]; + NSString *username = e[@"username"] ?: @""; + NSString *fullName = e[@"fullName"] ?: @""; + + cell.textLabel.text = fullName.length ? fullName : (username.length ? [@"@" stringByAppendingString:username] : @"(unknown)"); + cell.detailTextLabel.text = username.length ? [@"@" stringByAppendingString:username] : @""; + cell.accessoryType = tv.isEditing ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator; + return cell; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + if (tv.isEditing) return; + [tv deselectRowAtIndexPath:indexPath animated:YES]; + NSDictionary *e = self.filtered[indexPath.row]; + NSString *username = e[@"username"]; + if (!username.length) return; + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]]; + if ([[UIApplication sharedApplication] canOpenURL:url]) + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; +} + +- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath { + NSDictionary *e = self.filtered[indexPath.row]; + NSString *pk = e[@"pk"]; + UIContextualAction *del = [UIContextualAction + contextualActionWithStyle:UIContextualActionStyleDestructive + title:@"Remove" + handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) { + [SCIExcludedStoryUsers removePK:pk]; + [self reload]; + cb(YES); + }]; + return [UISwipeActionsConfiguration configurationWithActions:@[del]]; +} + +@end diff --git a/src/Settings/SCISetting.h b/src/Settings/SCISetting.h index 5a45749..354f436 100644 --- a/src/Settings/SCISetting.h +++ b/src/Settings/SCISetting.h @@ -41,6 +41,8 @@ typedef NS_ENUM(NSInteger, SCITableCell) { @property (nonatomic, strong) UIMenu *baseMenu; +@property (nonatomic, copy, nullable) NSString *(^dynamicTitle)(void); + @property (nonatomic, strong) NSArray *navSections; @property (nonatomic, strong) UIViewController *navViewController; diff --git a/src/Settings/SCISettingsBackup.m b/src/Settings/SCISettingsBackup.m index 2857d10..a236847 100644 --- a/src/Settings/SCISettingsBackup.m +++ b/src/Settings/SCISettingsBackup.m @@ -404,6 +404,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { + (NSArray *)extraDataKeys { return @[ @"excluded_threads", + @"excluded_story_users", ]; } diff --git a/src/Settings/SCISettingsViewController.m b/src/Settings/SCISettingsViewController.m index 8cd2e56..c593b78 100644 --- a/src/Settings/SCISettingsViewController.m +++ b/src/Settings/SCISettingsViewController.m @@ -81,6 +81,11 @@ static char rowStaticRef[] = "row"; } } +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self.tableView reloadData]; +} + #pragma mark - Search - (BOOL)isSearching { @@ -178,7 +183,7 @@ static char rowStaticRef[] = "row"; UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil]; UIListContentConfiguration *cellContentConfig = cell.defaultContentConfiguration; - cellContentConfig.text = row.title; + cellContentConfig.text = row.dynamicTitle ? row.dynamicTitle() : row.title; // While searching, show the breadcrumb path instead of the row subtitle. NSString *displaySubtitle = [self isSearching] && searchBreadcrumb.length ? searchBreadcrumb : row.subtitle; diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index 922c056..7984138 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -2,6 +2,8 @@ #import "SCISettingsBackup.h" #import "SCIExcludedChatsViewController.h" #import "../Features/StoriesAndMessages/SCIExcludedThreads.h" +#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h" +#import "SCIExcludedStoryUsersViewController.h" @implementation SCITweakSettings @@ -147,6 +149,7 @@ [SCISetting switchCellWithTitle:@"Disable story seen receipt" subtitle:@"Hides the notification for others when you view their story" defaultsKey:@"no_seen_receipt"], [SCISetting switchCellWithTitle:@"Keep stories visually unseen" subtitle:@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" defaultsKey:@"no_seen_visual"], [SCISetting switchCellWithTitle:@"Mark seen on story like" subtitle:@"Marks a story as seen the moment you tap the heart, even with seen blocking on" defaultsKey:@"seen_on_story_like"], + [SCISetting menuCellWithTitle:@"Manual seen button mode" subtitle:@"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" menu:[self menus][@"story_seen_mode"]], ] }, @{ @@ -156,6 +159,34 @@ [SCISetting switchCellWithTitle:@"Advance when marking as seen" subtitle:@"Tapping the eye button to mark a story as seen advances to the next story automatically" defaultsKey:@"advance_on_mark_seen"], ] }, + @{ + @"header": @"Excluded users", + @"footer": @"Excluded users' stories behave normally — your view shows up in their viewer list. Add via the long-press menu on the eye button while viewing a story, or via the 3-dot menu on the story header.", + @"rows": @[ + [SCISetting switchCellWithTitle:@"Enable story user exclusions" subtitle:@"Master toggle. When off, exclusions are ignored" defaultsKey:@"enable_story_user_exclusions"], + [SCISetting switchCellWithTitle:@"Show un-exclude eye on excluded users" subtitle:@"When viewing an excluded user's story, the eye button appears so you can un-exclude with one tap. Off = use the 3-dot menu only" defaultsKey:@"story_excluded_show_unexclude_eye"], + ({ + SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list" + subtitle:@"Search, sort, swipe to remove" + icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"] + action:^(void) { + UIWindow *win = nil; + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { win = w; break; } + } + UIViewController *top = win.rootViewController; + while (top.presentedViewController) top = top.presentedViewController; + if ([top isKindOfClass:[UINavigationController class]]) { + [(UINavigationController *)top pushViewController:[SCIExcludedStoryUsersViewController new] animated:YES]; + } else if (top.navigationController) { + [top.navigationController pushViewController:[SCIExcludedStoryUsersViewController new] animated:YES]; + } + }]; + s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedStoryUsers count]]; }; + s; + }), + ] + }, @{ @"header": @"Other", @"rows": @[ @@ -207,22 +238,26 @@ @"rows": @[ [SCISetting switchCellWithTitle:@"Enable chat exclusions" subtitle:@"Master toggle. When off, the inbox menu item disappears and exclusions are ignored" defaultsKey:@"enable_chat_exclusions"], [SCISetting switchCellWithTitle:@"Default: also exclude keep-deleted" subtitle:@"Excluded chats also bypass keep-deleted-messages by default. Each chat can override this in the list" defaultsKey:@"exclusions_default_keep_deleted"], - [SCISetting buttonCellWithTitle:[NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedThreads count]] + ({ + SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list" subtitle:@"Search, sort, swipe to remove or toggle keep-deleted" icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"] action:^(void) { - UIWindow *win = nil; - for (UIWindow *w in [UIApplication sharedApplication].windows) { - if (w.isKeyWindow) { win = w; break; } - } - UIViewController *top = win.rootViewController; - while (top.presentedViewController) top = top.presentedViewController; - if ([top isKindOfClass:[UINavigationController class]]) { - [(UINavigationController *)top pushViewController:[SCIExcludedChatsViewController new] animated:YES]; - } else if (top.navigationController) { - [top.navigationController pushViewController:[SCIExcludedChatsViewController new] animated:YES]; - } - }], + UIWindow *win = nil; + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { win = w; break; } + } + UIViewController *top = win.rootViewController; + while (top.presentedViewController) top = top.presentedViewController; + if ([top isKindOfClass:[UINavigationController class]]) { + [(UINavigationController *)top pushViewController:[SCIExcludedChatsViewController new] animated:YES]; + } else if (top.navigationController) { + [top.navigationController pushViewController:[SCIExcludedChatsViewController new] animated:YES]; + } + }]; + s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedThreads count]]; }; + s; + }), ] }, @{ @@ -428,6 +463,25 @@ + (NSDictionary *)menus { return @{ + @"story_seen_mode": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:@"Button" + image:nil + action:@selector(menuChanged:) + propertyList:@{ + @"defaultsKey": @"story_seen_mode", + @"value": @"button" + } + ], + [UICommand commandWithTitle:@"Toggle" + image:nil + action:@selector(menuChanged:) + propertyList:@{ + @"defaultsKey": @"story_seen_mode", + @"value": @"toggle" + } + ] + ]], + @"seen_mode": [UIMenu menuWithChildren:@[ [UICommand commandWithTitle:@"Button" image:nil diff --git a/src/Tweak.x b/src/Tweak.x index a168874..d18569f 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -61,7 +61,10 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"warn_refresh_clears_preserved": @(NO), @"enable_chat_exclusions": @(YES), @"exclusions_default_keep_deleted": @(NO), - @"unexclude_inbox_button": @(YES) + @"unexclude_inbox_button": @(YES), + @"enable_story_user_exclusions": @(YES), + @"story_excluded_show_unexclude_eye": @(YES), + @"story_seen_mode": @"button" }; [[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults]; @@ -611,7 +614,9 @@ shouldPersistLastBugReportId:(id)arg6 } - return %orig([filteredObjs copy], edr, headerLabelText); + extern NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *); + NSArray *finalObjs = sciMaybeAppendStoryExcludeMenuItem([filteredObjs copy]); + return %orig(finalObjs, edr, headerLabelText); } %end diff --git a/src/Utils.m b/src/Utils.m index aa60a61..a5f07c8 100644 --- a/src/Utils.m +++ b/src/Utils.m @@ -121,6 +121,7 @@ // Open settings and push straight into a named top-level entry (e.g. "Messages"). + (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle { UIViewController *rootController = [window rootViewController]; + while (rootController.presentedViewController) rootController = rootController.presentedViewController; SCISettingsViewController *root = [SCISettingsViewController new]; UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root];