From 7782ca34b38ef8789ae539753d531df0cfef586b Mon Sep 17 00:00:00 2001 From: faroukbmiled Date: Mon, 6 Apr 2026 17:53:45 +0100 Subject: [PATCH] - Fixed keep deleted messages - Unsent message indicator: visual "Unsent" label on preserved messages - Unsent message notification pill when a message is preserved - Reorganized DM settings into sub-pages (keep deleted messages, read receipts) --- README.md | 6 +- .../StoriesAndMessages/KeepDeletedMessages.x | 308 +++++++++++++++++- src/InstagramHeaders.h | 3 + src/Settings/TweakSettings.m | 29 +- src/Tweak.x | 4 +- 5 files changed, 330 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b685300..f9d514d 100644 --- a/README.md +++ b/README.md @@ -78,9 +78,10 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - *Customize hold time for long-press* ### Stories and messages -- Keep deleted messages +- Keep deleted messages (preserves unsent messages with visual indicator and notification pill) **\*** - Manually mark messages as seen (button or toggle mode) **\*** - Auto mark seen on send (marks messages as read when you send any message) **\*** +- Send audio as file — send audio files as voice messages from the DM plus menu **\*** - Disable typing status - Unlimited replay of direct stories - Disable view-once limitations @@ -120,6 +121,9 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com ### Optimization - Automatically clears unneeded cache folders, reducing the size of your Instagram installation +## Known Issues +- Preserved unsent messages cannot be removed using "Delete for you". Pull to refresh in the DMs tab clears all preserved messages as a workaround. + # Opening Tweak Settings | | | diff --git a/src/Features/StoriesAndMessages/KeepDeletedMessages.x b/src/Features/StoriesAndMessages/KeepDeletedMessages.x index 014b66d..7367456 100644 --- a/src/Features/StoriesAndMessages/KeepDeletedMessages.x +++ b/src/Features/StoriesAndMessages/KeepDeletedMessages.x @@ -1,22 +1,302 @@ #import "../../Utils.h" #import "../../InstagramHeaders.h" +#import +#import -%hook IGDirectRealtimeIrisThreadDelta -+ (id)removeItemWithMessageId:(id)arg1 { - if ([SCIUtils getBoolPref:@"keep_deleted_message"]) { - arg1 = NULL; +// ============ KEEP DELETED MESSAGES ============ +// Blocks remote unsends while allowing local deletes. +// IGDirectMessageUpdate._removeMessages_reason: 0 = unsend, 2 = delete-for-you. +// Delete-for-you fires reason=2 then reason=0 follow-up — tracked with a counter. +// Remote unsend fires only reason=0 — blocked when counter is 0 and not local. + +static BOOL sciKeepDeletedEnabled() { + return [SCIUtils getBoolPref:@"keep_deleted_message"]; +} + +static BOOL sciIndicateUnsentEnabled() { + return [SCIUtils getBoolPref:@"indicate_unsent_messages"]; +} + +static void sciUpdateCellIndicator(id cell); +static BOOL sciLocalDeleteInProgress = NO; +static NSMutableArray *sciPendingUpdates = nil; +static NSInteger sciDeleteForYouCount = 0; +static NSMutableSet *sciPreservedIds = nil; + +#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds" +#define SCI_PRESERVED_MAX 200 +#define SCI_PRESERVED_TAG 1399 + +static NSMutableSet *sciGetPreservedIds() { + if (!sciPreservedIds) { + NSArray *saved = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_PRESERVED_IDS_KEY]; + sciPreservedIds = saved ? [NSMutableSet setWithArray:saved] : [NSMutableSet set]; + } + return sciPreservedIds; +} + +static void sciSavePreservedIds() { + NSMutableSet *ids = sciGetPreservedIds(); + while (ids.count > SCI_PRESERVED_MAX) + [ids removeObject:[ids anyObject]]; + [[NSUserDefaults standardUserDefaults] setObject:[ids allObjects] forKey:SCI_PRESERVED_IDS_KEY]; +} + +static void sciClearPreservedIds() { + [sciGetPreservedIds() removeAllObjects]; + [[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY]; +} + +// ============ 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 (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array]; + @synchronized(sciPendingUpdates) { + [sciPendingUpdates addObject:instance]; + while (sciPendingUpdates.count > 10) + [sciPendingUpdates removeObjectAtIndex:0]; + } + } + return instance; +} + +// ============ REMOTE UNSEND DETECTION ============ + +static BOOL sciConsumeRemoteUnsend() { + if (!sciPendingUpdates) return NO; + + BOOL shouldBlock = NO; + @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); + } + + if (reason == 2) { + sciDeleteForYouCount++; + continue; + } + + if (reason == 0 && !sciLocalDeleteInProgress) { + if (sciDeleteForYouCount > 0) { + sciDeleteForYouCount--; + continue; + } + for (id key in keys) { + Ivar sidIvar = class_getInstanceVariable([key class], "_messageServerId"); + if (sidIvar) { + NSString *sid = object_getIvar(key, sidIvar); + if ([sid isKindOfClass:[NSString class]] && sid.length > 0) + [sciGetPreservedIds() addObject:sid]; + } + } + sciSavePreservedIds(); + shouldBlock = YES; + break; + } + } @catch(id e) {} + } + [sciPendingUpdates removeAllObjects]; + } + return shouldBlock; +} + +// ============ CACHE UPDATE HOOK ============ + +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() && sciConsumeRemoteUnsend()) { + dispatch_async(dispatch_get_main_queue(), ^{ + // Update visible cell indicators + 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]; + } + } + + // Show pill notification + 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]; + }]; + }); + } + } + }); + return; + } + orig_applyUpdates(self, _cmd, updates, completion, userAccess); +} + +// ============ LOCAL DELETE TRACKING ============ + +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) { + 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(), ^{ + sciLocalDeleteInProgress = NO; + }); +} + +// ============ VISUAL INDICATOR ============ + +static NSString * _Nullable sciGetCellServerId(id cell) { + @try { + Ivar vmIvar = class_getInstanceVariable([cell class], "_viewModel"); + if (!vmIvar) return nil; + id vm = object_getIvar(cell, vmIvar); + if (!vm) return nil; + + SEL metaSel = NSSelectorFromString(@"messageMetadata"); + if (![vm respondsToSelector:metaSel]) return nil; + id meta = ((id(*)(id,SEL))objc_msgSend)(vm, metaSel); + if (!meta) return nil; + + Ivar keyIvar = class_getInstanceVariable([meta class], "_key"); + if (!keyIvar) return nil; + id keyObj = object_getIvar(meta, keyIvar); + if (!keyObj) return nil; + + Ivar sidIvar = class_getInstanceVariable([keyObj class], "_serverId"); + if (!sidIvar) return nil; + NSString *serverId = object_getIvar(keyObj, sidIvar); + return [serverId isKindOfClass:[NSString class]] ? serverId : nil; + } @catch(id e) {} + return nil; +} + +static void sciUpdateCellIndicator(id cell) { + UIView *view = (UIView *)cell; + UIView *oldIndicator = [view viewWithTag:SCI_PRESERVED_TAG]; + + if (!sciIndicateUnsentEnabled()) { + if (oldIndicator) [oldIndicator removeFromSuperview]; + return; } - return %orig(arg1); -} -%end + NSString *serverId = sciGetCellServerId(cell); + BOOL isPreserved = serverId && [sciGetPreservedIds() containsObject:serverId]; -%hook IGDirectMessageUpdate -+ (id)removeMessageWithMessageId:(id)arg1{ - if ([SCIUtils getBoolPref:@"keep_deleted_message"]) { - arg1 = NULL; + if (isPreserved) { + if (!oldIndicator) { + Ivar bubbleIvar = class_getInstanceVariable([cell class], "_messageContentContainerView"); + UIView *bubble = bubbleIvar ? object_getIvar(cell, bubbleIvar) : nil; + UIView *anchor = bubble ?: view; + + UILabel *label = [[UILabel alloc] init]; + label.tag = SCI_PRESERVED_TAG; + label.text = @"Unsent"; + label.font = [UIFont italicSystemFontOfSize:10]; + label.textColor = [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.9]; + label.translatesAutoresizingMaskIntoConstraints = NO; + [view addSubview:label]; + + [NSLayoutConstraint activateConstraints:@[ + [label.leadingAnchor constraintEqualToAnchor:anchor.trailingAnchor constant:4], + [label.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor], + ]]; + } + } else if (oldIndicator) { + [oldIndicator removeFromSuperview]; + } +} + +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); + sciUpdateCellIndicator(self); +} + +// ============ RUNTIME HOOKS ============ + +%ctor { + Class msgUpdateClass = NSClassFromString(@"IGDirectMessageUpdate"); + if (msgUpdateClass) { + MSHookMessageEx(object_getClass(msgUpdateClass), @selector(alloc), + (IMP)new_msgUpdate_alloc, (IMP *)&orig_msgUpdate_alloc); + } + + Class cacheClass = NSClassFromString(@"IGDirectCacheUpdatesApplicator"); + if (cacheClass) { + SEL sel = NSSelectorFromString(@"_applyThreadUpdates:completion:userAccess:"); + if (class_getInstanceMethod(cacheClass, sel)) + MSHookMessageEx(cacheClass, sel, (IMP)new_applyUpdates, (IMP *)&orig_applyUpdates); + } + + Class cellClass = NSClassFromString(@"IGDirectMessageCell"); + if (cellClass) { + SEL configSel = NSSelectorFromString(@"configureWithViewModel:ringViewSpecFactory:launcherSet:"); + if (class_getInstanceMethod(cellClass, configSel)) + MSHookMessageEx(cellClass, configSel, + (IMP)new_configureCell, (IMP *)&orig_configureCell); + } + + Class removeMutationClass = NSClassFromString(@"IGDirectMessageOutgoingUpdateRemoveMessagesMutationProcessor"); + if (removeMutationClass) { + SEL execSel = NSSelectorFromString(@"executeWithResultHandler:accessoryPackage:"); + if (class_getInstanceMethod(removeMutationClass, execSel)) + MSHookMessageEx(removeMutationClass, execSel, + (IMP)new_removeMutation_execute, (IMP *)&orig_removeMutation_execute); + } + + if (![SCIUtils getBoolPref:@"indicate_unsent_messages"]) { + sciClearPreservedIds(); } - - return %orig(arg1); } -%end \ No newline at end of file diff --git a/src/InstagramHeaders.h b/src/InstagramHeaders.h index 365bfef..3f04510 100644 --- a/src/InstagramHeaders.h +++ b/src/InstagramHeaders.h @@ -500,6 +500,9 @@ @interface IGDirectMessageSenderFeatureController : NSObject @end +@interface MDCoreDelta : NSObject +@end + @interface IGTabBarButton : UIButton - (void)addHandleLongPress; // new @end diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index 7fc98ca..1933a26 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -139,10 +139,31 @@ navSections:@[@{ @"header": @"Messages", @"rows": @[ - [SCISetting switchCellWithTitle:@"Keep deleted messages" subtitle:@"Saves deleted messages in chat conversations" defaultsKey:@"keep_deleted_message"], - [SCISetting switchCellWithTitle:@"Manually mark messages as seen" subtitle:@"Adds a button to DM threads, which will mark messages as seen" defaultsKey:@"remove_lastseen"], - [SCISetting menuCellWithTitle:@"Read receipt mode" subtitle:@"How the seen button behaves" menu:[self menus][@"seen_mode"]], - [SCISetting switchCellWithTitle:@"Auto: mark seen on interact" subtitle:@"Locally marks messages as seen when you send a message or react" defaultsKey:@"seen_auto_on_interact"], + [SCISetting navigationCellWithTitle:@"Keep deleted messages" + subtitle:@"Preserve messages that others unsend" + icon:nil + navSections:@[@{ + @"header": @"", + @"footer": @"Pull to refresh in the messages tab will remove preserved messages", + @"rows": @[ + [SCISetting switchCellWithTitle:@"Keep deleted messages" subtitle:@"Preserves messages that others unsend" defaultsKey:@"keep_deleted_message"], + [SCISetting switchCellWithTitle:@"Indicate unsent messages" subtitle:@"Shows an \"Unsent\" label on preserved messages" defaultsKey:@"indicate_unsent_messages"], + [SCISetting switchCellWithTitle:@"Unsent message notification" subtitle:@"Shows a notification pill when a message is unsent" defaultsKey:@"unsent_message_toast"], + ] + }] + ], + [SCISetting navigationCellWithTitle:@"Read receipts" + subtitle:@"Control when messages are marked as seen" + icon:nil + navSections:@[@{ + @"header": @"", + @"rows": @[ + [SCISetting switchCellWithTitle:@"Manually mark messages as seen" subtitle:@"Adds a button to DM threads to mark messages as seen" defaultsKey:@"remove_lastseen"], + [SCISetting menuCellWithTitle:@"Read receipt mode" subtitle:@"How the seen button behaves" menu:[self menus][@"seen_mode"]], + [SCISetting switchCellWithTitle:@"Auto mark seen on interact" subtitle:@"Locally marks messages as seen when you send any message" defaultsKey:@"seen_auto_on_interact"], + ] + }] + ], [SCISetting switchCellWithTitle:@"Disable typing status" subtitle:@"Prevents the typing indicator from being shown to others when you're typing in DMs" defaultsKey:@"disable_typing_status"], [SCISetting switchCellWithTitle:@"Send audio as file" subtitle:@"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages" defaultsKey:@"send_audio_as_file"], ] diff --git a/src/Tweak.x b/src/Tweak.x index f9f23e0..4da9215 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -49,7 +49,9 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"send_audio_as_file": @(YES), @"unlock_password_reels": @(YES), @"seen_mode": @"button", - @"seen_auto_on_interact": @(YES) + @"seen_auto_on_interact": @(YES), + @"indicate_unsent_messages": @(NO), + @"unsent_message_toast": @(NO) }; [[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults];