feat: Long-press menu on the DM seen button for quick actions

Fix: Prevent play/pause patch from triggering Instagram’s “Reel has no sound” message when forcing audio on silent Reels
imp: Un-exclude button in excluded chats (toggleable)
imp: Show error for unsupported audio files that can't be processed  - we can add ffmpeg at some point
chore: Remove qr code text from import/export buttons
This commit is contained in:
faroukbmiled
2026-04-09 00:32:09 +01:00
parent fee6a026b4
commit ae6f70e47c
9 changed files with 308 additions and 54 deletions
+3 -2
View File
@@ -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
+60 -7
View File
@@ -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<NSString *> *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 {
@@ -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 <objc/runtime.h>
@@ -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");
+129 -4
View File
@@ -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<UIMenuElement *> *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 <UIBarButtonItem *> *)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];
}
@@ -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 <objc/runtime.h>
@@ -18,13 +20,13 @@ static BOOL sciDMMenuPending = NO;
#pragma mark - Send audio through IG pipeline
static NSSet<NSString *> *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<NSString *> *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;
}
+4 -3
View File
@@ -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]; }
]
+2 -1
View File
@@ -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];
+1
View File
@@ -33,6 +33,7 @@
+ (void)showQuickLookVC:(NSArray<id> *)items;
+ (void)showShareVC:(id)item;
+ (void)showSettingsVC:(UIWindow *)window;
+ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle;
// Colours
+ (UIColor *)SCIColor_Primary;
+29 -1
View File
@@ -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];