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
This commit is contained in:
faroukbmiled
2026-04-09 18:46:21 +01:00
parent d03da10941
commit ceb89e65d2
17 changed files with 923 additions and 100 deletions
+3 -1
View File
@@ -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) **\***
@@ -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
@@ -73,8 +73,7 @@ static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray<UIMenuElement *> *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 *_) {
@@ -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 <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
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];
}
+201 -57
View File
@@ -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;
@@ -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 <Foundation/Foundation.h>
@interface SCIExcludedStoryUsers : NSObject
+ (BOOL)isFeatureEnabled;
+ (BOOL)isUserPKExcluded:(NSString *)pk;
+ (NSDictionary *)entryForPK:(NSString *)pk;
+ (NSArray<NSDictionary *> *)allEntries;
+ (NSUInteger)count;
+ (void)addOrUpdateEntry:(NSDictionary *)entry; // {pk, username, fullName}
+ (void)removePK:(NSString *)pk;
@end
@@ -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<NSDictionary *> *)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
@@ -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);
}];
+81 -14
View File
@@ -6,14 +6,17 @@
@property (nonatomic, strong) UISearchBar *searchBar;
@property (nonatomic, copy) NSArray<NSDictionary *> *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<NSIndexPath *> *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<NSIndexPath *> *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 {
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIExcludedStoryUsersViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate>
@end
@@ -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<NSDictionary *> *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<NSIndexPath *> *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 (AZ)"];
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
+2
View File
@@ -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;
+1
View File
@@ -404,6 +404,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
+ (NSArray<NSString *> *)extraDataKeys {
return @[
@"excluded_threads",
@"excluded_story_users",
];
}
+6 -1
View File
@@ -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;
+67 -13
View File
@@ -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
+7 -2
View File
@@ -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
+1
View File
@@ -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];