mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-04-29 07:46:25 +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:
@@ -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
|
||||
@@ -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 (A–Z)"];
|
||||
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
|
||||
@@ -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
@@ -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];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user