From ae6f70e47c1f314701e0472bad9008a27ec7659b Mon Sep 17 00:00:00 2001 From: faroukbmiled Date: Thu, 9 Apr 2026 00:32:09 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Long-press=20menu=20on=20the=20DM=20see?= =?UTF-8?q?n=20button=20for=20quick=20actions=20Fix:=20Prevent=20play/paus?= =?UTF-8?q?e=20patch=20from=20triggering=20Instagram=E2=80=99s=20=E2=80=9C?= =?UTF-8?q?Reel=20has=20no=20sound=E2=80=9D=20message=20when=20forcing=20a?= =?UTF-8?q?udio=20on=20silent=20Reels=20imp:=20Un-exclude=20button=20in=20?= =?UTF-8?q?excluded=20chats=20(toggleable)=20imp:=20Show=20error=20for=20u?= =?UTF-8?q?nsupported=20audio=20files=20that=20can't=20be=20processed=20?= =?UTF-8?q?=20-=20we=20can=20add=20ffmpeg=20at=20some=20point=20chore:=20R?= =?UTF-8?q?emove=20qr=20code=20text=20from=20import/export=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 +- src/Features/Reels/EnhancedPlayback.xm | 67 ++++++++- .../DownloadAudioMessage.xm | 29 ++-- src/Features/StoriesAndMessages/SeenButtons.x | 133 +++++++++++++++++- .../StoriesAndMessages/SendAudioAsFile.xm | 87 +++++++++--- src/Settings/TweakSettings.m | 7 +- src/Tweak.x | 3 +- src/Utils.h | 1 + src/Utils.m | 30 +++- 9 files changed, 308 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index c8f92af..467992e 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Hide trailing action buttons on preserved messages - Warn before clearing on refresh — optional confirmation when pulling to refresh the DMs tab if preserved messages would be cleared **\*** - Manually mark messages as seen (button or toggle mode) **\*** +- Long-press the seen button for quick actions **\*** - Auto mark seen on send (marks messages as read when you send any message) **\*** - 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 **\*** @@ -131,8 +132,8 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location ### Backup & Restore **\*** -- Export RyukGram settings as a JSON file or scannable QR code -- Import settings from a file in Files or a QR code from your photo library +- Export RyukGram settings as a JSON file +- Import settings from a JSON file - Searchable, collapsible, editable preview before saving or applying ### Optimization diff --git a/src/Features/Reels/EnhancedPlayback.xm b/src/Features/Reels/EnhancedPlayback.xm index 63c6951..0ff8900 100644 --- a/src/Features/Reels/EnhancedPlayback.xm +++ b/src/Features/Reels/EnhancedPlayback.xm @@ -112,31 +112,60 @@ static void sciSetPlayViewOpacity(id cell, CGFloat opacity) { } } -// Force unmute by calling _didTapSoundButton on the section controller +// Swallow IG's "no sound" toast and remember the media so we don't retry it. +static NSString * const SCINoSoundToastText = @"This reel has no sound."; +static BOOL sciSuppressNoSoundToast = NO; +static BOOL sciSawNoSoundDuringUnmute = NO; +static NSMutableSet *sciNoAudioMediaIds = nil; + +static NSString *sciMediaIdFor(id media) { + if (!media) return nil; + for (NSString *k in @[@"pk", @"mediaPk", @"mediaID", @"mpk"]) { + @try { + id v = [media valueForKey:k]; + if (v) return [NSString stringWithFormat:@"%@", v]; + } @catch (__unused id e) {} + } + return nil; +} + static void sciForceUnmuteCell(id videoCell) { if (!videoCell) return; Ivar delegateIvar = class_getInstanceVariable([videoCell class], "_delegate"); if (!delegateIvar) return; id sectionCtrl = object_getIvar(videoCell, delegateIvar); if (!sectionCtrl) return; + + Ivar mediaIvar = class_getInstanceVariable([sectionCtrl class], "_media"); + id media = mediaIvar ? object_getIvar(sectionCtrl, mediaIvar) : nil; + NSString *mediaId = sciMediaIdFor(media); + if (mediaId && [sciNoAudioMediaIds containsObject:mediaId]) return; + SEL isAudioSel = NSSelectorFromString(@"isAudioEnabled"); if (![sectionCtrl respondsToSelector:isAudioSel]) return; BOOL audioOn = ((BOOL(*)(id,SEL))objc_msgSend)(sectionCtrl, isAudioSel); if (audioOn) return; + SEL tapSel = NSSelectorFromString(@"_didTapSoundButton"); - if ([sectionCtrl respondsToSelector:tapSel]) { - ((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel); + if (![sectionCtrl respondsToSelector:tapSel]) return; + + sciSuppressNoSoundToast = YES; + sciSawNoSoundDuringUnmute = NO; + ((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel); + sciSuppressNoSoundToast = NO; + + if (sciSawNoSoundDuringUnmute && mediaId) { + if (!sciNoAudioMediaIds) sciNoAudioMediaIds = [NSMutableSet new]; + [sciNoAudioMediaIds addObject:mediaId]; } } %hook IGSundialViewerVideoCell -// Video playing/unpausing — use hidden (IG sets hidden=NO on next pause) +// hidden=YES on play; IG resets it on the next pause. - (void)sundialVideoPlaybackViewDidStartPlaying:(id)view { %orig; if (sciIsPausePlayMode()) { sciHidePlayView(self); - // Force unmute if in reels tab — this fires when the video ACTUALLY starts - // playing, guaranteed to have a ready section controller if (sciIsInReelsTab) sciForceUnmuteCell(self); } } @@ -226,7 +255,7 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) { - (void)viewDidAppear:(BOOL)animated { %orig; sciIsInReelsTab = YES; - // Force unmute first reel — retry until the cell is ready + // Retry-until-ready: the first reel's cell may not be wired up yet. if (sciIsPausePlayMode()) { id feedVC = self; for (int i = 0; i < 10; i++) { @@ -256,6 +285,30 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) { } %end +%hook UILabel +- (void)setText:(NSString *)text { + if (sciSuppressNoSoundToast && [text isEqualToString:SCINoSoundToastText]) { + sciSawNoSoundDuringUnmute = YES; + %orig(@""); + self.hidden = YES; + // Container view is attached to a window after we return — detach the + // topmost non-window ancestor on the next tick to remove the outline. + __weak UILabel *weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + UILabel *s = weakSelf; + if (!s) return; + UIView *top = s; + while (top.superview && ![top.superview isKindOfClass:[UIWindow class]]) { + top = top.superview; + } + [top removeFromSuperview]; + }); + return; + } + %orig; +} +%end + // ============ RUNTIME HOOKS ============ %ctor { diff --git a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm index 0561a3c..3aa1d95a 100644 --- a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm +++ b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm @@ -1,8 +1,8 @@ -// 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. +// Download voice messages from DMs. Detects audio messages via the +// menuConfiguration hook, then injects a Download item into the long-press +// PrismMenu. Tries to convert to .m4a; falls back to the source extension +// (e.g. .ogg from web users) if AVFoundation can't decode the format. + #import "../../Utils.h" #import "../../InstagramHeaders.h" #import @@ -17,12 +17,9 @@ static inline id sciDAF(id obj, SEL sel) { 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 @@ -47,10 +44,7 @@ static id sciLastAudioViewModel = nil; %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) +// PrismMenu uses Swift classes with mangled names — hook via MSHookMessageEx in %ctor. static id (*orig_prismMenuView_init3)(id, SEL, NSArray *, id, BOOL); @@ -74,7 +68,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade void (^handler)(void) = ^{ if (!capturedVM) return; - // Audio URL path: vm -> audio (IGDirectAudio) -> _server_audio (IGAudio) -> playbackURL + // vm -> audio (IGDirectAudio) -> _server_audio (IGAudio) -> playbackURL id directAudio = nil; @try { directAudio = [capturedVM valueForKey:@"audio"]; } @catch (NSException *e) {} if (!directAudio) { @@ -112,9 +106,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade return; } - // 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. + // Try to convert to .m4a; on failure (e.g. Ogg/Opus) keep the source extension. NSString *urlExt = [[playbackURL.path pathExtension] lowercaseString]; if (urlExt.length == 0) urlExt = @"m4a"; @@ -150,7 +142,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade present(m4aURL); return; } - // Conversion failed — keep the original file with its real extension. + // Conversion failed — keep the original with its real extension. [[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil]; NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent: [NSString stringWithFormat:@"audio_%@.%@", mediaId, urlExt]]; @@ -166,14 +158,13 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade [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) + // Wrap in IGDSPrismMenuElement: clone _subtype from a sibling, attach the menuItem. id templateEl = elements[0]; id newElement = [[templateEl class] new]; Ivar subtypeIvar = class_getInstanceVariable([templateEl class], "_subtype"); diff --git a/src/Features/StoriesAndMessages/SeenButtons.x b/src/Features/StoriesAndMessages/SeenButtons.x index a7c27a5..5e5ef16 100644 --- a/src/Features/StoriesAndMessages/SeenButtons.x +++ b/src/Features/StoriesAndMessages/SeenButtons.x @@ -71,13 +71,121 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) { // ============ NAV BAR BUTTONS ============ +// Re-runs setRightBarButtonItems with the live items. The hook tags its own +// buttons so they get stripped and rebuilt against the new exclusion state. +static void sciRefreshNavBarItems(UIView *anchor) { + if (!anchor || ![anchor respondsToSelector:@selector(setRightBarButtonItems:)]) return; + NSArray *cur = [(id)anchor performSelector:@selector(rightBarButtonItems)]; + [(id)anchor performSelector:@selector(setRightBarButtonItems:) withObject:cur]; +} + +// Long-press menu shared by the seen button and the un-exclude button. +static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIWindow *window) { + BOOL excluded = threadId && [SCIExcludedThreads isThreadIdExcluded:threadId]; + BOOL seenFeatureOn = [SCIUtils getBoolPref:@"remove_lastseen"]; + + NSMutableArray *items = [NSMutableArray array]; + + if (seenFeatureOn && !excluded) { + BOOL toggleMode = sciIsSeenToggleMode(); + NSString *title; + UIImage *img; + if (toggleMode) { + title = dmSeenToggleEnabled ? @"Disable read receipts" : @"Enable read receipts"; + img = [UIImage systemImageNamed:dmSeenToggleEnabled ? @"eye.slash" : @"eye"]; + } else { + title = @"Mark messages as seen"; + img = [UIImage systemImageNamed:@"eye"]; + } + UIAction *seenAction = [UIAction actionWithTitle:title image:img identifier:nil + handler:^(__kindof UIAction *_) { + UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor]; + if (![nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) return; + if (toggleMode) { + dmSeenToggleEnabled = !dmSeenToggleEnabled; + if (dmSeenToggleEnabled) { + [(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen]; + [SCIUtils showToastForDuration:2.0 title:@"Read receipts enabled"]; + } else { + [SCIUtils showToastForDuration:2.0 title:@"Read receipts disabled"]; + } + } else { + [(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen]; + [SCIUtils showToastForDuration:2.0 title:@"Marked messages as seen"]; + } + }]; + [items addObject:seenAction]; + } + + NSString *toggleTitle = excluded ? @"Remove from exclusion" : @"Add to exclusion"; + UIImage *toggleImg = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"]; + __weak UIView *weakAnchor = anchor; + UIAction *toggle = [UIAction actionWithTitle:toggleTitle image:toggleImg identifier:nil + handler:^(__kindof UIAction *_) { + if (!threadId) return; + if (excluded) { + [SCIExcludedThreads removeThreadId:threadId]; + [SCIUtils showToastForDuration:2.0 title:@"Removed from exclusion"]; + } else { + [SCIExcludedThreads addOrUpdateEntry:@{ @"threadId": threadId, + @"threadName": @"", + @"isGroup": @NO, + @"users": @[] }]; + [SCIUtils showToastForDuration:2.0 title:@"Added to exclusion"]; + } + sciRefreshNavBarItems(weakAnchor); + }]; + if (excluded) toggle.attributes = UIMenuElementAttributesDestructive; + [items addObject:toggle]; + + UIAction *openSettings = [UIAction actionWithTitle:@"Messages settings" + image:[UIImage systemImageNamed:@"gear"] + identifier:nil + handler:^(__kindof UIAction *_) { + UIWindow *win = window; + if (!win) { + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { win = w; break; } + } + } + [SCIUtils showSettingsVC:win atTopLevelEntry:@"Messages"]; + }]; + [items addObject:openSettings]; + + return [UIMenu menuWithTitle:@"" children:items]; +} + %hook IGTallNavigationBarView + +%new - (void)sciUnexcludeButtonHandler:(UIBarButtonItem *)sender { + UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self]; + NSString *tid = sciThreadIdForVC(nearestVC); + if (!tid) return; + + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:@"Remove from exclusion?" + message:@"This chat will resume normal read-receipt behavior." + preferredStyle:UIAlertControllerStyleAlert]; + __weak typeof(self) weakSelf = self; + [alert addAction:[UIAlertAction actionWithTitle:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { + [SCIExcludedThreads removeThreadId:tid]; + [SCIUtils showToastForDuration:2.0 title:@"Removed from exclusion"]; + sciRefreshNavBarItems(weakSelf); + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [nearestVC presentViewController:alert animated:YES completion:nil]; +} - (void)setRightBarButtonItems:(NSArray *)items { + // Strip our own injected buttons so re-running this hook doesn't dupe them. NSMutableArray *new_items = [[items filteredArrayUsingPredicate: - [NSPredicate predicateWithBlock:^BOOL(UIView *value, NSDictionary *_) { + [NSPredicate predicateWithBlock:^BOOL(UIBarButtonItem *value, NSDictionary *_) { + NSString *aid = value.accessibilityIdentifier; + if ([aid isEqualToString:@"sci-seen-btn"] || + [aid isEqualToString:@"sci-unex-btn"] || + [aid isEqualToString:@"sci-visual-btn"]) return NO; if ([SCIUtils getBoolPref:@"hide_reels_blend"]) - return ![value.accessibilityIdentifier isEqualToString:@"blend-button"]; - return true; + return ![aid isEqualToString:@"blend-button"]; + return YES; }] ] mutableCopy]; @@ -88,14 +196,31 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) { 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:)]; + UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"eye"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)]; + seenButton.accessibilityIdentifier = @"sci-seen-btn"; if (sciIsSeenToggleMode()) [seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor]; + seenButton.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window); [new_items addObject:seenButton]; } + // Excluded chats hide the seen button — surface an un-exclude affordance instead. + if ([SCIUtils getBoolPref:@"remove_lastseen"] && navExcluded && + [SCIUtils getBoolPref:@"unexclude_inbox_button"]) { + UIBarButtonItem *unexBtn = [[UIBarButtonItem alloc] + initWithImage:[UIImage systemImageNamed:@"eye.slash.fill"] + style:UIBarButtonItemStylePlain + target:self + action:@selector(sciUnexcludeButtonHandler:)]; + unexBtn.accessibilityIdentifier = @"sci-unex-btn"; + unexBtn.tintColor = SCIUtils.SCIColor_Primary; + unexBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window); + [new_items addObject:unexBtn]; + } + if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded) { UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)]; + dmVisualMsgsViewedButton.accessibilityIdentifier = @"sci-visual-btn"; [new_items addObject:dmVisualMsgsViewedButton]; [dmVisualMsgsViewedButton setTintColor:dmVisualMsgsViewedButtonEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor]; } diff --git a/src/Features/StoriesAndMessages/SendAudioAsFile.xm b/src/Features/StoriesAndMessages/SendAudioAsFile.xm index d7e2876..9cba0cc 100644 --- a/src/Features/StoriesAndMessages/SendAudioAsFile.xm +++ b/src/Features/StoriesAndMessages/SendAudioAsFile.xm @@ -1,6 +1,8 @@ -// Send audio file as voice message in DMs -// Injects native "Upload Audio" item into the DM plus menu via IGDSMenuItem, -// presents file/video picker with trim support, converts to AAC, sends through IG's voice pipeline. +// Send audio/video files as voice messages in DMs. +// Injects an Upload Audio item into the DM plus menu, runs the file through a +// trim UI, transcodes to AAC m4a (or passes formats IG accepts as-is), then +// hands the URL to IG's native voice pipeline. + #import "../../Utils.h" #import "../../InstagramHeaders.h" #import @@ -18,13 +20,13 @@ static BOOL sciDMMenuPending = NO; #pragma mark - Send audio through IG pipeline +static NSSet *sciPassthroughAudioExts(void); + static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) { AVAsset *asset = [AVAsset assetWithURL:audioURL]; double duration = CMTimeGetSeconds(asset.duration); - if (duration <= 0) { - [SCIUtils showErrorHUDWithDescription:@"Invalid audio duration"]; - return; - } + // AVFoundation returns 0/NaN for containers it can't parse (e.g. Ogg). + if (duration <= 0 || isnan(duration)) duration = 1.0; id voiceController = sciAF(threadVC, @selector(voiceController)); id voiceRecordVC = nil; @@ -33,7 +35,6 @@ static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) { voiceRecordVC = vrIvar ? object_getIvar(voiceController, vrIvar) : nil; } - // generate waveform id waveform = nil; Class wfClass = NSClassFromString(@"IGDirectAudioWaveform"); NSMutableArray *fallbackArr = [NSMutableArray array]; @@ -101,19 +102,60 @@ static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) { #pragma mark - Audio conversion with optional trim +// Unified failure alert: explains why, lets the user try sending raw, and links +// to the GitHub issues page for format requests. +static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewController *threadVC) { + NSString *fileExt = [[url pathExtension] lowercaseString]; + NSString *displayExt = (fileExt.length > 0) ? [NSString stringWithFormat:@".%@", fileExt] : @"This file"; + NSString *title = [NSString stringWithFormat:@"%@ can't be converted", displayExt]; + NSString *msg = [NSString stringWithFormat: + @"iOS audio APIs couldn't process this file%@%@\n\n" + "You can try sending it to Instagram as-is — IG's server may accept it " + "(e.g. Opus/Ogg from web users), or it may silently fail.\n\n" + "If you'd like RyukGram to support this format natively, open an issue:\n" + "https://github.com/faroukbmiled/RyukGram/issues", + reason.length > 0 ? @":\n" : @".", + reason.length > 0 ? reason : @""]; + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title + message:msg + preferredStyle:UIAlertControllerStyleAlert]; + __weak UIViewController *weakVC = threadVC; + [alert addAction:[UIAlertAction actionWithTitle:@"Send anyway" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) { + sciSendAudioFile(url, weakVC); + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"Open GitHub" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) { + [[UIApplication sharedApplication] + openURL:[NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram/issues"] + options:@{} completionHandler:nil]; + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]]; + + UIViewController *presenter = threadVC ?: [UIApplication sharedApplication].keyWindow.rootViewController; + [presenter presentViewController:alert animated:YES completion:nil]; +} + static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) { BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) && CMTimeGetSeconds(trimRange.duration) > 0; + // Allowlisted formats skip AVFoundation entirely; trim is ignored since + // AVFoundation can't read their timelines anyway. + NSString *ext = [[url pathExtension] lowercaseString]; + if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) { + sciSendAudioFile(url, threadVC); + return; + } + [SCIUtils showToastForDuration:1.5 title:isVideo ? @"Extracting audio..." : @"Converting..."]; dispatch_async(dispatch_get_global_queue(0, 0), ^{ AVAsset *asset = [AVAsset assetWithURL:url]; - - // build composition — extract audio track (works for both audio-only and video files) AVAssetTrack *audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject]; if (!audioTrack) { - dispatch_async(dispatch_get_main_queue(), ^{ [SCIUtils showErrorHUDWithDescription:@"No audio track found"]; }); + dispatch_async(dispatch_get_main_queue(), ^{ + sciShowUnsupportedAlert(url, @"no audio track could be read", threadVC); + }); return; } @@ -124,7 +166,9 @@ static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVide NSError *insertErr = nil; [ct insertTimeRange:sourceRange ofTrack:audioTrack atTime:kCMTimeZero error:&insertErr]; if (insertErr) { - dispatch_async(dispatch_get_main_queue(), ^{ [SCIUtils showErrorHUDWithDescription:@"Failed to process audio"]; }); + dispatch_async(dispatch_get_main_queue(), ^{ + sciShowUnsupportedAlert(url, insertErr.localizedDescription, threadVC); + }); return; } @@ -141,19 +185,28 @@ static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVide if (exp.status == AVAssetExportSessionStatusCompleted) { sciSendAudioFile([NSURL fileURLWithPath:out], threadVC); } else { - [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Export failed: %@", - exp.error.localizedDescription ?: @"unknown"]]; + sciShowUnsupportedAlert(url, exp.error.localizedDescription, threadVC); } }); }]; }); } -// convenience: no trim +// Extensions IG accepts as voice messages without conversion. Append after testing. +// m4a/aac — native iOS recording format +// ogg/opus — what web/desktop IG sends +static NSSet *sciPassthroughAudioExts(void) { + static NSSet *set; + static dispatch_once_t once; + dispatch_once(&once, ^{ + set = [NSSet setWithArray:@[@"m4a", @"aac", @"ogg", @"opus"]]; + }); + return set; +} + static void sciConvertAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo) { NSString *ext = [[url pathExtension] lowercaseString]; - // if audio file already in the right format and no trim needed, send directly - if (!isVideo && ([ext isEqualToString:@"m4a"] || [ext isEqualToString:@"aac"])) { + if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) { sciSendAudioFile(url, threadVC); return; } diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index 53b7b98..922c056 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -193,6 +193,7 @@ [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:@"Auto mark seen on typing" subtitle:@"Marks messages as seen the moment you start typing in a DM (works even when typing status is hidden)" defaultsKey:@"seen_auto_on_typing"], + [SCISetting switchCellWithTitle:@"Un-exclude button in excluded chats" subtitle:@"Show a small eye button in excluded chats to remove them from the exclusion list (with confirmation). Long-press it for more options." defaultsKey:@"unexclude_inbox_button"], ] }] ], @@ -295,15 +296,15 @@ icon:[SCISymbol symbolWithName:@"arrow.up.arrow.down.square"] navSections:@[@{ @"header": @"", - @"footer": @"Export your RyukGram settings to a JSON file or QR code, and import them later from Files or a photo. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.", + @"footer": @"Export your RyukGram settings to a JSON file and import them later. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.", @"rows": @[ [SCISetting buttonCellWithTitle:@"Export settings" - subtitle:@"Save as a file or scannable QR code" + subtitle:@"Save settings as a JSON file" icon:[SCISymbol symbolWithName:@"square.and.arrow.up"] action:^(void) { [SCISettingsBackup presentExport]; } ], [SCISetting buttonCellWithTitle:@"Import settings" - subtitle:@"From a file or QR code in your library" + subtitle:@"Load settings from a JSON file" icon:[SCISymbol symbolWithName:@"square.and.arrow.down"] action:^(void) { [SCISettingsBackup presentImport]; } ] diff --git a/src/Tweak.x b/src/Tweak.x index 4035022..a168874 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -60,7 +60,8 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"unsent_message_toast": @(NO), @"warn_refresh_clears_preserved": @(NO), @"enable_chat_exclusions": @(YES), - @"exclusions_default_keep_deleted": @(NO) + @"exclusions_default_keep_deleted": @(NO), + @"unexclude_inbox_button": @(YES) }; [[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults]; diff --git a/src/Utils.h b/src/Utils.h index 91dc661..388e49d 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -33,6 +33,7 @@ + (void)showQuickLookVC:(NSArray *)items; + (void)showShareVC:(id)item; + (void)showSettingsVC:(UIWindow *)window; ++ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle; // Colours + (UIColor *)SCIColor_Primary; diff --git a/src/Utils.m b/src/Utils.m index 1f0226b..aa60a61 100644 --- a/src/Utils.m +++ b/src/Utils.m @@ -1,5 +1,6 @@ #import "Utils.h" #import "PhotoAlbum.h" +#import "Settings/TweakSettings.h" @implementation SCIUtils @@ -113,10 +114,37 @@ UIViewController *rootController = [window rootViewController]; SCISettingsViewController *settingsViewController = [SCISettingsViewController new]; UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:settingsViewController]; - + [rootController presentViewController:navigationController animated:YES completion:nil]; } +// Open settings and push straight into a named top-level entry (e.g. "Messages"). ++ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle { + UIViewController *rootController = [window rootViewController]; + SCISettingsViewController *root = [SCISettingsViewController new]; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root]; + + NSArray *targetNavSections = nil; + for (NSDictionary *section in [SCITweakSettings sections]) { + for (SCISetting *row in section[@"rows"]) { + if (row.type == SCITableCellNavigation && [row.title isEqualToString:entryTitle]) { + targetNavSections = row.navSections; + break; + } + } + if (targetNavSections) break; + } + + if (targetNavSections) { + SCISettingsViewController *child = [[SCISettingsViewController alloc] + initWithTitle:entryTitle sections:targetNavSections reduceMargin:NO]; + child.title = entryTitle; + [nav pushViewController:child animated:NO]; + } + + [rootController presentViewController:nav animated:YES completion:nil]; +} + // Colours + (UIColor *)SCIColor_Primary { return [UIColor colorWithRed:0/255.0 green:152/255.0 blue:254/255.0 alpha:1];