feat: Per-chat read-receipt exclusions - can exclude keep-delete messages as well

fix: Keep-deleted messages now reliable for cold-start backlogs
fix: Fix downloading some audio file formats (tries converting falls back to original file type)
This commit is contained in:
faroukbmiled
2026-04-08 11:18:28 +01:00
parent 84b4405b84
commit 7300fe893e
13 changed files with 780 additions and 122 deletions
+1
View File
@@ -88,6 +88,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\***
- Mark seen on story like **\***
- Advance to next story when marking as seen — tapping the eye button auto-skips to the next story **\***
- 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 **\***
- 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
@@ -112,36 +112,55 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
return;
}
// Move downloaded file to named temp path
NSString *mediaId = sciDAF(serverAudio, @selector(mediaId)) ?: @"voice_message";
NSString *mp4Path = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"tmp_%@.mp4", mediaId]];
NSURL *mp4URL = [NSURL fileURLWithPath:mp4Path];
[[NSFileManager defaultManager] removeItemAtURL:mp4URL error:nil];
[[NSFileManager defaultManager] moveItemAtURL:tempURL toURL:mp4URL error:nil];
// Always try to convert to .m4a via AVFoundation. If that fails (e.g. Ogg/Opus
// from PC/web users — iOS has no decoder), fall back to keeping the original
// extension from the URL so the share sheet still treats it as audio.
NSString *urlExt = [[playbackURL.path pathExtension] lowercaseString];
if (urlExt.length == 0) urlExt = @"m4a";
NSString *mediaId = sciDAF(serverAudio, @selector(mediaId)) ?: @"voice_message";
NSString *srcPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"tmp_%@.%@", mediaId, urlExt]];
NSURL *srcURL = [NSURL fileURLWithPath:srcPath];
[[NSFileManager defaultManager] removeItemAtURL:srcURL error:nil];
[[NSFileManager defaultManager] moveItemAtURL:tempURL toURL:srcURL error:nil];
void (^present)(NSURL *) = ^(NSURL *url) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill setText:@"Done!"];
[pill dismissAfterDelay:0.5];
[SCIUtils showShareVC:url];
});
};
// Convert mp4 container to m4a (AAC audio only)
NSString *m4aPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"audio_%@.m4a", mediaId]];
NSURL *m4aURL = [NSURL fileURLWithPath:m4aPath];
[[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil];
AVAsset *asset = [AVAsset assetWithURL:mp4URL];
AVAsset *asset = [AVAsset assetWithURL:srcURL];
AVAssetExportSession *exp = [AVAssetExportSession
exportSessionWithAsset:asset presetName:AVAssetExportPresetAppleM4A];
exp.outputURL = m4aURL;
exp.outputFileType = AVFileTypeAppleM4A;
[exp exportAsynchronouslyWithCompletionHandler:^{
[[NSFileManager defaultManager] removeItemAtURL:mp4URL error:nil];
NSURL *finalURL = (exp.status == AVAssetExportSessionStatusCompleted) ? m4aURL : mp4URL;
dispatch_async(dispatch_get_main_queue(), ^{
[pill setText:@"Done!"];
[pill dismissAfterDelay:0.5];
[SCIUtils showShareVC:finalURL];
});
if (exp.status == AVAssetExportSessionStatusCompleted) {
[[NSFileManager defaultManager] removeItemAtURL:srcURL error:nil];
present(m4aURL);
return;
}
// Conversion failed — keep the original file with its real extension.
[[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil];
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"audio_%@.%@", mediaId, urlExt]];
NSURL *outURL = [NSURL fileURLWithPath:outPath];
[[NSFileManager defaultManager] removeItemAtURL:outURL error:nil];
if (![[NSFileManager defaultManager] moveItemAtURL:srcURL toURL:outURL error:nil]) {
present(srcURL);
return;
}
present(outURL);
}];
}];
[task resume];
@@ -0,0 +1,143 @@
// Per-chat exclusion list. Injects an Add/Remove item into the inbox row
// context menu, and tracks the currently-visible thread for the gating sites
// in SeenButtons / OverlayButtons / VisualMsgModifier. Storage lives in
// SCIExcludedThreads.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "SCIExcludedThreads.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static id sci_safeKey(id obj, NSString *k) {
@try { return [obj valueForKey:k]; } @catch (__unused id e) { return nil; }
}
// Build a persistence-ready dict from a live IGDirectInboxThreadCellViewModel.
static NSDictionary *sci_entryFromVM(id vm) {
if (!vm) return nil;
NSString *tid = sci_safeKey(vm, @"threadId");
NSString *name = sci_safeKey(vm, @"threadName");
NSNumber *grp = sci_safeKey(vm, @"isGroupThread");
if (tid.length == 0) return nil;
NSMutableArray *users = [NSMutableArray array];
id active = sci_safeKey(vm, @"recentlyActiveUsers");
if ([active isKindOfClass:[NSArray class]]) {
for (id u in (NSArray *)active) {
id pk = sci_safeKey(u, @"pk");
id un = sci_safeKey(u, @"username");
id fn = sci_safeKey(u, @"fullName");
NSMutableDictionary *d = [NSMutableDictionary dictionary];
if (pk) d[@"pk"] = [NSString stringWithFormat:@"%@", pk];
if (un) d[@"username"] = [NSString stringWithFormat:@"%@", un];
if (fn) d[@"fullName"] = [NSString stringWithFormat:@"%@", fn];
if (d.count) [users addObject:d];
}
}
return @{
@"threadId": tid,
@"threadName": name ?: @"",
@"isGroup": @([grp boolValue]),
@"users": users,
};
}
// Inbox row context menu — wrap IG's UIContextMenuConfiguration to append our
// add/remove item without losing any of IG's own actions.
static id (*orig_ctxMenuCfg)(id, SEL, id);
static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
id cfg = orig_ctxMenuCfg(self, _cmd, indexPath);
if (![SCIExcludedThreads isFeatureEnabled]) return cfg;
if (![cfg isKindOfClass:[UIContextMenuConfiguration class]]) return cfg;
id adapter = sci_safeKey(self, @"listAdapter");
if (!adapter || ![indexPath respondsToSelector:@selector(section)]) return cfg;
NSInteger section = [(NSIndexPath *)indexPath section];
SEL secSel = NSSelectorFromString(@"sectionControllerForSection:");
if (![adapter respondsToSelector:secSel]) return cfg;
id secCtrl = ((id(*)(id,SEL,NSInteger))objc_msgSend)(adapter, secSel, section);
id vm = sci_safeKey(secCtrl, @"viewModel");
if (!vm) vm = sci_safeKey(secCtrl, @"item");
NSDictionary *entry = sci_entryFromVM(vm);
if (!entry) return cfg;
NSString *tid = entry[@"threadId"];
// actionProvider / previewProvider aren't public on UIContextMenuConfiguration
UIContextMenuConfiguration *orig = (UIContextMenuConfiguration *)cfg;
UIContextMenuActionProvider origProvider = sci_safeKey(orig, @"actionProvider");
id<NSCopying> origIdent = sci_safeKey(orig, @"identifier");
UIContextMenuContentPreviewProvider origPreview = sci_safeKey(orig, @"previewProvider");
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";
UIImage *img = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"];
UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil
handler:^(__kindof UIAction *_) {
if (excluded) {
[SCIExcludedThreads removeThreadId:tid];
} else {
[SCIExcludedThreads addOrUpdateEntry:entry];
}
}];
NSMutableArray *kids = [base.children mutableCopy] ?: [NSMutableArray array];
[kids addObject:toggle];
return [base menuByReplacingChildren:kids];
};
return [UIContextMenuConfiguration configurationWithIdentifier:origIdent
previewProvider:origPreview
actionProvider:wrapped];
}
// Active thread tracking. Set on viewWillAppear so visual-message viewMode
// reads it before the chat finishes loading. Only cleared on a real leave —
// a visual viewer modal pushed on top mustn't drop context.
%hook IGDirectThreadViewController
- (void)viewWillAppear:(BOOL)animated {
%orig;
NSString *tid = sci_safeKey(self, @"threadId");
if (tid) [SCIExcludedThreads setActiveThreadId:tid];
}
- (void)viewDidDisappear:(BOOL)animated {
%orig;
if (self.isMovingFromParentViewController || self.isBeingDismissed || self.parentViewController == nil) {
NSString *cur = [SCIExcludedThreads activeThreadId];
NSString *mine = sci_safeKey(self, @"threadId");
if (cur && mine && [cur isEqualToString:mine]) {
[SCIExcludedThreads setActiveThreadId:nil];
}
}
}
- (void)dealloc {
NSString *cur = [SCIExcludedThreads activeThreadId];
NSString *mine = sci_safeKey(self, @"threadId");
if (cur && mine && [cur isEqualToString:mine]) {
[SCIExcludedThreads setActiveThreadId:nil];
}
%orig;
}
%end
%ctor {
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);
}
@@ -1,18 +1,30 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "SCIExcludedThreads.h"
#import <objc/runtime.h>
#import <substrate.h>
// ============ KEEP DELETED MESSAGES ============
// Blocks remote unsends while allowing local deletes-for-you.
// Keep-deleted messages.
// Each iris delta carries a threadId; we stash it in TLS while orig runs so the
// IGDirectMessageUpdate alloc hook can stamp the new update. At apply time we
// neuter remote-unsend updates in-place by clearing _removeMessages_messageKeys,
// letting IG run its apply call without actually removing anything.
//
// IGDirectMessageUpdate._removeMessages_reason: 0 = unsend, 2 = delete-for-you.
// Delete-for-you fires reason=2 first, then a reason=0 follow-up. Remote
// unsends only fire reason=0. We remember the message keys from reason=2
// updates; a later reason=0 with matching keys is passed through (it's the
// follow-up), anything else is treated as a remote unsend and blocked.
// Tracked keys expire after 10s so a partial delete-for-you can't permanently
// swallow future remote unsends.
// _removeMessages_reason: 0 = unsend, 2 = delete-for-you. Delete-for-you fires
// reason=2 then a reason=0 follow-up; we track the keys for 10s so the follow-up
// passes through.
static NSString * const kSCIDeltaTidTLSKey = @"SCI.currentDeltaTid";
static const void *kSCIUpdateThreadIdKey = &kSCIUpdateThreadIdKey;
static NSString *sciGetCurrentDeltaTid(void) {
return [NSThread currentThread].threadDictionary[kSCIDeltaTidTLSKey];
}
static void sciSetCurrentDeltaTid(NSString *tid) {
NSMutableDictionary *td = [NSThread currentThread].threadDictionary;
if (tid) td[kSCIDeltaTidTLSKey] = tid;
else [td removeObjectForKey:kSCIDeltaTidTLSKey];
}
static BOOL sciKeepDeletedEnabled() {
return [SCIUtils getBoolPref:@"keep_deleted_message"];
@@ -25,16 +37,13 @@ static BOOL sciIndicateUnsentEnabled() {
static void sciUpdateCellIndicator(id cell);
static BOOL sciLocalDeleteInProgress = NO;
static NSMutableArray *sciPendingUpdates = nil;
// Server message ID -> timestamp the reason=2 (delete-for-you) was observed.
static NSMutableDictionary<NSString *, NSDate *> *sciDeleteForYouKeys = nil;
static NSMutableSet *sciPreservedIds = nil;
// Server message ID -> content class name for messages we recognize as
// reaction/action-log bookkeeping (e.g. "X liked a message" thread entries).
// Populated by hooking the data model class init. Used to skip preserving
// these IDs when their remove arrives.
// serverId -> message content class name; populated when messages are inserted
// so we can recognize reaction/action-log records and never preserve them.
static NSMutableDictionary<NSString *, NSString *> *sciMessageContentClasses = nil;
#define SCI_CONTENT_CLASSES_MAX 4000
#define SCI_PENDING_MAX 50
#define SCI_PENDING_MAX 500
#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds"
#define SCI_PRESERVED_MAX 200
@@ -70,14 +79,11 @@ static void sciTrackInsertedMessage(NSString *sid, NSString *className) {
NSMutableDictionary *map = sciGetContentClasses();
map[sid] = className;
if (map.count > SCI_CONTENT_CLASSES_MAX) {
// Drop ~10% oldest by simply removing arbitrary keys
NSArray *keys = [map allKeys];
for (NSUInteger i = 0; i < keys.count / 10; i++) [map removeObjectForKey:keys[i]];
}
}
// Returns YES if the message at this server ID is known to be reaction-related
// (action log entry, reaction record, etc.) — i.e. should never be preserved.
static BOOL sciIsReactionRelatedMessage(NSString *sid) {
if (!sid.length) return NO;
NSString *className = sciGetContentClasses()[sid];
@@ -88,12 +94,52 @@ static BOOL sciIsReactionRelatedMessage(NSString *sid) {
[className containsString:@"actionLog"];
}
// ============ IRIS DELTA STAMPING ============
static NSString *sciDeltaThreadId(id delta) {
@try {
id payload = [delta valueForKey:@"payload"];
if (!payload) return nil;
Ivar tdIvar = class_getInstanceVariable([payload class], "_threadDeltaPayload");
id threadDelta = tdIvar ? object_getIvar(payload, tdIvar) : nil;
if (!threadDelta) return nil;
return [threadDelta valueForKey:@"threadId"];
} @catch (__unused id e) { return nil; }
}
// Iterate per-delta so the alloc hook can attribute each new IGDirectMessageUpdate
// to its source thread via TLS. Live delivery comes through here.
static void (*orig_handleIrisDeltas)(id self, SEL _cmd, NSArray *deltas);
static void new_handleIrisDeltas(id self, SEL _cmd, NSArray *deltas) {
if (!deltas || deltas.count == 0) { orig_handleIrisDeltas(self, _cmd, deltas); return; }
for (id delta in deltas) {
sciSetCurrentDeltaTid(sciDeltaThreadId(delta));
@try { orig_handleIrisDeltas(self, _cmd, @[delta]); } @catch (__unused id e) {}
sciSetCurrentDeltaTid(nil);
}
}
// Some internal IG paths bypass the top-level handler and call the per-thread
// grouped variant directly. All deltas in one call belong to the same thread.
static void (*orig_handleIrisDeltasGrouped)(id self, SEL _cmd, NSArray *deltas);
static void new_handleIrisDeltasGrouped(id self, SEL _cmd, NSArray *deltas) {
if (!deltas || deltas.count == 0) { orig_handleIrisDeltasGrouped(self, _cmd, deltas); return; }
sciSetCurrentDeltaTid(sciDeltaThreadId(deltas.firstObject));
@try { orig_handleIrisDeltasGrouped(self, _cmd, deltas); } @catch (__unused id e) {}
sciSetCurrentDeltaTid(nil);
}
// ============ ALLOC TRACKING ============
static id (*orig_msgUpdate_alloc)(id self, SEL _cmd);
static id new_msgUpdate_alloc(id self, SEL _cmd) {
id instance = orig_msgUpdate_alloc(self, _cmd);
if (sciKeepDeletedEnabled() && instance) {
if ([SCIUtils getBoolPref:@"keep_deleted_message"] && instance) {
NSString *tid = sciGetCurrentDeltaTid();
if (tid) {
objc_setAssociatedObject(instance, kSCIUpdateThreadIdKey, tid,
OBJC_ASSOCIATION_COPY_NONATOMIC);
}
if (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array];
@synchronized(sciPendingUpdates) {
[sciPendingUpdates addObject:instance];
@@ -128,72 +174,93 @@ static void sciPruneStaleDeleteForYouKeys() {
}
}
// Walks every pending IGDirectMessageUpdate, preserves the IDs of any reason=0
// remove that isn't a delete-for-you follow-up, and returns the set of preserved
// IDs. The caller decides whether to actually block + show a toast based on
// whether those IDs match real (rendered) messages.
static NSSet<NSString *> *sciConsumePendingPreserves() {
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
if (!sciPendingUpdates) return preserved;
if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary];
// Clear the remove-keys ivar in place. IG's later apply iterates an empty
// list, so the cache-removal becomes a no-op without disturbing call ordering.
static void sciNeuterRemoveUpdate(id update) {
@try {
Ivar ivar = class_getInstanceVariable([update class], "_removeMessages_messageKeys");
if (ivar) object_setIvar(update, ivar, nil);
} @catch (__unused id e) {}
}
// Classify a single update and append any real-unsend server ids to `preserved`.
// Returns silently for inserts, delete-for-you initiators, follow-ups, and reactions.
static void sciProcessOneUpdate(id update, NSMutableSet<NSString *> *preserved) {
@try {
Ivar removeIvar = class_getInstanceVariable([update class], "_removeMessages_messageKeys");
if (!removeIvar) return;
NSArray *keys = object_getIvar(update, removeIvar);
if (!keys || keys.count == 0) return;
long long reason = -1;
Ivar reasonIvar = class_getInstanceVariable([update class], "_removeMessages_reason");
if (reasonIvar) {
ptrdiff_t off = ivar_getOffset(reasonIvar);
reason = *(long long *)((char *)(__bridge void *)update + off);
}
// Delete-for-you initiator — remember keys for the follow-up.
if (reason == 2) {
NSDate *now = [NSDate date];
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) sciDeleteForYouKeys[sid] = now;
}
return;
}
if (reason != 0 || sciLocalDeleteInProgress) return;
// Delete-for-you follow-up: every key already tracked → let through.
BOOL allMatched = YES;
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
}
if (allMatched) {
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
}
return;
}
// Real remote unsend — preserve, skipping reaction/action-log records.
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid) continue;
if (sciIsReactionRelatedMessage(sid)) continue;
[sciGetPreservedIds() addObject:sid];
[preserved addObject:sid];
}
} @catch (__unused id e) {}
}
// For every pending update stamped with `tid`: classify it, preserve the ids
// if it's a real unsend, and neuter the update so the upcoming apply runs but
// doesn't remove anything. Excluded threads are dropped untouched.
static NSSet<NSString *> *sciNeuterAndPreserveForThread(NSString *tid) {
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
if (!sciPendingUpdates || tid.length == 0) return preserved;
if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary];
sciPruneStaleDeleteForYouKeys();
BOOL excluded = [SCIExcludedThreads shouldKeepDeletedBeBlockedForThreadId:tid];
@synchronized(sciPendingUpdates) {
for (id update in [sciPendingUpdates copy]) {
@try {
Ivar removeIvar = class_getInstanceVariable([update class], "_removeMessages_messageKeys");
if (!removeIvar) continue;
NSArray *keys = object_getIvar(update, removeIvar);
if (!keys || keys.count == 0) continue;
long long reason = -1;
Ivar reasonIvar = class_getInstanceVariable([update class], "_removeMessages_reason");
if (reasonIvar) {
ptrdiff_t off = ivar_getOffset(reasonIvar);
reason = *(long long *)((char *)(__bridge void *)update + off);
}
// Delete-for-you initiator — remember keys for the follow-up.
if (reason == 2) {
NSDate *now = [NSDate date];
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) sciDeleteForYouKeys[sid] = now;
}
continue;
}
if (reason != 0 || sciLocalDeleteInProgress) continue;
// If every key matches a recent delete-for-you, drop the
// tracking entries and let it through (it's the follow-up).
BOOL allMatched = YES;
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
}
if (allMatched) {
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
}
continue;
}
// Real remove — preserve only keys whose content class isn't a
// known reaction / action-log entry. Reaction events also fire
// reason=0 removes for the action-log record they create.
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid) continue;
if (sciIsReactionRelatedMessage(sid)) continue;
[sciGetPreservedIds() addObject:sid];
[preserved addObject:sid];
}
} @catch(id e) {}
NSMutableArray *remaining = [NSMutableArray array];
for (id update in sciPendingUpdates) {
NSString *stamp = objc_getAssociatedObject(update, kSCIUpdateThreadIdKey);
if (![stamp isEqualToString:tid]) {
[remaining addObject:update];
continue;
}
if (excluded) continue;
NSUInteger before = preserved.count;
sciProcessOneUpdate(update, preserved);
if (preserved.count > before) sciNeuterRemoveUpdate(update);
}
[sciPendingUpdates removeAllObjects];
[sciPendingUpdates setArray:remaining];
}
if (preserved.count > 0) sciSavePreservedIds();
return preserved;
@@ -208,12 +275,25 @@ static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id us
return;
}
NSSet<NSString *> *preserved = sciConsumePendingPreserves();
// Neuter remote-unsend updates for the threads in this batch, then hand
// off to IG. Apply call sequencing is preserved exactly as IG expects.
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
if ([updates isKindOfClass:[NSArray class]]) {
for (id tu in (NSArray *)updates) {
NSString *tid = nil;
@try { tid = [tu valueForKey:@"threadId"]; } @catch (__unused id e) {}
if (tid.length == 0) continue;
NSSet *p = sciNeuterAndPreserveForThread(tid);
if (p.count > 0) [preserved unionSet:p];
}
}
// Hand off to IG — every update applies, neutered ones become no-ops.
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
if (preserved.count > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
// Refresh visible cells so newly preserved messages show the
// "Unsent" indicator immediately without waiting for a scroll.
// Refresh visible cells so the "Unsent" indicator shows immediately.
Class cellClass = NSClassFromString(@"IGDirectMessageCell");
if (cellClass) {
UIWindow *window = [UIApplication sharedApplication].keyWindow;
@@ -230,7 +310,6 @@ static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id us
}
}
// Top-of-screen toast notifying the user that an unsend was caught.
if ([SCIUtils getBoolPref:@"unsent_message_toast"]) {
UIView *hostView = [UIApplication sharedApplication].keyWindow;
if (hostView) {
@@ -273,9 +352,7 @@ static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id us
}
}
});
return;
}
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
}
// ============ LOCAL DELETE TRACKING ============
@@ -472,6 +549,21 @@ static id new_actionLogFullInit(id self, SEL _cmd,
MSHookMessageEx(cacheClass, sel, (IMP)new_applyUpdates, (IMP *)&orig_applyUpdates);
}
Class irisClass = NSClassFromString(@"IGDirectRealtimeIrisDeltaHandler");
if (irisClass) {
SEL sel1 = NSSelectorFromString(@"handleIrisDeltas:");
if (class_getInstanceMethod(irisClass, sel1))
MSHookMessageEx(irisClass, sel1,
(IMP)new_handleIrisDeltas,
(IMP *)&orig_handleIrisDeltas);
SEL sel2 = NSSelectorFromString(@"_handleIrisDeltasGroupedByThread:");
if (class_getInstanceMethod(irisClass, sel2))
MSHookMessageEx(irisClass, sel2,
(IMP)new_handleIrisDeltasGrouped,
(IMP *)&orig_handleIrisDeltasGrouped);
}
Class cellClass = NSClassFromString(@"IGDirectMessageCell");
if (cellClass) {
SEL configSel = NSSelectorFromString(@"configureWithViewModel:ringViewSpecFactory:launcherSet:");
@@ -1,5 +1,6 @@
// Download + mark seen buttons on story/DM visual message overlay
#import "StoryHelpers.h"
#import "SCIExcludedThreads.h"
extern "C" BOOL sciSeenBypassActive;
extern "C" BOOL sciAdvanceBypassActive;
@@ -125,7 +126,10 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
}
// mark seen button (stories: mark as seen, DMs: mark as viewed + dismiss)
if ([SCIUtils getBoolPref:@"no_seen_receipt"] && ![self viewWithTag:1339]) {
// 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];
@@ -0,0 +1,33 @@
// Persistent per-chat exclusion list for read-receipt features. Lookup is by
// canonical thread id (the MSYS string used by both inbox view models and
// IGDirectThreadViewController). Each entry carries a per-thread keep-deleted
// override that can force-include or force-exclude regardless of the global
// default.
#import <Foundation/Foundation.h>
typedef NS_ENUM(NSInteger, SCIKeepDeletedOverride) {
SCIKeepDeletedOverrideDefault = 0, // follow exclusions_default_keep_deleted
SCIKeepDeletedOverrideExcluded = 1, // force keep-deleted OFF for this thread
SCIKeepDeletedOverrideIncluded = 2, // force keep-deleted ON for this thread
};
@interface SCIExcludedThreads : NSObject
+ (BOOL)isFeatureEnabled;
+ (BOOL)isThreadIdExcluded:(NSString *)threadId;
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId;
+ (NSDictionary *)entryForThreadId:(NSString *)threadId;
+ (NSArray<NSDictionary *> *)allEntries;
+ (NSUInteger)count;
+ (void)addOrUpdateEntry:(NSDictionary *)entry;
+ (void)removeThreadId:(NSString *)threadId;
+ (void)setKeepDeletedOverride:(SCIKeepDeletedOverride)mode forThreadId:(NSString *)threadId;
// Currently-visible thread, set by IGDirectThreadViewController hooks.
+ (void)setActiveThreadId:(NSString *)threadId;
+ (NSString *)activeThreadId;
+ (BOOL)isActiveThreadExcluded;
@end
@@ -0,0 +1,100 @@
#import "SCIExcludedThreads.h"
#import "../../Utils.h"
#define SCI_EXCL_KEY @"excluded_threads"
@implementation SCIExcludedThreads
static NSString *sciActiveTid = nil;
+ (BOOL)isFeatureEnabled {
return [SCIUtils getBoolPref:@"enable_chat_exclusions"];
}
+ (NSArray<NSDictionary *> *)allEntries {
NSArray *raw = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_EXCL_KEY];
return raw ?: @[];
}
+ (NSUInteger)count {
return [self allEntries].count;
}
+ (void)saveAll:(NSArray *)entries {
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:SCI_EXCL_KEY];
}
+ (NSDictionary *)entryForThreadId:(NSString *)threadId {
if (threadId.length == 0) return nil;
for (NSDictionary *e in [self allEntries]) {
if ([e[@"threadId"] isEqualToString:threadId]) return e;
}
return nil;
}
+ (BOOL)isThreadIdExcluded:(NSString *)threadId {
if (![self isFeatureEnabled]) return NO;
return [self entryForThreadId:threadId] != nil;
}
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId {
if (![self isFeatureEnabled]) return NO;
NSDictionary *e = [self entryForThreadId:threadId];
if (!e) return NO;
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
if (mode == SCIKeepDeletedOverrideExcluded) return YES;
if (mode == SCIKeepDeletedOverrideIncluded) return NO;
return [SCIUtils getBoolPref:@"exclusions_default_keep_deleted"];
}
+ (void)addOrUpdateEntry:(NSDictionary *)entry {
NSString *tid = entry[@"threadId"];
if (tid.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
NSInteger existingIdx = -1;
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
if ([all[i][@"threadId"] isEqualToString:tid]) { existingIdx = i; break; }
}
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"];
all[existingIdx] = merged;
} else {
if (!merged[@"addedAt"]) merged[@"addedAt"] = @([[NSDate date] timeIntervalSince1970]);
if (!merged[@"keepDeletedOverride"]) merged[@"keepDeletedOverride"] = @(SCIKeepDeletedOverrideDefault);
[all addObject:merged];
}
[self saveAll:all];
}
+ (void)removeThreadId:(NSString *)threadId {
if (threadId.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
[all filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
return ![e[@"threadId"] isEqualToString:threadId];
}]];
[self saveAll:all];
}
+ (void)setKeepDeletedOverride:(SCIKeepDeletedOverride)mode forThreadId:(NSString *)threadId {
if (threadId.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
if ([all[i][@"threadId"] isEqualToString:threadId]) {
NSMutableDictionary *m = [all[i] mutableCopy];
m[@"keepDeletedOverride"] = @(mode);
all[i] = m;
break;
}
}
[self saveAll:all];
}
+ (void)setActiveThreadId:(NSString *)threadId { sciActiveTid = [threadId copy]; }
+ (NSString *)activeThreadId { return sciActiveTid; }
+ (BOOL)isActiveThreadExcluded { return [self isThreadIdExcluded:sciActiveTid]; }
@end
+22 -4
View File
@@ -1,10 +1,17 @@
#import "../../InstagramHeaders.h"
#import "../../Tweak.h"
#import "../../Utils.h"
#import "SCIExcludedThreads.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// Returns the threadId for an IGDirectThreadViewController, or nil.
static NSString *sciThreadIdForVC(id vc) {
if (!vc) return nil;
@try { return [vc valueForKey:@"threadId"]; } @catch (__unused id e) { return nil; }
}
// Seen buttons (in DMs)
// - Enables no seen for messages
@@ -19,10 +26,12 @@ static BOOL sciIsSeenToggleMode() {
}
static BOOL sciAutoInteractEnabled() {
if ([SCIExcludedThreads isActiveThreadExcluded]) return NO;
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_interact"];
}
BOOL sciAutoTypingEnabled() {
if ([SCIExcludedThreads isActiveThreadExcluded]) return NO;
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_typing"];
}
@@ -72,14 +81,20 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
}]
] mutableCopy];
if ([SCIUtils getBoolPref:@"remove_lastseen"]) {
// setRightBarButtonItems: runs before viewDidAppear: fires, so the global
// active thread id isn't reliable here — read it directly from the VC.
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *navThreadId = sciThreadIdForVC(navNearestVC);
BOOL navExcluded = navThreadId && [SCIExcludedThreads isThreadIdExcluded:navThreadId];
if ([SCIUtils getBoolPref:@"remove_lastseen"] && !navExcluded) {
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.message"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
if (sciIsSeenToggleMode())
[seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
[new_items addObject:seenButton];
}
if ([SCIUtils getBoolPref:@"unlimited_replay"]) {
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded) {
UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)];
[new_items addObject:dmVisualMsgsViewedButton];
[dmVisualMsgsViewedButton setTintColor:dmVisualMsgsViewedButtonEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
@@ -131,6 +146,7 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
%hook IGDirectThreadViewListAdapterDataSource
- (BOOL)shouldUpdateLastSeenMessage {
if ([SCIUtils getBoolPref:@"remove_lastseen"]) {
if ([SCIExcludedThreads isActiveThreadExcluded]) return %orig; // excluded → behave normally
if (sciIsSeenToggleMode() && dmSeenToggleEnabled) return %orig;
if (sciSeenAutoBypass) return %orig;
return false;
@@ -143,11 +159,13 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
%hook IGDirectVisualMessageViewerEventHandler
- (void)visualMessageViewerController:(id)arg1 didBeginPlaybackForVisualMessage:(id)arg2 atIndex:(NSInteger)arg3 {
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled) return;
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled
&& ![SCIExcludedThreads isActiveThreadExcluded]) return;
%orig;
}
- (void)visualMessageViewerController:(id)arg1 didEndPlaybackForVisualMessage:(id)arg2 atIndex:(NSInteger)arg3 mediaCurrentTime:(CGFloat)arg4 forNavType:(NSInteger)arg5 {
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled) return;
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled
&& ![SCIExcludedThreads isActiveThreadExcluded]) return;
%orig;
}
%end
@@ -1,21 +1,16 @@
#import "../../Utils.h"
#import "SCIExcludedThreads.h"
%hook IGDirectVisualMessage
- (NSInteger)viewMode {
NSInteger mode = %orig;
// * Modes *
// 0 - View Once
// 1 - Replayable
if ([SCIUtils getBoolPref:@"disable_view_once_limitations"]) {
if (mode == 0) {
mode = 1;
NSLog(@"[SCInsta] Modifying visual message from read-once to replayable");
}
// 0 = view once, 1 = replayable. Force view-once behavior to leak through
// when the active thread is excluded so the message expires normally.
if ([SCIUtils getBoolPref:@"disable_view_once_limitations"]
&& mode == 0
&& ![SCIExcludedThreads isActiveThreadExcluded]) {
return 1;
}
return mode;
}
%end
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIExcludedChatsViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate>
@end
@@ -0,0 +1,221 @@
#import "SCIExcludedChatsViewController.h"
#import "../Features/StoriesAndMessages/SCIExcludedThreads.h"
@interface SCIExcludedChatsViewController ()
@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; // 0=added desc, 1=name asc
@end
@implementation SCIExcludedChatsViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Excluded chats";
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.searchBar = [[UISearchBar alloc] init];
self.searchBar.delegate = self;
self.searchBar.placeholder = @"Search by name or username";
[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.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.tableView];
[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],
]];
UIBarButtonItem *sortBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"]
style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)];
self.navigationItem.rightBarButtonItem = sortBtn;
[self reload];
}
- (void)toggleSort {
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by"
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *titles = @[@"Recently added", @"Name (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.navigationItem.rightBarButtonItem;
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)reload {
NSArray *all = [SCIExcludedThreads allEntries];
NSString *q = [self.query lowercaseString];
if (q.length > 0) {
all = [all filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
if ([[e[@"threadName"] lowercaseString] containsString:q]) return YES;
for (NSDictionary *u in (NSArray *)e[@"users"]) {
if ([[u[@"username"] lowercaseString] containsString:q]) return YES;
if ([[u[@"fullName"] lowercaseString] containsString:q]) return YES;
}
return NO;
}]];
}
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];
}];
} else {
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
NSString *na = a[@"threadName"] ?: @"", *nb = b[@"threadName"] ?: @"";
return [na caseInsensitiveCompare:nb];
}];
}
self.filtered = all;
self.title = [NSString stringWithFormat:@"Excluded chats (%lu)", (unsigned long)self.filtered.count];
[self.tableView reloadData];
}
#pragma mark - Search
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText {
self.query = searchText;
[self reload];
}
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { [searchBar resignFirstResponder]; }
#pragma mark - Table
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return self.filtered.count;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *reuse = @"sciExclCell";
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:reuse];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuse];
NSDictionary *e = self.filtered[indexPath.row];
NSString *name = e[@"threadName"] ?: @"(unknown)";
BOOL isGroup = [e[@"isGroup"] boolValue];
NSMutableArray *unames = [NSMutableArray array];
for (NSDictionary *u in (NSArray *)e[@"users"]) {
if (u[@"username"]) [unames addObject:[@"@" stringByAppendingString:u[@"username"]]];
}
NSString *subtitle = [unames componentsJoinedByString:@", "];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
NSString *kdLabel = (mode == SCIKeepDeletedOverrideExcluded) ? @" • Keep-deleted: OFF"
: (mode == SCIKeepDeletedOverrideIncluded) ? @" • Keep-deleted: ON"
: @"";
if (kdLabel.length) subtitle = [subtitle stringByAppendingString:kdLabel];
cell.textLabel.text = [NSString stringWithFormat:@"%@%@", isGroup ? @"👥 " : @"", name];
cell.detailTextLabel.text = subtitle;
cell.detailTextLabel.numberOfLines = 2;
cell.accessoryType = isGroup ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator;
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tv deselectRowAtIndexPath:indexPath animated:YES];
NSDictionary *e = self.filtered[indexPath.row];
NSArray *users = e[@"users"];
if ([e[@"isGroup"] boolValue] || users.count != 1) return;
NSString *username = users.firstObject[@"username"];
if (!username) 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 *tid = e[@"threadId"];
UIContextualAction *del = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive
title:@"Remove"
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
[SCIExcludedThreads removeThreadId:tid];
[self reload];
cb(YES);
}];
return [UISwipeActionsConfiguration configurationWithActions:@[del]];
}
- (UIContextMenuConfiguration *)tableView:(UITableView *)tv contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point {
NSDictionary *e = self.filtered[indexPath.row];
NSString *tid = e[@"threadId"];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
return [UIContextMenuConfiguration configurationWithIdentifier:nil
previewProvider:nil
actionProvider:^UIMenu *(NSArray<UIMenuElement *> *_) {
UIAction *(^kdAction)(NSString *, SCIKeepDeletedOverride) = ^UIAction *(NSString *title, SCIKeepDeletedOverride v) {
UIAction *a = [UIAction actionWithTitle:title image:nil identifier:nil
handler:^(__kindof UIAction *_) {
[SCIExcludedThreads setKeepDeletedOverride:v forThreadId:tid];
[self reload];
}];
if (v == mode) a.state = UIMenuElementStateOn;
return a;
};
UIMenu *kdMenu = [UIMenu menuWithTitle:@"Keep-deleted override"
image:[UIImage systemImageNamed:@"trash.slash"]
identifier:nil
options:0
children:@[
kdAction(@"Follow default", SCIKeepDeletedOverrideDefault),
kdAction(@"Force ON (preserve unsends)", SCIKeepDeletedOverrideIncluded),
kdAction(@"Force OFF (allow unsends)", SCIKeepDeletedOverrideExcluded),
]];
UIAction *remove = [UIAction actionWithTitle:@"Remove from list"
image:[UIImage systemImageNamed:@"trash"]
identifier:nil
handler:^(__kindof UIAction *_) {
[SCIExcludedThreads removeThreadId:tid];
[self reload];
}];
remove.attributes = UIMenuElementAttributesDestructive;
return [UIMenu menuWithChildren:@[kdMenu, remove]];
}];
}
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv leadingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *e = self.filtered[indexPath.row];
NSString *tid = e[@"threadId"];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
SCIKeepDeletedOverride next = (mode + 1) % 3;
NSString *title = (next == SCIKeepDeletedOverrideExcluded) ? @"KD: OFF"
: (next == SCIKeepDeletedOverrideIncluded) ? @"KD: ON"
: @"KD: default";
UIContextualAction *toggle = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleNormal
title:title
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
[SCIExcludedThreads setKeepDeletedOverride:next forThreadId:tid];
[self reload];
cb(YES);
}];
toggle.backgroundColor = [UIColor systemBlueColor];
return [UISwipeActionsConfiguration configurationWithActions:@[toggle]];
}
@end
+26
View File
@@ -1,5 +1,7 @@
#import "TweakSettings.h"
#import "SCISettingsBackup.h"
#import "SCIExcludedChatsViewController.h"
#import "../Features/StoriesAndMessages/SCIExcludedThreads.h"
@implementation SCITweakSettings
@@ -198,6 +200,30 @@
[SCISetting switchCellWithTitle:@"Hide reels blend button" subtitle:@"Hides the button in DMs to open a reels blend" defaultsKey:@"hide_reels_blend"],
]
},
@{
@"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.",
@"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]]
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];
}
}],
]
},
@{
@"header": @"Voice messages",
@"rows": @[
+3 -1
View File
@@ -58,7 +58,9 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"advance_on_mark_seen": @(NO),
@"indicate_unsent_messages": @(NO),
@"unsent_message_toast": @(NO),
@"warn_refresh_clears_preserved": @(NO)
@"warn_refresh_clears_preserved": @(NO),
@"enable_chat_exclusions": @(YES),
@"exclusions_default_keep_deleted": @(NO)
};
[[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults];