Files
RyukGram/src/Features/StoriesAndMessages/NotesActions.x
T
faroukbmiled 2977873932 [release] RyukGram v1.2.2
- Profile Analyzer (beta) — follower/following scans with mutuals, non-followbacks, new/lost trackers, and profile change history; searchable lists with batch follow/unfollow
- Theme settings — force dark mode, Full OLED, OLED chat theme, and keyboard theme picker
- Confirm story like
- Confirm story emoji reaction
- Swipe down to dismiss media viewer
- Manually add users to story/chat exclusion lists by username
- Keep stories visually seen locally
- Auto-scroll reels mode
- Quality picker: audio-only and raw photo download rows
- Clear cache button with optional auto-clear interval
- Spanish, Russian, Korean, Arabic, and Chinese (Traditional) translations
- About page with version, credits, and links
- Release notes popup on first launch of a new version
- Anonymous live viewing
- Toggle live comments
- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle
- Hide RyukGram UI on screenshots, screen recordings, and mirroring
- Open link from clipboard — long-press the search tab
- Messages-only mode: optional "Hide tab bar" sub-toggle
- Fake profile stats — verified badge and follower/following/post counts on your own profile
- Language switcher + import/export localization from Debug
- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting
- Force legacy Quiz sticker back into the story composer tray
- Advanced experimental features menu — toggle hidden IG experiments (QuickSnap, Homecoming, Prism, Direct Notes reply types) with apply-on-restart batching and a crash-loop auto-reset
- Shortcut to Advanced experimental features from the General experimental features section

- Push notifications render with rich previews on sideload again
- IG 426 compatibility across story audio toggle, like confirmation, seen-on-like, live comments, notes audio download
- Call confirm split into separate voice-call and video-call toggles
- Messages-only mode: tab swiping disabled
- Settings quick-access broken in non-English languages
- Story seen-receipt block restored on IG v426
- Block selected mode no longer marks listed stories as seen
- Hide explore posts grid works again on recent IG versions
- Hide suggested stories no longer breaks profile highlights
- Hide trending searches now also hides the category chip bar
- Story eye long-press menu opens next to the button
- Disable video autoplay: tap-to-play now works on videos inside carousels
- Disable vanish mode swipe fixed on IG 426

- "Confirm shh mode" renamed to "Confirm vanish mode" across all languages
- Confirm sticker interaction split into separate story and highlight toggles
- Shared link embed presets: added eeinstagram.com and vxinstagram.com
- Downloaded media filenames follow `@username_context_timestamp`
- Reels pause mode: optional tap-to-mute on photo reels
- Backup & Restore — scope picker with live preview for Settings / Excluded lists / Analyzer data
- Profile Analyzer: filter by Not verified
- Settings header: tap to open a sheet with GitHub and Telegram channel links

