mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-06-08 08:23:54 +02:00
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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user