mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-07-24 21:20:51 +02:00
2977873932
- 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
305 lines
13 KiB
Plaintext
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);
|
|
}
|