- Thanks to Furamako for the Spanish translation
- Thanks to [ZomkaDEV](https://github.com/ZomkaDEV) for the Russian translation
- Thanks to [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) for the Korean translation
- Thanks to [@bruuhim](https://github.com/bruuhim) for the Arabic translation
- Thanks to [@jaydenjcpy](https://github.com/jaydenjcpy) for the Chinese (Traditional) translation
- Thanks to [@darthplagueiswise](https://github.com/darthplagueiswise) (Radan) for the experimental flag feature set
- Thanks to [@asdfzxcvbn](https://github.com/asdfzxcvbn) for [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject) and [ipapatch](https://github.com/asdfzxcvbn/ipapatch)

- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh clears them (warning available in settings)
- "Delete for you" detection uses a ~2s window after the local action — a real unsend landing in that window may be missed (rare)
- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures
2026-04-24 02:50:30 +01:00

305 lines
13 KiB
Plaintext

// Notes actions — copy text, download GIF/audio from notes long-press menu.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
@interface SCIDownloadDelegate (NotesExt)
- (void)downloadDidFinishWithFileURL:(NSURL *)fileURL;
@end
// Find the note model matching a username from visible tray cells
static id sciFindNoteForUser(UIView *root, NSString *username) {
NSMutableArray *q = [NSMutableArray arrayWithObject:root];
int scanned = 0;
while (q.count && scanned < 500) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
NSString *cls = NSStringFromClass([cur class]);
if (![cls containsString:@"NotesTray"] && ![cls containsString:@"NotesUser"]) {
for (UIView *s in cur.subviews) [q addObject:s];
continue;
}
unsigned int cnt = 0;
Ivar *ivars = class_copyIvarList([cur class], &cnt);
for (unsigned int i = 0; i < cnt; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(cur, ivars[i]);
if (!val || ![val respondsToSelector:NSSelectorFromString(@"note")]) continue;
id note = [val valueForKey:@"note"];
if (!note || ![note respondsToSelector:@selector(text)]) continue;
NSString *noteUser = nil;
@try {
id uf = [note valueForKey:@"userFields"];
if ([uf respondsToSelector:NSSelectorFromString(@"username")])
noteUser = [uf valueForKey:@"username"];
} @catch (__unused id e) {}
if (!username || [noteUser isEqualToString:username])
{ free(ivars); return note; }
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
for (UIView *s in cur.subviews) [q addObject:s];
}
return nil;
}
// Find the cell view model for a specific note, return the cell view
static UIView *sciFindCellForNote(UIView *root, id targetNote) {
NSMutableArray *q = [NSMutableArray arrayWithObject:root];
int scanned = 0;
while (q.count && scanned < 300) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
if (![NSStringFromClass([cur class]) containsString:@"Notes"]) {
for (UIView *s in cur.subviews) [q addObject:s];
continue;
}
Ivar vmIvar = class_getInstanceVariable([cur class], "viewModel");
if (!vmIvar) vmIvar = class_getInstanceVariable([cur class], "_viewModel");
if (!vmIvar) { for (UIView *s in cur.subviews) [q addObject:s]; continue; }
id vm = object_getIvar(cur, vmIvar);
if (!vm || ![vm respondsToSelector:NSSelectorFromString(@"note")]) {
for (UIView *s in cur.subviews) [q addObject:s]; continue;
}
if ([vm valueForKey:@"note"] == targetNote) return cur;
for (UIView *s in cur.subviews) [q addObject:s];
}
return nil;
}
// Get GIF image from a cell's IGGIFView only
static UIImage *sciGIFImageFromCell(UIView *cell) {
if (!cell) return nil;
NSMutableArray *q = [NSMutableArray arrayWithObject:cell];
int s = 0;
while (q.count && s < 100) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; s++;
// Only match IGGIFView — not profile pics or other image views
if ([NSStringFromClass([cur class]) containsString:@"GIFView"]) {
if ([cur isKindOfClass:[UIImageView class]]) {
UIImage *img = [(UIImageView *)cur image];
if (img && img.size.width > 20) return img;
}
// Check subviews of GIFView for the actual image view
for (UIView *sub in cur.subviews) {
if ([sub isKindOfClass:[UIImageView class]]) {
UIImage *img = [(UIImageView *)sub image];
if (img && img.size.width > 20) return img;
}
}
}
for (UIView *sub in cur.subviews) [q addObject:sub];
}
return nil;
}
// Audio track from the note cell's view model. 426 added launcherSet.
static id sciAudioTrackFromCell(UIView *cell) {
if (!cell) return nil;
Ivar vmIvar = class_getInstanceVariable([cell class], "viewModel");
if (!vmIvar) vmIvar = class_getInstanceVariable([cell class], "_viewModel");
if (!vmIvar) return nil;
id vm = object_getIvar(cell, vmIvar);
if (!vm) return nil;
SEL audioSel2 = NSSelectorFromString(@"audioTrackWithUserMap:launcherSet:");
SEL audioSel1 = NSSelectorFromString(@"audioTrackWithUserMap:");
@try {
if ([vm respondsToSelector:audioSel2]) {
id session = [SCIUtils activeUserSession];
id launcher = nil;
@try { launcher = session ? [session valueForKey:@"launcherSet"] : nil; } @catch (__unused id e) {}
return ((id(*)(id,SEL,id,id))objc_msgSend)(vm, audioSel2, nil, launcher);
}
if ([vm respondsToSelector:audioSel1]) {
return ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel1, nil);
}
} @catch (__unused id e) {}
return nil;
}
// Pull URL from the track's IGAsyncTask — sync if cached, else async.
static void sciResolveAudioURL(id track, void (^completion)(NSURL *)) {
if (!track || !completion) { if (completion) completion(nil); return; }
id task = nil;
@try {
if ([track respondsToSelector:@selector(audioFileURLTask)])
task = ((id(*)(id,SEL))objc_msgSend)(track, @selector(audioFileURLTask));
} @catch (__unused id e) {}
if (!task) { completion(nil); return; }
@try {
id res = [task valueForKey:@"result"];
if ([res isKindOfClass:[NSURL class]]) { completion(res); return; }
} @catch (__unused id e) {}
SEL onSuccess = NSSelectorFromString(@"onSuccess:");
if (![task respondsToSelector:onSuccess]) { completion(nil); return; }
void (^cb)(id) = ^(id resolved) {
NSURL *u = [resolved isKindOfClass:[NSURL class]] ? resolved : nil;
dispatch_async(dispatch_get_main_queue(), ^{ completion(u); });
};
@try {
((void(*)(id,SEL,id))objc_msgSend)(task, onSuccess, cb);
} @catch (__unused id e) { completion(nil); }
}
static SCIDownloadDelegate *sciNoteDl = nil;
static void (*orig_present)(UIViewController *, SEL, UIViewController *, BOOL, id);
static void hook_present(UIViewController *self, SEL _cmd, UIViewController *vc, BOOL animated, id completion) {
if (![NSStringFromClass([vc class]) isEqualToString:@"IGActionSheetController"]) {
orig_present(self, _cmd, vc, animated, completion);
return;
}
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
if (!actIvar) { orig_present(self, _cmd, vc, animated, completion); return; }
NSArray *actions = object_getIvar(vc, actIvar);
BOOL isNotes = NO;
for (id a in actions) {
if (![a respondsToSelector:@selector(title)]) continue;
NSString *t = [a valueForKey:@"title"];
if ([t isKindOfClass:[NSString class]] && [t containsString:@"Mute notes"])
{ isNotes = YES; break; }
}
if (!isNotes) { orig_present(self, _cmd, vc, animated, completion); return; }
BOOL copyOnHold = [SCIUtils getBoolPref:@"note_copy_on_hold"];
BOOL noteActions = [SCIUtils getBoolPref:@"note_actions"];
if (!copyOnHold && !noteActions) {
orig_present(self, _cmd, vc, animated, completion);
return;
}
// Copy text immediately on long press, then let the menu open normally
if (copyOnHold) {
id note = sciFindNoteForUser(self.view, nil);
NSString *text = nil;
@try { text = [note valueForKey:@"text"]; } @catch (__unused id e) {}
if (text.length) {
[[UIPasteboard generalPasteboard] setString:text];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note text copied")];
}
}
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
if (!actionCls || ![actionCls instancesRespondToSelector:initSel]) {
orig_present(self, _cmd, vc, animated, completion);
return;
}
__weak UIViewController *weakSelf = self;
__weak UIViewController *weakVC = vc;
void (^handler)(void) = ^{
UIViewController *sheet = weakVC;
UIViewController *presenter = weakSelf;
if (!presenter) return;
// Read username from the visible sheet
NSString *user = nil;
if (sheet && sheet.isViewLoaded) {
NSMutableArray *lq = [NSMutableArray arrayWithObject:sheet.view];
int ls = 0;
while (lq.count && ls < 100) {
UIView *cur = lq.firstObject; [lq removeObjectAtIndex:0]; ls++;
if ([cur isKindOfClass:[UILabel class]]) {
NSString *t = [(UILabel *)cur text];
if (t.length > 0 && t.length < 30
&& ![t isEqualToString:@"Cancel"]
&& ![t isEqualToString:@"Report"]
&& ![t isEqualToString:@"Mute notes"]
&& ![t isEqualToString:@"View profile"]
&& ![t isEqualToString:@"Note actions"]) {
user = t; break;
}
}
for (UIView *s in cur.subviews) [lq addObject:s];
}
}
id note = sciFindNoteForUser(presenter.view, user);
if (!note) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Note not found")]; return; }
NSString *text = nil;
@try { text = [note valueForKey:@"text"]; } @catch (__unused id e) {}
UIView *cell = sciFindCellForNote(presenter.view, note);
// Build submenu
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:nil message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
if (text.length) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy text")
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[[UIPasteboard generalPasteboard] setString:text];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note text copied")];
}]];
}
// GIF: save via downloader (respects RyukGram album)
UIImage *gifImage = sciGIFImageFromCell(cell);
if (gifImage) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save GIF")
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
NSData *data = UIImagePNGRepresentation(gifImage);
if (!data) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Failed to encode GIF")]; return; }
NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"note_gif_%@.png", [[NSUUID UUID] UUIDString]]];
[data writeToFile:path atomically:YES];
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
[sciNoteDl downloadDidFinishWithFileURL:[NSURL fileURLWithPath:path]];
}]];
}
id audioTrack = sciAudioTrackFromCell(cell);
if (audioTrack) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Download audio")
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
sciResolveAudioURL(audioTrack, ^(NSURL *audioURL) {
if (!audioURL) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Audio URL not available")];
return;
}
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:NO];
[sciNoteDl downloadFileWithURL:audioURL fileExtension:@"m4a" hudLabel:nil];
});
}]];
}
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel")
style:UIAlertActionStyleCancel handler:nil]];
[sheet dismissViewControllerAnimated:YES completion:^{
[presenter presentViewController:alert animated:YES completion:nil];
}];
};
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
id noteAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
@"Note actions", nil, (NSInteger)0, handler, nil, nil);
if (noteActions && noteAction) {
NSMutableArray *newActions = [actions mutableCopy];
[newActions insertObject:noteAction atIndex:0];
object_setIvar(vc, actIvar, [newActions copy]);
}
orig_present(self, _cmd, vc, animated, completion);
}
%ctor {
MSHookMessageEx([UIViewController class],
@selector(presentViewController:animated:completion:),
(IMP)hook_present, (IMP *)&orig_present);
}