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
@@ -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