From 7300fe893e030ef7f804e6febd36d851adee4750 Mon Sep 17 00:00:00 2001 From: faroukbmiled Date: Wed, 8 Apr 2026 11:18:28 +0100 Subject: [PATCH] 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) --- README.md | 1 + .../DownloadAudioMessage.xm | 55 ++-- .../StoriesAndMessages/ExcludeFromSeen.x | 143 ++++++++++ .../StoriesAndMessages/KeepDeletedMessages.x | 264 ++++++++++++------ .../StoriesAndMessages/OverlayButtons.xm | 6 +- .../StoriesAndMessages/SCIExcludedThreads.h | 33 +++ .../StoriesAndMessages/SCIExcludedThreads.m | 100 +++++++ src/Features/StoriesAndMessages/SeenButtons.x | 26 +- .../StoriesAndMessages/VisualMsgModifier.x | 19 +- src/Settings/SCIExcludedChatsViewController.h | 4 + src/Settings/SCIExcludedChatsViewController.m | 221 +++++++++++++++ src/Settings/TweakSettings.m | 26 ++ src/Tweak.x | 4 +- 13 files changed, 780 insertions(+), 122 deletions(-) create mode 100644 src/Features/StoriesAndMessages/ExcludeFromSeen.x create mode 100644 src/Features/StoriesAndMessages/SCIExcludedThreads.h create mode 100644 src/Features/StoriesAndMessages/SCIExcludedThreads.m create mode 100644 src/Settings/SCIExcludedChatsViewController.h create mode 100644 src/Settings/SCIExcludedChatsViewController.m diff --git a/README.md b/README.md index b95fe40..c8f92af 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm index 8b53921..0561a3c 100644 --- a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm +++ b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm @@ -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]; diff --git a/src/Features/StoriesAndMessages/ExcludeFromSeen.x b/src/Features/StoriesAndMessages/ExcludeFromSeen.x new file mode 100644 index 0000000..f3d8b8a --- /dev/null +++ b/src/Features/StoriesAndMessages/ExcludeFromSeen.x @@ -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 +#import +#import + +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 origIdent = sci_safeKey(orig, @"identifier"); + UIContextMenuContentPreviewProvider origPreview = sci_safeKey(orig, @"previewProvider"); + + UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray *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); +} diff --git a/src/Features/StoriesAndMessages/KeepDeletedMessages.x b/src/Features/StoriesAndMessages/KeepDeletedMessages.x index 9e52b38..e91e0a8 100644 --- a/src/Features/StoriesAndMessages/KeepDeletedMessages.x +++ b/src/Features/StoriesAndMessages/KeepDeletedMessages.x @@ -1,18 +1,30 @@ #import "../../Utils.h" #import "../../InstagramHeaders.h" +#import "SCIExcludedThreads.h" #import #import -// ============ 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 *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 *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 *sciConsumePendingPreserves() { - NSMutableSet *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 *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 *sciNeuterAndPreserveForThread(NSString *tid) { + NSMutableSet *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 *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 *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:"); diff --git a/src/Features/StoriesAndMessages/OverlayButtons.xm b/src/Features/StoriesAndMessages/OverlayButtons.xm index 49d97c3..70e3b56 100644 --- a/src/Features/StoriesAndMessages/OverlayButtons.xm +++ b/src/Features/StoriesAndMessages/OverlayButtons.xm @@ -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]; diff --git a/src/Features/StoriesAndMessages/SCIExcludedThreads.h b/src/Features/StoriesAndMessages/SCIExcludedThreads.h new file mode 100644 index 0000000..85d1642 --- /dev/null +++ b/src/Features/StoriesAndMessages/SCIExcludedThreads.h @@ -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 + +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 *)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 diff --git a/src/Features/StoriesAndMessages/SCIExcludedThreads.m b/src/Features/StoriesAndMessages/SCIExcludedThreads.m new file mode 100644 index 0000000..b85377a --- /dev/null +++ b/src/Features/StoriesAndMessages/SCIExcludedThreads.m @@ -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 *)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 diff --git a/src/Features/StoriesAndMessages/SeenButtons.x b/src/Features/StoriesAndMessages/SeenButtons.x index 71004c8..a7c27a5 100644 --- a/src/Features/StoriesAndMessages/SeenButtons.x +++ b/src/Features/StoriesAndMessages/SeenButtons.x @@ -1,10 +1,17 @@ #import "../../InstagramHeaders.h" #import "../../Tweak.h" #import "../../Utils.h" +#import "SCIExcludedThreads.h" #import #import #import +// 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 diff --git a/src/Features/StoriesAndMessages/VisualMsgModifier.x b/src/Features/StoriesAndMessages/VisualMsgModifier.x index 31ce8ff..8e5d796 100644 --- a/src/Features/StoriesAndMessages/VisualMsgModifier.x +++ b/src/Features/StoriesAndMessages/VisualMsgModifier.x @@ -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 \ No newline at end of file diff --git a/src/Settings/SCIExcludedChatsViewController.h b/src/Settings/SCIExcludedChatsViewController.h new file mode 100644 index 0000000..b4c3051 --- /dev/null +++ b/src/Settings/SCIExcludedChatsViewController.h @@ -0,0 +1,4 @@ +#import + +@interface SCIExcludedChatsViewController : UIViewController +@end diff --git a/src/Settings/SCIExcludedChatsViewController.m b/src/Settings/SCIExcludedChatsViewController.m new file mode 100644 index 0000000..aa2ae18 --- /dev/null +++ b/src/Settings/SCIExcludedChatsViewController.m @@ -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 *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 *_) { + 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 diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index f5f1b57..53b7b98 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -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": @[ diff --git a/src/Tweak.x b/src/Tweak.x index 88a6102..4035022 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -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];