feat: Per-chat and per-story blocking modes — "Block all" (exclude list) or "Block selected" (include list) with independent storage, per-entry

keep-deleted override, and adaptive UI
feat: Quick list buttons in chats and stories — add/remove directly from DM threads and story viewer
fix: KVO observer crash from multiple registrations in story audio toggle
fix: Seen auto-bypass race condition when overlapping events (boolean → counter)
fix: Confirm reel refresh not working after first pull
fix: Startup class scan replaced with direct class lookup
imp: All menu/button text adapts to active blocking mode
imp: Mark-seen triggers at the correct point per mode
imp: Migrated unexclude_inbox_button to chat_quick_list_button
imp: Menu changes in settings now reload table for dynamic titles
This commit is contained in:
faroukbmiled
2026-04-10 13:41:58 +01:00
parent 7952877545
commit 06b2626714
17 changed files with 325 additions and 112 deletions
+2 -2
View File
@@ -98,7 +98,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Mark seen on story like **\***
- Advance to next story when marking as seen — tapping the eye button auto-skips to the next story **\***
- Advance on story like — liking a story auto-skips to the next one **\***
- Per-chat read-receipt exclusions — long-press any DM chat to exclude it from all read-receipt features. Excluded chats behave like a vanilla install (manual seen button, auto seen on send/typing, visual message blocking, view-once override are all skipped). Settings page lists excluded chats with search, sort, swipe-to-remove, and a per-entry override to also bypass keep-deleted-messages **\***
- Per-chat read-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Long-press any DM chat to add/remove. Settings page with search, sort, multi-select, and per-entry keep-deleted override **\***
- Send audio as file — send audio files as voice messages from the DM plus menu **\***
- Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\***
- Disable typing status
@@ -109,7 +109,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- 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 (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 **\***
- Per-user story seen-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Manage via 3-dot menu, eye button long-press, or settings list **\***
- Story audio mute/unmute toggle — button on the story overlay and 3-dot menu to toggle audio **\***
- Stop story auto-advance — stories won't auto-skip when the timer ends **\***
- Story download button — download directly from the story overlay **\***
+4 -4
View File
@@ -28,7 +28,7 @@
}
%end
static BOOL sciReelRefreshInFlight = NO;
static BOOL sciReelRefreshBypassing = NO;
%hook IGSundialFeedViewController
- (void)_refreshReelsWithParamsForNetworkRequest:(NSInteger)arg1 userDidPullToRefresh:(BOOL)arg2 {
@@ -38,7 +38,7 @@ static BOOL sciReelRefreshInFlight = NO;
return;
}
if (![(UIViewController *)self isViewLoaded] || sciReelRefreshInFlight || ![SCIUtils getBoolPref:@"refresh_reel_confirm"]) {
if (![(UIViewController *)self isViewLoaded] || sciReelRefreshBypassing || ![SCIUtils getBoolPref:@"refresh_reel_confirm"]) {
%orig(arg1, arg2);
return;
}
@@ -60,10 +60,10 @@ static BOOL sciReelRefreshInFlight = NO;
__weak id weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
sciReelRefreshInFlight = YES;
sciReelRefreshBypassing = YES;
SEL rSel = @selector(_refreshReelsWithParamsForNetworkRequest:userDidPullToRefresh:);
((void(*)(id,SEL,NSInteger,BOOL))objc_msgSend)(weakSelf, rSel, arg1, arg2);
sciReelRefreshInFlight = NO;
sciReelRefreshBypassing = NO;
}]];
UIViewController *presenter = (UIViewController *)self;
@@ -72,12 +72,15 @@ 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 ? @"Un-exclude chat" : @"Exclude chat";
UIImage *img = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"];
BOOL inList = [SCIExcludedThreads isInList:tid];
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *title = inList ? removeLabel : addLabel;
UIImage *img = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil
handler:^(__kindof UIAction *_) {
if (excluded) {
if (inList) {
[SCIExcludedThreads removeThreadId:tid];
} else {
[SCIExcludedThreads addOrUpdateEntry:entry];
@@ -123,20 +126,9 @@ static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
%end
%ctor {
Class cls = NSClassFromString(@"IGDirectInboxViewController");
if (!cls) return;
SEL sel = NSSelectorFromString(@"networkingCoordinator_contextMenuConfigurationForThreadCellAtIndexPath:");
unsigned int n = 0;
Class *all = objc_copyClassList(&n);
for (unsigned int i = 0; i < n; i++) {
unsigned int mn = 0;
Method *ms = class_copyMethodList(all[i], &mn);
BOOL has = NO;
for (unsigned int j = 0; j < mn; j++) {
if (sel_isEqual(method_getName(ms[j]), sel)) { has = YES; break; }
}
if (ms) free(ms);
if (has) {
MSHookMessageEx(all[i], sel, (IMP)new_ctxMenuCfg, (IMP *)&orig_ctxMenuCfg);
}
}
if (all) free(all);
if (class_getInstanceMethod(cls, sel))
MSHookMessageEx(cls, sel, (IMP)new_ctxMenuCfg, (IMP *)&orig_ctxMenuCfg);
}
@@ -215,27 +215,30 @@ NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) {
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; }
}
BOOL inList = [SCIExcludedStoryUsers isInList:pk];
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
NSString *title = excluded ? @"Un-exclude story seen" : @"Exclude story seen";
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
NSString *title = inList ? removeLabel : addLabel;
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
void (^handler)(void) = ^{
if (excluded) {
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[SCIUtils showToastForDuration:2.0 title:@"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
// Removing in block_selected = normal behavior → mark seen
if (blockSelected) sciTriggerStoryMarkSeen(weakVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": pk, @"username": username, @"fullName": fullName
}];
[SCIUtils showToastForDuration:2.0 title:@"Excluded"];
sciTriggerStoryMarkSeen(weakVC);
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
// Adding in block_all = normal behavior → mark seen
if (!blockSelected) sciTriggerStoryMarkSeen(weakVC);
}
sciRefreshAllVisibleOverlays(weakVC);
};
@@ -179,19 +179,34 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// 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;
BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"];
BOOL storyBlockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
// In block_selected mode, show the eye for list management even if global toggle is off
if (!seenBlockingOn && !storyBlockSelected) return;
// Skip for DM visual messages inside an excluded thread
NSString *activeTid = [SCIExcludedThreads activeThreadId];
if (activeTid && [SCIExcludedThreads isInList:activeTid] && ![SCIExcludedThreads isBlockSelectedMode]) 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 ownerInList = ownerPK.length && [SCIExcludedStoryUsers isInList:ownerPK];
// block_all + in list: show remove icon (excluded user, behaves normally)
// block_selected + in list: show normal eye (blocked user, needs mark-seen)
// block_selected + not in list: show add icon
BOOL showExcludeIcon = ownerInList && !storyBlockSelected;
BOOL showAddIcon = storyBlockSelected && !ownerInList;
BOOL listBtnPref = [SCIUtils getBoolPref:@"story_excluded_show_unexclude_eye"];
BOOL hideForListedOwner = (showExcludeIcon || showAddIcon) && !listBtnPref;
BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"];
NSString *symName;
UIColor *tint;
if (ownerExcluded) {
if (showExcludeIcon) {
// block_all + in list: remove-from-exclude icon
symName = @"eye.slash.fill"; tint = SCIUtils.SCIColor_Primary;
} else if (storyBlockSelected && !ownerInList) {
// block_selected + not in list: add-to-block icon
symName = @"eye.slash"; tint = [UIColor whiteColor];
} else if (toggleMode) {
symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
@@ -201,7 +216,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
UIButton *existing = (UIButton *)[self viewWithTag:1339];
if (hideForExcludedOwner) {
if (hideForListedOwner) {
[existing removeFromSuperview];
return;
}
@@ -320,18 +335,49 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
BOOL excluded = ownerInfo && [SCIExcludedStoryUsers isUserPKExcluded:ownerInfo[@"pk"]];
NSString *ownerPK = ownerInfo[@"pk"];
BOOL inList = ownerPK && [SCIExcludedStoryUsers isInList:ownerPK];
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
// Excluded owner: tap to un-exclude
if (excluded) {
// Block selected + not in list: tap to ADD to block list (with confirmation)
if (bs && !inList && ownerPK) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Un-exclude story seen?"
message:[NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""]
alertControllerWithTitle:@"Add to block list?"
message:[NSString stringWithFormat:@"Story seen receipts will be blocked for @%@.", 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"];
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": ownerPK,
@"username": ownerInfo[@"username"] ?: @"",
@"fullName": ownerInfo[@"fullName"] ?: @""
}];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
// Block selected + in list: blocked story, tap = mark seen (long-press to remove)
if (bs && inList) {
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender);
return;
}
// Block all + in list: tap to remove from exclude list
if (inList) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude story seen?";
NSString *alertMsg = bs ? [NSString stringWithFormat:@"@%@ will no longer have seen receipts blocked.", ownerInfo[@"username"] ?: @""]
: [NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:bs ? @"Unblock" : @"Un-exclude" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers removePK:ownerPK];
[SCIUtils showToastForDuration:2.0 title:bs ? @"Unblocked" : @"Un-excluded"];
if (bs) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
@@ -368,22 +414,26 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
NSString *pk = ownerInfo[@"pk"];
NSString *username = ownerInfo[@"username"] ?: @"";
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
BOOL excluded = pk && [SCIExcludedStoryUsers isUserPKExcluded:pk];
BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk];
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
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) {
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
NSString *t = inList ? removeLabel : addLabel;
[sheet addAction:[UIAlertAction actionWithTitle:t style:inList ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[SCIUtils showToastForDuration:2.0 title:@"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
[SCIUtils showToastForDuration:2.0 title:@"Excluded"];
sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
@@ -6,8 +6,10 @@
@interface SCIExcludedStoryUsers : NSObject
+ (BOOL)isFeatureEnabled;
+ (BOOL)isBlockSelectedMode;
+ (BOOL)isUserPKExcluded:(NSString *)pk;
+ (BOOL)isInList:(NSString *)pk;
+ (NSDictionary *)entryForPK:(NSString *)pk;
+ (NSArray<NSDictionary *> *)allEntries;
+ (NSUInteger)count;
@@ -2,6 +2,7 @@
#import "../../Utils.h"
#define SCI_STORY_EXCL_KEY @"excluded_story_users"
#define SCI_STORY_INCL_KEY @"included_story_users"
@implementation SCIExcludedStoryUsers
@@ -9,15 +10,22 @@
return [SCIUtils getBoolPref:@"enable_story_user_exclusions"];
}
+ (BOOL)isBlockSelectedMode {
return [[SCIUtils getStringPref:@"story_blocking_mode"] isEqualToString:@"block_selected"];
}
+ (NSString *)activeKey {
return [self isBlockSelectedMode] ? SCI_STORY_INCL_KEY : SCI_STORY_EXCL_KEY;
}
+ (NSArray<NSDictionary *> *)allEntries {
NSArray *raw = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_STORY_EXCL_KEY];
return raw ?: @[];
return [[NSUserDefaults standardUserDefaults] arrayForKey:[self activeKey]] ?: @[];
}
+ (NSUInteger)count { return [self allEntries].count; }
+ (void)saveAll:(NSArray *)entries {
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:SCI_STORY_EXCL_KEY];
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:[self activeKey]];
}
+ (NSDictionary *)entryForPK:(NSString *)pk {
@@ -28,9 +36,14 @@
return nil;
}
+ (BOOL)isInList:(NSString *)pk {
return [self entryForPK:pk] != nil;
}
+ (BOOL)isUserPKExcluded:(NSString *)pk {
if (![self isFeatureEnabled]) return NO;
return [self entryForPK:pk] != nil;
BOOL inList = [self isInList:pk];
return [self isBlockSelectedMode] ? !inList : inList;
}
+ (void)addOrUpdateEntry:(NSDictionary *)entry {
@@ -14,8 +14,10 @@ typedef NS_ENUM(NSInteger, SCIKeepDeletedOverride) {
@interface SCIExcludedThreads : NSObject
+ (BOOL)isFeatureEnabled;
+ (BOOL)isBlockSelectedMode; // YES = only listed chats get blocked
+ (BOOL)isThreadIdExcluded:(NSString *)threadId;
+ (BOOL)isInList:(NSString *)threadId; // raw list check, ignores mode
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId;
+ (NSDictionary *)entryForThreadId:(NSString *)threadId;
+ (NSArray<NSDictionary *> *)allEntries;
@@ -2,6 +2,7 @@
#import "../../Utils.h"
#define SCI_EXCL_KEY @"excluded_threads"
#define SCI_INCL_KEY @"included_threads"
@implementation SCIExcludedThreads
@@ -11,17 +12,22 @@ static NSString *sciActiveTid = nil;
return [SCIUtils getBoolPref:@"enable_chat_exclusions"];
}
+ (NSArray<NSDictionary *> *)allEntries {
NSArray *raw = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_EXCL_KEY];
return raw ?: @[];
+ (BOOL)isBlockSelectedMode {
return [[SCIUtils getStringPref:@"chat_blocking_mode"] isEqualToString:@"block_selected"];
}
+ (NSUInteger)count {
return [self allEntries].count;
+ (NSString *)activeKey {
return [self isBlockSelectedMode] ? SCI_INCL_KEY : SCI_EXCL_KEY;
}
+ (NSArray<NSDictionary *> *)allEntries {
return [[NSUserDefaults standardUserDefaults] arrayForKey:[self activeKey]] ?: @[];
}
+ (NSUInteger)count { return [self allEntries].count; }
+ (void)saveAll:(NSArray *)entries {
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:SCI_EXCL_KEY];
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:[self activeKey]];
}
+ (NSDictionary *)entryForThreadId:(NSString *)threadId {
@@ -32,14 +38,32 @@ static NSString *sciActiveTid = nil;
return nil;
}
+ (BOOL)isInList:(NSString *)threadId {
return [self entryForThreadId:threadId] != nil;
}
+ (BOOL)isThreadIdExcluded:(NSString *)threadId {
if (![self isFeatureEnabled]) return NO;
return [self entryForThreadId:threadId] != nil;
BOOL inList = [self isInList:threadId];
return [self isBlockSelectedMode] ? !inList : inList;
}
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId {
if (![self isFeatureEnabled]) return NO;
NSDictionary *e = [self entryForThreadId:threadId];
if ([self isBlockSelectedMode]) {
// block_selected: listed chats are blocked
// NOT in list normal chat block keep-deleted if default pref is on
// IN list blocked chat keep-deleted should work (not blocked) unless overridden
if (!e) return [SCIUtils getBoolPref:@"exclusions_default_keep_deleted"];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
if (mode == SCIKeepDeletedOverrideExcluded) return YES;
if (mode == SCIKeepDeletedOverrideIncluded) return NO;
return NO; // default: keep-deleted works in blocked chats
}
// block_all: listed chats are excluded (behave normally)
if (!e) return NO;
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
if (mode == SCIKeepDeletedOverrideExcluded) return YES;
@@ -57,7 +81,6 @@ static NSString *sciActiveTid = nil;
}
NSMutableDictionary *merged = [entry mutableCopy];
if (existingIdx >= 0) {
// Preserve existing addedAt + override
NSDictionary *old = all[existingIdx];
if (old[@"addedAt"]) merged[@"addedAt"] = old[@"addedAt"];
if (old[@"keepDeletedOverride"]) merged[@"keepDeletedOverride"] = old[@"keepDeletedOverride"];
+107 -31
View File
@@ -18,7 +18,7 @@ static NSString *sciThreadIdForVC(id vc) {
// - Enables unlimited views of DM visual messages
BOOL dmSeenToggleEnabled = NO;
static BOOL sciSeenAutoBypass = NO;
static NSInteger sciSeenAutoBypassCount = 0;
__weak IGDirectThreadViewController *sciActiveThreadVC = nil;
static BOOL sciIsSeenToggleMode() {
@@ -36,10 +36,10 @@ BOOL sciAutoTypingEnabled() {
}
void sciDoAutoSeen(IGDirectThreadViewController *threadVC) {
sciSeenAutoBypass = YES;
sciSeenAutoBypassCount++;
[threadVC markLastMessageAsSeen];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciSeenAutoBypass = NO;
sciSeenAutoBypassCount--;
});
}
@@ -79,9 +79,13 @@ static void sciRefreshNavBarItems(UIView *anchor) {
[(id)anchor performSelector:@selector(setRightBarButtonItems:) withObject:cur];
}
static NSDictionary *sciEntryFromThreadVC(UIViewController *vc);
// Long-press menu shared by the seen button and the un-exclude button.
static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIWindow *window) {
BOOL inList = threadId && [SCIExcludedThreads isInList:threadId];
BOOL excluded = threadId && [SCIExcludedThreads isThreadIdExcluded:threadId];
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
BOOL seenFeatureOn = [SCIUtils getBoolPref:@"remove_lastseen"];
NSMutableArray<UIMenuElement *> *items = [NSMutableArray array];
@@ -117,25 +121,35 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
[items addObject:seenAction];
}
NSString *toggleTitle = excluded ? @"Un-exclude chat" : @"Exclude chat";
UIImage *toggleImg = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"];
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *toggleTitle = inList ? removeLabel : addLabel;
UIImage *toggleImg = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
__weak UIView *weakAnchor = anchor;
UIAction *toggle = [UIAction actionWithTitle:toggleTitle image:toggleImg identifier:nil
handler:^(__kindof UIAction *_) {
if (!threadId) return;
if (excluded) {
if (inList) {
[SCIExcludedThreads removeThreadId:threadId];
[SCIUtils showToastForDuration:2.0 title:@"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
// In block_selected, removing = normal behavior → mark seen
if (blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
}
} else {
[SCIExcludedThreads addOrUpdateEntry:@{ @"threadId": threadId,
@"threadName": @"",
@"isGroup": @NO,
@"users": @[] }];
[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];
UIViewController *anchorVC = [SCIUtils nearestViewControllerForView:anchor];
NSDictionary *entry = sciEntryFromThreadVC(anchorVC);
if (!entry) entry = @{ @"threadId": threadId, @"threadName": @"", @"isGroup": @NO, @"users": @[] };
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
// In block_all, excluding = normal behavior → mark seen
if (!blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
}
}
sciRefreshNavBarItems(weakAnchor);
}];
@@ -159,21 +173,74 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
return [UIMenu menuWithTitle:@"" children:items];
}
// Extract thread info from an IGDirectThreadViewController
static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
if (!vc) return nil;
NSString *tid = sciThreadIdForVC(vc);
if (!tid) return nil;
NSString *name = @"";
NSMutableArray *users = [NSMutableArray array];
@try {
// Try to get thread title from navigation item
name = vc.navigationItem.title ?: @"";
// Try to get the thread object for user info
id thread = [vc valueForKey:@"thread"];
if (thread) {
id threadUsers = [thread valueForKey:@"users"];
if ([threadUsers isKindOfClass:[NSArray class]]) {
for (id u in (NSArray *)threadUsers) {
NSMutableDictionary *d = [NSMutableDictionary dictionary];
@try {
id pk = [u valueForKey:@"pk"];
id un = [u valueForKey:@"username"];
id fn = [u valueForKey:@"fullName"];
if (pk) d[@"pk"] = [NSString stringWithFormat:@"%@", pk];
if (un) d[@"username"] = [NSString stringWithFormat:@"%@", un];
if (fn) d[@"fullName"] = [NSString stringWithFormat:@"%@", fn];
} @catch (__unused id e) {}
if (d.count) [users addObject:d];
}
}
}
} @catch (__unused id e) {}
return @{ @"threadId": tid, @"threadName": name, @"isGroup": @NO, @"users": users };
}
%hook IGTallNavigationBarView
%new - (void)sciAddToListHandler:(UIBarButtonItem *)sender {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
NSDictionary *entry = sciEntryFromThreadVC(nearestVC);
if (!entry) return;
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:@"Read receipts will be blocked for this chat."
preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[nearestVC presentViewController:alert animated:YES completion:nil];
}
%new - (void)sciUnexcludeButtonHandler:(UIBarButtonItem *)sender {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *tid = sciThreadIdForVC(nearestVC);
if (!tid) return;
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude chat?";
NSString *alertMsg = bs ? @"Read receipts will no longer be blocked for this chat."
: @"This chat will resume normal read-receipt behavior.";
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Remove from exclusion?"
message:@"This chat will resume normal read-receipt behavior."
preferredStyle:UIAlertControllerStyleAlert];
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedThreads removeThreadId:tid];
[SCIUtils showToastForDuration:2.0 title:@"Removed from exclusion"];
[SCIUtils showToastForDuration:2.0 title:@"Removed"];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
@@ -198,6 +265,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *navThreadId = sciThreadIdForVC(navNearestVC);
BOOL navExcluded = navThreadId && [SCIExcludedThreads isThreadIdExcluded:navThreadId];
BOOL navInList = navThreadId && [SCIExcludedThreads isInList:navThreadId];
if ([SCIUtils getBoolPref:@"remove_lastseen"] && !navExcluded) {
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"eye"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
@@ -208,18 +276,26 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
[new_items addObject:seenButton];
}
// Excluded chats hide the seen button — surface an un-exclude affordance instead.
if ([SCIUtils getBoolPref:@"remove_lastseen"] && navExcluded &&
[SCIUtils getBoolPref:@"unexclude_inbox_button"]) {
UIBarButtonItem *unexBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:@"eye.slash.fill"]
// In block_all: show remove button for listed (excluded) chats
// In block_selected: show remove button for listed chats, or add button for non-listed chats
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
BOOL showListButton = [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"chat_quick_list_button"];
// block_all + in list: show remove button (no seen button shown for excluded chats)
// block_selected + NOT in list: show add-to-list button
// block_selected + in list: DON'T show (seen button already visible with long-press menu)
BOOL showRemoveBtn = !blockSelected && navInList && navExcluded;
BOOL showAddBtn = blockSelected && !navInList;
if (showListButton && (showRemoveBtn || showAddBtn)) {
SEL action = showRemoveBtn ? @selector(sciUnexcludeButtonHandler:) : @selector(sciAddToListHandler:);
UIBarButtonItem *listBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:showRemoveBtn ? @"eye.slash.fill" : @"eye.slash"]
style:UIBarButtonItemStylePlain
target:self
action:@selector(sciUnexcludeButtonHandler:)];
unexBtn.accessibilityIdentifier = @"sci-unex-btn";
unexBtn.tintColor = SCIUtils.SCIColor_Primary;
unexBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
[new_items addObject:unexBtn];
action:action];
listBtn.accessibilityIdentifier = @"sci-unex-btn";
listBtn.tintColor = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
listBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
[new_items addObject:listBtn];
}
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded) {
@@ -277,7 +353,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if ([SCIUtils getBoolPref:@"remove_lastseen"]) {
if ([SCIExcludedThreads isActiveThreadExcluded]) return %orig; // excluded → behave normally
if (sciIsSeenToggleMode() && dmSeenToggleEnabled) return %orig;
if (sciSeenAutoBypass) return %orig;
if (sciSeenAutoBypassCount > 0) return %orig;
return false;
}
return %orig;
@@ -55,19 +55,25 @@ BOOL sciIsStoryAudioEnabled(void) {
return sciIGAudioEnabled();
}
static BOOL sciKVORegistered = NO;
void sciInitStoryAudioState(void) {
if (sciKVORegistered) return;
if (!sciVolumeObserver) sciVolumeObserver = [_SciVolumeObserver new];
@try {
[[AVAudioSession sharedInstance] addObserver:sciVolumeObserver
forKeyPath:@"outputVolume"
options:NSKeyValueObservingOptionNew
context:NULL];
sciKVORegistered = YES;
} @catch (__unused id e) {}
}
void sciResetStoryAudioState(void) {
if (!sciKVORegistered) return;
@try {
[[AVAudioSession sharedInstance] removeObserver:sciVolumeObserver forKeyPath:@"outputVolume"];
sciKVORegistered = NO;
} @catch (__unused id e) {}
}
@@ -154,7 +154,9 @@
}];
}
self.filtered = all;
self.title = [NSString stringWithFormat:@"Chats (%lu)", (unsigned long)self.filtered.count];
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *label = bs ? @"Included chats" : @"Excluded chats";
self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count];
[self.tableView reloadData];
}
@@ -122,7 +122,9 @@
}];
}
self.filtered = all;
self.title = [NSString stringWithFormat:@"Story users (%lu)", (unsigned long)self.filtered.count];
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
NSString *label = bs ? @"Included users" : @"Excluded users";
self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count];
[self.tableView reloadData];
}
+2
View File
@@ -404,7 +404,9 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
+ (NSArray<NSString *> *)extraDataKeys {
return @[
@"excluded_threads",
@"included_threads",
@"excluded_story_users",
@"included_story_users",
@"embed_custom_domains",
];
}
+2 -1
View File
@@ -386,7 +386,8 @@ static char rowStaticRef[] = "row";
NSLog(@"Menu changed: %@", command.propertyList[@"value"]);
[self reloadCellForView:command.sender animated:YES];
[self.tableView reloadData];
if (properties[@"requiresRestart"]) {
[SCIUtils showRestartConfirmation];
}
+47 -10
View File
@@ -209,11 +209,12 @@
]
},
@{
@"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.",
@"header": @"Story user list",
@"footer": @"Block all: all stories blocked — listed users are exceptions.\nBlock selected: only listed users are blocked — everything else is normal.\nBoth lists are saved independently.",
@"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 switchCellWithTitle:@"Enable story user list" subtitle:@"Master toggle. When off, the list is ignored" defaultsKey:@"enable_story_user_exclusions"],
[SCISetting menuCellWithTitle:@"Blocking mode" subtitle:@"Which stories get seen-receipt blocking" menu:[self menus][@"story_blocking_mode"]],
[SCISetting switchCellWithTitle:@"Quick list button in stories" subtitle:@"Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only" defaultsKey:@"story_excluded_show_unexclude_eye"],
({
SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list"
subtitle:@"Search, sort, swipe to remove"
@@ -279,8 +280,7 @@
[SCISetting menuCellWithTitle:@"Read receipt mode" subtitle:@"How the seen button behaves" menu:[self menus][@"seen_mode"]],
[SCISetting switchCellWithTitle:@"Auto mark seen on interact" subtitle:@"Locally marks messages as seen when you send any message" defaultsKey:@"seen_auto_on_interact"],
[SCISetting switchCellWithTitle:@"Auto mark seen on typing" subtitle:@"Marks messages as seen the moment you start typing in a DM (works even when typing status is hidden)" defaultsKey:@"seen_auto_on_typing"],
[SCISetting switchCellWithTitle:@"Un-exclude button in excluded chats" subtitle:@"Show a small eye button in excluded chats to remove them from the exclusion list (with confirmation). Long-press it for more options." defaultsKey:@"unexclude_inbox_button"],
]
]
}]
],
[SCISetting switchCellWithTitle:@"Disable typing status" subtitle:@"Prevents the typing indicator from being shown to others when you're typing in DMs" defaultsKey:@"disable_typing_status"],
@@ -288,11 +288,22 @@
]
},
@{
@"header": @"Excluded chats",
@"footer": @"Excluded chats and groups behave normally — read-receipt blocking, manual seen button, auto-seen on send/typing are all skipped for them. Long-press a chat in the inbox to add or remove it.",
@"header": @"Chat list",
@"footer": @"Block all: all chats blocked — listed chats are exceptions.\nBlock selected: only listed chats are blocked — everything else is normal.\nBoth lists are saved independently. Long-press a chat in the inbox to add or remove.",
@"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 switchCellWithTitle:@"Enable chat list" subtitle:@"Master toggle. When off, the list is ignored" defaultsKey:@"enable_chat_exclusions"],
[SCISetting menuCellWithTitle:@"Blocking mode" subtitle:@"Which chats get read-receipt blocking" menu:[self menus][@"chat_blocking_mode"]],
({
SCISetting *s = [SCISetting switchCellWithTitle:@"" subtitle:@"" defaultsKey:@"exclusions_default_keep_deleted"];
s.dynamicTitle = ^{
BOOL bs = [[SCIUtils getStringPref:@"chat_blocking_mode"] isEqualToString:@"block_selected"];
return bs ? @"Block keep-deleted for unlisted chats"
: @"Block keep-deleted for excluded chats";
};
s.subtitle = @"Each chat can override this in the list";
s;
}),
[SCISetting switchCellWithTitle:@"Quick list button in chats" subtitle:@"Shows a button in DM threads to add/remove chats from the list. Long-press for more options" defaultsKey:@"chat_quick_list_button"],
({
SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list"
subtitle:@"Search, sort, swipe to remove or toggle keep-deleted"
@@ -523,6 +534,32 @@
+ (NSDictionary *)menus {
return @{
@"chat_blocking_mode": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Block all"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"chat_blocking_mode", @"value": @"block_all" }
],
[UICommand commandWithTitle:@"Block selected"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"chat_blocking_mode", @"value": @"block_selected" }
]
]],
@"story_blocking_mode": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Block all"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"story_blocking_mode", @"value": @"block_all" }
],
[UICommand commandWithTitle:@"Block selected"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"story_blocking_mode", @"value": @"block_selected" }
]
]],
@"story_seen_mode": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Button"
image:nil
+3 -1
View File
@@ -62,9 +62,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"unsent_message_toast": @(NO),
@"warn_refresh_clears_preserved": @(NO),
@"enable_chat_exclusions": @(YES),
@"chat_blocking_mode": @"block_all",
@"exclusions_default_keep_deleted": @(NO),
@"unexclude_inbox_button": @(YES),
@"chat_quick_list_button": @(YES),
@"enable_story_user_exclusions": @(YES),
@"story_blocking_mode": @"block_all",
@"story_excluded_show_unexclude_eye": @(YES),
@"story_seen_mode": @"button",
@"story_audio_toggle": @(NO),