From d03da109417ebf169fd726eb1cdbef46ffea9229 Mon Sep 17 00:00:00 2001 From: faroukbmiled Date: Thu, 9 Apr 2026 04:57:05 +0100 Subject: [PATCH] fix: fixed a bug where manually deleted messages from chats are marked us unsend and are preserved when "keep deleted messages" is on --- .github/workflows/release.yml | 17 +- .gitignore | 1 + README.md | 1 + .../StoriesAndMessages/KeepDeletedMessages.x | 427 ++++++++++++------ 4 files changed, 300 insertions(+), 146 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c42f45..58d664d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,18 +107,23 @@ jobs: if: steps.check_release.outputs.should_release == 'true' id: notes run: | - BODY=$(git log -1 --pretty=%b) + PENDING_FILE="PENDING_CHANGES.md" + PREV_TAG=$(git tag --sort=-creatordate | grep -v "v${VERSION}$" | head -n1 || true) { echo "## Changelog" echo "" - if [ -n "$BODY" ]; then - echo '```' - echo "$BODY" - echo '```' + if [ -f "$PENDING_FILE" ]; then + # Drop the copy-as-commit header lines (HTML comment + [release] token) + # so they don't appear in the published release body. + sed -e '/^$/d' -e '/^\[release\]/d' "$PENDING_FILE" fi echo "" - echo "**[All commits](https://github.com/${{ github.repository }}/commits/main)**" + if [ -n "$PREV_TAG" ]; then + echo "**Full changelog:** [\`${PREV_TAG}\`...\`v${VERSION}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...v${VERSION})" + else + echo "**[All commits](https://github.com/${{ github.repository }}/commits/main)**" + fi } > /tmp/release_notes.md - name: Create Release diff --git a/.gitignore b/.gitignore index e1b2683..fcc380e 100644 --- a/.gitignore +++ b/.gitignore @@ -40,4 +40,5 @@ upstream-scinsta *.dylib deploy.sh PENDING_CHANGES.md +PENDING_CHANGES.md.bk wrapper/ diff --git a/README.md b/README.md index 467992e..31e66ae 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com ## Known Issues - Preserved unsent messages cannot be removed using "Delete for you". Pull to refresh in the DMs tab clears all preserved messages (with optional confirmation if "Warn before clearing on refresh" is enabled). +- "Delete for you" detection uses a ~2 second window after the local action. If a real other-party unsend happens to land in the same window, it may not be preserved. Rare in practice and limited to that specific overlap. # Opening Tweak Settings diff --git a/src/Features/StoriesAndMessages/KeepDeletedMessages.x b/src/Features/StoriesAndMessages/KeepDeletedMessages.x index e91e0a8..f7ed6a7 100644 --- a/src/Features/StoriesAndMessages/KeepDeletedMessages.x +++ b/src/Features/StoriesAndMessages/KeepDeletedMessages.x @@ -5,21 +5,43 @@ #import // 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. // -// _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. +// Pipeline: each iris delta is per-thread, so its threadId is stashed in TLS +// while the orig handler runs. The IGDirectMessageUpdate alloc hook stamps +// new updates with that tid. At apply time we classify each update; remote +// unsends get their _removeMessages_messageKeys cleared in place so IG's +// applicator runs but removes nothing. +// +// _removeMessages_reason: 0 = unsend, 2 = delete-for-you. + +// ============ STATE ============ + +#define SCI_SENDER_MAP_MAX 4000 +#define SCI_CONTENT_CLASSES_MAX 4000 +#define SCI_PENDING_MAX 500 +#define SCI_PRESERVED_MAX 200 +#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds" +#define SCI_PRESERVED_TAG 1399 static NSString * const kSCIDeltaTidTLSKey = @"SCI.currentDeltaTid"; static const void *kSCIUpdateThreadIdKey = &kSCIUpdateThreadIdKey; +static BOOL sciLocalDeleteInProgress = NO; +static NSMutableArray *sciPendingUpdates = nil; +static NSMutableDictionary *sciDeleteForYouKeys = nil; +static NSMutableSet *sciPreservedIds = nil; +static NSMutableDictionary *sciMessageContentClasses = nil; +static NSMutableDictionary *sciSenderPkBySid = nil; +static NSMutableSet *sciPendingLocalSids = nil; + +static void sciUpdateCellIndicator(id cell); + +// ============ HELPERS ============ + static NSString *sciGetCurrentDeltaTid(void) { return [NSThread currentThread].threadDictionary[kSCIDeltaTidTLSKey]; } + static void sciSetCurrentDeltaTid(NSString *tid) { NSMutableDictionary *td = [NSThread currentThread].threadDictionary; if (tid) td[kSCIDeltaTidTLSKey] = tid; @@ -34,21 +56,6 @@ static BOOL sciIndicateUnsentEnabled() { return [SCIUtils getBoolPref:@"indicate_unsent_messages"]; } -static void sciUpdateCellIndicator(id cell); -static BOOL sciLocalDeleteInProgress = NO; -static NSMutableArray *sciPendingUpdates = nil; -static NSMutableDictionary *sciDeleteForYouKeys = nil; -static NSMutableSet *sciPreservedIds = nil; -// 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 500 - -#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds" -#define SCI_PRESERVED_MAX 200 -#define SCI_PRESERVED_TAG 1399 - NSMutableSet *sciGetPreservedIds() { if (!sciPreservedIds) { NSArray *saved = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_PRESERVED_IDS_KEY]; @@ -69,6 +76,26 @@ void sciClearPreservedIds() { [[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY]; } +static NSMutableSet *sciGetPendingLocalSids() { + if (!sciPendingLocalSids) sciPendingLocalSids = [NSMutableSet set]; + return sciPendingLocalSids; +} + +static NSMutableDictionary *sciGetSenderMap() { + if (!sciSenderPkBySid) sciSenderPkBySid = [NSMutableDictionary dictionary]; + return sciSenderPkBySid; +} + +static void sciTrackSenderPk(NSString *sid, NSString *pk) { + if (!sid.length || !pk.length) return; + NSMutableDictionary *m = sciGetSenderMap(); + m[sid] = pk; + if (m.count > SCI_SENDER_MAP_MAX) { + NSArray *keys = [m allKeys]; + for (NSUInteger i = 0; i < keys.count / 10; i++) [m removeObjectForKey:keys[i]]; + } +} + static NSMutableDictionary *sciGetContentClasses() { if (!sciMessageContentClasses) sciMessageContentClasses = [NSMutableDictionary dictionary]; return sciMessageContentClasses; @@ -94,6 +121,47 @@ static BOOL sciIsReactionRelatedMessage(NSString *sid) { [className containsString:@"actionLog"]; } +// Walks IGWindow.userSession.user trying common pk field names. Cached. +static NSString *sciCurrentUserPk() { + static NSString *cached = nil; + if (cached) return cached; + @try { + for (UIWindow *w in [UIApplication sharedApplication].windows) { + id session = nil; + @try { session = [w valueForKey:@"userSession"]; } @catch (__unused id e) {} + if (!session) continue; + id user = nil; + @try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {} + if (!user) continue; + for (NSString *key in @[@"pk", @"instagramUserID", @"instagramUserId", @"userID", @"userId", @"identifier"]) { + @try { + id v = [user valueForKey:key]; + if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) { + cached = [v copy]; + return cached; + } + if ([v isKindOfClass:[NSNumber class]]) { + cached = [[(NSNumber *)v stringValue] copy]; + return cached; + } + } @catch (__unused id e) {} + } + } + } @catch (__unused id e) {} + return nil; +} + +static NSString *sciExtractServerId(id key) { + @try { + Ivar sidIvar = class_getInstanceVariable([key class], "_messageServerId"); + if (sidIvar) { + NSString *sid = object_getIvar(key, sidIvar); + if ([sid isKindOfClass:[NSString class]] && sid.length > 0) return sid; + } + } @catch(id e) {} + return nil; +} + // ============ IRIS DELTA STAMPING ============ static NSString *sciDeltaThreadId(id delta) { @@ -107,8 +175,6 @@ static NSString *sciDeltaThreadId(id delta) { } @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; } @@ -119,8 +185,7 @@ static void new_handleIrisDeltas(id self, SEL _cmd, NSArray *deltas) { } } -// 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. +// Some IG paths bypass the top-level handler and call the per-thread variant. 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; } @@ -134,7 +199,7 @@ static void new_handleIrisDeltasGrouped(id self, SEL _cmd, NSArray *deltas) { 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 ([SCIUtils getBoolPref:@"keep_deleted_message"] && instance) { + if (instance && sciKeepDeletedEnabled()) { NSString *tid = sciGetCurrentDeltaTid(); if (tid) { objc_setAssociatedObject(instance, kSCIUpdateThreadIdKey, tid, @@ -150,32 +215,18 @@ static id new_msgUpdate_alloc(id self, SEL _cmd) { return instance; } - // ============ REMOTE UNSEND DETECTION ============ -static NSString *sciExtractServerId(id key) { - @try { - Ivar sidIvar = class_getInstanceVariable([key class], "_messageServerId"); - if (sidIvar) { - NSString *sid = object_getIvar(key, sidIvar); - if ([sid isKindOfClass:[NSString class]] && sid.length > 0) return sid; - } - } @catch(id e) {} - return nil; -} - static void sciPruneStaleDeleteForYouKeys() { if (!sciDeleteForYouKeys) return; NSDate *cutoff = [NSDate dateWithTimeIntervalSinceNow:-10.0]; - NSArray *allKeys = [sciDeleteForYouKeys allKeys]; - for (NSString *k in allKeys) { + for (NSString *k in [sciDeleteForYouKeys allKeys]) { if ([sciDeleteForYouKeys[k] compare:cutoff] == NSOrderedAscending) [sciDeleteForYouKeys removeObjectForKey:k]; } } -// 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. +// Clear the keys ivar in place — IG's later apply iterates an empty list. static void sciNeuterRemoveUpdate(id update) { @try { Ivar ivar = class_getInstanceVariable([update class], "_removeMessages_messageKeys"); @@ -183,8 +234,6 @@ static void sciNeuterRemoveUpdate(id update) { } @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"); @@ -199,7 +248,8 @@ static void sciProcessOneUpdate(id update, NSMutableSet *preserved) reason = *(long long *)((char *)(__bridge void *)update + off); } - // Delete-for-you initiator — remember keys for the follow-up. + // reason 2 = delete-for-you. Track keys so the reason=0 follow-up + // (if any) can be recognised and let through. if (reason == 2) { NSDate *now = [NSDate date]; for (id key in keys) { @@ -209,15 +259,35 @@ static void sciProcessOneUpdate(id update, NSMutableSet *preserved) return; } - if (reason != 0 || sciLocalDeleteInProgress) return; + if (reason != 0) return; - // Delete-for-you follow-up: every key already tracked → let through. - BOOL allMatched = YES; + // Per-sid intent: sids the user just locally removed via a hooked + // mutation processor. Exact, raceless. Consumed on match. + { + NSMutableSet *pending = sciGetPendingLocalSids(); + BOOL anyIntent = NO; + for (id key in keys) { + NSString *sid = sciExtractServerId(key); + if (sid && [pending containsObject:sid]) { anyIntent = YES; break; } + } + if (anyIntent) { + for (id key in keys) { + NSString *sid = sciExtractServerId(key); + if (sid) [pending removeObject:sid]; + } + return; + } + } + + if (sciLocalDeleteInProgress) return; + + // Delete-for-you follow-up: any tracked key → let the whole batch through. + BOOL anyMatched = NO; for (id key in keys) { NSString *sid = sciExtractServerId(key); - if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; } + if (sid && sciDeleteForYouKeys[sid]) { anyMatched = YES; break; } } - if (allMatched) { + if (anyMatched) { for (id key in keys) { NSString *sid = sciExtractServerId(key); if (sid) [sciDeleteForYouKeys removeObjectForKey:sid]; @@ -225,20 +295,23 @@ static void sciProcessOneUpdate(id update, NSMutableSet *preserved) return; } - // Real remote unsend — preserve, skipping reaction/action-log records. + // Real remote unsend — preserve, skipping reactions/action-logs and + // any message recorded as sent by the current user. + NSString *myPk = sciCurrentUserPk(); for (id key in keys) { NSString *sid = sciExtractServerId(key); if (!sid) continue; if (sciIsReactionRelatedMessage(sid)) continue; + NSString *senderPk = sciGetSenderMap()[sid]; + if (senderPk && myPk && [senderPk isEqualToString:myPk]) 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. +// Classify and neuter every pending update stamped with `tid`. Excluded +// threads are passed through untouched. static NSSet *sciNeuterAndPreserveForThread(NSString *tid) { NSMutableSet *preserved = [NSMutableSet set]; if (!sciPendingUpdates || tid.length == 0) return preserved; @@ -268,6 +341,63 @@ static NSSet *sciNeuterAndPreserveForThread(NSString *tid) { // ============ CACHE UPDATE HOOK ============ +static void sciShowUnsentToast() { + UIView *hostView = [UIApplication sharedApplication].keyWindow; + if (!hostView) return; + + UIView *pill = [[UIView alloc] init]; + pill.backgroundColor = [UIColor colorWithRed:0.85 green:0.15 blue:0.15 alpha:0.95]; + pill.layer.cornerRadius = 18; + pill.layer.shadowColor = [UIColor blackColor].CGColor; + pill.layer.shadowOpacity = 0.4; + pill.layer.shadowOffset = CGSizeMake(0, 2); + pill.layer.shadowRadius = 8; + pill.translatesAutoresizingMaskIntoConstraints = NO; + pill.alpha = 0; + + UILabel *label = [[UILabel alloc] init]; + label.text = @"A message was unsent"; + label.textColor = [UIColor whiteColor]; + label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold]; + label.textAlignment = NSTextAlignmentCenter; + label.translatesAutoresizingMaskIntoConstraints = NO; + [pill addSubview:label]; + [hostView addSubview:pill]; + + [NSLayoutConstraint activateConstraints:@[ + [pill.topAnchor constraintEqualToAnchor:hostView.safeAreaLayoutGuide.topAnchor constant:8], + [pill.centerXAnchor constraintEqualToAnchor:hostView.centerXAnchor], + [pill.heightAnchor constraintEqualToConstant:36], + [label.centerXAnchor constraintEqualToAnchor:pill.centerXAnchor], + [label.centerYAnchor constraintEqualToAnchor:pill.centerYAnchor], + [label.leadingAnchor constraintEqualToAnchor:pill.leadingAnchor constant:20], + [label.trailingAnchor constraintEqualToAnchor:pill.trailingAnchor constant:-20], + ]]; + + [UIView animateWithDuration:0.3 animations:^{ pill.alpha = 1; }]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [UIView animateWithDuration:0.3 animations:^{ pill.alpha = 0; } completion:^(BOOL f) { + [pill removeFromSuperview]; + }]; + }); +} + +static void sciRefreshVisibleCellIndicators() { + Class cellClass = NSClassFromString(@"IGDirectMessageCell"); + if (!cellClass) return; + UIWindow *window = [UIApplication sharedApplication].keyWindow; + NSMutableArray *stack = [NSMutableArray arrayWithObject:window]; + while (stack.count > 0) { + UIView *v = stack.lastObject; + [stack removeLastObject]; + if ([v isKindOfClass:cellClass]) { + sciUpdateCellIndicator(v); + continue; + } + for (UIView *sub in v.subviews) [stack addObject:sub]; + } +} + static void (*orig_applyUpdates)(id self, SEL _cmd, id updates, id completion, id userAccess); static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id userAccess) { if (!sciKeepDeletedEnabled()) { @@ -275,8 +405,6 @@ static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id us return; } - // 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) { @@ -288,84 +416,95 @@ static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id us } } - // 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 the "Unsent" indicator shows immediately. - Class cellClass = NSClassFromString(@"IGDirectMessageCell"); - if (cellClass) { - UIWindow *window = [UIApplication sharedApplication].keyWindow; - NSMutableArray *stack = [NSMutableArray arrayWithObject:window]; - while (stack.count > 0) { - UIView *v = stack.lastObject; - [stack removeLastObject]; - if ([v isKindOfClass:cellClass]) { - sciUpdateCellIndicator(v); - continue; - } - for (UIView *sub in v.subviews) - [stack addObject:sub]; - } - } - - if ([SCIUtils getBoolPref:@"unsent_message_toast"]) { - UIView *hostView = [UIApplication sharedApplication].keyWindow; - if (hostView) { - UIView *pill = [[UIView alloc] init]; - pill.backgroundColor = [UIColor colorWithRed:0.85 green:0.15 blue:0.15 alpha:0.95]; - pill.layer.cornerRadius = 18; - pill.layer.shadowColor = [UIColor blackColor].CGColor; - pill.layer.shadowOpacity = 0.4; - pill.layer.shadowOffset = CGSizeMake(0, 2); - pill.layer.shadowRadius = 8; - pill.translatesAutoresizingMaskIntoConstraints = NO; - pill.alpha = 0; - - UILabel *label = [[UILabel alloc] init]; - label.text = @"A message was unsent"; - label.textColor = [UIColor whiteColor]; - label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold]; - label.textAlignment = NSTextAlignmentCenter; - label.translatesAutoresizingMaskIntoConstraints = NO; - [pill addSubview:label]; - - [hostView addSubview:pill]; - - [NSLayoutConstraint activateConstraints:@[ - [pill.topAnchor constraintEqualToAnchor:hostView.safeAreaLayoutGuide.topAnchor constant:8], - [pill.centerXAnchor constraintEqualToAnchor:hostView.centerXAnchor], - [pill.heightAnchor constraintEqualToConstant:36], - [label.centerXAnchor constraintEqualToAnchor:pill.centerXAnchor], - [label.centerYAnchor constraintEqualToAnchor:pill.centerYAnchor], - [label.leadingAnchor constraintEqualToAnchor:pill.leadingAnchor constant:20], - [label.trailingAnchor constraintEqualToAnchor:pill.trailingAnchor constant:-20], - ]]; - - [UIView animateWithDuration:0.3 animations:^{ pill.alpha = 1; }]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - [UIView animateWithDuration:0.3 animations:^{ pill.alpha = 0; } completion:^(BOOL f) { - [pill removeFromSuperview]; - }]; - }); - } - } + sciRefreshVisibleCellIndicators(); + if ([SCIUtils getBoolPref:@"unsent_message_toast"]) sciShowUnsentToast(); }); } } // ============ LOCAL DELETE TRACKING ============ +// Hooked on the unsend mutation processor. Reads target sids straight off +// _messageKeys for the per-sid intent path; the time-window flag stays as +// a safety net for any sid the extraction may miss. static void (*orig_removeMutation_execute)(id self, SEL _cmd, id handler, id pkg); static void new_removeMutation_execute(id self, SEL _cmd, id handler, id pkg) { + @try { + Ivar mkIvar = class_getInstanceVariable([self class], "_messageKeys"); + id keys = mkIvar ? object_getIvar(self, mkIvar) : nil; + if ([keys isKindOfClass:[NSArray class]]) { + static const char *kSidNames[] = {"_serverId", "_messageServerId"}; + for (id k in (NSArray *)keys) { + NSString *sid = nil; + for (int ni = 0; ni < 2; ni++) { + Ivar sidIvar = class_getInstanceVariable([k class], kSidNames[ni]); + if (sidIvar) { + id v = object_getIvar(k, sidIvar); + if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) { + sid = v; break; + } + } + } + if (sid) [sciGetPendingLocalSids() addObject:sid]; + } + } + } @catch (__unused id e) {} + sciLocalDeleteInProgress = YES; orig_removeMutation_execute(self, _cmd, handler, pkg); - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ sciLocalDeleteInProgress = NO; }); } +// Sweeps every IGDirect*Outgoing*MutationProcessor and wraps its execute. +// IGDirectGenericOutgoingMutationProcessor is the empirical DFY signal — +// it fires for "Delete for you" but not for sends (sends use the *GraphQL* +// or NonMedia variants). Other classes are wrapped only when their name +// suggests removal, as a defensive net. Each class gets its own block so +// origImp is captured per-class. +static void sciHookAllRemovalMutationProcessors(void) { + unsigned int count = 0; + Class *all = objc_copyClassList(&count); + if (!all) return; + SEL execSel = NSSelectorFromString(@"executeWithResultHandler:accessoryPackage:"); + Class baseUnsend = NSClassFromString(@"IGDirectMessageOutgoingUpdateRemoveMessagesMutationProcessor"); + for (unsigned int i = 0; i < count; i++) { + Class c = all[i]; + const char *cn = class_getName(c); + if (!cn) continue; + if (c == baseUnsend) continue; + if (strstr(cn, "MutationProcessor") == NULL) continue; + if (strstr(cn, "IGDirect") == NULL) continue; + if (strstr(cn, "Outgoing") == NULL) continue; + Method m = class_getInstanceMethod(c, execSel); + if (!m) continue; + + BOOL isDfySignal = (strcmp(cn, "IGDirectGenericOutgoingMutationProcessor") == 0); + BOOL looksLikeRemoval = (strstr(cn, "Remove") != NULL || + strstr(cn, "Delete") != NULL || + strstr(cn, "Hide") != NULL || + strstr(cn, "Visibility") != NULL); + if (!isDfySignal && !looksLikeRemoval) continue; + + __block IMP origImp = method_getImplementation(m); + IMP newImp = imp_implementationWithBlock(^(id self, id handler, id pkg) { + sciLocalDeleteInProgress = YES; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + sciLocalDeleteInProgress = NO; + }); + ((void(*)(id, SEL, id, id))origImp)(self, execSel, handler, pkg); + }); + IMP prev = class_replaceMethod(c, execSel, newImp, method_getTypeEncoding(m)); + if (prev) origImp = prev; + } + free(all); +} + // ============ VISUAL INDICATOR ============ static NSString * _Nullable sciGetCellServerId(id cell) { @@ -393,18 +532,12 @@ static NSString * _Nullable sciGetCellServerId(id cell) { return nil; } -// Hide trailing action buttons (forward, share, AI, etc.) on preserved cells — -// they don't work on preserved messages and overlap the "Unsent" label. -// _tappableAccessoryViews holds the inner tap targets; their visible wrapper -// (gray circle) is the closest squarish ancestor. - static BOOL sciCellIsPreserved(id cell) { NSString *sid = sciGetCellServerId(cell); return sid && [sciGetPreservedIds() containsObject:sid]; } -// Returns the closest squarish ancestor (32-60 pt, roughly equal width/height), -// which is the visible button wrapper. Falls back to the view itself. +// Closest squarish ancestor (32-60pt, ~equal w/h) — the visible button wrapper. static UIView *sciFindAccessoryWrapper(UIView *view) { UIView *cur = view; while (cur && cur.superview) { @@ -418,6 +551,8 @@ static UIView *sciFindAccessoryWrapper(UIView *view) { return view; } +// Hide trailing action buttons on preserved cells — they don't work and +// overlap the "Unsent" label. static void sciSetTrailingButtonsHidden(UIView *cell, BOOL hidden) { if (!cell) return; Ivar accIvar = class_getInstanceVariable([cell class], "_tappableAccessoryViews"); @@ -466,7 +601,6 @@ static void sciUpdateCellIndicator(id cell) { } sciSetTrailingButtonsHidden(view, YES); - if (oldIndicator) return; UIView *parent = bubble ?: view; @@ -487,6 +621,27 @@ static void sciUpdateCellIndicator(id cell) { static void (*orig_configureCell)(id self, SEL _cmd, id vm, id ringSpec, id launcherSet); static void new_configureCell(id self, SEL _cmd, id vm, id ringSpec, id launcherSet) { orig_configureCell(self, _cmd, vm, ringSpec, launcherSet); + // Capture serverId -> senderPk for every configured cell so the apply + // hook can identify "from me" messages and skip preserving them. + @try { + Ivar vmIvar = class_getInstanceVariable([self class], "_viewModel"); + id vmObj = vmIvar ? object_getIvar(self, vmIvar) : nil; + SEL metaSel = NSSelectorFromString(@"messageMetadata"); + id meta = (vmObj && [vmObj respondsToSelector:metaSel]) + ? ((id(*)(id,SEL))objc_msgSend)(vmObj, metaSel) : nil; + if (meta) { + Ivar keyIvar = class_getInstanceVariable([meta class], "_key"); + id keyObj = keyIvar ? object_getIvar(meta, keyIvar) : nil; + Ivar sidIvar = keyObj ? class_getInstanceVariable([keyObj class], "_serverId") : NULL; + NSString *sid = sidIvar ? object_getIvar(keyObj, sidIvar) : nil; + + Ivar pkIvar = class_getInstanceVariable([meta class], "_senderPk"); + id pk = pkIvar ? object_getIvar(meta, pkIvar) : nil; + if ([sid isKindOfClass:[NSString class]] && [pk isKindOfClass:[NSString class]]) { + sciTrackSenderPk(sid, pk); + } + } + } @catch (__unused id e) {} sciUpdateCellIndicator(self); } @@ -497,13 +652,9 @@ static void new_cellLayoutSubviews(id self, SEL _cmd) { } // ============ ACTION LOG TRACKING ============ -// -// IGDirectThreadActionLog is the local data-model class for "X liked a -// message" thread entries. IG instantiates one whenever an action log row -// is created — reaction add/remove, theme change, etc. We hook its full -// init, grab the message ID via the messageId getter, and store the class -// name in our content-class map. Later when a remove for that ID arrives, -// the consume path recognizes it as bookkeeping and skips preserving it. + +// IGDirectThreadActionLog is the local model for "X liked a message" rows. +// Recording the message id lets the unsend path skip these as bookkeeping. static id (*orig_actionLogFullInit)(id, SEL, id, id, id, id, id, BOOL, BOOL, id); static id new_actionLogFullInit(id self, SEL _cmd, id message, id title, id textAttributes, id textParts, @@ -525,9 +676,6 @@ static id new_actionLogFullInit(id self, SEL _cmd, // ============ RUNTIME HOOKS ============ %ctor { - // Action log entries (e.g. "X liked a message") — record their message IDs - // when IG creates them so we can later recognize a remove for those IDs as - // action-log bookkeeping rather than a real unsend. Class actionLogCls = NSClassFromString(@"IGDirectThreadActionLog"); if (actionLogCls) { SEL fullInit = NSSelectorFromString(@"initWithMessage:title:textAttributes:textParts:actionLogType:collapsible:hidden:genAIMetadata:"); @@ -541,7 +689,6 @@ static id new_actionLogFullInit(id self, SEL _cmd, (IMP)new_msgUpdate_alloc, (IMP *)&orig_msgUpdate_alloc); } - Class cacheClass = NSClassFromString(@"IGDirectCacheUpdatesApplicator"); if (cacheClass) { SEL sel = NSSelectorFromString(@"_applyThreadUpdates:completion:userAccess:"); @@ -554,14 +701,12 @@ static id new_actionLogFullInit(id self, SEL _cmd, SEL sel1 = NSSelectorFromString(@"handleIrisDeltas:"); if (class_getInstanceMethod(irisClass, sel1)) MSHookMessageEx(irisClass, sel1, - (IMP)new_handleIrisDeltas, - (IMP *)&orig_handleIrisDeltas); + (IMP)new_handleIrisDeltas, (IMP *)&orig_handleIrisDeltas); SEL sel2 = NSSelectorFromString(@"_handleIrisDeltasGroupedByThread:"); if (class_getInstanceMethod(irisClass, sel2)) MSHookMessageEx(irisClass, sel2, - (IMP)new_handleIrisDeltasGrouped, - (IMP *)&orig_handleIrisDeltasGrouped); + (IMP)new_handleIrisDeltasGrouped, (IMP *)&orig_handleIrisDeltasGrouped); } Class cellClass = NSClassFromString(@"IGDirectMessageCell"); @@ -589,6 +734,8 @@ static id new_actionLogFullInit(id self, SEL _cmd, (IMP)new_removeMutation_execute, (IMP *)&orig_removeMutation_execute); } + sciHookAllRemovalMutationProcessors(); + if (![SCIUtils getBoolPref:@"indicate_unsent_messages"]) { sciClearPreservedIds(); }