From 349053194187bf609f9c5d2747a2fe32d183d7db Mon Sep 17 00:00:00 2001 From: faroukbmiled Date: Mon, 6 Apr 2026 21:39:46 +0100 Subject: [PATCH] - Addede Download voice messages from DMs - Fixed Unset text position changing when i hold down on the unset message - Fixed upload audio button not showing in some chats --- README.md | 1 + .../DownloadAudioMessage.xm | 188 ++++++++++++++++++ .../StoriesAndMessages/KeepDeletedMessages.x | 11 +- .../StoriesAndMessages/SendAudioAsFile.xm | 12 +- src/Settings/TweakSettings.m | 1 + src/Tweak.x | 1 + 6 files changed, 204 insertions(+), 10 deletions(-) create mode 100644 src/Features/StoriesAndMessages/DownloadAudioMessage.xm diff --git a/README.md b/README.md index f9d514d..e2fe144 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - 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 **\*** +- Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\*** - Disable typing status - Unlimited replay of direct stories - Disable view-once limitations diff --git a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm new file mode 100644 index 0000000..8eead9a --- /dev/null +++ b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm @@ -0,0 +1,188 @@ +// Download voice messages from DMs +// Hooks IGDirectMessageMenuConfiguration to detect audio messages, then injects a "Download" +// item into the IGDSPrismMenuView long-press menu. Downloads the audio via the playbackURL +// on IGAudio (accessed through vm -> audio -> _server_audio), converts mp4 to m4a, and +// presents a share sheet. +#import "../../Utils.h" +#import "../../InstagramHeaders.h" +#import +#import +#import +#import +#import "../../Downloader/Download.h" + +typedef id (*SCIMsgSendId)(id, SEL); +static inline id sciDAF(id obj, SEL sel) { + if (!obj || ![obj respondsToSelector:sel]) return nil; + return ((SCIMsgSendId)objc_msgSend)(obj, sel); +} + +// Flag set by menuConfig hook when a voice_media menu is about to be created +static BOOL sciAudioMenuPending = NO; +static id sciLastAudioViewModel = nil; + +#pragma mark - Detect audio message long-press + +// Demangled: IGDirectMessageMenuConfiguration.IGDirectMessageMenuConfiguration +%hook _TtC32IGDirectMessageMenuConfiguration32IGDirectMessageMenuConfiguration + ++ (id)menuConfigurationWithEligibleOptions:(id)options + messageViewModel:(id)arg2 + contentType:(id)arg3 + isSticker:(_Bool)arg4 + isMusicSticker:(_Bool)arg5 + directNuxManager:(id)arg6 + sessionUserDefaults:(id)arg7 + launcherSet:(id)arg8 + userSession:(id)arg9 + tapHandler:(id)arg10 +{ + if ([SCIUtils getBoolPref:@"download_audio_message"] && + [arg3 isKindOfClass:[NSString class]] && [arg3 isEqualToString:@"voice_media"]) { + sciAudioMenuPending = YES; + sciLastAudioViewModel = arg2; + } + return %orig; +} + +%end + +#pragma mark - Inject Download item into PrismMenu + +// PrismMenu uses Swift classes — must use MSHookMessageEx with runtime class lookup +// (dot-notation names like liquid glass hooks in Tweak.x) + +static id (*orig_prismMenuView_init3)(id, SEL, NSArray *, id, BOOL); + +static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id header, BOOL edr) { + if (!sciAudioMenuPending) return orig_prismMenuView_init3(self, _cmd, elements, header, edr); + sciAudioMenuPending = NO; + + if (![SCIUtils getBoolPref:@"download_audio_message"]) + return orig_prismMenuView_init3(self, _cmd, elements, header, edr); + + Class builderClass = NSClassFromString(@"IGDSPrismMenuItemBuilder"); + Class elementClass = NSClassFromString(@"IGDSPrismMenuElement"); + if (!builderClass || !elementClass || elements.count == 0) + return orig_prismMenuView_init3(self, _cmd, elements, header, edr); + + typedef id (*InitFn)(id, SEL, id); + typedef id (*WithFn)(id, SEL, id); + typedef id (*BuildFn)(id, SEL); + + id capturedVM = sciLastAudioViewModel; + void (^handler)(void) = ^{ + if (!capturedVM) return; + + // Audio URL path: vm -> audio (IGDirectAudio) -> _server_audio (IGAudio) -> playbackURL + id directAudio = nil; + @try { directAudio = [capturedVM valueForKey:@"audio"]; } @catch (NSException *e) {} + if (!directAudio) { + [SCIUtils showErrorHUDWithDescription:@"Could not get audio data. Try again after refreshing the chat."]; + return; + } + + Ivar serverAudioIvar = class_getInstanceVariable([directAudio class], "_server_audio"); + id serverAudio = serverAudioIvar ? object_getIvar(directAudio, serverAudioIvar) : nil; + if (!serverAudio) { + [SCIUtils showErrorHUDWithDescription:@"Audio not loaded yet. Play the message first and try again."]; + return; + } + + NSURL *playbackURL = sciDAF(serverAudio, @selector(playbackURL)); + if (!playbackURL) playbackURL = sciDAF(serverAudio, @selector(fallbackURL)); + if (!playbackURL) { + [SCIUtils showErrorHUDWithDescription:@"No audio URL found. Try again after refreshing the chat."]; + return; + } + + UIView *topView = [UIApplication sharedApplication].keyWindow; + SCIDownloadPillView *pill = [[SCIDownloadPillView alloc] init]; + [pill setText:@"Downloading audio..."]; + [pill showInView:topView]; + + NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] + downloadTaskWithURL:playbackURL + completionHandler:^(NSURL *tempURL, NSURLResponse *response, NSError *error) { + if (error || !tempURL) { + dispatch_async(dispatch_get_main_queue(), ^{ + [pill dismiss]; + [SCIUtils showErrorHUDWithDescription:error.localizedDescription ?: @"Download failed. Try again."]; + }); + 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]; + + // 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]; + 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]; + + UIActivityViewController *shareVC = [[UIActivityViewController alloc] + initWithActivityItems:@[finalURL] + applicationActivities:nil]; + UIViewController *top = [UIApplication sharedApplication].keyWindow.rootViewController; + while (top.presentedViewController) top = top.presentedViewController; + [top presentViewController:shareVC animated:YES completion:nil]; + }); + }]; + }]; + [task resume]; + }; + + // Build menu item via IGDSPrismMenuItemBuilder + id builder = ((InitFn)objc_msgSend)([builderClass alloc], @selector(initWithTitle:), @"Download"); + builder = ((WithFn)objc_msgSend)(builder, @selector(withImage:), [UIImage systemImageNamed:@"arrow.down.circle"]); + builder = ((WithFn)objc_msgSend)(builder, @selector(withHandler:), handler); + id menuItem = ((BuildFn)objc_msgSend)(builder, @selector(build)); + if (!menuItem) return orig_prismMenuView_init3(self, _cmd, elements, header, edr); + + // Wrap in IGDSPrismMenuElement (copy _subtype from existing element, set _item_menuItem) + id templateEl = elements[0]; + id newElement = [[templateEl class] new]; + Ivar subtypeIvar = class_getInstanceVariable([templateEl class], "_subtype"); + Ivar itemIvar = class_getInstanceVariable([templateEl class], "_item_menuItem"); + if (!newElement || !subtypeIvar || !itemIvar) + return orig_prismMenuView_init3(self, _cmd, elements, header, edr); + + ptrdiff_t offset = ivar_getOffset(subtypeIvar); + *(uint64_t *)((uint8_t *)(__bridge void *)newElement + offset) = + *(uint64_t *)((uint8_t *)(__bridge void *)templateEl + offset); + object_setIvar(newElement, itemIvar, menuItem); + + NSMutableArray *newElements = [NSMutableArray arrayWithObject:newElement]; + [newElements addObjectsFromArray:elements]; + return orig_prismMenuView_init3(self, _cmd, newElements, header, edr); +} + +%ctor { + Class prismMenuView = objc_getClass("IGDSPrismMenu.IGDSPrismMenuView"); + if (prismMenuView) { + SEL sel = @selector(initWithMenuElements:headerText:edrEnabled:); + if ([prismMenuView instancesRespondToSelector:sel]) + MSHookMessageEx(prismMenuView, sel, (IMP)new_prismMenuView_init3, (IMP *)&orig_prismMenuView_init3); + } +} diff --git a/src/Features/StoriesAndMessages/KeepDeletedMessages.x b/src/Features/StoriesAndMessages/KeepDeletedMessages.x index 7367456..e5a12fe 100644 --- a/src/Features/StoriesAndMessages/KeepDeletedMessages.x +++ b/src/Features/StoriesAndMessages/KeepDeletedMessages.x @@ -238,7 +238,7 @@ static void sciUpdateCellIndicator(id cell) { if (!oldIndicator) { Ivar bubbleIvar = class_getInstanceVariable([cell class], "_messageContentContainerView"); UIView *bubble = bubbleIvar ? object_getIvar(cell, bubbleIvar) : nil; - UIView *anchor = bubble ?: view; + UIView *parent = bubble ?: view; UILabel *label = [[UILabel alloc] init]; label.tag = SCI_PRESERVED_TAG; @@ -246,11 +246,14 @@ static void sciUpdateCellIndicator(id cell) { 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]; + // Add as subview of the bubble so it moves with the bubble during + // long-press context menu animation (otherwise it stays on the cell + // and gets exposed behind the bubble). + [parent addSubview:label]; [NSLayoutConstraint activateConstraints:@[ - [label.leadingAnchor constraintEqualToAnchor:anchor.trailingAnchor constant:4], - [label.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor], + [label.leadingAnchor constraintEqualToAnchor:parent.trailingAnchor constant:4], + [label.centerYAnchor constraintEqualToAnchor:parent.centerYAnchor], ]]; } } else if (oldIndicator) { diff --git a/src/Features/StoriesAndMessages/SendAudioAsFile.xm b/src/Features/StoriesAndMessages/SendAudioAsFile.xm index 2be9d38..d7e2876 100644 --- a/src/Features/StoriesAndMessages/SendAudioAsFile.xm +++ b/src/Features/StoriesAndMessages/SendAudioAsFile.xm @@ -14,6 +14,7 @@ static inline id sciAF(id obj, SEL sel) { } static __weak UIViewController *sciAudioThreadVC = nil; +static BOOL sciDMMenuPending = NO; #pragma mark - Send audio through IG pipeline @@ -554,12 +555,10 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) { - (id)initWithMenuItems:(NSArray *)items edr:(BOOL)edr headerLabelText:(id)header { if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return %orig; - BOOL isDMMenu = NO; - for (id item in items) { - id title = sciAF(item, @selector(title)); - if ([title isKindOfClass:[NSString class]] && [title isEqualToString:@"Location"]) { isDMMenu = YES; break; } - } - if (!isDMMenu) return %orig; + // Only inject into DM plus menus — sciDMMenuPending is set right before + // this menu is created by the composer overflow button callback + if (!sciDMMenuPending) return %orig; + sciDMMenuPending = NO; for (id item in items) { id title = sciAF(item, @selector(title)); @@ -599,6 +598,7 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) { %orig; if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return; sciAudioThreadVC = self; + sciDMMenuPending = YES; } // file picker delegate — show trim UI diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index 1933a26..bdb6e43 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -166,6 +166,7 @@ ], [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"], + [SCISetting switchCellWithTitle:@"Download voice messages" subtitle:@"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" defaultsKey:@"download_audio_message"], ] }, @{ diff --git a/src/Tweak.x b/src/Tweak.x index 4da9215..d1e3406 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -47,6 +47,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"doom_scrolling_reel_count": @(1), @"no_seen_visual": @(YES), @"send_audio_as_file": @(YES), + @"download_audio_message": @(NO), @"unlock_password_reels": @(YES), @"seen_mode": @"button", @"seen_auto_on_interact": @(YES),