- 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
This commit is contained in:
faroukbmiled
2026-04-06 21:39:46 +01:00
parent 7782ca34b3
commit 3490531941
6 changed files with 204 additions and 10 deletions
+1
View File
@@ -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
@@ -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 <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
#import <AVFoundation/AVFoundation.h>
#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);
}
}
@@ -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) {
@@ -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
+1
View File
@@ -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"],
]
},
@{
+1
View File
@@ -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),