[release] RyukGram v1.2.0

### Features
- **Open Instagram links in app (Safari extension)** — bundled Safari web extension (sideload IPA only). Enable in Safari → Extensions; instagram.com links open in the app.
- **Localization** — every user-facing string flows through a central translation layer. Globe button in Settings; missing keys fall back to English. Ships English only — see the "Translating RyukGram" section in the README to add more.
- **Action buttons** — context-aware menus on feed, reels, and stories (expand, repost, download, copy caption, etc.) with per-context default tap action and carousel/multi-story bulk download
- **Enhanced HD downloads** — up to 1080p via DASH + FFmpegKit with quality picker, preview playback, encoding-speed options, and 720p fallback
- **Repost**, **media viewer**, **media zoom** (long-press), **download pill** (frosted glass, stacks concurrent downloads)
- **Fake location** — overrides CoreLocation app-wide, map picker + saved presets, optional quick-toggle button on the Friends Map
- **Messages-only mode** — strips every tab except DM inbox + profile
- **Launch tab** — pick which tab the app opens to
- Full last active date in DMs — show full date instead of "Active 2h ago"
- Custom date format — 12 formats with per-surface toggles (feed, notes/comments/stories, DMs)
- Send files in DMs (experimental)
- View story mentions
- Hide suggested stories
- Story tray long-press actions — view HD profile picture from the tray menu
- Advance on story reply — auto-skip to next story after sending a reply or reaction
- Mark story as seen on reply or emoji reaction
- Hide metrics (likes, comments, shares counts)
- Hide messages tab
- Hide voice/video call buttons in DM thread header (independent toggles)
- Disable app haptics
- Disable reels tab refresh
- Disable disappearing messages mode in DMs
- Follow indicator — shows whether the profile user follows you
- Copy note text on long press
- Zoom profile photo — long press opens full-screen viewer
- Notes actions — copy text, download GIF/audio from notes long-press menu
- Confirm unfollow
- Feed refresh controls — disable background refresh, home button refresh, and home button scroll

### Improvements
- Default tap action: added copy URL, repost, and view mentions options; dynamic menu generation per context
- Settings pages reordered: General → Feed → Stories → Reels → Messages → Profile → Navigation → Saving → Confirmations
- Fake location picker: native Apple Maps-style UI (search, long-press to drop pin, current location)
- Liquid glass floating tab bar + dynamic sizing
- Upload audio: FFmpegKit re-encode + trim for any audio/video input
- Settings reorganized with per-context action button config; new Profile page
- Highlight cover: full-screen viewer replaces direct download
- Switched HD encoder to `h264_videotoolbox` (hardware) — no GPL FFmpegKit required
- Legacy long-press download deprecated (off by default), replaced by action buttons

### Fixes
- Hide suggested stories no longer removes followed users' stories on scroll
- Settings search bar transparency with liquid glass off; auto-deactivates on push
- HD download cancel: tapping pill aborts in-flight downloads + FFmpeg sessions cleanly
- Download pill stuck state on background/foreground, progress reset per download
- Disappearing messages mode confirmation not firing on swipe
- Detailed color picker not working on story draw `†`
- DM seen toggle menu not updating after tap
- Reel refresh confirmation appearing on first app launch `†`
- Reels action button displacing profile pictures on photo reels
- Disappearing DM media download (expand, share, save to Photos with progress pill)
- Carousel "Download all" not showing item count in feed
- Encoding speed setting being ignored for HD downloads
- Various upstream SCInsta merges (Meta AI hiding, suggested chats hiding, notes tray) — marked `†`

> `†` Merged from upstream [SCInsta](https://github.com/SoCuul/SCInsta) by SoCuul

### Credits
- Thanks to [@erupts0](https://github.com/erupts0) (John) for testing and feature suggestions
- Thanks to [@euoradan](https://t.me/euoradan) (Radan) for experimental Instagram feature flag research
- Safari extension forked/cleaned from [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension)

### Known Issues
- 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)
This commit is contained in:
faroukbmiled
2026-04-16 03:03:30 +01:00
parent 9b2c7dc202
commit 86eaa95019
124 changed files with 11523 additions and 1393 deletions
@@ -0,0 +1,259 @@
// Feed action button — hooks IGUFIInteractionCountsView.
// Media lives on sibling cells (IGFeedItemPhotoCell, IGModernFeedVideoCell)
// in the same collection view section, NOT on the UFI cell itself.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import <objc/runtime.h>
#import <objc/message.h>
static const NSInteger kFeedActionBtnTag = 13370;
static const void *kFeedPageIndexKey = &kFeedPageIndexKey;
// Read _currentMediaPK from IGFeedItemUFICell.
static NSString *sciFeedCurrentMediaPK(UIView *button) {
UIResponder *r = button;
Class ufiCls = NSClassFromString(@"IGFeedItemUFICell");
while (r && !(ufiCls && [r isKindOfClass:ufiCls])) r = [r nextResponder];
if (!r) return nil;
Ivar iv = class_getInstanceVariable(object_getClass(r), "_currentMediaPK");
if (!iv) return nil;
id val = object_getIvar(r, iv);
return [val isKindOfClass:[NSString class]] ? val : nil;
}
// Current carousel page index. Returns -1 if not found.
static NSInteger sciFeedCarouselPageIndex(UIView *button) {
// Walk up to collection view
UIView *v = button;
UICollectionViewCell *ufiCell = nil;
UICollectionView *cv = nil;
while (v) {
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
ufiCell = (UICollectionViewCell *)v;
}
if ([v isKindOfClass:[UICollectionView class]]) {
cv = (UICollectionView *)v;
break;
}
v = v.superview;
}
if (!ufiCell || !cv) return -1;
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
if (!ufiPath) return -1;
NSInteger section = ufiPath.section;
// Find IGFeedItemPageCell in same section
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
NSString *cls = NSStringFromClass([cell class]);
if (![cls containsString:@"Page"]) continue;
// BFS for IGPageMediaView
Class pmvCls = NSClassFromString(@"IGPageMediaView");
if (pmvCls) {
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
int scanned = 0;
UIView *pmv = nil;
while (queue.count && scanned < 50) {
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:pmvCls]) { pmv = cur; break; }
for (UIView *s in cur.subviews) [queue addObject:s];
}
if (pmv && [pmv respondsToSelector:@selector(currentMediaItem)] && [pmv respondsToSelector:@selector(items)]) {
@try {
id current = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(currentMediaItem));
NSArray *items = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(items));
if (current && items.count) {
NSUInteger idx = [items indexOfObjectIdenticalTo:current];
if (idx != NSNotFound) return (NSInteger)idx;
}
} @catch (__unused id e) {}
}
}
// Fallback: _currentIndex ivar on the page cell
Ivar idxIvar = class_getInstanceVariable([cell class], "_currentIndex");
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentPage");
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentMediaIndex");
if (idxIvar) {
ptrdiff_t offset = ivar_getOffset(idxIvar);
NSInteger idx = *(NSInteger *)((char *)(__bridge void *)cell + offset);
return idx;
}
// Fallback: compute page from scroll view content offset
{
NSMutableArray *sq = [NSMutableArray arrayWithObject:cell];
int sc = 0;
while (sq.count && sc < 100) {
UIView *cur = sq.firstObject; [sq removeObjectAtIndex:0]; sc++;
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
UIScrollView *sv = (UIScrollView *)cur;
CGFloat pageW = sv.bounds.size.width;
// Horizontal paging scroll view
if (pageW > 100 && sv.contentSize.width > pageW * 1.5) {
NSInteger idx = (NSInteger)round(sv.contentOffset.x / pageW);
return idx;
}
}
for (UIView *s in cur.subviews) [sq addObject:s];
}
}
}
return -1;
}
// Resolve current carousel child using page index.
static id sciFeedResolveCarouselChild(id parentMedia, UIView *button) {
if (!parentMedia) return nil;
if (![SCIMediaActions isCarouselMedia:parentMedia]) return parentMedia;
NSInteger idx = sciFeedCarouselPageIndex(button);
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
if (idx >= 0 && (NSUInteger)idx < children.count) {
return children[idx];
}
return parentMedia;
}
// Extract IGMedia from sibling cells in the same collection view section.
static IGMedia *sciFeedMediaFromButton(UIView *button) {
if (!button) return nil;
Class mediaClass = NSClassFromString(@"IGMedia");
if (!mediaClass) return nil;
// Walk up to find UFI cell and collection view
UIView *v = button;
UICollectionViewCell *ufiCell = nil;
UICollectionView *cv = nil;
while (v) {
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
ufiCell = (UICollectionViewCell *)v;
}
if ([v isKindOfClass:[UICollectionView class]]) {
cv = (UICollectionView *)v;
break;
}
v = v.superview;
}
if (!ufiCell || !cv) return nil;
// Get section
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
if (!ufiPath) return nil;
NSInteger section = ufiPath.section;
// Search sibling cells for IGMedia
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
if (cell == ufiCell) continue;
// Filter to media cell classes
NSString *cls = NSStringFromClass([cell class]);
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
// Scan ivars for IGMedia
unsigned int count = 0;
Class c = object_getClass(cell);
while (c && c != [UICollectionViewCell class]) {
Ivar *ivars = class_copyIvarList(c, &count);
for (unsigned int i = 0; i < count; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(cell, ivars[i]);
if (val && [val isKindOfClass:mediaClass]) {
free(ivars);
return (IGMedia *)val;
}
// Try .media selector on wrapper objects
if (val && [val respondsToSelector:@selector(media)]) {
id m = ((id(*)(id,SEL))objc_msgSend)(val, @selector(media));
if (m && [m isKindOfClass:mediaClass]) {
free(ivars);
return (IGMedia *)m;
}
}
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
c = class_getSuperclass(c);
}
// Try mediaCellFeedItem (video cells)
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
@try {
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
if (m && [m isKindOfClass:mediaClass]) {
return (IGMedia *)m;
}
} @catch (__unused id e) {}
}
}
return nil;
}
%hook IGUFIInteractionCountsView
- (void)updateUFIWithButtonsConfig:(id)config interactionCountProvider:(id)provider {
%orig;
if (![SCIUtils getBoolPref:@"feed_action_button"]) return;
UIButton *btn = (UIButton *)[self viewWithTag:kFeedActionBtnTag];
if (!btn) {
btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = kFeedActionBtnTag;
UIImageSymbolConfiguration *cfg =
[UIImageSymbolConfiguration configurationWithPointSize:21 weight:UIImageSymbolWeightRegular];
[btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = [UIColor labelColor];
btn.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:btn];
// Position: right side, left of bookmark. Shifted up 4pt to
// align with the native like/comment/share icons.
[NSLayoutConstraint activateConstraints:@[
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-44],
[btn.centerYAnchor constraintEqualToAnchor:self.centerYAnchor constant:-6],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36],
]];
}
// Reconfigure with fresh media provider.
[SCIActionButton configureButton:btn
context:SCIActionContextFeed
prefKey:@"feed_action_default"
mediaProvider:^id (UIView *sourceView) {
id parentMedia = sciFeedMediaFromButton(sourceView);
if (!parentMedia) return nil;
if ([SCIMediaActions isCarouselMedia:parentMedia]) {
NSInteger idx = sciFeedCarouselPageIndex(sourceView);
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
if (idx >= 0 && (NSUInteger)idx < children.count) {
// Stash page index for the menu builder to find the parent.
objc_setAssociatedObject(sourceView, kFeedPageIndexKey,
@(idx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return children[idx];
}
}
return parentMedia;
}];
}
%end
@@ -0,0 +1,171 @@
// Reels action button — injects a RyukGram action button above the reel's
// vertical like/comment/share sidebar (IGSundialViewerVerticalUFI).
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import <objc/runtime.h>
#import <objc/message.h>
static const NSInteger kReelActionBtnTag = 1337;
static UIView *sciFindSuperviewOfClass(UIView *view, NSString *className) {
Class cls = NSClassFromString(className);
if (!cls) return nil;
UIView *current = view.superview;
for (int depth = 0; current && depth < 20; depth++) {
if ([current isKindOfClass:cls]) return current;
current = current.superview;
}
return nil;
}
static id sciFindMediaIvar(UIView *view) {
if (!view) return nil;
Class mediaClass = NSClassFromString(@"IGMedia");
if (!mediaClass) return nil;
unsigned int count = 0;
Ivar *ivars = class_copyIvarList([view class], &count);
id found = nil;
for (unsigned int i = 0; i < count; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(view, ivars[i]);
if (val && [val isKindOfClass:mediaClass]) { found = val; break; }
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
return found;
}
// Resolve the current carousel child from _currentIndex.
static id sciCurrentCarouselChildMedia(UIView *carouselCell, id parentMedia) {
if (!carouselCell || !parentMedia) return parentMedia;
// Try _currentIndex ivar
Ivar idxIvar = class_getInstanceVariable([carouselCell class], "_currentIndex");
NSInteger currentIdx = 0;
if (idxIvar) {
ptrdiff_t offset = ivar_getOffset(idxIvar);
currentIdx = *(NSInteger *)((char *)(__bridge void *)carouselCell + offset);
}
// Fallback: _currentFractionalIndex
if (!idxIvar || currentIdx == 0) {
Ivar fracIvar = class_getInstanceVariable([carouselCell class], "_currentFractionalIndex");
if (fracIvar) {
ptrdiff_t fOffset = ivar_getOffset(fracIvar);
double fracIdx = *(double *)((char *)(__bridge void *)carouselCell + fOffset);
NSInteger roundedIdx = (NSInteger)round(fracIdx);
if (roundedIdx > 0) currentIdx = roundedIdx;
}
}
// Fallback: inner collection view content offset
Ivar cvIvar = class_getInstanceVariable([carouselCell class], "_collectionView");
if (cvIvar) {
UICollectionView *cv = object_getIvar(carouselCell, cvIvar);
if (cv) {
CGFloat pageWidth = cv.bounds.size.width;
if (pageWidth > 0) {
NSInteger cvIdx = (NSInteger)round(cv.contentOffset.x / pageWidth);
if (cvIdx > currentIdx) currentIdx = cvIdx;
}
}
}
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
if (currentIdx >= 0 && (NSUInteger)currentIdx < children.count) {
return children[currentIdx];
}
return parentMedia;
}
// Media provider for reels. Returns current page's child for carousels.
static id sciReelsMediaProvider(UIView *sourceView) {
// Video reel
UIView *videoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerVideoCell");
if (videoCell) {
id m = sciFindMediaIvar(videoCell);
if (m) return m;
}
// Photo reel
UIView *photoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerPhotoCell");
if (photoCell) {
id m = sciFindMediaIvar(photoCell);
if (m) return m;
}
// Carousel reel
UIView *carouselCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerCarouselCell");
if (carouselCell) {
id parentMedia = sciFindMediaIvar(carouselCell);
if (parentMedia) {
return sciCurrentCarouselChildMedia(carouselCell, parentMedia);
}
}
return nil;
}
%hook IGSundialViewerVerticalUFI
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"reels_action_button"]) return;
if (!self.superview) return;
UIButton *btn = (UIButton *)[self viewWithTag:kReelActionBtnTag];
if (!btn) {
btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = kReelActionBtnTag;
UIImageSymbolConfiguration *symCfg =
[UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold];
UIImage *base = [UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:symCfg];
// Bake the drop shadow into a single UIImage so no CALayer shadow is
// applied to the button itself.
CGFloat pad = 8;
CGSize sz = CGSizeMake(base.size.width + pad * 2, base.size.height + pad * 2);
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz];
UIImage *icon = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
CGContextRef c = ctx.CGContext;
CGContextSaveGState(c);
CGContextSetShadowWithColor(c, CGSizeMake(0, 1), 3,
[UIColor colorWithWhite:0 alpha:0.55].CGColor);
UIImage *tinted = [base imageWithTintColor:[UIColor whiteColor]
renderingMode:UIImageRenderingModeAlwaysOriginal];
[tinted drawInRect:CGRectMake(pad, pad, base.size.width, base.size.height)];
CGContextRestoreGState(c);
}];
[btn setImage:icon forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
self.clipsToBounds = NO;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[btn.bottomAnchor constraintEqualToAnchor:self.topAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:40],
[btn.heightAnchor constraintEqualToConstant:40]
]];
}
// Reconfigure with fresh media provider.
[SCIActionButton configureButton:btn
context:SCIActionContextReels
prefKey:@"reels_action_default"
mediaProvider:^id (UIView *sourceView) {
return sciReelsMediaProvider(sourceView);
}];
}
%end
+12 -7
View File
@@ -18,17 +18,22 @@
// Follow button on profile page
%hook IGFollowController
- (void)_didPressFollowButton {
// Get user follow status (check if already following user)
NSInteger UserFollowStatus = self.user.followStatus;
// Only show confirm dialog if user is not following
if (UserFollowStatus == 2) {
NSInteger status = self.user.followStatus;
if (status == 2) {
CONFIRMFOLLOW(%orig);
}
else {
} else {
return %orig;
}
}
// Unfollow from profile action sheet
- (void)_performUnfollow {
if ([SCIUtils getBoolPref:@"unfollow_confirm"]) {
[SCIUtils showConfirmation:^(void) { %orig; } title:SCILocalized(@"Unfollow?")];
} else {
%orig;
}
}
%end
// Follow button on discover people page
+22 -27
View File
@@ -1,33 +1,28 @@
#import "../../Utils.h"
%hook IGDirectDisappearingModeSwipeHandler
- (void)handleBottomSwipeableScrollUpdate {
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return;
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
[SCIUtils showConfirmation:^(void) { %orig; }];
else %orig;
}
- (id)getSwipeableScrollHintTextInfo {
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return nil;
return %orig;
}
%end
%hook IGDirectThreadViewController
- (void)swipeableScrollManagerDidEndDraggingAboveSwipeThreshold:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
NSLog(@"[SCInsta] Confirm shh mode triggered");
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
- (void)shhModeTransitionButtonDidTap:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
NSLog(@"[SCInsta] Confirm shh mode triggered");
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
- (void)messageListViewControllerDidToggleShhMode:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
NSLog(@"[SCInsta] Confirm shh mode triggered");
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
else %orig;
}
%end
- (void)messageListViewControllerDidReplayInShhMode:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
[SCIUtils showConfirmation:^(void) { %orig; }];
else %orig;
}
%end
+185
View File
@@ -0,0 +1,185 @@
// Story tray long-press actions — adds "View profile picture" to the action sheet.
// Fetches HD profile pic via /api/v1/users/{pk}/info/.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import "../../Networking/SCIInstagramAPI.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static __weak id sciLongPressedTrayCell = nil;
// ── Helpers ──
static UIImage *sciProfileImageFromCell(id cell) {
Ivar avIvar = class_getInstanceVariable([cell class], "_avatarView");
if (!avIvar) return nil;
UIView *avatarView = object_getIvar(cell, avIvar);
if (!avatarView) return nil;
Ivar imgIvar = class_getInstanceVariable([avatarView class], "_ownerImageView");
if (!imgIvar) return nil;
UIImageView *imgView = object_getIvar(avatarView, imgIvar);
if ([imgView isKindOfClass:[UIImageView class]]) return imgView.image;
return nil;
}
static NSString *sciUsernameFromCell(id cell) {
@try {
Ivar mi = class_getInstanceVariable([cell class], "_model");
if (!mi) return nil;
id model = object_getIvar(cell, mi);
id title = [model valueForKey:@"title"];
if ([title isKindOfClass:[NSAttributedString class]])
return [[(NSAttributedString *)title string] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
} @catch (NSException *e) {}
return nil;
}
static NSString *sciFullNameFromCell(id cell) {
@try {
Ivar mi = class_getInstanceVariable([cell class], "_model");
if (!mi) return nil;
id model = object_getIvar(cell, mi);
id owner = [model valueForKey:@"reelOwner"];
if (!owner) return nil;
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!ui) return nil;
id igUser = object_getIvar(owner, ui);
Ivar fi = NULL;
for (Class c = [igUser class]; c && !fi; c = class_getSuperclass(c))
fi = class_getInstanceVariable(c, "_fieldCache");
if (!fi) return nil;
id fc = object_getIvar(igUser, fi);
if (![fc isKindOfClass:[NSDictionary class]]) return nil;
id name = [(NSDictionary *)fc objectForKey:@"full_name"];
if ([name isKindOfClass:[NSString class]] && [(NSString *)name length] > 0) return name;
} @catch (NSException *e) {}
return nil;
}
static NSString *sciCaptionFromCell(id cell) {
NSString *username = sciUsernameFromCell(cell);
NSString *fullName = sciFullNameFromCell(cell);
if (username && fullName) return [NSString stringWithFormat:@"%@\n%@", username, fullName];
return username ?: fullName;
}
static NSString *sciUserPKFromCell(id cell) {
@try {
Ivar mi = class_getInstanceVariable([cell class], "_model");
if (!mi) return nil;
id model = object_getIvar(cell, mi);
id owner = [model valueForKey:@"reelOwner"];
if (!owner) return nil;
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!ui) return nil;
id igUser = object_getIvar(owner, ui);
Ivar pi = NULL;
for (Class c = [igUser class]; c && !pi; c = class_getSuperclass(c))
pi = class_getInstanceVariable(c, "_pk");
if (!pi) return nil;
return [object_getIvar(igUser, pi) description];
} @catch (NSException *e) {}
return nil;
}
// Fetch HD profile pic via API, fallback to local avatar
static void sciShowHDProfilePic(NSString *pk, NSString *caption, UIImage *fallback) {
NSString *path = [NSString stringWithFormat:@"users/%@/info/", pk];
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *response, NSError *error) {
if (error || !response) {
if (fallback) {
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
[d writeToFile:p atomically:YES];
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
}
return;
}
NSDictionary *user = response[@"user"];
NSString *hdURL = nil;
NSDictionary *hdInfo = user[@"hd_profile_pic_url_info"];
if ([hdInfo isKindOfClass:[NSDictionary class]]) hdURL = hdInfo[@"url"];
if (!hdURL) {
NSArray *versions = user[@"hd_profile_pic_versions"];
if ([versions isKindOfClass:[NSArray class]] && versions.count > 0)
hdURL = [versions.lastObject objectForKey:@"url"];
}
if (!hdURL) hdURL = user[@"profile_pic_url"];
if (hdURL) {
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL URLWithString:hdURL] caption:caption];
} else if (fallback) {
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
[d writeToFile:p atomically:YES];
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
}
}];
}
// ── Capture long-pressed cell ──
static void (*orig_didLongPressCell)(id, SEL, UIGestureRecognizer *);
static void hook_didLongPressCell(id self, SEL _cmd, UIGestureRecognizer *gesture) {
if (gesture.state == UIGestureRecognizerStateBegan)
sciLongPressedTrayCell = gesture.view;
orig_didLongPressCell(self, _cmd, gesture);
}
// ── Inject action into the sheet ──
static void (*orig_present)(id, SEL, id, BOOL, id);
static void hook_present(id self, SEL _cmd, id vc, BOOL animated, id completion) {
if (sciLongPressedTrayCell && [SCIUtils getBoolPref:@"story_tray_actions"]) {
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
NSArray *actions = actIvar ? object_getIvar(vc, actIvar) : nil;
if (actions) {
id cell = sciLongPressedTrayCell;
sciLongPressedTrayCell = nil;
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
NSString *pk = sciUserPKFromCell(cell);
if (actionCls && pk) {
NSString *caption = sciCaptionFromCell(cell);
UIImage *localPic = sciProfileImageFromCell(cell);
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
void (^handler)(void) = ^{ sciShowHDProfilePic(pk, caption, localPic); };
id action = ((InitFn)objc_msgSend)([actionCls alloc],
@selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:),
@"View profile picture", nil, (NSInteger)0, handler, nil, nil);
if (action) {
NSMutableArray *newActions = [actions mutableCopy];
[newActions insertObject:action atIndex:0];
object_setIvar(vc, actIvar, [newActions copy]);
}
}
}
}
if (sciLongPressedTrayCell) sciLongPressedTrayCell = nil;
orig_present(self, _cmd, vc, animated, completion);
}
%ctor {
Class scCls = NSClassFromString(@"IGStorySectionController");
if (scCls) {
SEL sel = NSSelectorFromString(@"_didLongPressCell:");
if (class_getInstanceMethod(scCls, sel))
MSHookMessageEx(scCls, sel, (IMP)hook_didLongPressCell, (IMP *)&orig_didLongPressCell);
}
MSHookMessageEx([UIViewController class], @selector(presentViewController:animated:completion:),
(IMP)hook_present, (IMP *)&orig_present);
}
+2 -2
View File
@@ -59,7 +59,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
NSMutableArray *extra = [NSMutableArray array];
if (hasText && [SCIUtils getBoolPref:@"copy_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Copy"
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Copy")
image:[UIImage systemImageNamed:@"doc.on.doc"]
identifier:nil
handler:^(__kindof UIAction *_) {
@@ -68,7 +68,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
}
if (hasGif && [SCIUtils getBoolPref:@"download_gif_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Download GIF"
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Download GIF")
image:[UIImage systemImageNamed:@"arrow.down.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
+1 -1
View File
@@ -41,7 +41,7 @@
// Notify user
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
HUD.textLabel.text = @"Copied text to clipboard";
HUD.textLabel.text = SCILocalized(@"Copied text to clipboard");
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
[HUD showInView:topMostController().view];
+1 -1
View File
@@ -27,7 +27,7 @@
UIColorPickerViewController *colorPickerController = [[UIColorPickerViewController alloc] init];
colorPickerController.delegate = (id<UIColorPickerViewControllerDelegate>)self; // cast to suppress warnings
colorPickerController.title = @"Select color";
colorPickerController.title = SCILocalized(@"Select color");
colorPickerController.modalPresentationStyle = UIModalPresentationPopover;
colorPickerController.supportsAlpha = NO;
colorPickerController.selectedColor = self.color;
@@ -0,0 +1,210 @@
// Disable feed refresh — background refresh and home tab refresh.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
static BOOL sciDisableBgRefresh(void) {
return [SCIUtils getBoolPref:@"disable_bg_refresh"];
}
static BOOL sciDisableHomeRefresh(void) {
return [SCIUtils getBoolPref:@"disable_home_refresh"];
}
static BOOL sciDisableHomeScroll(void) {
return [SCIUtils getBoolPref:@"disable_home_scroll"];
}
static BOOL sciDisableReelsRefresh(void) {
return [SCIUtils getBoolPref:@"disable_reels_tab_refresh"];
}
// Returns 999999s when disabled (effectively never), -1 to keep IG's value.
static double sciOverrideInterval(void) {
if (sciDisableBgRefresh()) return 999999;
return -1;
}
// MARK: - Refresh-utility class-method overrides
// IGMainFeedRefreshUtility recomputes the intervals at runtime, ignoring the
// init args on IGMainFeedNetworkSource — override the 4 class methods too.
static double (*orig_wsRefresh)(id, SEL, id, id);
static double new_wsRefresh(id self, SEL _cmd, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_wsRefresh(self, _cmd, ls, store);
}
static double (*orig_wsBgRefresh)(id, SEL, id, id);
static double new_wsBgRefresh(id self, SEL _cmd, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_wsBgRefresh(self, _cmd, ls, store);
}
static double (*orig_peakWsRefresh)(id, SEL, double, id, id);
static double new_peakWsRefresh(id self, SEL _cmd, double iv, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_peakWsRefresh(self, _cmd, iv, ls, store);
}
static double (*orig_peakWsBgRefresh)(id, SEL, id, id);
static double new_peakWsBgRefresh(id self, SEL _cmd, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_peakWsBgRefresh(self, _cmd, ls, store);
}
%ctor {
Class c = NSClassFromString(@"IGMainFeedViewModelUtility.IGMainFeedRefreshUtility");
if (!c) return;
Class meta = object_getClass(c);
SEL s1 = NSSelectorFromString(@"warmStartRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s1))
MSHookMessageEx(meta, s1, (IMP)new_wsRefresh, (IMP *)&orig_wsRefresh);
SEL s2 = NSSelectorFromString(@"warmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s2))
MSHookMessageEx(meta, s2, (IMP)new_wsBgRefresh, (IMP *)&orig_wsBgRefresh);
SEL s3 = NSSelectorFromString(@"onPeakWarmStartRefreshIntervalWithWarmStartFetchInterval:launcherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s3))
MSHookMessageEx(meta, s3, (IMP)new_peakWsRefresh, (IMP *)&orig_peakWsRefresh);
SEL s4 = NSSelectorFromString(@"onPeakWarmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s4))
MSHookMessageEx(meta, s4, (IMP)new_peakWsBgRefresh, (IMP *)&orig_peakWsBgRefresh);
}
// MARK: - Background refresh
%hook IGMainFeedNetworkSource
- (instancetype)initWithDeps:(id)a1
posts:(id)a2
nextMaxID:(id)a3
initialPaginationSource:(id)a4
contentCoordinator:(id)a5
dataSourceSupplementaryItemsProvider:(id)a6
disableAutomaticRefresh:(BOOL)disable
disableSerialization:(BOOL)a8
sessionId:(id)a9
analyticsModule:(id)a10
serializationSuffix:(id)a11
disableFlashFeedTLI:(BOOL)a12
disableFlashFeedOnColdStart:(BOOL)a13
disableResponseDeferral:(BOOL)a14
hidesStoriesTray:(BOOL)a15
isSecondaryFeed:(BOOL)a16
collectionViewBackgroundColorOverride:(id)a17
minWarmStartFetchInterval:(double)a18
peakMinWarmStartFetchInterval:(double)a19
minimumWarmStartBackgroundedInterval:(double)a20
peakMinimumWarmStartBackgroundedInterval:(double)a21
supplementalFeedHoistedMediaID:(id)a22
headerTitleOverride:(id)a23
isInFollowingTab:(BOOL)a24
useShimmerLoadingWhenNoStoriesTray:(BOOL)a25 {
double override = sciOverrideInterval();
if (sciDisableBgRefresh()) disable = YES;
if (override > 0) { a18 = override; a19 = override; a20 = override; a21 = override; }
return %orig(a1, a2, a3, a4, a5, a6, disable, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24, a25);
}
// Getter overrides for instances created before the class hooks landed.
- (double)minWarmStartFetchInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
- (double)peakMinWarmStartFetchInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
- (double)minimumWarmStartBackgroundedInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
- (double)peakMinimumWarmStartBackgroundedInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
%end
// MARK: - Hot start refresh
%hook IGMainFeedViewController
- (void)hotStartRefresh {
if (sciDisableBgRefresh()) return;
%orig;
}
%end
// MARK: - Home tab refresh
%hook IGTabBarController
- (void)_timelineButtonPressed {
BOOL noRefresh = sciDisableHomeRefresh();
BOOL noScroll = sciDisableHomeScroll();
if (!noRefresh && !noScroll) { %orig; return; }
UIViewController *selected = nil;
if ([self respondsToSelector:@selector(selectedViewController)])
selected = [self valueForKey:@"selectedViewController"];
BOOL onFeedTab = NO;
if (selected) {
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
? [(UINavigationController *)selected topViewController] : selected;
onFeedTab = [NSStringFromClass([top class]) containsString:@"MainFeed"];
}
if (!onFeedTab) { %orig; return; }
if (noScroll) return;
// noRefresh only — scroll to top without refreshing.
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
? [(UINavigationController *)selected topViewController] : selected;
NSMutableArray *queue = [NSMutableArray arrayWithObject:top.view];
int scanned = 0;
while (queue.count && scanned < 30) {
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:[UICollectionView class]]) {
UIScrollView *sv = (UIScrollView *)cur;
[sv setContentOffset:CGPointMake(0, -sv.adjustedContentInset.top) animated:YES];
return;
}
for (UIView *s in cur.subviews) [queue addObject:s];
}
}
// MARK: - Reels tab refresh
- (void)_discoverVideoButtonPressed {
if (!sciDisableReelsRefresh()) { %orig; return; }
UIViewController *selected = nil;
if ([self respondsToSelector:@selector(selectedViewController)])
selected = [self valueForKey:@"selectedViewController"];
BOOL onReelsTab = NO;
if (selected) {
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
? [(UINavigationController *)selected topViewController] : selected;
NSString *cls = NSStringFromClass([top class]);
onReelsTab = [cls containsString:@"Sundial"] || [cls containsString:@"Reels"]
|| [cls containsString:@"DiscoverVideo"];
}
if (!onReelsTab) { %orig; return; }
}
%end
+33
View File
@@ -0,0 +1,33 @@
#import "../../Utils.h"
%hook UIImpactFeedbackGenerator
- (void)impactOccurred {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
}
- (void)impactOccurredWithIntensity:(CGFloat)intensity {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(intensity);
}
%end
%hook UINotificationFeedbackGenerator
- (void)notificationOccurred:(UINotificationFeedbackType)notificationType {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(notificationType);
}
%end
%hook UISelectionFeedbackGenerator
- (void)selectionChanged {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
}
%end
%hook CHHapticEngine
- (BOOL)startAndReturnError:(NSError **)outError {
if (![SCIUtils getBoolPref:@"disable_haptics"]) {
return %orig(outError);
}
else {
return NO;
}
}
%end
+49
View File
@@ -0,0 +1,49 @@
// Fake location — overrides CLLocationManager so any IG location read returns our coord.
#import "../../Utils.h"
#import <CoreLocation/CoreLocation.h>
#import <objc/message.h>
static BOOL sciFakeLocOn(void) {
return [SCIUtils getBoolPref:@"fake_location_enabled"];
}
static CLLocation *sciFakeLocation(void) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
double lat = [[d objectForKey:@"fake_location_lat"] doubleValue];
double lon = [[d objectForKey:@"fake_location_lon"] doubleValue];
return [[CLLocation alloc] initWithCoordinate:CLLocationCoordinate2DMake(lat, lon)
altitude:35
horizontalAccuracy:5
verticalAccuracy:5
timestamp:[NSDate date]];
}
static void sciFeedFake(CLLocationManager *mgr) {
id<CLLocationManagerDelegate> d = mgr.delegate;
if (![d respondsToSelector:@selector(locationManager:didUpdateLocations:)]) return;
CLLocation *loc = sciFakeLocation();
NSArray *locs = @[ loc ];
dispatch_async(dispatch_get_main_queue(), ^{
[d locationManager:mgr didUpdateLocations:locs];
});
}
%hook CLLocationManager
- (CLLocation *)location {
if (sciFakeLocOn()) return sciFakeLocation();
return %orig;
}
- (void)startUpdatingLocation {
%orig;
if (sciFakeLocOn()) sciFeedFake(self);
}
- (void)requestLocation {
if (sciFakeLocOn()) { sciFeedFake(self); return; }
%orig;
}
%end
@@ -0,0 +1,260 @@
// Quick fake-location toggle injected into IG's Friends Map (DMs > Maps).
#import "../../Utils.h"
#import "../../Settings/SCIFakeLocationSettingsVC.h"
#import "../../Settings/SCIFakeLocationPickerVC.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static const NSInteger kSciMapBtnTag = 0x5C1F4B;
static UIViewController *sciTopMost(void) {
UIWindow *win = nil;
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *w in ((UIWindowScene *)sc).windows) if (w.isKeyWindow) { win = w; break; }
if (win) break;
}
UIViewController *v = win.rootViewController;
while (v.presentedViewController) v = v.presentedViewController;
return v;
}
static void sciRefreshMapButton(UIView *mapView);
static void sciAddMapButton(UIView *mapView);
static void sciRemoveMapButton(UIView *mapView);
static UIMenu *sciBuildMapMenu(void);
static void sciWalkMapViews(UIView *root, Class mapCls, void (^block)(UIView *)) {
if (!root) return;
if (mapCls && [root isKindOfClass:mapCls]) block(root);
for (UIView *s in root.subviews) sciWalkMapViews(s, mapCls, block);
}
static void sciRefreshActiveMapButton(void) {
Class mapCls = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *w in ((UIWindowScene *)sc).windows) {
sciWalkMapViews(w, mapCls, ^(UIView *mv) {
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
sciRemoveMapButton(mv);
} else {
sciAddMapButton(mv);
sciRefreshMapButton(mv);
}
});
}
}
}
static void sciOpenPickerForCurrent(void) {
UIViewController *top = sciTopMost();
if (!top) return;
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
[[d objectForKey:@"fake_location_lon"] doubleValue]);
vc.titleText = SCILocalized(@"Set location");
vc.onPick = ^(double lat, double lon, NSString *name) {
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
[u setObject:@(lat) forKey:@"fake_location_lat"];
[u setObject:@(lon) forKey:@"fake_location_lon"];
[u setObject:(name ?: @"") forKey:@"fake_location_name"];
if (![u boolForKey:@"fake_location_enabled"]) [u setBool:YES forKey:@"fake_location_enabled"];
sciRefreshActiveMapButton();
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
[top presentViewController:nav animated:YES completion:nil];
}
static void sciOpenPickerForNewPreset(void) {
UIViewController *top = sciTopMost();
if (!top) return;
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
[[d objectForKey:@"fake_location_lon"] doubleValue]);
vc.titleText = SCILocalized(@"Add preset");
vc.onPick = ^(double lat, double lon, NSString *name) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Save preset")
message:nil
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"Name"); tf.text = name; }];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
NSString *n = alert.textFields.firstObject.text.length ? alert.textFields.firstObject.text : name;
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
NSArray *raw = [u objectForKey:@"fake_location_presets"];
NSMutableArray *presets = [raw isKindOfClass:[NSArray class]] ? [raw mutableCopy] : [NSMutableArray array];
[presets addObject:@{@"name": n ?: @"", @"lat": @(lat), @"lon": @(lon)}];
[u setObject:presets forKey:@"fake_location_presets"];
sciRefreshActiveMapButton();
}]];
[sciTopMost() presentViewController:alert animated:YES completion:nil];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
[top presentViewController:nav animated:YES completion:nil];
}
static UIMenu *sciBuildMapMenu(void) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
BOOL enabled = [d boolForKey:@"fake_location_enabled"];
NSString *name = [d objectForKey:@"fake_location_name"] ?: @"(unset)";
// Header section: current location (disabled), enable/disable, change location
UIAction *header = [UIAction actionWithTitle:[NSString stringWithFormat:SCILocalized(@"Current: %@"), name]
image:[UIImage systemImageNamed:@"mappin.and.ellipse"]
identifier:nil handler:^(__unused UIAction *a) {}];
header.attributes = UIMenuElementAttributesDisabled;
UIAction *toggle = [UIAction actionWithTitle:enabled ? SCILocalized(@"Disable") : SCILocalized(@"Enable")
image:[UIImage systemImageNamed:enabled ? @"location.slash.fill" : @"location.fill"]
identifier:nil
handler:^(__unused UIAction *a) {
[d setBool:!enabled forKey:@"fake_location_enabled"];
sciRefreshActiveMapButton();
}];
if (enabled) toggle.attributes = UIMenuElementAttributesDestructive;
UIAction *change = [UIAction actionWithTitle:SCILocalized(@"Change location")
image:[UIImage systemImageNamed:@"map"]
identifier:nil
handler:^(__unused UIAction *a) { sciOpenPickerForCurrent(); }];
UIMenu *headerSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:@[header, toggle, change]];
// Presets + Add
NSMutableArray<UIMenuElement *> *presetItems = [NSMutableArray array];
NSArray *presets = [d objectForKey:@"fake_location_presets"];
if ([presets isKindOfClass:[NSArray class]]) {
for (NSDictionary *p in presets) {
if (![p isKindOfClass:[NSDictionary class]]) continue;
NSString *pname = p[@"name"] ?: @"Preset";
BOOL active = [p[@"name"] isEqualToString:name];
UIAction *act = [UIAction actionWithTitle:pname
image:[UIImage systemImageNamed:@"mappin.circle.fill"]
identifier:nil
handler:^(__unused UIAction *x) {
[d setObject:p[@"lat"] forKey:@"fake_location_lat"];
[d setObject:p[@"lon"] forKey:@"fake_location_lon"];
[d setObject:p[@"name"] ?: @"" forKey:@"fake_location_name"];
if (![d boolForKey:@"fake_location_enabled"]) [d setBool:YES forKey:@"fake_location_enabled"];
sciRefreshActiveMapButton();
}];
if (active) act.state = UIMenuElementStateOn;
[presetItems addObject:act];
}
}
[presetItems addObject:[UIAction actionWithTitle:SCILocalized(@"Add location")
image:[UIImage systemImageNamed:@"plus.circle.fill"]
identifier:nil
handler:^(__unused UIAction *x) { sciOpenPickerForNewPreset(); }]];
UIMenu *presetSection = [UIMenu menuWithTitle:SCILocalized(@"Saved locations") image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:presetItems];
// Settings
UIAction *openSettings = [UIAction actionWithTitle:SCILocalized(@"Settings…")
image:[UIImage systemImageNamed:@"gearshape.fill"]
identifier:nil
handler:^(__unused UIAction *x) {
UIViewController *top = sciTopMost();
if (!top) return;
SCIFakeLocationSettingsVC *vc = [SCIFakeLocationSettingsVC new];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFormSheet;
[top presentViewController:nav animated:YES completion:nil];
}];
UIMenu *settingsSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:@[openSettings]];
return [UIMenu menuWithTitle:SCILocalized(@"Fake location") image:nil identifier:nil options:0
children:@[headerSection, presetSection, settingsSection]];
}
static void sciRemoveMapButton(UIView *mapView) {
UIView *btn = [mapView viewWithTag:kSciMapBtnTag];
if (btn) [btn removeFromSuperview];
}
static void sciAddMapButton(UIView *mapView) {
if (!mapView) return;
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) { sciRemoveMapButton(mapView); return; }
if ([mapView viewWithTag:kSciMapBtnTag]) return;
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = kSciMapBtnTag;
btn.translatesAutoresizingMaskIntoConstraints = NO;
btn.backgroundColor = [UIColor secondarySystemBackgroundColor];
btn.layer.cornerRadius = 24;
btn.layer.shadowColor = [UIColor blackColor].CGColor;
btn.layer.shadowOpacity = 0.18;
btn.layer.shadowRadius = 5;
btn.layer.shadowOffset = CGSizeMake(0, 2);
btn.showsMenuAsPrimaryAction = YES;
btn.menu = sciBuildMapMenu();
// Refresh menu on each press so toggle/preset state is current.
[btn addAction:[UIAction actionWithHandler:^(__unused UIAction *a) {
btn.menu = sciBuildMapMenu();
}] forControlEvents:UIControlEventMenuActionTriggered];
[mapView addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16],
[btn.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78],
[btn.widthAnchor constraintEqualToConstant:48],
[btn.heightAnchor constraintEqualToConstant:48],
]];
}
static void sciRefreshMapButton(UIView *mapView) {
UIButton *btn = (UIButton *)[mapView viewWithTag:kSciMapBtnTag];
if (!btn) return;
BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"];
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:on ? @"location.fill" : @"location.slash" withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = on ? [UIColor systemGreenColor] : [UIColor labelColor];
btn.menu = sciBuildMapMenu();
}
static void (*orig_mapLayout)(UIView *, SEL);
static void new_mapLayout(UIView *self, SEL _cmd) {
orig_mapLayout(self, _cmd);
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
sciRemoveMapButton(self);
return;
}
sciAddMapButton(self);
sciRefreshMapButton(self);
UIView *btn = [self viewWithTag:kSciMapBtnTag];
if (btn) [self bringSubviewToFront:btn];
}
static void sciInstallMapHooks(void) {
static BOOL installed = NO;
if (installed) return;
Class c = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
if (!c) return;
installed = YES;
SEL sel = @selector(layoutSubviews);
if (class_getInstanceMethod(c, sel))
MSHookMessageEx(c, sel, (IMP)new_mapLayout, (IMP *)&orig_mapLayout);
}
%ctor {
sciInstallMapHooks();
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciInstallMapHooks();
});
[[NSNotificationCenter defaultCenter] addObserverForName:@"SCIFakeLocationMapBtnPrefChanged"
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *n) {
sciRefreshActiveMapButton();
}];
}
+115
View File
@@ -0,0 +1,115 @@
// Date format hooks — replace IG's relative timestamps with a custom format.
// Each NSDate formatter selector is independently toggleable via prefs
// (date_fmt_<name>) so users can apply the format surface-by-surface.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "SCIDateFormatEntries.h"
#import <substrate.h>
static NSDictionary *sciDateFormats(BOOL sec) {
return sec ? @{
@"short": @"MMM d",
@"medium": @"MMM d, yyyy",
@"full": @"MMM d, yyyy 'at' h:mm:ss a",
@"time_12": @"MMM d 'at' h:mm:ss a",
@"time_24": @"MMM d 'at' HH:mm:ss",
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm:ss a",
@"day_slash": @"dd/MM/yyyy h:mm:ss a",
@"month_slash": @"MM/dd/yyyy h:mm:ss a",
@"euro": @"dd.MM.yyyy HH:mm:ss",
@"iso": @"yyyy-MM-dd",
@"iso_time": @"yyyy-MM-dd HH:mm:ss",
} : @{
@"short": @"MMM d",
@"medium": @"MMM d, yyyy",
@"full": @"MMM d, yyyy 'at' h:mm a",
@"time_12": @"MMM d 'at' h:mm a",
@"time_24": @"MMM d 'at' HH:mm",
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm a",
@"day_slash": @"dd/MM/yyyy h:mm a",
@"month_slash": @"MM/dd/yyyy h:mm a",
@"euro": @"dd.MM.yyyy HH:mm",
@"iso": @"yyyy-MM-dd",
@"iso_time": @"yyyy-MM-dd HH:mm",
};
}
static NSString *sciFormat(NSDate *date) {
NSString *fmt = [SCIUtils getStringPref:@"feed_date_format"];
if (!fmt.length || [fmt isEqualToString:@"default"]) return nil;
BOOL sec = [[NSUserDefaults standardUserDefaults] boolForKey:@"feed_date_show_seconds"];
NSString *pattern = sciDateFormats(sec)[fmt];
if (!pattern) return nil;
static NSDateFormatter *df = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{ df = [NSDateFormatter new]; });
df.dateFormat = pattern;
return [df stringFromDate:date];
}
// Per-arity hook generators. When the entry's pref is on, return the custom
// format; otherwise forward to orig with the original arguments.
#define SCI_HOOK0(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd); \
}
#define SCI_HOOK1(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1); \
}
#define SCI_HOOK2(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1, a2); \
}
#define SCI_HOOK3(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1, a2, a3); \
}
#define SCI_HOOK4(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3, NSInteger a4) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1, a2, a3, a4); \
}
#define SCI_EMIT_HOOK(NAME, SEL_, LABEL, ARITY, PREF) SCI_HOOK##ARITY(NAME, SEL_, LABEL, PREF)
SCI_DATE_FORMAT_ENTRIES(SCI_EMIT_HOOK)
#define SCI_INSTALL_HOOK(NAME, SEL_, LABEL, ARITY, PREF) do { \
SEL s = sel_registerName(SEL_); \
if ([[NSDate class] instancesRespondToSelector:s]) \
MSHookMessageEx([NSDate class], s, (IMP)hook_##NAME, (IMP *)&orig_##NAME); \
} while (0);
%ctor {
SCI_DATE_FORMAT_ENTRIES(SCI_INSTALL_HOOK)
}
+55 -10
View File
@@ -135,22 +135,35 @@
// Write with meta ai in message composer
%hook IGDirectComposer
- (id)initWithLayoutSpecProvider:(id)arg1
userLauncherSetProviding:(id)arg2
userSession:(id)arg2
userLauncherSet:(id)arg3
config:(IGDirectComposerConfig *)config
style:(id)arg4
text:(id)arg5
style:(id)arg5
text:(id)arg6
{
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5);
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6);
}
- (id)initWithLayoutSpecProvider:(id)arg1
userLauncherSetProviding:(id)arg2
userSession:(id)arg2
userLauncherSet:(id)arg3
config:(IGDirectComposerConfig *)config
style:(id)arg4
text:(id)arg5
shouldUpdateModeLater:(BOOL)arg6
style:(id)arg5
text:(id)arg6
shouldUpdateModeLater:(BOOL)arg7
{
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5, arg6);
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
}
- (id)_initializeWithLayoutSpecProvider:(id)arg1
userSession:(id)arg2
userLauncherSet:(id)arg3
config:(IGDirectComposerConfig *)config
style:(id)arg5
text:(id)arg6
shouldUpdateModeLater:(BOOL)arg7
{
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
}
- (void)setConfig:(IGDirectComposerConfig *)config {
@@ -178,6 +191,20 @@
}
%end
// Demangled name: IGAIRewrite.IGAIRewriteStoryRepliesPresenter
%hook _TtC11IGAIRewrite32IGAIRewriteStoryRepliesPresenter
- (BOOL)shouldShowAIRewriteButton:(id)arg1 input:(id)arg2 {
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
NSLog(@"[SCInsta] Hiding meta ai: disable ai rewrite story reply presenter");
return NO;
}
return %orig(arg1, arg2);
}
%end
// Direct sticker tray picker view
%hook IGStickerTrayListAdapterDataSource
- (id)objectsForListAdapter:(id)arg1 {
@@ -346,6 +373,24 @@
// Reels/Sundial
// Suggested AI searches in comment section
%hook IGCommentConfig
- (id)initWithUserSession:(id)session
commentThreadConfiguration:(IGCommentThreadConfiguration *)threadConfig
sponsoredSupportConfiguration:(id)supportConfig
CTAPresenterContext:(id)context
replyText:(id)text
loggingDelegate:(id)loggingDelegate
presentingViewController:(id)vc
childCommentThreadDelegate:(id)threadDelegate
{
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
[threadConfig setValue:@(YES) forKey:@"disableMetaAICarousel"];
}
return %orig(session, threadConfig, supportConfig, context, text, loggingDelegate, vc, threadDelegate);
}
%end
// Suggested AI searches in comment section (workaround if setting comment thread config fails)
%hook IGCommentThreadAICarousel
- (id)initWithLauncherSet:(id)arg1 hasSearchPrefix:(BOOL)arg2 {
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
@@ -383,7 +428,7 @@
NSLog(@"[SCInsta] Hiding meta ai: ai images add to story suggestion");
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(10), @(11) ]];
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(9), @(10), @(11) ]];
newTools = [tools filteredArrayUsingPredicate:predicate];
}
+25
View File
@@ -0,0 +1,25 @@
#import "../../Utils.h"
%hook IGSundialViewerVerticalUFI
- (void)setNumLikes:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumReshares:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumComments:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumReposts:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumSaves:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
%end
%hook IGUFIButtonWithCountsView
- (void)setCountString:(id)string showButton:(BOOL)showButton {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? @"" : string, showButton);
}
%end
@@ -0,0 +1,90 @@
// Hide suggested stories from the tray. Drops items the user doesn't follow
// (friendship_status.following=0 or empty fieldCache); highlights pass through.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// IGListAdapter declared in InstagramHeaders.h
static __weak id sciTrayAdapter = nil;
// ── Suggested item detection ──
// Returns YES if the item should be kept. Highlights / non-tray rows pass
// through; followed reels keep; empty fieldCache (freshly-streamed suggested
// users) drops; otherwise check friendship_status.following.
static BOOL sciIsFollowedTrayItem(id obj) {
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return YES;
@try {
if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) return YES;
id owner = [obj valueForKey:@"reelOwner"];
if (!owner) return YES;
Ivar userIvar = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!userIvar) return YES;
id igUser = object_getIvar(owner, userIvar);
if (!igUser) return YES;
Ivar fcIvar = NULL;
for (Class c = [igUser class]; c && !fcIvar; c = class_getSuperclass(c))
fcIvar = class_getInstanceVariable(c, "_fieldCache");
if (!fcIvar) return YES;
const char *fcType = ivar_getTypeEncoding(fcIvar);
if (!fcType || fcType[0] != '@') return YES;
id fc = object_getIvar(igUser, fcIvar);
if (![fc isKindOfClass:[NSDictionary class]]) return YES;
if ([(NSDictionary *)fc count] == 0) return NO;
id fs = [(NSDictionary *)fc objectForKey:@"friendship_status"];
if (!fs) return YES;
return [[fs valueForKey:@"following"] boolValue];
} @catch (__unused NSException *e) {
return YES;
}
}
// ── Data source filter ──
static NSArray *(*orig_objectsForListAdapter)(id, SEL, id);
static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
NSArray *objects = orig_objectsForListAdapter(self, _cmd, adapter);
sciTrayAdapter = adapter;
if (![SCIUtils getBoolPref:@"hide_suggested_stories"]) return objects;
NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count];
for (id obj in objects) {
if (sciIsFollowedTrayItem(obj)) [filtered addObject:obj];
}
return [filtered copy];
}
// ── Reload tray on pref change ──
static void sciReloadTray(void) {
dispatch_async(dispatch_get_main_queue(), ^{
IGListAdapter *adapter = sciTrayAdapter;
if (adapter) [adapter performUpdatesAnimated:YES completion:nil];
});
}
%ctor {
Class dsCls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
if (!dsCls) return;
SEL sel = NSSelectorFromString(@"objectsForListAdapter:");
if (class_getInstanceMethod(dsCls, sel))
MSHookMessageEx(dsCls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
[[NSNotificationCenter defaultCenter] addObserverForName:@"SCISuggestedStoriesReload"
object:nil queue:nil
usingBlock:^(NSNotification *n) { sciReloadTray(); }];
}
+14 -27
View File
@@ -1,15 +1,12 @@
// Download highlight cover image from the profile long-press menu.
// Captures the long-pressed IGStoryTrayCell, finds the IGImageView inside it,
// and saves the cover using the user's download settings.
// View highlight cover — opens the cover image in the full-screen media viewer.
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static SCIDownloadDelegate *sciHighlightDl = nil;
// Find the IGStoryTrayCell with an active long-press gesture
static UIView *sciFindLongPressedCell(UIView *root) {
Class cellCls = NSClassFromString(@"IGStoryTrayCell");
@@ -46,29 +43,20 @@ static UIImage *sciCoverImageFromCell(UIView *cell) {
return nil;
}
static void sciSaveCoverImage(UIImage *image, UIViewController *presenter) {
static void sciViewCoverImage(UIImage *image) {
if (!image) {
[SCIUtils showErrorHUDWithDescription:@"Could not find cover image"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find cover image")];
return;
}
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
if ([method isEqualToString:@"photos"]) {
// Save to Photos (respects RyukGram album pref)
NSData *data = UIImageJPEGRepresentation(image, 1.0);
if (!data) return;
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]]];
[data writeToFile:tmpPath atomically:YES];
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
sciHighlightDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
[sciHighlightDl downloadDidFinishWithFileURL:tmpURL];
} else {
// Share sheet
UIActivityViewController *activityVC = [[UIActivityViewController alloc]
initWithActivityItems:@[image] applicationActivities:nil];
if (presenter) [presenter presentViewController:activityVC animated:YES completion:nil];
}
// Save to temp and open in the media viewer
NSData *data = UIImageJPEGRepresentation(image, 1.0);
if (!data) return;
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"cover_%@.jpg", [[NSUUID UUID] UUIDString]]];
[data writeToFile:tmpPath atomically:YES];
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
[SCIMediaViewer showWithVideoURL:nil photoURL:tmpURL caption:nil];
}
// Stored reference to the long-pressed cell (captured at presentation time)
@@ -90,16 +78,15 @@ static void new_present(id self, SEL _cmd, id vc, BOOL animated, id completion)
if (actions && actions.count >= 2 && actions.count <= 6) {
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
if (actionCls) {
__weak UIViewController *weakSelf = (UIViewController *)self;
void (^handler)(void) = ^{
UIImage *cover = sciCoverImageFromCell(sciLongPressedHighlightCell);
sciSaveCoverImage(cover, weakSelf);
sciViewCoverImage(cover);
};
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
id newAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
@"Download cover", nil, 0, handler, nil, nil);
@"View cover", nil, 0, handler, nil, nil);
if (newAction) {
NSMutableArray *newActions = [actions mutableCopy];
+33
View File
@@ -0,0 +1,33 @@
// Force launch into a chosen tab. Ignored while messages_only is active.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/message.h>
static NSString *sciSelectorForLaunchPref(NSString *p) {
if ([p isEqualToString:@"feed"]) return @"_timelineButtonPressed";
if ([p isEqualToString:@"explore"]) return @"_exploreButtonPressed";
if ([p isEqualToString:@"reels"]) return @"_discoverVideoButtonPressed";
if ([p isEqualToString:@"inbox"]) return @"_directInboxButtonPressed";
if ([p isEqualToString:@"profile"]) return @"_profileButtonPressed";
return nil;
}
%hook IGTabBarController
- (void)viewWillAppear:(BOOL)animated {
if (![SCIUtils getBoolPref:@"messages_only"]) {
static BOOL fired = NO;
if (!fired) {
fired = YES;
NSString *pref = [SCIUtils getStringPref:@"launch_tab"];
NSString *selName = sciSelectorForLaunchPref(pref);
if (selName) {
SEL s = NSSelectorFromString(selName);
if ([self respondsToSelector:s])
((void(*)(id, SEL))objc_msgSend)(self, s);
}
}
}
%orig;
}
%end
+198
View File
@@ -0,0 +1,198 @@
// Media zoom — long press on feed media to expand in full-screen viewer.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIMediaActions.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import <objc/runtime.h>
#import <objc/message.h>
// IGFeedItemPageVideoCell declared in InstagramHeaders.h
static const void *kZoomGestureKey = &kZoomGestureKey;
static BOOL sciZoomEnabled(void) {
return [SCIUtils getBoolPref:@"feed_media_zoom"];
}
// Walk up to the feed's outer collection view (skip carousel inner CVs)
static UICollectionView *sciFeedCollectionView(UIView *view) {
UIView *v = view;
while (v) {
if ([v isKindOfClass:[UICollectionView class]]) {
NSString *cls = NSStringFromClass([v class]);
if (![cls containsString:@"Carousel"] && ![cls containsString:@"Page"])
return (UICollectionView *)v;
}
v = v.superview;
}
return nil;
}
static NSInteger sciFeedSectionForView(UIView *view, UICollectionView *cv) {
UIView *v = view;
while (v) {
if ([v isKindOfClass:[UICollectionViewCell class]]) {
NSIndexPath *ip = [cv indexPathForCell:(UICollectionViewCell *)v];
if (ip) return ip.section;
}
v = v.superview;
}
return -1;
}
// Extract IGMedia from sibling cells in the same section
static IGMedia *sciZoomFeedMedia(UIView *view) {
Class mediaClass = NSClassFromString(@"IGMedia");
if (!mediaClass) return nil;
UICollectionView *cv = sciFeedCollectionView(view);
if (!cv) return nil;
NSInteger section = sciFeedSectionForView(view, cv);
if (section < 0) return nil;
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
NSString *cls = NSStringFromClass([cell class]);
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
unsigned int count = 0;
Class c = object_getClass(cell);
while (c && c != [UICollectionViewCell class]) {
Ivar *ivars = class_copyIvarList(c, &count);
for (unsigned int i = 0; i < count; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(cell, ivars[i]);
if (val && [val isKindOfClass:mediaClass]) { free(ivars); return (IGMedia *)val; }
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
c = class_getSuperclass(c);
}
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
if (m && [m isKindOfClass:mediaClass]) return (IGMedia *)m;
}
}
return nil;
}
// Carousel page index from the horizontal scroll view in the Page cell
static NSInteger sciZoomPageIndex(UIView *view) {
UICollectionView *cv = sciFeedCollectionView(view);
if (!cv) return 0;
NSInteger section = sciFeedSectionForView(view, cv);
if (section < 0) return 0;
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
if (![NSStringFromClass([cell class]) containsString:@"Page"]) continue;
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
int scanned = 0;
while (queue.count && scanned < 100) {
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
UIScrollView *sv = (UIScrollView *)cur;
CGFloat pageW = sv.bounds.size.width;
if (pageW > 100 && sv.contentSize.width > pageW * 1.5)
return (NSInteger)round(sv.contentOffset.x / pageW);
}
for (UIView *s in cur.subviews) [queue addObject:s];
}
}
return 0;
}
static void sciZoomFired(UILongPressGestureRecognizer *g) {
if (g.state != UIGestureRecognizerStateBegan) return;
if (!sciZoomEnabled()) return;
UIView *view = g.view;
IGMedia *media = sciZoomFeedMedia(view);
if (!media) return;
NSString *caption = [SCIMediaActions captionForMedia:media];
if ([SCIMediaActions isCarouselMedia:media]) {
NSArray *children = [SCIMediaActions carouselChildrenForMedia:media];
NSMutableArray *items = [NSMutableArray array];
for (id child in children) {
NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)child];
NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)child];
if (!v && !p) p = [SCIMediaActions bestURLForMedia:child];
if (v || p) [items addObject:[SCIMediaViewerItem itemWithVideoURL:v photoURL:p caption:caption]];
}
if (items.count) {
NSInteger idx = sciZoomPageIndex(view);
if (idx < 0 || idx >= (NSInteger)items.count) idx = 0;
[SCIMediaViewer showItems:items startIndex:idx];
return;
}
}
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
if (!videoUrl && !photoUrl) photoUrl = [SCIMediaActions bestURLForMedia:media];
if (!videoUrl && !photoUrl) return;
[SCIMediaViewer showWithVideoURL:videoUrl photoURL:photoUrl caption:caption];
}
// MARK: - Gesture setup
@interface _SCIZoomTarget : NSObject @end
@implementation _SCIZoomTarget
- (void)fired:(UILongPressGestureRecognizer *)g { sciZoomFired(g); }
@end
static void sciAddZoomGesture(UIView *view) {
if (objc_getAssociatedObject(view, kZoomGestureKey)) return;
_SCIZoomTarget *target = [_SCIZoomTarget new];
objc_setAssociatedObject(view, kZoomGestureKey, target, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
UILongPressGestureRecognizer *gesture = [[UILongPressGestureRecognizer alloc]
initWithTarget:target action:@selector(fired:)];
gesture.minimumPressDuration = 0.5;
[view addGestureRecognizer:gesture];
}
// MARK: - Hooks
%hook IGFeedPhotoView
- (void)didMoveToSuperview {
%orig;
if (self.superview) sciAddZoomGesture(self);
}
%end
%hook IGModernFeedVideoCell.IGModernFeedVideoCell
- (void)didMoveToSuperview {
%orig;
if (((UIView *)self).superview) sciAddZoomGesture((UIView *)self);
}
%end
%hook IGFeedItemPagePhotoCell
- (void)didMoveToSuperview {
%orig;
if (self.superview) sciAddZoomGesture((UIView *)self);
}
%end
%hook IGFeedItemPageVideoCell
- (void)didMoveToSuperview {
%orig;
if (self.superview) sciAddZoomGesture((UIView *)self);
}
%end
+65
View File
@@ -0,0 +1,65 @@
// Messages-only mode — no-op the tab creators we don't want, force inbox at launch.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/runtime.h>
#import <objc/message.h>
static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
%hook IGTabBarController
// Block tab creation entirely so they never enter the buttons array (no gaps).
- (void)_createAndConfigureTimelineButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureReelsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureExploreButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureCameraButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureDynamicTabButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureNewsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureStreamsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
// Force initial selection to inbox once after the tab bar has fully laid out.
- (void)viewDidAppear:(BOOL)animated {
%orig;
static BOOL launched = NO;
if (sciMsgOnly() && !launched) {
launched = YES;
SEL s = NSSelectorFromString(@"_directInboxButtonPressed");
if ([self respondsToSelector:s])
((void(*)(id, SEL))objc_msgSend)(self, s);
}
}
// Surface enum no longer maps cleanly to the trimmed _buttons array, so flip
// the selected state ourselves and nudge the liquid-glass indicator.
%new - (void)sciSyncTabBarSelection:(NSString *)which {
Class c = [self class];
Ivar ibIv = class_getInstanceVariable(c, "_directInboxButton");
Ivar pbIv = class_getInstanceVariable(c, "_profileButton");
UIButton *inbox = ibIv ? object_getIvar(self, ibIv) : nil;
UIButton *profile = pbIv ? object_getIvar(self, pbIv) : nil;
BOOL profileActive = [which isEqualToString:@"profile"];
if ([inbox respondsToSelector:@selector(setSelected:)]) inbox.selected = !profileActive;
if ([profile respondsToSelector:@selector(setSelected:)]) profile.selected = profileActive;
// No-op on classic tab bar (selector only exists on IGLiquidGlassInteractiveTabBar).
Ivar tbIv = class_getInstanceVariable(c, "_tabBar");
id tabBar = tbIv ? object_getIvar(self, tbIv) : nil;
NSInteger idx = profileActive ? 1 : 0;
SEL setIdx = NSSelectorFromString(@"setSelectedTabBarItemIndex:animateIndicator:");
if ([tabBar respondsToSelector:setIdx])
((void(*)(id, SEL, NSInteger, BOOL))objc_msgSend)(tabBar, setIdx, idx, YES);
}
- (void)_directInboxButtonPressed {
%orig;
if (sciMsgOnly())
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"inbox");
}
- (void)_profileButtonPressed {
%orig;
if (sciMsgOnly())
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"profile");
}
%end
+20
View File
@@ -13,6 +13,11 @@ BOOL isSurfaceShown(IGMainAppSurfaceIntent *surface) {
isShown = NO;
}
// Messages
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"DIRECT"] && [SCIUtils getBoolPref:@"hide_messages_tab"]) {
isShown = NO;
}
// Explore
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"SEARCH"] && [SCIUtils getBoolPref:@"hide_explore_tab"]) {
isShown = NO;
@@ -97,4 +102,19 @@ NSArray *filterSurfacesArray(NSArray *surfaces) {
- (void)setIsTabSwipingEnabled:(BOOL)arg1 {
return;
}
%end
%hook IGHomeFeedHeaderView
- (void)didMoveToWindow {
%orig;
if ([SCIUtils getBoolPref:@"hide_messages_tab"]) {
UIButton *rightButton = [self valueForKey:@"rightButton"];
if (rightButton) {
NSLog(@"[SCInsta] Hiding messages tab (on feed)");
[rightButton removeFromSuperview];
}
}
}
%end
+2 -2
View File
@@ -38,13 +38,13 @@
// Recent dm message recipients search bar
%hook IGDirectRecipientRecentSearchStorage
- (id)initWithDiskManager:(id)arg1 directCache:(id)arg2 userStore:(id)arg3 currentUser:(id)arg4 featureSets:(id)arg5 {
- (id)initWithDiskManager:(id)arg1 directRepo:(id)arg2 userMap:(id)arg3 currentUser:(id)arg4 launcherSet:(id)arg5 {
if ([SCIUtils getBoolPref:@"no_recent_searches"]) {
NSLog(@"[SCInsta] Disabling recent searches");
return nil;
}
return %orig;
return %orig(arg1, arg2, arg3, arg4, arg5);
}
%end
+1 -1
View File
@@ -64,7 +64,7 @@
// Section header
if ([obj isKindOfClass:%c(IGLabelItemViewModel)]) {
// Suggested for you
if ([[obj labelTitle] isEqualToString:@"Suggested for you"]) {
if ([[obj valueForKey:@"tag"] intValue] == 2) { // 2 == Suggested Users
if ([SCIUtils getBoolPref:@"no_suggested_users"]) {
NSLog(@"[SCInsta] Hiding suggested users (header: activity feed)");
+4 -4
View File
@@ -130,12 +130,12 @@ static char targetStaticRef[] = "target";
[rightButton sizeToFit];
[rightButton addAction:[UIAction actionWithHandler:^(__kindof UIAction * _Nonnull action) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Enter Emoji Text"
message:@"Click the Apply button after this to see the emoji"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Enter Emoji Text")
message:SCILocalized(@"Click the Apply button after this to see the emoji")
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *textField) {
textField.placeholder = @"Type emoji...";
textField.placeholder = SCILocalized(@"Type emoji...");
}];
[alert addAction:[UIAlertAction actionWithTitle:@"OK"
@@ -145,7 +145,7 @@ static char targetStaticRef[] = "target";
[self applySCICustomTheme:@"Emoji"];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel"
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel")
style:UIAlertActionStyleCancel
handler:nil]];
+7 -8
View File
@@ -8,8 +8,7 @@
// a copy button alongside IG's own buttons, then opens a menu to copy
// username/name/bio.
@interface IGProfileViewController : UIViewController
@end
// IGProfileViewController declared in InstagramHeaders.h
static id sci_safeValueForKey(id obj, NSString *key) {
@try { return [obj valueForKey:key]; }
@@ -107,7 +106,7 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
username, fullName, (unsigned long)biography.length);
UIAlertController *menu = [UIAlertController alertControllerWithTitle:@"Copy from profile"
UIAlertController *menu = [UIAlertController alertControllerWithTitle:SCILocalized(@"Copy from profile")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
@@ -117,12 +116,12 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]];
}
if (fullName.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy name"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy name")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]];
}
if (biography.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy bio"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy bio")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]];
}
@@ -134,16 +133,16 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
if (parts.count >= 2) {
NSString *combined = [parts componentsJoinedByString:@"\n\n"];
[menu addAction:[UIAlertAction actionWithTitle:@"Copy all"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy all")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]];
}
if (menu.actions.count == 0) {
[menu addAction:[UIAlertAction actionWithTitle:@"Nothing to copy" style:UIAlertActionStyleDefault handler:nil]];
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Nothing to copy") style:UIAlertActionStyleDefault handler:nil]];
}
[menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
if (sender) {
menu.popoverPresentationController.sourceView = sender;
@@ -0,0 +1,26 @@
// Single source of truth for date-format hook entries.
// Format: X(name, selector_cstring, label, arity, pref_key)
// Entries sharing a pref_key are toggled together; label is shown in the
// picker for the first entry sharing a given pref_key (use "" for others).
#define SCI_DATE_FORMAT_ENTRIES(X) \
X(mixed, "formattedDateInMixedFormat", "Feed posts", 0, "date_fmt_mixed") \
X(rel, "formattedDateRelativeToNow", "Notes, comments, stories",0, "date_fmt_notes_comments_stories") \
X(shortRel, "shortenedFormattedDateRelativeToNow", "", 0, "date_fmt_notes_comments_stories") \
X(shortRelHs, "shortenedFormattedDateRelativeToNowHideSeconds:", "DMs", 1, "date_fmt_dms")
// Kept for future use — other NSDate relative formatters IG uses across
// surfaces. Enable by adding to SCI_DATE_FORMAT_ENTRIES above.
//
// X(partialRel, "partiallyShortenedFormattedDateRelativeToNow", "Partially shortened relative", 0, "date_fmt_partialRel")
// X(shortRelYears, "shortenedFormattedDateRelativeToNowIncludeYears", "Shortened relative (incl. years)", 0, "date_fmt_shortRelYears")
// X(shortRelOpts, "shortenedFormattedDateRelativeToNowWithOptions:", "Shortened relative (options)", 1, "date_fmt_shortRelOpts")
// X(shortRelFloor, "shortenedFormattedDateRelativeToNowWithFloorDaysWeeks:", "Shortened rel. (floor days/weeks)", 1, "date_fmt_shortRelFloor")
// X(mixedShortRelMDY, "formattedDateInMixedShortenedRelativeAndMonthDayYearFormatWithThreshold:", "Mixed shortened + M/D/Y", 1, "date_fmt_mixedShortRelMDY")
// X(relHs, "formattedDateRelativeToNowHideSeconds:", "Relative (hide seconds)", 1, "date_fmt_relHs")
// X(relYearsHs, "formattedDateRelativeToNowIncludingYearsHideSeconds:", "Rel. incl. years (hide seconds)", 1, "date_fmt_relYearsHs")
// X(partialRelHsOpts, "partiallyShortenedFormattedDateRelativeToNowHideSeconds:options:", "Partial rel. (hide secs, opts)", 2, "date_fmt_partialRelHsOpts")
// X(relHsFloor, "formattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:", "Relative (hide secs, floor)", 2, "date_fmt_relHsFloor")
// X(shortRelHsFloor, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:", "Shortened rel. (hide secs, floor)", 2, "date_fmt_shortRelHsFloor")
// X(shortRelHsFloorOpts, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:options:", "Shortened rel. (hide secs, floor, opts)", 3, "date_fmt_shortRelHsFloorOpts")
// X(shortRelHsFloorYearsOpts, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:includeYears:options:","Shortened rel. (full signature)", 4, "date_fmt_shortRelHsFloorYearsOpts")
+6 -4
View File
@@ -30,14 +30,16 @@
}
%end
// Quick access to tweak settings by holding on home tab button
// Quick access to tweak settings by holding on the home tab button.
// In messages-only mode the home tab is gone — fall back to the inbox tab.
%hook IGTabBarButton
- (void)didMoveToSuperview {
%orig;
// Only work on home/feed tab
if (![self.accessibilityIdentifier isEqualToString:@"mainfeed-tab"]) return;
BOOL msgOnly = [SCIUtils getBoolPref:@"messages_only"];
NSString *target = msgOnly ? SCILocalized(@"direct-inbox-tab") : SCILocalized(@"mainfeed-tab");
if (![self.accessibilityIdentifier isEqualToString:target]) return;
if ([SCIUtils getBoolPref:@"settings_shortcut"]) {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = 0.3;
+210 -511
View File
@@ -1,6 +1,21 @@
// Legacy download gestures — off by default, kept for users who prefer the
// old multi-finger long-press workflow over the action button menu.
//
// The modern flow lives in:
// src/ActionButton/ — menu + handlers
// src/Features/ActionButton/ — per-context button injection
// src/Features/StoriesAndMessages/OverlayButtons.xm — stories action button
//
// This file only contains:
// 1. Long-press gesture recognizers on feed/story/reel media views, gated
// by `dw_legacy_gesture`. When on, they reuse the old sciDownload* path
// and save via the user's `dw_save_action` preference.
// 2. The profile-picture long-press gesture (always on when `save_profile`).
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import <objc/runtime.h>
static SCIDownloadDelegate *imageDownloadDelegate;
@@ -12,220 +27,25 @@ static DownloadAction sciGetDownloadAction() {
return share;
}
static void initDownloaders () {
// Re-init each time to pick up the current save action preference
static void initDownloaders() {
DownloadAction action = sciGetDownloadAction();
DownloadAction imgAction = (action == saveToPhotos) ? saveToPhotos : quickLook;
imageDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO];
videoDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES];
}
// Helper: run a download block with optional confirmation dialog
static void sciConfirmAndDownload(NSString *title, void(^downloadBlock)(void)) {
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
[SCIUtils showConfirmation:downloadBlock title:title];
} else {
downloadBlock();
}
}
// Helper: recursively search within a view tree for downloadable media (bounded to one post)
static BOOL sciFindAndDownloadMediaInView(UIView *root) {
if (!root) return NO;
// Check for video media via mediaCellFeedItem
if ([root respondsToSelector:@selector(mediaCellFeedItem)]) {
IGMedia *media = [root performSelector:@selector(mediaCellFeedItem)];
if (media) {
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (videoUrl) {
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
if (photoUrl) {
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
}
}
// Check for IGFeedPhotoView with delegate chain
if ([root isKindOfClass:NSClassFromString(@"IGFeedPhotoView")] && [root respondsToSelector:@selector(delegate)]) {
id delegate = [root performSelector:@selector(delegate)];
if ([delegate isKindOfClass:NSClassFromString(@"IGFeedItemPhotoCell")]) {
@try {
Ivar cfgIvar = class_getInstanceVariable([delegate class], "_configuration");
if (cfgIvar) {
id cfg = object_getIvar(delegate, cfgIvar);
if (cfg) {
Ivar photoIvar = class_getInstanceVariable([cfg class], "_photo");
if (photoIvar) {
IGPhoto *photo = object_getIvar(cfg, photoIvar);
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
if (photoUrl) {
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
}
}
}
} @catch (NSException *e) {}
}
if ([delegate isKindOfClass:NSClassFromString(@"IGFeedItemPagePhotoCell")]) {
@try {
if ([delegate respondsToSelector:@selector(pagePhotoPost)]) {
id pagePhotoPost = [delegate performSelector:@selector(pagePhotoPost)];
if (pagePhotoPost && [pagePhotoPost respondsToSelector:@selector(photo)]) {
IGPhoto *photo = [pagePhotoPost performSelector:@selector(photo)];
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
if (photoUrl) {
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
}
}
} @catch (NSException *e) {}
}
}
// Recurse into subviews
for (UIView *sub in root.subviews) {
if (sciFindAndDownloadMediaInView(sub)) return YES;
}
return NO;
}
// Helper: find IGMedia from a cell using runtime ivar scanning
// Avoids property getters which can cause EXC_BAD_ACCESS on certain IG versions
static IGMedia * _Nullable sciGetMediaFromView(UIView *view) {
if (!view) return nil;
unsigned int ivarCount = 0;
Ivar *ivars = class_copyIvarList([view class], &ivarCount);
if (!ivars) return nil;
IGMedia *found = nil;
Class mediaClass = NSClassFromString(@"IGMedia");
for (unsigned int i = 0; i < ivarCount; i++) {
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *ivarName = [NSString stringWithUTF8String:name];
NSString *lower = [ivarName lowercaseString];
if ([lower containsString:@"video"] || [lower containsString:@"media"] || [lower containsString:@"item"]) {
id value = object_getIvar(view, ivars[i]);
if (value && mediaClass && [value isKindOfClass:mediaClass]) {
found = (IGMedia *)value;
NSLog(@"[SCInsta] Found IGMedia in ivar '%@' of %@", ivarName, NSStringFromClass([view class]));
break;
}
}
}
free(ivars);
return found;
}
// Helper: walk superview chain to find a view of a given class
static UIView * _Nullable sciFindSuperviewOfClass(UIView *view, NSString *className) {
Class cls = NSClassFromString(className);
if (!cls) return nil;
UIView *current = view.superview;
int depth = 0;
while (current && depth < 15) {
if ([current isKindOfClass:cls]) return current;
current = current.superview;
depth++;
}
return nil;
}
// Helper: show debug ivar dump when media extraction fails (survives IG updates)
static void sciShowDebugIvarDump(UIView *cell) {
NSMutableString *debug = [NSMutableString stringWithFormat:@"No IGMedia found in %@\n\nIvars:\n", NSStringFromClass([cell class])];
unsigned int count = 0;
Ivar *ivars = class_copyIvarList([cell class], &count);
for (unsigned int i = 0; i < count && i < 50; i++) {
const char *name = ivar_getName(ivars[i]);
const char *type = ivar_getTypeEncoding(ivars[i]);
if (name) [debug appendFormat:@"%s (%s)\n", name, type ? type : "?"];
}
if (ivars) free(ivars);
NSLog(@"[SCInsta] Debug: %@", debug);
dispatch_async(dispatch_get_main_queue(), ^{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"RyukGram Debug"
message:debug
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Copy & Close" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[[UIPasteboard generalPasteboard] setString:debug];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleCancel handler:nil]];
UIViewController *topVC = topMostController();
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
});
}
// Whether download buttons (not long-press) are enabled
static BOOL sciUseDownloadButtons() {
return [[SCIUtils getStringPref:@"dw_method"] isEqualToString:@"button"];
static BOOL sciLegacyGestureEnabled() {
return [SCIUtils getBoolPref:@"dw_legacy_gesture"];
}
/* * Feed * */
/* * Feed (legacy gesture) * */
// Download feed images
%hook IGFeedPhotoView
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"dw_feed_posts"]) return;
if (sciUseDownloadButtons()) {
[self sciAddDownloadButton];
} else {
[self addLongPressGestureRecognizer];
}
}
%new - (void)sciAddDownloadButton {
if ([self viewWithTag:1338]) return;
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1338;
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:@"arrow.down.to.line" withConfiguration:config] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 12;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciDownloadBtnTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:10],
[btn.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:24],
[btn.heightAnchor constraintEqualToConstant:24]
]];
}
%new - (void)sciDownloadBtnTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.75, 0.75); }
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
sciConfirmAndDownload(@"Download photo?", ^{
[self handleLongPress:nil];
});
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
@@ -237,75 +57,30 @@ static BOOL sciUseDownloadButtons() {
if (sender && sender.state != UIGestureRecognizerStateBegan) return;
IGPhoto *photo;
if ([self.delegate isKindOfClass:%c(IGFeedItemPhotoCell)]) {
IGFeedItemPhotoCellConfiguration *_configuration = MSHookIvar<IGFeedItemPhotoCellConfiguration *>(self.delegate, "_configuration");
if (!_configuration) return;
photo = MSHookIvar<IGPhoto *>(_configuration, "_photo");
}
else if ([self.delegate isKindOfClass:%c(IGFeedItemPagePhotoCell)]) {
} else if ([self.delegate isKindOfClass:%c(IGFeedItemPagePhotoCell)]) {
IGFeedItemPagePhotoCell *pagePhotoCell = self.delegate;
photo = pagePhotoCell.pagePhotoPost.photo;
}
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
if (!photoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from post"];
return;
}
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from post")]; return; }
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent]pathExtension]
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
}
%end
// Download feed videos
%hook IGModernFeedVideoCell.IGModernFeedVideoCell
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"dw_feed_posts"]) return;
if (sciUseDownloadButtons()) {
[self sciAddDownloadButton];
} else {
[self addLongPressGestureRecognizer];
}
}
%new - (void)sciAddDownloadButton {
UIView *selfView = (UIView *)self;
if ([selfView viewWithTag:1338]) return;
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1338;
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:@"arrow.down.to.line" withConfiguration:config] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 12;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciDownloadBtnTapped:) forControlEvents:UIControlEventTouchUpInside];
[selfView addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.leadingAnchor constraintEqualToAnchor:selfView.leadingAnchor constant:10],
[btn.bottomAnchor constraintEqualToAnchor:selfView.bottomAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:24],
[btn.heightAnchor constraintEqualToConstant:24]
]];
}
%new - (void)sciDownloadBtnTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.75, 0.75); }
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
sciConfirmAndDownload(@"Download video?", ^{
[self handleLongPress:nil];
});
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
@@ -317,10 +92,7 @@ static BOOL sciUseDownloadButtons() {
if (sender && sender.state != UIGestureRecognizerStateBegan) return;
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:[self mediaCellFeedItem]];
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from post"];
return;
}
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from post")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
@@ -330,277 +102,50 @@ static BOOL sciUseDownloadButtons() {
%end
/* * Stories (legacy gesture) * */
/* * Reels * */
// Download reels (photos) — long press only when gesture mode selected
%hook IGSundialViewerPhotoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_reels"] && !sciUseDownloadButtons()) {
[self addLongPressGestureRecognizer];
}
return;
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
IGPhoto *_photo = nil;
@try {
_photo = MSHookIvar<IGPhoto *>(self, "_photo");
} @catch (NSException *e) {}
if (!_photo) {
[SCIUtils showErrorHUDWithDescription:@"Could not access reel photo"];
return;
}
NSURL *photoUrl = [SCIUtils getPhotoUrl:_photo];
if (!photoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from reel"];
return;
}
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent]pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel photo download error: %@", exception);
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Reel photo download failed: %@", exception.reason]];
}
}
%end
// Download reels (videos) — long press only when gesture mode selected
%hook IGSundialViewerVideoCell
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_reels"] && !sciUseDownloadButtons()) {
[self addLongPressGestureRecognizer];
}
return;
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
IGMedia *media = sciGetMediaFromView(self);
if (!media) {
[SCIUtils showErrorHUDWithDescription:@"Could not access reel media"];
return;
}
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from reel"];
return;
}
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
fileExtension:[[videoUrl lastPathComponent] pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel download error: %@", exception);
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Reel download failed: %@", exception.reason]];
}
}
%end
// Download button on reels vertical UFI (like/comment/share sidebar)
%hook IGSundialViewerVerticalUFI
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"dw_reels"]) return;
if (!sciUseDownloadButtons()) return;
if (!self.superview) return;
// Add to superview so we're not clipped by the narrow 29pt UFI
UIView *parent = self.superview;
if ([parent viewWithTag:1337]) return;
UIButton *downloadBtn = [UIButton buttonWithType:UIButtonTypeCustom];
downloadBtn.tag = 1337;
// Match IG reel sidebar style: outline icon, semi-transparent white
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold];
UIImage *icon = [UIImage systemImageNamed:@"arrow.down" withConfiguration:config];
[downloadBtn setImage:icon forState:UIControlStateNormal];
downloadBtn.tintColor = [UIColor colorWithWhite:1.0 alpha:0.9];
downloadBtn.layer.shadowColor = [UIColor blackColor].CGColor;
downloadBtn.layer.shadowOffset = CGSizeMake(0, 1);
downloadBtn.layer.shadowOpacity = 0.5;
downloadBtn.layer.shadowRadius = 3;
downloadBtn.translatesAutoresizingMaskIntoConstraints = NO;
[downloadBtn addTarget:self action:@selector(sciDownloadTapped:) forControlEvents:UIControlEventTouchUpInside];
[parent addSubview:downloadBtn];
[NSLayoutConstraint activateConstraints:@[
[downloadBtn.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[downloadBtn.bottomAnchor constraintEqualToAnchor:self.topAnchor constant:-10],
[downloadBtn.widthAnchor constraintEqualToConstant:40],
[downloadBtn.heightAnchor constraintEqualToConstant:40]
]];
}
%new - (void)sciDownloadTapped:(UIButton *)sender {
NSLog(@"[SCInsta] Reel download button tapped");
// Haptic + visual feedback
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{
sender.transform = CGAffineTransformMakeScale(0.75, 0.75);
} completion:^(BOOL finished) {
[UIView animateWithDuration:0.1 animations:^{
sender.transform = CGAffineTransformIdentity;
}];
}];
sciConfirmAndDownload(@"Download reel?", ^{
// Find IGSundialViewerVideoCell in superview chain
UIView *videoCell = sciFindSuperviewOfClass(self, @"IGSundialViewerVideoCell");
if (videoCell) {
IGMedia *media = sciGetMediaFromView(videoCell);
if (media) {
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (videoUrl) {
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
fileExtension:[[videoUrl lastPathComponent] pathExtension]
hudLabel:nil];
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not extract video URL from reel"];
return;
}
sciShowDebugIvarDump(videoCell);
return;
}
// Try photo reel
UIView *photoView = sciFindSuperviewOfClass(self, @"IGSundialViewerPhotoView");
if (photoView) {
unsigned int count = 0;
Ivar *ivars = class_copyIvarList([photoView class], &count);
Class photoClass = NSClassFromString(@"IGPhoto");
for (unsigned int i = 0; i < count; i++) {
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *ivarName = [NSString stringWithUTF8String:name];
if ([[ivarName lowercaseString] containsString:@"photo"]) {
id value = object_getIvar(photoView, ivars[i]);
if (value && photoClass && [value isKindOfClass:photoClass]) {
NSURL *photoUrl = [SCIUtils getPhotoUrl:(IGPhoto *)value];
if (photoUrl) {
free(ivars);
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
return;
}
}
}
}
if (ivars) free(ivars);
sciShowDebugIvarDump(photoView);
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not find reel cell in view hierarchy"];
});
}
%end
/* * Stories * */
// Download story (images)
%hook IGStoryPhotoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_story"]) {
[self addLongPressGestureRecognizer];
}
return;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:[self item]];
if (!photoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from story"];
return;
}
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from story")]; return; }
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent]pathExtension]
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
}
%end
// Download story (videos)
%hook IGStoryModernVideoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_story"]) {
[self addLongPressGestureRecognizer];
}
return;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:self.item];
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from story"];
return;
}
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from story")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
@@ -609,35 +154,26 @@ static BOOL sciUseDownloadButtons() {
}
%end
// Download story (videos, legacy)
%hook IGStoryVideoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_story"]) {
[self addLongPressGestureRecognizer];
}
return;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
NSURL *videoUrl;
IGStoryFullscreenSectionController *captionDelegate = self.captionDelegate;
if (captionDelegate) {
videoUrl = [SCIUtils getVideoUrlForMedia:captionDelegate.currentStoryItem];
}
else {
// Direct messages video player
} else {
id parentVC = [SCIUtils nearestViewControllerForView:self];
if (!parentVC || ![parentVC isKindOfClass:%c(IGDirectVisualMessageViewerController)]) return;
@@ -653,11 +189,7 @@ static BOOL sciUseDownloadButtons() {
videoUrl = [SCIUtils getVideoUrl:rawVideo];
}
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from story"];
return;
}
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from story")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
@@ -667,17 +199,176 @@ static BOOL sciUseDownloadButtons() {
%end
/* * Reels (legacy gesture) * */
%hook IGSundialViewerPhotoView
- (void)didMoveToSuperview {
%orig;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
IGPhoto *_photo = MSHookIvar<IGPhoto *>(self, "_photo");
if (!_photo) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not access reel photo")]; return; }
NSURL *photoUrl = [SCIUtils getPhotoUrl:_photo];
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from reel")]; return; }
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel photo download error: %@", exception);
}
}
%end
%hook IGSundialViewerVideoCell
- (void)didMoveToSuperview {
%orig;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
// Runtime ivar scan: the exact name varies across IG releases.
unsigned int ivarCount = 0;
Ivar *ivars = class_copyIvarList([self class], &ivarCount);
Class mediaClass = NSClassFromString(@"IGMedia");
IGMedia *media = nil;
for (unsigned int i = 0; i < ivarCount; i++) {
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *lower = [[NSString stringWithUTF8String:name] lowercaseString];
if ([lower containsString:@"video"] || [lower containsString:@"media"] || [lower containsString:@"item"]) {
id val = object_getIvar(self, ivars[i]);
if (val && mediaClass && [val isKindOfClass:mediaClass]) { media = val; break; }
}
}
if (ivars) free(ivars);
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not access reel media")]; return; }
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from reel")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
fileExtension:[[videoUrl lastPathComponent] pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel download error: %@", exception);
}
}
%end
/* * Profile pictures * */
// Get profile info by walking up to IGProfileViewController
static NSString *sciProfileCaption(UIView *view) {
Class profileCls = NSClassFromString(@"IGProfileViewController");
Class userCls = NSClassFromString(@"IGUser");
UIResponder *r = view;
while (r) {
if (profileCls && [r isKindOfClass:profileCls]) {
id user = nil;
for (NSString *key in @[@"user", @"userGQL", @"profileUser"]) {
@try { user = [(UIViewController *)r valueForKey:key]; } @catch (__unused id e) {}
if (user) break;
}
if (!user && userCls) {
unsigned int cnt = 0;
Ivar *ivars = class_copyIvarList([r class], &cnt);
for (unsigned int i = 0; i < cnt; i++) {
id v = object_getIvar(r, ivars[i]);
if (v && [v isKindOfClass:userCls]) { user = v; break; }
}
if (ivars) free(ivars);
}
if (user) {
NSString *name = nil, *username = nil, *bio = nil;
@try { username = [user valueForKey:@"username"]; } @catch (__unused id e) {}
@try { name = [user valueForKey:@"fullName"]; } @catch (__unused id e) {}
if (!name) @try { name = [user valueForKey:@"name"]; } @catch (__unused id e) {}
@try { bio = [user valueForKey:@"biography"]; } @catch (__unused id e) {}
NSMutableString *caption = [NSMutableString string];
if (name.length) [caption appendString:name];
if (username.length) {
if (caption.length) [caption appendString:@"\n"];
[caption appendFormat:@"@%@", username];
}
if (bio.length) {
if (caption.length) [caption appendString:@"\n\n"];
[caption appendString:bio];
}
return caption.length ? caption : nil;
}
}
r = [r nextResponder];
}
return nil;
}
// Profile photo zoom — intercepts IG's profile pic long press
%hook IGProfilePhotoCoinFlipUI.IGProfilePhotoCoinFlipView
- (void)viewLongPressedWithGesture:(UILongPressGestureRecognizer *)gesture {
if (![SCIUtils getBoolPref:@"zoom_profile_photo"]) { %orig; return; }
if (gesture.state != UIGestureRecognizerStateBegan) { %orig; return; }
// Find the IGProfilePictureImageView inside us
UIView *source = gesture.view;
NSMutableArray *q = [NSMutableArray arrayWithObject:source];
int scanned = 0;
while (q.count && scanned < 30) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:NSClassFromString(@"IGProfilePictureImageView")]) {
IGImageView *imgView = MSHookIvar<IGImageView *>(cur, "_imageView");
if (imgView) {
IGImageSpecifier *spec = imgView.imageSpecifier;
NSURL *url = spec ? spec.url : nil;
if (url) {
NSString *caption = sciProfileCaption(cur);
[SCIMediaViewer showWithVideoURL:nil photoURL:url caption:caption];
return;
}
}
}
for (UIView *s in cur.subviews) [q addObject:s];
}
%orig;
}
%end
%hook IGProfilePictureImageView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"save_profile"]) {
if ([SCIUtils getBoolPref:@"save_profile"] || [SCIUtils getBoolPref:@"zoom_profile_photo"]) {
[self addLongPressGestureRecognizer];
}
return;
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
@@ -695,6 +386,14 @@ static BOOL sciUseDownloadButtons() {
NSURL *imageUrl = imageSpecifier.url;
if (!imageUrl) return;
// Zoom: open in full-screen viewer with profile info
if ([SCIUtils getBoolPref:@"zoom_profile_photo"]) {
NSString *caption = sciProfileCaption(self);
[SCIMediaViewer showWithVideoURL:nil photoURL:imageUrl caption:caption];
return;
}
// Legacy: direct download
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:imageUrl
fileExtension:[[imageUrl lastPathComponent] pathExtension]
+125
View File
@@ -0,0 +1,125 @@
// Follow indicator — shows whether the profile user follows you.
// Fetches via /api/v1/friendships/show/{pk}/, renders inside the stats container.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../Networking/SCIInstagramAPI.h"
#import <objc/runtime.h>
#import <objc/message.h>
// IGProfileViewController declared in InstagramHeaders.h
static const NSInteger kFollowBadgeTag = 99788;
static NSString *sciPKFromUser(id igUser) {
if (!igUser) return nil;
Ivar pkIvar = NULL;
for (Class c = [igUser class]; c && !pkIvar; c = class_getSuperclass(c))
pkIvar = class_getInstanceVariable(c, "_pk");
if (!pkIvar) return nil;
return [object_getIvar(igUser, pkIvar) description];
}
static NSString *sciCurrentUserPK(void) {
@try {
for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) {
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *window in scene.windows) {
id session = [window valueForKey:@"userSession"];
if (!session) continue;
id su = [session valueForKey:@"user"];
if (su) return sciPKFromUser(su);
}
}
} @catch (NSException *e) {}
return nil;
}
// Cache follow status on the VC to avoid re-fetching
static const char kFollowStatusKey;
static NSNumber *sciGetFollowStatus(id vc) {
return objc_getAssociatedObject(vc, &kFollowStatusKey);
}
static void sciSetFollowStatus(id vc, NSNumber *status) {
objc_setAssociatedObject(vc, &kFollowStatusKey, status, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
static void sciRenderBadge(UIViewController *vc) {
NSNumber *status = sciGetFollowStatus(vc);
if (!status) return;
BOOL followedBy = [status boolValue];
UIView *statContainer = nil;
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
if ([NSStringFromClass([v class]) containsString:@"StatButtonContainerView"]) {
statContainer = v;
break;
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
if (!statContainer) return;
UIView *old = [statContainer viewWithTag:kFollowBadgeTag];
if (old) [old removeFromSuperview];
UILabel *badge = [[UILabel alloc] init];
badge.tag = kFollowBadgeTag;
badge.text = followedBy ? SCILocalized(@"Follows you") : SCILocalized(@"Doesn't follow you");
badge.font = [UIFont systemFontOfSize:11 weight:UIFontWeightMedium];
badge.textColor = followedBy
? [UIColor colorWithRed:0.3 green:0.75 blue:0.4 alpha:1.0]
: [UIColor colorWithRed:0.85 green:0.3 blue:0.3 alpha:1.0];
[badge sizeToFit];
CGFloat x = 0;
for (UIView *sub in statContainer.subviews) {
if (!sub.isHidden && sub.frame.size.width > 0) {
x = sub.frame.origin.x;
break;
}
}
badge.frame = CGRectMake(x, statContainer.bounds.size.height - badge.frame.size.height - 2,
badge.frame.size.width, badge.frame.size.height);
[statContainer addSubview:badge];
}
%hook IGProfileViewController
- (void)viewDidAppear:(BOOL)animated {
%orig;
if (![SCIUtils getBoolPref:@"follow_indicator"]) return;
// Already fetched — just re-render
if (sciGetFollowStatus(self)) {
sciRenderBadge(self);
return;
}
id igUser = nil;
@try { igUser = [self valueForKey:@"user"]; } @catch (NSException *e) {}
if (!igUser) return;
NSString *profilePK = sciPKFromUser(igUser);
NSString *myPK = sciCurrentUserPK();
if (!profilePK || !myPK || [profilePK isEqualToString:myPK]) return;
__weak UIViewController *weakSelf = self;
NSString *path = [NSString stringWithFormat:@"friendships/show/%@/", profilePK];
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *response, NSError *error) {
if (error || !response) return;
BOOL followedBy = [response[@"followed_by"] boolValue];
dispatch_async(dispatch_get_main_queue(), ^{
UIViewController *vc = weakSelf;
if (!vc) return;
sciSetFollowStatus(vc, @(followedBy));
sciRenderBadge(vc);
});
}];
}
%end
+40
View File
@@ -0,0 +1,40 @@
// Copy note text on long press — long-press the note bubble to copy text.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
// IGDirectNotesThoughtBubbleView declared in InstagramHeaders.h
%hook IGDirectNotesThoughtBubbleView
- (void)layoutSubviews {
%orig;
if (![SCIUtils getBoolPref:@"profile_note_copy"]) return;
// Only add once
static const NSInteger kCopyGestureTag = 99791;
for (UIGestureRecognizer *gr in self.gestureRecognizers) {
if (gr.view.tag == kCopyGestureTag) return;
}
self.tag = kCopyGestureTag;
UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc]
initWithTarget:self action:@selector(sciCopyNoteLongPress:)];
lp.minimumPressDuration = 0.5;
[self addGestureRecognizer:lp];
}
%new - (void)sciCopyNoteLongPress:(UILongPressGestureRecognizer *)gesture {
if (gesture.state != UIGestureRecognizerStateBegan) return;
Ivar textIvar = class_getInstanceVariable([self class], "_noteText");
if (!textIvar) return;
NSString *text = object_getIvar(self, textIvar);
if (!text.length) return;
[[UIPasteboard generalPasteboard] setString:text];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note copied")];
}
%end
+5 -5
View File
@@ -136,13 +136,13 @@ static UIView * _Nullable sciFindSubmitButton(UIView *root) {
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
return;
}
UITextField *textField = sciFindTextField(self);
if (!textField) {
[SCIUtils showErrorHUDWithDescription:@"No text field found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No text field found")];
return;
}
@@ -172,16 +172,16 @@ static UIView * _Nullable sciFindSubmitButton(UIView *root) {
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
return;
}
[[UIPasteboard generalPasteboard] setString:password];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Password"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Password")
message:password
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Copied!" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copied!") style:UIAlertActionStyleCancel handler:nil]];
UIViewController *topVC = topMostController();
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
}
+3 -3
View File
@@ -54,12 +54,12 @@ static BOOL sciReelRefreshBypassing = NO;
((void(*)(id,SEL))objc_msgSend)(rc, @selector(endRefreshing));
[self refreshControlDidEndFinishLoadingAnimation:rc];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Refresh Reels?"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Refresh Reels?")
message:nil
preferredStyle:UIAlertControllerStyleAlert];
__weak id weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Refresh") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
sciReelRefreshBypassing = YES;
SEL rSel = @selector(_refreshReelsWithParamsForNetworkRequest:userDidPullToRefresh:);
((void(*)(id,SEL,NSInteger,BOOL))objc_msgSend)(weakSelf, rSel, arg1, arg2);
@@ -123,7 +123,7 @@ static BOOL sciShouldBlockSeenVisualForObj(id obj) {
// Hooks all known like entry points to trigger mark-seen and auto-advance on like.
// Uses sciMarkSeenTapped: from OverlayButtons.xm for the actual seen flow.
static __weak UIViewController *sciActiveStoryVC = nil;
__weak UIViewController *sciActiveStoryVC = nil;
%hook IGStoryViewerViewController
- (void)viewDidAppear:(BOOL)animated {
@@ -72,27 +72,27 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
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."];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"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."];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"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."];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"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 setText:SCILocalized(@"Downloading audio...")];
[pill showInView:topView];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
@@ -119,7 +119,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
void (^present)(NSURL *) = ^(NSURL *url) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill setText:@"Done!"];
[pill setText:SCILocalized(@"Done!")];
[pill dismissAfterDelay:0.5];
[SCIUtils showShareVC:url];
});
@@ -74,8 +74,8 @@ static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
UIMenu *base = origProvider ? origProvider(suggested) : [UIMenu menuWithChildren:suggested];
BOOL inList = [SCIExcludedThreads isInList:tid];
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude chat");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude chat");
NSString *title = inList ? removeLabel : addLabel;
UIImage *img = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil
@@ -221,22 +221,22 @@ NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) {
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen");
NSString *title = inList ? removeLabel : addLabel;
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
void (^handler)(void) = ^{
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
// Removing in block_selected = normal behavior → mark seen
if (blockSelected) sciTriggerStoryMarkSeen(weakVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": pk, @"username": username, @"fullName": fullName
}];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
// Adding in block_all = normal behavior → mark seen
if (!blockSelected) sciTriggerStoryMarkSeen(weakVC);
}
@@ -0,0 +1,108 @@
// Full last active — replaces "Active Xm ago" with full date in DM chats.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
static NSDateFormatter *sciDMDateFormatter(void) {
static NSDateFormatter *df = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
df = [NSDateFormatter new];
df.dateFormat = @"MMM d 'at' h:mm a";
});
return df;
}
// Replace "Active Xm/h ago" with full date using _lastActiveDate from the thread
static void sciUpdateSubtitleLabel(UIView *titleView) {
if (![SCIUtils getBoolPref:@"dm_full_last_active"]) return;
// Get _subtitleLabel
Ivar subIvar = class_getInstanceVariable([titleView class], "_subtitleLabel");
if (!subIvar) return;
UILabel *label = object_getIvar(titleView, subIvar);
if (![label isKindOfClass:[UILabel class]]) return;
NSString *text = label.text;
if (!text.length) return;
// Only replace "Active X ago" patterns, not "Active now" or "Typing..."
if (![text hasPrefix:@"Active "] || ![text hasSuffix:@"ago"]) return;
// Get the _titleViewModel to find lastActiveDate
Ivar vmIvar = class_getInstanceVariable([titleView class], "_titleViewModel");
if (!vmIvar) return;
id vm = object_getIvar(titleView, vmIvar);
if (!vm) return;
// Try to get lastActiveDate from the view model
NSDate *activeDate = nil;
// Check vm for lastActiveDate / lastActive / activeDate
for (NSString *sel in @[@"lastActiveDate", @"lastActive", @"activeDate"]) {
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
id val = [vm valueForKey:sel];
if ([val isKindOfClass:[NSDate class]]) { activeDate = val; break; }
if ([val isKindOfClass:[NSNumber class]]) {
activeDate = [NSDate dateWithTimeIntervalSince1970:[(NSNumber *)val doubleValue]];
break;
}
}
}
// If no date on VM, parse from the label text as fallback
if (!activeDate) {
// "Active 8m ago" → 8 minutes ago
// "Active 2h ago" → 2 hours ago
NSTimeInterval delta = 0;
NSScanner *scanner = [NSScanner scannerWithString:text];
[scanner scanString:@"Active " intoString:nil];
double val = 0;
if ([scanner scanDouble:&val]) {
NSString *rest = [text substringFromIndex:scanner.scanLocation];
if ([rest hasPrefix:@"m"]) delta = val * 60;
else if ([rest hasPrefix:@"h"]) delta = val * 3600;
else if ([rest hasPrefix:@"d"]) delta = val * 86400;
}
if (delta > 0) {
activeDate = [NSDate dateWithTimeIntervalSinceNow:-delta];
}
}
if (!activeDate) return;
NSString *formatted = [sciDMDateFormatter() stringFromDate:activeDate];
if (formatted.length) {
label.text = formatted;
// Also update _subtitleView and _transitionalSubtitleLabel if they exist
Ivar svIvar = class_getInstanceVariable([titleView class], "_subtitleView");
if (svIvar) {
id sv = object_getIvar(titleView, svIvar);
if ([sv isKindOfClass:[UILabel class]])
[(UILabel *)sv setText:label.text];
}
Ivar tsIvar = class_getInstanceVariable([titleView class], "_transitionalSubtitleLabel");
if (tsIvar) {
id ts = object_getIvar(titleView, tsIvar);
if ([ts isKindOfClass:[UILabel class]])
[(UILabel *)ts setText:label.text];
}
}
}
%hook IGDirectLeftAlignedTitleView
- (void)setTitleViewModel:(id)vm {
%orig;
sciUpdateSubtitleLabel(self);
}
- (void)animationCoordinatorDidUpdate:(id)coordinator {
%orig;
sciUpdateSubtitleLabel(self);
}
%end
@@ -0,0 +1,90 @@
// Hide voice/video call buttons in DM thread header.
#import "../../Utils.h"
// IGDirectThreadCallButtonsCoordinator / IGDirectCallButton / IGNavigationBar
// declared in InstagramHeaders.h
static BOOL sciShouldHide(UIView *b) {
if (![b isKindOfClass:NSClassFromString(@"IGDirectCallButton")]) return NO;
NSString *axId = b.accessibilityIdentifier;
if ([axId isEqualToString:@"audio-call"]) return [SCIUtils getBoolPref:@"hide_voice_call_button"];
if ([axId isEqualToString:@"video-chat"]) return [SCIUtils getBoolPref:@"hide_video_call_button"];
return NO;
}
static BOOL sciPlatterContainsHiddenButton(UIView *platter) {
NSMutableArray *q = [NSMutableArray arrayWithObject:platter];
while (q.count) {
UIView *v = q.firstObject;
[q removeObjectAtIndex:0];
if (sciShouldHide(v)) return YES;
[q addObjectsFromArray:v.subviews];
}
return NO;
}
// Block taps in case a hidden button still receives hit-test events during transitions.
%hook IGDirectThreadCallButtonsCoordinator
- (void)_didTapAudioButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"hide_voice_call_button"]) return;
%orig;
}
- (void)_didTapVideoButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"hide_video_call_button"]) return;
%orig;
}
%end
%hook IGDirectCallButton
- (void)didMoveToWindow {
%orig;
if (!self.window) return;
if (sciShouldHide((UIView *)self)) self.hidden = YES;
}
%end
// Re-pack platters on each layout: shift every non-back platter right by the
// total width of the hidden call platters to eliminate the gap.
static void sciRepackPlatters(UIView *container) {
NSMutableArray *platters = [NSMutableArray array];
for (UIView *sv in container.subviews)
if ([NSStringFromClass([sv class]) isEqualToString:@"_UINavigationBarPlatterView"])
[platters addObject:sv];
CGFloat hiddenWidth = 0;
NSMutableArray *alive = [NSMutableArray array];
for (UIView *p in platters) {
if (sciPlatterContainsHiddenButton(p)) {
hiddenWidth += p.frame.size.width;
p.hidden = YES;
} else {
p.hidden = NO;
[alive addObject:p];
}
}
if (!alive.count || hiddenWidth == 0) {
for (UIView *p in alive) p.transform = CGAffineTransformIdentity;
return;
}
for (UIView *p in alive) {
if (p.frame.origin.x < 60) { p.transform = CGAffineTransformIdentity; continue; }
p.transform = CGAffineTransformMakeTranslation(hiddenWidth, 0);
}
}
%hook IGNavigationBar
- (void)layoutSubviews {
%orig;
NSMutableArray *q = [NSMutableArray arrayWithObject:self];
while (q.count) {
UIView *v = q.firstObject;
[q removeObjectAtIndex:0];
if ([NSStringFromClass([v class]) containsString:@"NavigationBarPlatterContainer"]) {
sciRepackPlatters(v);
break;
}
[q addObjectsFromArray:v.subviews];
}
}
%end
@@ -97,18 +97,18 @@ static void new_pullToRefresh(id self, SEL _cmd) {
@"Refreshing the DMs tab will clear %lu preserved unsent message%@. This cannot be undone.",
(unsigned long)count, count == 1 ? @"" : @"s"];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Clear preserved messages?"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Clear preserved messages?")
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakSelf = vc;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel
handler:^(UIAlertAction *a) {
sciCancelRefresh(weakSelf);
sciRefreshAlertVisible = NO;
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDestructive
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Refresh") style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *a) {
sciRefreshAlertVisible = NO;
id strongSelf = weakSelf;
@@ -356,7 +356,7 @@ static void sciShowUnsentToast() {
pill.alpha = 0;
UILabel *label = [[UILabel alloc] init];
label.text = @"A message was unsent";
label.text = SCILocalized(@"A message was unsent");
label.textColor = [UIColor whiteColor];
label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
label.textAlignment = NSTextAlignmentCenter;
@@ -606,7 +606,7 @@ static void sciUpdateCellIndicator(id cell) {
UIView *parent = bubble ?: view;
UILabel *label = [[UILabel alloc] init];
label.tag = SCI_PRESERVED_TAG;
label.text = @"Unsent";
label.text = SCILocalized(@"Unsent");
label.font = [UIFont italicSystemFontOfSize:10];
label.textColor = [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.9];
label.translatesAutoresizingMaskIntoConstraints = NO;
@@ -0,0 +1,292 @@
// 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;
}
// Get audio URL from the cell's view model
static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) {
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 audioSel = NSSelectorFromString(@"audioTrackWithUserMap:");
if (![vm respondsToSelector:audioSel]) return nil;
@try {
id track = ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel, nil);
if (!track) return nil;
// audioFileURL is an IGAsyncTask — try to resolve it
if ([track respondsToSelector:NSSelectorFromString(@"audioFileURL")]) {
id urlOrTask = [track valueForKey:@"audioFileURL"];
if ([urlOrTask isKindOfClass:[NSURL class]]) return urlOrTask;
// IGAsyncTask — try .result, .value, .get
for (NSString *prop in @[@"result", @"value", @"get", @"cachedResult"]) {
if ([urlOrTask respondsToSelector:NSSelectorFromString(prop)]) {
@try {
id resolved = [urlOrTask valueForKey:prop];
if ([resolved isKindOfClass:[NSURL class]]) return resolved;
} @catch (__unused id e) {}
}
}
SEL awaitSel = NSSelectorFromString(@"await");
if ([urlOrTask respondsToSelector:awaitSel]) {
@try {
id resolved = ((id(*)(id,SEL))objc_msgSend)(urlOrTask, awaitSel);
if ([resolved isKindOfClass:[NSURL class]]) return resolved;
} @catch (__unused id e) {}
}
}
} @catch (__unused id e) {}
return 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]];
}]];
}
// Audio (style=1): download from audioFileURL
NSURL *audioURL = sciAudioURLFromCell(cell, note);
if (audioURL) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Download audio")
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
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);
}
+248 -134
View File
@@ -1,7 +1,14 @@
// Download + mark seen buttons on story/DM visual message overlay
// Action + mark-seen buttons on story/DM visual message overlay
// Tags: [1339] eye [1340] action [1341] audio
#import "StoryHelpers.h"
#import "SCIExcludedThreads.h"
#import "SCIExcludedStoryUsers.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import "../../ActionButton/SCIActionMenu.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import "../../Downloader/Download.h"
extern "C" BOOL sciSeenBypassActive;
extern "C" BOOL sciAdvanceBypassActive;
@@ -18,92 +25,110 @@ extern "C" void sciToggleStoryAudio(void);
extern "C" BOOL sciIsStoryAudioEnabled(void);
extern "C" void sciInitStoryAudioState(void);
extern "C" void sciResetStoryAudioState(void);
extern "C" void sciShowStoryMentions(UIViewController *, UIView *);
static SCIDownloadDelegate *sciStoryVideoDl = nil;
static SCIDownloadDelegate *sciStoryImageDl = nil;
static void sciInitStoryDownloaders() {
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
DownloadAction action = [method isEqualToString:@"photos"] ? saveToPhotos : share;
DownloadAction imgAction = [method isEqualToString:@"photos"] ? saveToPhotos : quickLook;
sciStoryVideoDl = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES];
sciStoryImageDl = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO];
}
static void sciDownloadMedia(IGMedia *media) {
sciInitStoryDownloaders();
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (videoUrl) {
[sciStoryVideoDl downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil];
return;
}
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
if (photoUrl) {
[sciStoryImageDl downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not extract URL"];
}
static void sciDownloadWithConfirm(void(^block)(void)) {
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
[SCIUtils showConfirmation:block title:@"Download?"];
} else {
block();
}
}
static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// ── Disappearing DM media ──
static NSURL *sciDisappearingMediaURL(UIViewController *dmVC, BOOL *outIsVideo) {
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
if (!ds) return;
Ivar msgIvar = class_getInstanceVariable([ds class], "_currentMessage");
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
if (!msg) return;
id rawVideo = sciCall(msg, @selector(rawVideo));
if (rawVideo) {
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryVideoDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
id rawPhoto = sciCall(msg, @selector(rawPhoto));
if (rawPhoto) {
NSURL *url = [SCIUtils getPhotoUrl:rawPhoto];
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
id imgSpec = sciCall(msg, NSSelectorFromString(@"imageSpecifier"));
if (imgSpec) {
NSURL *url = sciCall(imgSpec, @selector(url));
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
if (!msg) return nil;
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
if (vmi) {
Ivar mediaIvar = class_getInstanceVariable([vmi class], "_media");
id mediaObj = mediaIvar ? object_getIvar(vmi, mediaIvar) : nil;
if (mediaObj) {
IGMedia *media = sciExtractMediaFromItem(mediaObj);
if (!media && [mediaObj isKindOfClass:NSClassFromString(@"IGMedia")]) media = (IGMedia *)mediaObj;
if (media) { sciDownloadWithConfirm(^{ sciDownloadMedia(media); }); return; }
}
}
Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil;
id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil;
if (!visMedia) return nil;
[SCIUtils showErrorHUDWithDescription:@"Could not find media"];
// Video
@try {
id rawVideo = [msg valueForKey:@"rawVideo"];
if (rawVideo) {
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
if (url) { if (outIsVideo) *outIsVideo = YES; return url; }
}
} @catch (NSException *e) {}
// Photo
Ivar pi = class_getInstanceVariable([visMedia class], "_photo_photo");
id photo = pi ? object_getIvar(visMedia, pi) : nil;
if (photo) {
if (outIsVideo) *outIsVideo = NO;
return [SCIUtils getPhotoUrl:photo];
}
return nil;
}
static SCIDownloadDelegate *sciDMDownloadDelegate = nil;
static void sciDownloadDisappearingMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
sciDMDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:YES];
[sciDMDownloadDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
}
static SCIDownloadDelegate *sciDMShareDelegate = nil;
static void sciShareDisappearingMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
sciDMShareDelegate = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:YES];
[sciDMShareDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
}
static void sciExpandDisappearingMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
if (isVideo) {
[SCIMediaViewer showWithVideoURL:url photoURL:nil caption:nil];
} else {
[SCIMediaViewer showWithVideoURL:nil photoURL:url caption:nil];
}
}
// ── Story playback control ──
static void sciPauseStoryPlayback(UIView *sourceView) {
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
if (!storyVC) return;
id sc = sciFindSectionController(storyVC);
SEL pauseSel = NSSelectorFromString(@"pauseWithReason:");
if (sc && [sc respondsToSelector:pauseSel]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, pauseSel, 10);
return;
}
if ([storyVC respondsToSelector:pauseSel]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, pauseSel, 10);
return;
}
}
static void sciResumeStoryPlayback(UIView *sourceView) {
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
if (!storyVC) return;
id sc = sciFindSectionController(storyVC);
SEL resumeSel1 = NSSelectorFromString(@"tryResumePlaybackWithReason:");
SEL resumeSel2 = NSSelectorFromString(@"tryResumePlayback");
if (sc && [sc respondsToSelector:resumeSel1]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, resumeSel1, 0);
return;
}
if ([storyVC respondsToSelector:resumeSel2]) {
((void(*)(id, SEL))objc_msgSend)(storyVC, resumeSel2);
return;
}
if ([storyVC respondsToSelector:resumeSel1]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, resumeSel1, 0);
return;
}
}
%hook IGStoryFullscreenOverlayView
@@ -114,18 +139,17 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
%orig;
if (!self.superview) return;
// Download button
if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) {
// Action button
if ([SCIUtils getBoolPref:@"stories_action_button"] && ![self viewWithTag:1340]) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1340;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:@"arrow.down" withConfiguration:cfg] forState:UIControlStateNormal];
[btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 18;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciDownloadTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
@@ -133,9 +157,108 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
[SCIActionButton configureButton:btn
context:SCIActionContextStories
prefKey:@"stories_action_default"
mediaProvider:^id (UIView *sourceView) {
// DM disappearing message — handle directly
UIViewController *dmVC = sciFindVC(sourceView, @"IGDirectVisualMessageViewerController");
if (dmVC) {
sciDownloadDisappearingMedia(dmVC);
return (id)kCFNull;
}
// Story path
sciPauseStoryPlayback(sourceView);
id item = sciGetCurrentStoryItem(sourceView);
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) return item;
return sciExtractMediaFromItem(item);
}];
// For DM visual messages: override menu with download/share/expand
btn.menu = [UIMenu menuWithChildren:@[
[UIDeferredMenuElement elementWithUncachedProvider:^(void (^completion)(NSArray<UIMenuElement *> *)) {
UIViewController *dmVC = sciFindVC(btn, @"IGDirectVisualMessageViewerController");
if (dmVC) {
completion(@[
[UIAction actionWithTitle:SCILocalized(@"Expand") image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"]
identifier:nil handler:^(UIAction *a) { sciExpandDisappearingMedia(dmVC); }],
[UIAction actionWithTitle:SCILocalized(@"Share") image:[UIImage systemImageNamed:@"square.and.arrow.up"]
identifier:nil handler:^(UIAction *a) { sciShareDisappearingMedia(dmVC); }],
[UIAction actionWithTitle:SCILocalized(@"Save to Photos") image:[UIImage systemImageNamed:@"square.and.arrow.down"]
identifier:nil handler:^(UIAction *a) { sciDownloadDisappearingMedia(dmVC); }],
]);
} else {
// Story — use normal action menu
id media = nil;
sciPauseStoryPlayback(btn);
id item = sciGetCurrentStoryItem(btn);
media = [item isKindOfClass:NSClassFromString(@"IGMedia")] ? item : sciExtractMediaFromItem(item);
NSArray *actions = [SCIMediaActions actionsForContext:SCIActionContextStories media:media fromView:btn];
UIMenu *built = [SCIActionMenu buildMenuWithActions:actions];
completion(built.children);
}
}]
]];
btn.showsMenuAsPrimaryAction = YES;
// KVO highlighted → resume playback when menu dismisses.
[btn addObserver:self forKeyPath:@"highlighted"
options:NSKeyValueObservingOptionNew context:NULL];
// Story reel items provider for "download all" detection.
static const void *kStoryReelItemsProvider = &kStoryReelItemsProvider;
objc_setAssociatedObject(btn, kStoryReelItemsProvider, ^NSArray *(UIView *src) {
UIViewController *storyVC = sciFindVC(src, @"IGStoryViewerViewController");
if (!storyVC) return nil;
id vm = sciCall(storyVC, @selector(currentViewModel));
if (!vm) return nil;
// Try known selectors
for (NSString *sel in @[@"items", @"storyItems", @"reelItems", @"mediaItems", @"allItems"]) {
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
@try {
id val = ((id(*)(id,SEL))objc_msgSend)(vm, NSSelectorFromString(sel));
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
return val;
}
} @catch (__unused id e) {}
}
}
// Scan vm ivars for arrays of IGMedia
Class mc = NSClassFromString(@"IGMedia");
unsigned int cnt = 0;
Ivar *ivs = class_copyIvarList(object_getClass(vm), &cnt);
for (unsigned int i = 0; i < cnt; i++) {
const char *type = ivar_getTypeEncoding(ivs[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(vm, ivs[i]);
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
id first = [(NSArray *)val firstObject];
if (mc && [first isKindOfClass:mc]) {
free(ivs);
return val;
}
// Items might be wrapped — try extracting media from first
IGMedia *extracted = sciExtractMediaFromItem(first);
if (extracted) {
free(ivs);
return val;
}
}
} @catch (__unused id e) {}
}
if (ivs) free(ivs);
return nil;
}, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
// Audio toggle button (left side, small)
// Audio toggle button
sciInitStoryAudioState();
if ([SCIUtils getBoolPref:@"story_audio_toggle"] && ![self viewWithTag:1341]) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
@@ -168,6 +291,17 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// ============ Seen button lifecycle ============
// KVO: action button highlighted → NO means UIMenu dismissed → resume.
%new - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
change:(NSDictionary *)change context:(void *)context {
if ([keyPath isEqualToString:@"highlighted"]) {
BOOL highlighted = [change[NSKeyValueChangeNewKey] boolValue];
if (!highlighted) {
sciResumeStoryPlayback(self);
}
}
}
// Refresh the audio toggle icon (tag 1341) to match current state.
%new - (void)sciRefreshAudioButton {
UIButton *btn = (UIButton *)[self viewWithTag:1341];
@@ -304,33 +438,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
[sender setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
}
// ============ Download handler ============
%new - (void)sciDownloadTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); }
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
@try {
id item = sciGetCurrentStoryItem(self);
IGMedia *media = sciExtractMediaFromItem(item);
if (media) {
sciDownloadWithConfirm(^{ sciDownloadMedia(media); });
return;
}
UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController");
if (dmVC) {
sciDownloadDMVisualMessage(dmVC);
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not find media"];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
}
}
// ============ Seen button tap ============
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
@@ -343,19 +450,19 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
if (bs && !inList && ownerPK) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:[NSString stringWithFormat:@"Story seen receipts will be blocked for @%@.", ownerInfo[@"username"] ?: @""]
alertControllerWithTitle:SCILocalized(@"Add to block list?")
message:[NSString stringWithFormat:SCILocalized(@"Story seen receipts will be blocked for @%@."), ownerInfo[@"username"] ?: @""]
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": ownerPK,
@"username": ownerInfo[@"username"] ?: @"",
@"fullName": ownerInfo[@"fullName"] ?: @""
}];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
@@ -369,18 +476,18 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// Block all + in list: tap to remove from exclude list
if (inList) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude story seen?";
NSString *alertTitle = bs ? SCILocalized(@"Remove from block list?") : SCILocalized(@"Un-exclude story seen?");
NSString *alertMsg = bs ? [NSString stringWithFormat:@"@%@ will no longer have seen receipts blocked.", ownerInfo[@"username"] ?: @""]
: [NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:bs ? @"Unblock" : @"Un-exclude" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:bs ? SCILocalized(@"Unblock") : SCILocalized(@"Un-exclude") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers removePK:ownerPK];
[SCIUtils showToastForDuration:2.0 title:bs ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
if (bs) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
@@ -391,7 +498,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
[sender setImage:[UIImage systemImageNamed:(sciStorySeenToggleEnabled ? @"eye.fill" : @"eye") withConfiguration:cfg] forState:UIControlStateNormal];
sender.tintColor = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? @"Story read receipts enabled" : @"Story read receipts disabled"];
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")];
return;
}
@@ -406,6 +513,9 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
UIView *btn = gr.view;
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
if (!host) return;
// Pause story while the sheet is open
sciPauseStoryPlayback(self);
UIWindow *capturedWin = btn.window ?: self.window;
if (!capturedWin) {
for (UIWindow *w in [UIApplication sharedApplication].windows) { if (w.isKeyWindow) { capturedWin = w; break; } }
@@ -417,31 +527,35 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk];
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
__weak UIView *weakSelf = self;
void (^resume)(void) = ^{ if (weakSelf) sciResumeStoryPlayback(weakSelf); };
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
[sheet addAction:[UIAlertAction actionWithTitle:@"Mark seen" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Mark seen") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn);
resume();
}]];
if (pk) {
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen");
NSString *t = inList ? removeLabel : addLabel;
[sheet addAction:[UIAlertAction actionWithTitle:t style:inList ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
resume();
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Stories settings" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIUtils showSettingsVC:capturedWin atTopLevelEntry:@"Stories"];
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:^(UIAlertAction *_) {
resume();
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.sourceView = btn;
sheet.popoverPresentationController.sourceRect = btn.bounds;
[host presentViewController:sheet animated:YES completion:nil];
@@ -466,7 +580,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
if (!storyItem) storyItem = sciGetCurrentStoryItem(self);
IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem);
if (!media) { [SCIUtils showErrorHUDWithDescription:@"Could not find story media"]; return; }
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; }
sciAllowSeenForPK(media);
sciSeenBypassActive = YES;
@@ -496,7 +610,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
}
}
sciSeenBypassActive = NO;
[SCIUtils showToastForDuration:2.0 title:@"Marked as seen" subtitle:@"Will sync when leaving stories"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked as seen") subtitle:SCILocalized(@"Will sync when leaving stories")];
// Advance to next story if enabled (skip when triggered programmatically via exclude)
if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) {
@@ -561,13 +675,13 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
dmVisualMsgsViewedButtonEnabled = wasEnabled;
});
[SCIUtils showToastForDuration:1.5 title:@"Marked as viewed"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")];
return;
}
[SCIUtils showErrorHUDWithDescription:@"VC not found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]];
}
}
+108 -64
View File
@@ -73,7 +73,7 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
// 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) {
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];
@@ -92,37 +92,50 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (seenFeatureOn && !excluded) {
BOOL toggleMode = sciIsSeenToggleMode();
NSString *title;
UIImage *img;
// Toggle mode: show toggle action + one-shot mark seen
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) {
NSString *toggleTitle = dmSeenToggleEnabled ? SCILocalized(@"Disable read receipts") : SCILocalized(@"Enable read receipts");
UIImage *toggleImg2 = [UIImage systemImageNamed:@"arrow.triangle.2.circlepath"];
UIAction *toggleAction = [UIAction actionWithTitle:toggleTitle image:toggleImg2 identifier:nil
handler:^(__kindof UIAction *_) {
dmSeenToggleEnabled = !dmSeenToggleEnabled;
if (dmSeenToggleEnabled) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if (dmSeenToggleEnabled && [nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(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];
[SCIUtils showToastForDuration:2.0 title:dmSeenToggleEnabled ? SCILocalized(@"Read receipts enabled") : SCILocalized(@"Read receipts disabled")];
sciRefreshNavBarItems(anchor);
}];
toggleAction.state = dmSeenToggleEnabled ? UIMenuElementStateOn : UIMenuElementStateOff;
[items addObject:toggleAction];
UIAction *markSeen = [UIAction actionWithTitle:SCILocalized(@"Mark messages as seen")
image:[UIImage systemImageNamed:@"checkmark.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked messages as seen")];
}];
[items addObject:markSeen];
} else {
// Button mode: just mark seen
UIAction *seenAction = [UIAction actionWithTitle:SCILocalized(@"Mark messages as seen")
image:[UIImage systemImageNamed:@"checkmark.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked messages as seen")];
}];
[items addObject:seenAction];
}
}
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude chat");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude chat");
NSString *toggleTitle = inList ? removeLabel : addLabel;
UIImage *toggleImg = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
__weak UIView *weakAnchor = anchor;
@@ -131,7 +144,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (!threadId) return;
if (inList) {
[SCIExcludedThreads removeThreadId:threadId];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
// In block_selected, removing = normal behavior → mark seen
if (blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
@@ -143,7 +156,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
NSDictionary *entry = sciEntryFromThreadVC(anchorVC);
if (!entry) entry = @{ @"threadId": threadId, @"threadName": @"", @"isGroup": @NO, @"users": @[] };
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
// In block_all, excluding = normal behavior → mark seen
if (!blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
@@ -156,7 +169,25 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (excluded) toggle.attributes = UIMenuElementAttributesDestructive;
[items addObject:toggle];
UIAction *openSettings = [UIAction actionWithTitle:@"Messages settings"
// Unlimited replay toggle
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !excluded) {
NSString *replayTitle = dmVisualMsgsViewedButtonEnabled
? SCILocalized(@"Visual messages: expiring")
: SCILocalized(@"Visual messages: unlimited replay");
UIImage *replayImg = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled
? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
UIAction *replayAction = [UIAction actionWithTitle:replayTitle image:replayImg identifier:nil
handler:^(__kindof UIAction *_) {
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
sciRefreshNavBarItems(anchor);
}];
replayAction.state = dmVisualMsgsViewedButtonEnabled ? UIMenuElementStateOff : UIMenuElementStateOn;
[items addObject:replayAction];
}
UIAction *openSettings = [UIAction actionWithTitle:SCILocalized(@"Messages settings")
image:[UIImage systemImageNamed:@"gear"]
identifier:nil
handler:^(__kindof UIAction *_) {
@@ -213,16 +244,16 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
NSDictionary *entry = sciEntryFromThreadVC(nearestVC);
if (!entry) return;
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:@"Read receipts will be blocked for this chat."
alertControllerWithTitle:SCILocalized(@"Add to block list?")
message:SCILocalized(@"Read receipts will be blocked for this chat.")
preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[nearestVC presentViewController:alert animated:YES completion:nil];
}
@@ -232,30 +263,40 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
if (!tid) return;
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude chat?";
NSString *alertMsg = bs ? @"Read receipts will no longer be blocked for this chat."
: @"This chat will resume normal read-receipt behavior.";
NSString *alertTitle = bs ? SCILocalized(@"Remove from block list?") : SCILocalized(@"Un-exclude chat?");
NSString *alertMsg = bs ? SCILocalized(@"Read receipts will no longer be blocked for this chat.")
: SCILocalized(@"This chat will resume normal read-receipt behavior.");
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Remove") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedThreads removeThreadId:tid];
[SCIUtils showToastForDuration:2.0 title:@"Removed"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Removed")];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"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.
// Strip our own injected buttons (so re-runs don't dupe) and drop
// IGDirectCallButton-backed items when their hide pref is on — some
// account variants bundle them into the same platter as our eye btn.
BOOL hideVoice = [SCIUtils getBoolPref:@"hide_voice_call_button"];
BOOL hideVideo = [SCIUtils getBoolPref:@"hide_video_call_button"];
BOOL hideBlend = [SCIUtils getBoolPref:@"hide_reels_blend"];
NSMutableArray *new_items = [[items filteredArrayUsingPredicate:
[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 ![aid isEqualToString:@"blend-button"];
if (hideBlend && [aid isEqualToString:@"blend-button"]) return NO;
UIView *cv = value.customView;
if (cv && [cv isKindOfClass:NSClassFromString(@"IGDirectCallButton")]) {
NSString *cvAx = cv.accessibilityIdentifier;
if (hideVoice && [cvAx isEqualToString:@"audio-call"]) return NO;
if (hideVideo && [cvAx isEqualToString:@"video-chat"]) return NO;
}
return YES;
}]
] mutableCopy];
@@ -298,11 +339,15 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
[new_items addObject:listBtn];
}
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];
// Replay toggle: in eye menu when eye button exists, standalone button otherwise
BOOL eyeButtonOn = [SCIUtils getBoolPref:@"remove_lastseen"];
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded && !eyeButtonOn) {
UIBarButtonItem *replayBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"]
style:UIBarButtonItemStylePlain target:self action:@selector(sciReplayToggleHandler:)];
replayBtn.accessibilityIdentifier = @"sci-visual-btn";
replayBtn.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
[new_items addObject:replayBtn];
}
%orig([new_items copy]);
@@ -318,32 +363,31 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.5 title:@"Read receipts enabled"];
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Read receipts enabled")];
} else {
[SCIUtils showToastForDuration:2.5 title:@"Read receipts disabled"];
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Read receipts disabled")];
}
} else {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.5 title:@"Marked messages as seen"];
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Marked messages as seen")];
}
}
// Rebuild menu so toggle text updates
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *tid = sciThreadIdForVC(navNearestVC);
sender.menu = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window);
}
// ============ DM VISUAL MESSAGES VIEWED BUTTON ============
%new - (void)dmVisualMsgsViewedButtonHandler:(UIBarButtonItem *)sender {
if (dmVisualMsgsViewedButtonEnabled) {
dmVisualMsgsViewedButtonEnabled = false;
[sender setTintColor:UIColor.labelColor];
[SCIUtils showToastForDuration:4.5 title:@"Visual messages can be replayed without expiring"];
} else {
dmVisualMsgsViewedButtonEnabled = true;
[sender setTintColor:SCIUtils.SCIColor_Primary];
[SCIUtils showToastForDuration:4.5 title:@"Visual messages will now expire after viewing"];
}
%new - (void)sciReplayToggleHandler:(UIBarButtonItem *)sender {
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
sender.image = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
sender.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
}
%end
// ============ SEEN BLOCKING LOGIC ============
@@ -0,0 +1,148 @@
// Mark seen + advance when replying or reacting to a story.
#import "../../Utils.h"
#import "StoryHelpers.h"
#import <objc/message.h>
#import <objc/runtime.h>
#import <substrate.h>
extern __weak UIViewController *sciActiveStoryVC;
extern BOOL sciAdvanceBypassActive;
static UIView *sciFindOverlayForStoryVC(UIViewController *vc) {
if (!vc) return nil;
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) return nil;
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
while (stack.count) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ([v isKindOfClass:overlayCls]) return v;
for (UIView *s in v.subviews) [stack addObject:s];
}
return nil;
}
static void sciMarkSeenOnReply(void) {
if (![SCIUtils getBoolPref:@"seen_on_story_reply"]) return;
UIView *overlay = sciFindOverlayForStoryVC(sciActiveStoryVC);
if (!overlay) return;
SEL sel = @selector(sciMarkSeenTapped:);
if ([overlay respondsToSelector:sel])
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
}
static uint64_t sciLastReplyAdvanceTime = 0;
static void sciAdvanceOnReply(void) {
if (![SCIUtils getBoolPref:@"advance_on_story_reply"]) return;
UIViewController *storyVC = sciActiveStoryVC;
if (!storyVC) return;
id sectionCtrl = sciFindSectionController(storyVC);
if (!sectionCtrl) return;
// Dedup across multiple hooks firing for the same event
uint64_t now = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
if (now - sciLastReplyAdvanceTime < 500000000ULL) return;
sciLastReplyAdvanceTime = now;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciAdvanceBypassActive = YES;
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
if ([sectionCtrl respondsToSelector:advSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sectionCtrl, advSel, 1);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
id sc2 = storyVC ? sciFindSectionController(storyVC) : nil;
if (sc2) {
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
if ([sc2 respondsToSelector:resumeSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
}
sciAdvanceBypassActive = NO;
});
});
}
static void sciOnStoryReply(void) {
sciMarkSeenOnReply();
sciAdvanceOnReply();
}
// Text reply — IGDirectComposer is shared with DMs, gate by active story VC.
%hook IGDirectComposer
- (void)_didTapSend:(id)arg {
%orig;
if (sciActiveStoryVC) sciOnStoryReply();
}
- (void)_send {
%orig;
if (sciActiveStoryVC) sciOnStoryReply();
}
%end
// Composer emoji reaction buttons (forwarded to the Swift footer delegate)
static void (*orig_footerEmojiQuick)(id, SEL, id, id);
static void new_footerEmojiQuick(id self, SEL _cmd, id inputView, id btn) {
orig_footerEmojiQuick(self, _cmd, inputView, btn);
sciOnStoryReply();
}
static void (*orig_footerEmojiReaction)(id, SEL, id, id);
static void new_footerEmojiReaction(id self, SEL _cmd, id inputView, id btn) {
orig_footerEmojiReaction(self, _cmd, inputView, btn);
sciOnStoryReply();
}
// Swipe-up quick reactions tray
static void (*orig_qrCtrlDidTapEmoji)(id, SEL, id, id, id);
static void new_qrCtrlDidTapEmoji(id self, SEL _cmd, id view, id sourceBtn, id emoji) {
orig_qrCtrlDidTapEmoji(self, _cmd, view, sourceBtn, emoji);
sciOnStoryReply();
}
static void (*orig_qrDelegateDidTapEmoji)(id, SEL, id, id, id);
static void new_qrDelegateDidTapEmoji(id self, SEL _cmd, id ctrl, id sourceBtn, id emoji) {
orig_qrDelegateDidTapEmoji(self, _cmd, ctrl, sourceBtn, emoji);
sciOnStoryReply();
}
// Swift classes aren't guaranteed to be registered at %ctor time — install
// lazily on first overlay appearance as a fallback.
static void sciInstallReplyHooks(void) {
static BOOL installed = NO;
if (installed) return;
Class footerCls = NSClassFromString(@"IGStoryDefaultFooter.IGStoryFullscreenDefaultFooterView");
Class qrCtrl = NSClassFromString(@"IGStoryQuickReactions.IGStoryQuickReactionsController");
Class qrDelegate = NSClassFromString(@"IGStoryQuickReactionsDelegate.IGStoryQuickReactionsDelegateImpl");
if (!footerCls || !qrCtrl || !qrDelegate) return;
installed = YES;
SEL quick = NSSelectorFromString(@"inputView:didTapEmojiQuickReactionButton:");
if (class_getInstanceMethod(footerCls, quick))
MSHookMessageEx(footerCls, quick, (IMP)new_footerEmojiQuick, (IMP *)&orig_footerEmojiQuick);
SEL reaction = NSSelectorFromString(@"inputView:didTapEmojiReactionButton:");
if (class_getInstanceMethod(footerCls, reaction))
MSHookMessageEx(footerCls, reaction, (IMP)new_footerEmojiReaction, (IMP *)&orig_footerEmojiReaction);
SEL qrSel = NSSelectorFromString(@"quickReactionsView:sourceEmojiButton:didTapEmoji:");
if (class_getInstanceMethod(qrCtrl, qrSel))
MSHookMessageEx(qrCtrl, qrSel, (IMP)new_qrCtrlDidTapEmoji, (IMP *)&orig_qrCtrlDidTapEmoji);
SEL qrdSel = NSSelectorFromString(@"storyQuickReactionsController:sourceEmojiButton:didTapEmoji:");
if (class_getInstanceMethod(qrDelegate, qrdSel))
MSHookMessageEx(qrDelegate, qrdSel, (IMP)new_qrDelegateDidTapEmoji, (IMP *)&orig_qrDelegateDidTapEmoji);
}
%hook IGStoryFullscreenOverlayView
- (void)didMoveToWindow {
%orig;
sciInstallReplyHooks();
}
%end
%ctor {
sciInstallReplyHooks();
}
@@ -5,6 +5,7 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../SCIFFmpeg.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <AVFoundation/AVFoundation.h>
@@ -77,26 +78,26 @@ static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
if ([threadVC respondsToSelector:vmSel]) {
typedef void (*Fn)(id, SEL, id, id, double, NSInteger, id);
((Fn)objc_msgSend)(threadVC, vmSel, audioURL, waveform, duration, (NSInteger)2, nil);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
SEL s7 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:aiVoiceEffectApplied:sendButtonTypeTapped:);
if ([threadVC respondsToSelector:s7]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger, id, id);
((Fn)objc_msgSend)(threadVC, s7, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2, nil, nil);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
SEL s5 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:);
if ([threadVC respondsToSelector:s5]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger);
((Fn)objc_msgSend)(threadVC, s5, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
[SCIUtils showErrorHUDWithDescription:@"No voice send method found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No voice send method found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Send failed: %@", e.reason]];
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Send failed: %@"), e.reason]];
}
}
@@ -121,10 +122,10 @@ static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewControll
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Send anyway" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Send anyway") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
sciSendAudioFile(url, weakVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Open GitHub" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Open GitHub") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[[UIApplication sharedApplication]
openURL:[NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram/issues"]
options:@{} completionHandler:nil];
@@ -135,19 +136,38 @@ static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewControll
[presenter presentViewController:alert animated:YES completion:nil];
}
static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
// FFmpeg path: any format → AAC M4A, with optional trim
static void sciFFmpegConvertAndSend(NSURL *url, UIViewController *threadVC, 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;
}
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_ffaudio_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
[SCIUtils showToastForDuration:1.5 title:isVideo ? @"Extracting audio..." : @"Converting..."];
NSMutableString *cmd = [NSMutableString stringWithFormat:@"-y -i \"%@\"", url.path];
if (hasTrim) {
double ss = CMTimeGetSeconds(trimRange.start);
double dur = CMTimeGetSeconds(trimRange.duration);
[cmd appendFormat:@" -ss %.3f -t %.3f", ss, dur];
}
[cmd appendFormat:@" -vn -c:a aac -b:a 128k -ar 44100 -ac 1 \"%@\"", out];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
} else {
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
}
});
}];
}
// AVFoundation fallback for iOS-native formats
static void sciAVFoundationConvertAndSend(NSURL *url, UIViewController *threadVC, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
dispatch_async(dispatch_get_global_queue(0, 0), ^{
AVAsset *asset = [AVAsset assetWithURL:url];
@@ -192,9 +212,36 @@ static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVide
});
}
// 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 void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
// Passthrough formats IG accepts directly (no conversion needed, trim ignored)
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && !hasTrim && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
[SCIUtils showToastForDuration:1.5 title:isVideo ? SCILocalized(@"Extracting audio...") : SCILocalized(@"Converting...")];
// FFmpeg handles any format + video→audio extraction
if ([SCIFFmpeg isAvailable]) {
sciFFmpegConvertAndSend(url, threadVC, trimRange);
return;
}
// Passthrough without trim when no FFmpeg
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
// AVFoundation fallback
sciAVFoundationConvertAndSend(url, threadVC, trimRange);
}
// Formats IG accepts as-is (no conversion needed)
static NSSet<NSString *> *sciPassthroughAudioExts(void) {
static NSSet *set;
static dispatch_once_t once;
@@ -261,7 +308,7 @@ static const CGFloat kTrackMargin = 24.0;
sendBtn.frame = CGRectMake(kTrackMargin, bottomY - 56, w - kTrackMargin * 2, 50);
sendBtn.backgroundColor = [UIColor systemBlueColor];
sendBtn.layer.cornerRadius = 14;
[sendBtn setTitle:@"Send Audio" forState:UIControlStateNormal];
[sendBtn setTitle:SCILocalized(@"Send Audio") forState:UIControlStateNormal];
[sendBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
sendBtn.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
[sendBtn addTarget:self action:@selector(sendTapped) forControlEvents:UIControlEventTouchUpInside];
@@ -364,7 +411,7 @@ static const CGFloat kTrackMargin = 24.0;
self.durationLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.3];
self.durationLabel.font = [UIFont systemFontOfSize:12];
self.durationLabel.textAlignment = NSTextAlignmentCenter;
self.durationLabel.text = [NSString stringWithFormat:@"Total: %@", [self formatTime:self.totalDuration]];
self.durationLabel.text = [NSString stringWithFormat:SCILocalized(@"Total: %@"), [self formatTime:self.totalDuration]];
[self.view addSubview:self.durationLabel];
// ── cancel X button (top-left) ──
@@ -532,7 +579,7 @@ static const CGFloat kTrackMargin = 24.0;
[self stopPlayback];
double dur = self.endTime - self.startTime;
if (dur < 0.5) {
[SCIUtils showErrorHUDWithDescription:@"Selection too short (min 0.5s)"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Selection too short (min 0.5s)")];
return;
}
@@ -564,28 +611,30 @@ static void sciShowTrimVC(NSURL *url, BOOL isVideo, UIViewController *threadVC)
static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciAudioThreadVC = threadVC;
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Upload Audio"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Upload Audio")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Audio/Video from Files" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Audio/Video from Files") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSArray *types = [SCIFFmpeg isAvailable]
? @[@"public.audio", @"public.audiovisual-content"]
: @[@"public.audio", @"public.mpeg-4-audio", @"public.mp3", @"com.microsoft.waveform-audio",
@"public.aiff-audio", @"com.apple.m4a-audio",
@"public.movie", @"public.mpeg-4", @"com.apple.quicktime-movie"];
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
initWithDocumentTypes:@[@"public.audio", @"public.mpeg-4-audio", @"public.mp3", @"com.microsoft.waveform-audio",
@"public.aiff-audio", @"com.apple.m4a-audio",
@"public.movie", @"public.mpeg-4", @"com.apple.quicktime-movie"]
inMode:UIDocumentPickerModeImport];
initWithDocumentTypes:types inMode:UIDocumentPickerModeImport];
#pragma clang diagnostic pop
picker.delegate = (id<UIDocumentPickerDelegate>)vc;
[vc presentViewController:picker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Video from Library" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Video from Library") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
UIImagePickerController *imgPicker = [[UIImagePickerController alloc] init];
@@ -597,7 +646,7 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
[vc presentViewController:imgPicker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[threadVC presentViewController:alert animated:YES completion:nil];
}
@@ -654,23 +703,52 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciDMMenuPending = YES;
}
// file picker delegate — show trim UI
// Convert unsupported formats to M4A before showing trim UI
static void sciPrepareAndShowTrim(NSURL *url, UIViewController *threadVC) {
AVAsset *asset = [AVAsset assetWithURL:url];
double dur = CMTimeGetSeconds(asset.duration);
BOOL avCanRead = dur > 0 && !isnan(dur);
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
if (avCanRead) {
sciShowTrimVC(url, isVideo, threadVC);
return;
}
// AVFoundation can't read it — pre-convert with FFmpeg
if ([SCIFFmpeg isAvailable]) {
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Converting...")];
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_pre_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
NSString *cmd = [NSString stringWithFormat:@"-y -i \"%@\" -vn -c:a aac -b:a 128k -ar 44100 \"%@\"", url.path, out];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
sciShowTrimVC([NSURL fileURLWithPath:out], NO, threadVC);
} else {
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
}
});
}];
return;
}
// No FFmpeg, can't read — unsupported
sciShowUnsupportedAlert(url, @"Format not supported without FFmpegKit", threadVC);
}
// File picker delegate
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
NSURL *url = urls.firstObject;
if (!url) return;
// detect if it's a video file
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url {
if (!url) return;
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {}
@@ -680,7 +758,7 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
[picker dismissViewControllerAnimated:YES completion:nil];
NSURL *videoURL = info[UIImagePickerControllerMediaURL];
if (!videoURL) {
[SCIUtils showErrorHUDWithDescription:@"Could not get video URL"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not get video URL")];
return;
}
// UIImagePickerController with allowsEditing already trimmed the video for us
+103
View File
@@ -0,0 +1,103 @@
// Send files in DMs — adds a "Send File" option to the plus menu.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
static BOOL sciFileMenuPending = NO;
static __weak UIViewController *sciFileThreadVC = nil;
@interface _SCIFilePickerDelegate : NSObject <UIDocumentPickerDelegate>
@property (nonatomic, weak) UIViewController *threadVC;
@end
static _SCIFilePickerDelegate *sciFilePickerDelegate = nil;
@implementation _SCIFilePickerDelegate
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
NSURL *url = urls.firstObject;
if (!url || !self.threadVC) return;
id msgSenderFC = nil;
@try { msgSenderFC = [self.threadVC valueForKey:@"messageSenderFeatureController"]; } @catch (__unused id e) {}
if (!msgSenderFC) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Message sender not found")]; return; }
id sender = nil;
@try { sender = [msgSenderFC valueForKey:@"messageSender"]; } @catch (__unused id e) {}
if (!sender) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Send service not found")]; return; }
SEL sendSel = NSSelectorFromString(@"sendFileWithURL:threadKey:attribution:replyMessagePk:quotedPublishedMessage:messageSentSpeedLogger:messageSentSpeedMarker:localSendSpeedLogger:localSendSpeedMarker:");
if (![sender respondsToSelector:sendSel]) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"File sending not supported")]; return; }
id threadKey = nil;
@try { threadKey = [self.threadVC valueForKey:@"threadKey"]; } @catch (__unused id e) {}
if (!threadKey) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No thread key")]; return; }
typedef void (*SendFn)(id, SEL, id, id, id, id, id, id, id, id, id);
((SendFn)objc_msgSend)(sender, sendSel, url, threadKey, nil, nil, nil, nil, nil, nil, nil);
}
@end
static void sciShowFilePicker(UIViewController *threadVC) {
sciFilePickerDelegate = [_SCIFilePickerDelegate new];
sciFilePickerDelegate.threadVC = threadVC;
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
initWithDocumentTypes:@[@"public.data"] inMode:UIDocumentPickerModeImport];
picker.delegate = sciFilePickerDelegate;
picker.allowsMultipleSelection = NO;
[threadVC presentViewController:picker animated:YES completion:nil];
}
// MARK: - Plus menu injection
%hook IGDSMenu
- (id)initWithMenuItems:(NSArray *)items edr:(BOOL)edr headerLabelText:(id)header {
if (![SCIUtils getBoolPref:@"send_file"] || !sciFileMenuPending) return %orig;
sciFileMenuPending = NO;
for (id item in items) {
if ([item respondsToSelector:@selector(title)]) {
id title = [item valueForKey:@"title"];
if ([title isKindOfClass:[NSString class]] && [title isEqualToString:@"Send File"]) return %orig;
}
}
Class itemClass = NSClassFromString(@"IGDSMenuItem");
if (!itemClass) return %orig;
UIImage *img = [[UIImage systemImageNamed:@"doc"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
void (^handler)(void) = ^{
if (sciFileThreadVC) sciShowFilePicker(sciFileThreadVC);
};
SEL initSel = @selector(initWithTitle:image:handler:);
if (![itemClass instancesRespondToSelector:initSel]) return %orig;
typedef id (*InitFn)(id, SEL, id, id, id);
id fileItem = ((InitFn)objc_msgSend)([itemClass alloc], initSel, @"Send File", img, handler);
if (!fileItem) return %orig;
NSMutableArray *newItems = [NSMutableArray arrayWithObject:fileItem];
[newItems addObjectsFromArray:items];
return %orig(newItems, edr, header);
}
%end
// MARK: - Thread VC hook
%hook IGDirectThreadViewController
- (void)composerOverflowButtonMenuWillPrepareExpandWithPlusButton:(id)plusButton {
%orig;
if (![SCIUtils getBoolPref:@"send_file"]) return;
sciFileThreadVC = self;
sciFileMenuPending = YES;
}
%end
@@ -118,7 +118,7 @@ extern "C" NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *items) {
if (!menuItemCls) return items;
BOOL on = sciIGAudioEnabled();
NSString *title = on ? @"Mute story audio" : @"Unmute story audio";
NSString *title = on ? SCILocalized(@"Mute story audio") : SCILocalized(@"Unmute story audio");
void (^handler)(void) = ^{ sciToggleStoryAudio(); };
id newItem = nil;
@@ -0,0 +1,523 @@
// View story mentions — list mentioned users for the current story item.
// Reachable via eye long-press menu and the 3-dot story menu.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../Networking/SCIInstagramAPI.h"
#import "StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
extern __weak UIViewController *sciActiveStoryViewerVC;
// Forward decl — defined below.
static id sciFieldCacheValue(id obj, NSString *key);
static NSString *sciUserPK(id userObj) {
if (!userObj) return nil;
id pk = sciFieldCacheValue(userObj, @"strong_id__");
if (!pk) pk = sciFieldCacheValue(userObj, @"pk");
if (!pk) {
@try {
Ivar pkIvar = class_getInstanceVariable([userObj class], "_pk");
if (pkIvar) pk = object_getIvar(userObj, pkIvar);
} @catch (__unused id e) {}
}
return pk ? [NSString stringWithFormat:@"%@", pk] : nil;
}
static void sciStyleFollowBtn(UIButton *btn, BOOL following) {
[btn setTitle:following ? SCILocalized(@"Following") : SCILocalized(@"Follow") forState:UIControlStateNormal];
btn.backgroundColor = following ? [UIColor tertiarySystemFillColor] : [UIColor systemBlueColor];
[btn setTitleColor:following ? [UIColor labelColor] : [UIColor whiteColor] forState:UIControlStateNormal];
}
// ============ Mention extraction ============
static NSArray *sciCurrentStoryMentions(UIView *anchor) {
UIViewController *storyVC = nil;
if (anchor) storyVC = sciFindVC(anchor, @"IGStoryViewerViewController");
if (!storyVC) storyVC = sciActiveStoryViewerVC;
if (!storyVC) return nil;
UIResponder *start = anchor ?: (UIResponder *)storyVC.view;
id item = sciGetCurrentStoryItem(start);
IGMedia *media = nil;
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) {
media = (IGMedia *)item;
} else {
media = sciExtractMediaFromItem(item);
}
if (!media) {
@try {
id sc = sciFindSectionController(storyVC);
if (sc) {
SEL csi = NSSelectorFromString(@"currentStoryItem");
if ([sc respondsToSelector:csi])
media = ((id(*)(id,SEL))objc_msgSend)(sc, csi);
}
} @catch (__unused id e) {}
}
if (!media) {
@try {
id vm = sciCall(storyVC, @selector(currentViewModel));
id storyItem = sciCall1(storyVC, @selector(currentStoryItemForViewModel:), vm);
if ([storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) {
media = (IGMedia *)storyItem;
} else {
media = sciExtractMediaFromItem(storyItem);
}
} @catch (__unused id e) {}
}
if (!media) return nil;
SEL sel = NSSelectorFromString(@"reelMentions");
if (![media respondsToSelector:sel]) return nil;
return ((id(*)(id,SEL))objc_msgSend)(media, sel);
}
// IGUser stores fields in a Pando-backed dictionary. KVC goes through a
// resolver that returns NSNull for many keys, so we read the dict directly.
static id sciFieldCacheValue(id obj, NSString *key) {
if (!obj || !key) return nil;
static Ivar fcIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGAPIStorableObject");
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
});
if (!fcIvar) return nil;
NSDictionary *fc = object_getIvar(obj, fcIvar);
if (!fc) return nil;
id val = fc[key];
if (!val || [val isKindOfClass:[NSNull class]]) return nil;
return val;
}
static NSDictionary *sciMentionUserInfo(id mention) {
if (!mention) return nil;
NSMutableDictionary *info = [NSMutableDictionary dictionary];
@try {
id user = [mention valueForKey:@"user"];
if (!user) return nil;
info[@"userObj"] = user;
NSString *username = sciFieldCacheValue(user, @"username");
if (username.length) info[@"username"] = username;
NSString *fullName = sciFieldCacheValue(user, @"full_name");
if (fullName.length) info[@"fullName"] = fullName;
NSString *picStr = sciFieldCacheValue(user, @"profile_pic_url");
if (picStr.length) {
NSURL *picURL = [NSURL URLWithString:picStr];
if (picURL) info[@"picURL"] = picURL;
}
} @catch (__unused id e) {}
return info.count > 1 ? [info copy] : nil;
}
// ============ Bottom sheet VC ============
#define kAvatarSize 52.0
#define kRowHeight 72.0
@interface SCIStoryMentionsVC : UIViewController <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, strong) NSArray<NSDictionary *> *userInfos;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) NSString *currentUsername;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSDictionary *> *friendshipStatuses;
@end
@implementation SCIStoryMentionsVC
- (void)viewDidLoad {
[super viewDidLoad];
@try {
id window = [[UIApplication sharedApplication] keyWindow];
if ([window respondsToSelector:@selector(userSession)])
self.currentUsername = ((IGUserSession *)[window valueForKey:@"userSession"]).user.username;
} @catch (__unused id e) {}
UIColor *bg = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) {
return tc.userInterfaceStyle == UIUserInterfaceStyleDark
? [UIColor colorWithRed:0.09 green:0.09 blue:0.09 alpha:1]
: [UIColor colorWithRed:0.98 green:0.98 blue:0.98 alpha:1];
}];
self.view.backgroundColor = bg;
UILabel *titleLabel = [[UILabel alloc] init];
titleLabel.text = SCILocalized(@"Mentions");
titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
titleLabel.textColor = [UIColor labelColor];
titleLabel.textAlignment = NSTextAlignmentCenter;
titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
UIButton *closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
UIImage *closeImg = [UIImage systemImageNamed:@"xmark"
withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:15
weight:UIImageSymbolWeightSemibold]];
[closeBtn setImage:closeImg forState:UIControlStateNormal];
closeBtn.tintColor = [UIColor secondaryLabelColor];
closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
[closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
UIView *sep = [[UIView alloc] init];
sep.backgroundColor = [UIColor separatorColor];
sep.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView.backgroundColor = bg;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleSingleLine;
self.tableView.separatorColor = [UIColor separatorColor];
self.tableView.separatorInset = UIEdgeInsetsMake(0, 16 + kAvatarSize + 14, 0, 0);
self.tableView.rowHeight = kRowHeight;
[self.view addSubview:titleLabel];
[self.view addSubview:closeBtn];
[self.view addSubview:sep];
[self.view addSubview:self.tableView];
[NSLayoutConstraint activateConstraints:@[
[titleLabel.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:22],
[titleLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[closeBtn.centerYAnchor constraintEqualToAnchor:titleLabel.centerYAnchor],
[closeBtn.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16],
[closeBtn.widthAnchor constraintEqualToConstant:30],
[closeBtn.heightAnchor constraintEqualToConstant:30],
[sep.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:14],
[sep.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[sep.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[sep.heightAnchor constraintEqualToConstant:1.0 / [UIScreen mainScreen].scale],
[self.tableView.topAnchor constraintEqualToAnchor:sep.bottomAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
]];
// Bulk-fetch friendship statuses for all mentions in one round trip.
self.friendshipStatuses = [NSMutableDictionary dictionary];
NSMutableArray *pks = [NSMutableArray array];
for (NSDictionary *info in self.userInfos) {
NSString *pk = sciUserPK(info[@"userObj"]);
if (pk.length) [pks addObject:pk];
}
if (pks.count) {
__weak typeof(self) weakSelf = self;
[SCIInstagramAPI fetchFriendshipStatusesForPKs:pks completion:^(NSDictionary *statuses, NSError *error) {
if (!statuses.count) return;
[weakSelf.friendshipStatuses addEntriesFromDictionary:statuses];
[weakSelf.tableView reloadData];
}];
}
if (self.userInfos.count == 0) {
UIImageView *emptyIcon = [[UIImageView alloc] initWithImage:
[UIImage systemImageNamed:@"at"
withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:36
weight:UIImageSymbolWeightLight]]];
emptyIcon.tintColor = [UIColor tertiaryLabelColor];
emptyIcon.translatesAutoresizingMaskIntoConstraints = NO;
UILabel *emptyLabel = [[UILabel alloc] init];
emptyLabel.text = SCILocalized(@"No mentions in this story");
emptyLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
emptyLabel.textColor = [UIColor secondaryLabelColor];
emptyLabel.textAlignment = NSTextAlignmentCenter;
emptyLabel.translatesAutoresizingMaskIntoConstraints = NO;
UIStackView *empty = [[UIStackView alloc] initWithArrangedSubviews:@[emptyIcon, emptyLabel]];
empty.axis = UILayoutConstraintAxisVertical;
empty.spacing = 12;
empty.alignment = UIStackViewAlignmentCenter;
empty.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:empty];
[NSLayoutConstraint activateConstraints:@[
[empty.centerXAnchor constraintEqualToAnchor:self.tableView.centerXAnchor],
[empty.centerYAnchor constraintEqualToAnchor:self.tableView.centerYAnchor],
]];
}
}
- (void)closeTapped {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
// Resume story playback when mentions sheet dismisses
if (sciActiveStoryViewerVC) {
SEL sel = NSSelectorFromString(@"tryResumePlayback");
if ([sciActiveStoryViewerVC respondsToSelector:sel]) {
((void(*)(id,SEL))objc_msgSend)(sciActiveStoryViewerVC, sel);
}
}
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.userInfos.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *rid = @"mention";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:rid];
UIImageView *avatar;
UILabel *nameLabel, *subLabel;
UIButton *followBtn;
UIActivityIndicatorView *spinner;
static const NSInteger kAvTag = 101, kNmTag = 102, kSbTag = 103, kFlTag = 104, kSpTag = 105;
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
cell.backgroundColor = [UIColor clearColor];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
avatar = [[UIImageView alloc] init];
avatar.tag = kAvTag;
avatar.layer.cornerRadius = kAvatarSize / 2.0;
avatar.clipsToBounds = YES;
avatar.contentMode = UIViewContentModeScaleAspectFill;
avatar.backgroundColor = [UIColor secondarySystemBackgroundColor];
avatar.translatesAutoresizingMaskIntoConstraints = NO;
nameLabel = [[UILabel alloc] init];
nameLabel.tag = kNmTag;
nameLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
nameLabel.textColor = [UIColor labelColor];
nameLabel.translatesAutoresizingMaskIntoConstraints = NO;
subLabel = [[UILabel alloc] init];
subLabel.tag = kSbTag;
subLabel.font = [UIFont systemFontOfSize:14];
subLabel.textColor = [UIColor secondaryLabelColor];
subLabel.translatesAutoresizingMaskIntoConstraints = NO;
followBtn = [UIButton buttonWithType:UIButtonTypeSystem];
followBtn.tag = kFlTag;
followBtn.titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
followBtn.layer.cornerRadius = 8;
followBtn.clipsToBounds = YES;
followBtn.translatesAutoresizingMaskIntoConstraints = NO;
spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
spinner.tag = kSpTag;
spinner.hidesWhenStopped = YES;
spinner.translatesAutoresizingMaskIntoConstraints = NO;
UIStackView *text = [[UIStackView alloc] initWithArrangedSubviews:@[nameLabel, subLabel]];
text.axis = UILayoutConstraintAxisVertical;
text.spacing = 2;
text.translatesAutoresizingMaskIntoConstraints = NO;
[cell.contentView addSubview:avatar];
[cell.contentView addSubview:text];
[cell.contentView addSubview:followBtn];
[followBtn addSubview:spinner];
[NSLayoutConstraint activateConstraints:@[
[avatar.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16],
[avatar.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[avatar.widthAnchor constraintEqualToConstant:kAvatarSize],
[avatar.heightAnchor constraintEqualToConstant:kAvatarSize],
[text.leadingAnchor constraintEqualToAnchor:avatar.trailingAnchor constant:14],
[text.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[text.trailingAnchor constraintLessThanOrEqualToAnchor:followBtn.leadingAnchor constant:-10],
[followBtn.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-16],
[followBtn.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[followBtn.widthAnchor constraintGreaterThanOrEqualToConstant:90],
[followBtn.heightAnchor constraintEqualToConstant:32],
[spinner.centerXAnchor constraintEqualToAnchor:followBtn.centerXAnchor],
[spinner.centerYAnchor constraintEqualToAnchor:followBtn.centerYAnchor],
]];
} else {
avatar = [cell.contentView viewWithTag:kAvTag];
nameLabel = [cell.contentView viewWithTag:kNmTag];
subLabel = [cell.contentView viewWithTag:kSbTag];
followBtn = [cell.contentView viewWithTag:kFlTag];
spinner = [followBtn viewWithTag:kSpTag];
}
NSDictionary *info = self.userInfos[indexPath.row];
NSString *username = info[@"username"] ?: @"Unknown";
NSString *fullName = info[@"fullName"];
NSURL *picURL = info[@"picURL"];
nameLabel.text = username;
subLabel.text = fullName ?: @"";
subLabel.hidden = !fullName.length;
avatar.image = [UIImage systemImageNamed:@"person.circle.fill"];
avatar.tintColor = [UIColor tertiaryLabelColor];
if (picURL) {
NSURL *url = [picURL copy];
NSInteger row = indexPath.row;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:url];
if (!data) return;
UIImage *img = [UIImage imageWithData:data];
if (!img) return;
dispatch_async(dispatch_get_main_queue(), ^{
UITableViewCell *c = [tableView cellForRowAtIndexPath:
[NSIndexPath indexPathForRow:row inSection:0]];
if (!c) return;
UIImageView *av = [c.contentView viewWithTag:kAvTag];
if (av) { av.image = img; av.tintColor = nil; }
});
});
}
[followBtn removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
[spinner stopAnimating];
spinner.color = [UIColor whiteColor];
BOOL isMe = self.currentUsername && [username isEqualToString:self.currentUsername];
if (isMe) {
followBtn.hidden = YES;
} else {
followBtn.hidden = NO;
id userObj = info[@"userObj"];
BOOL following = NO;
NSString *pk = sciUserPK(userObj);
NSDictionary *status = pk ? self.friendshipStatuses[pk] : nil;
if ([status isKindOfClass:[NSDictionary class]]) {
following = [status[@"following"] boolValue];
}
sciStyleFollowBtn(followBtn, following);
objc_setAssociatedObject(followBtn, "userObj", userObj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[followBtn addTarget:self action:@selector(followTapped:) forControlEvents:UIControlEventTouchUpInside];
}
return cell;
}
- (void)followTapped:(UIButton *)sender {
id userObj = objc_getAssociatedObject(sender, "userObj");
if (!userObj) return;
NSString *pk = sciUserPK(userObj);
if (!pk.length) return;
BOOL currentlyFollowing = [[sender titleForState:UIControlStateNormal] isEqualToString:@"Following"];
void (^doIt)(void) = ^{
UIActivityIndicatorView *spinner = [sender viewWithTag:105];
NSString *savedTitle = [sender titleForState:UIControlStateNormal];
[sender setTitle:@"" forState:UIControlStateNormal];
sender.userInteractionEnabled = NO;
[spinner startAnimating];
__weak typeof(self) weakSelf = self;
SCIAPICompletion done = ^(NSDictionary *response, NSError *error) {
[spinner stopAnimating];
sender.userInteractionEnabled = YES;
BOOL ok = (response && [response[@"status"] isEqualToString:@"ok"]);
if (ok) {
sciStyleFollowBtn(sender, !currentlyFollowing);
NSMutableDictionary *s = [weakSelf.friendshipStatuses[pk] mutableCopy] ?: [NSMutableDictionary dictionary];
s[@"following"] = @(!currentlyFollowing);
weakSelf.friendshipStatuses[pk] = [s copy];
} else {
[sender setTitle:savedTitle forState:UIControlStateNormal];
}
};
if (currentlyFollowing) [SCIInstagramAPI unfollowUserPK:pk completion:done];
else [SCIInstagramAPI followUserPK:pk completion:done];
};
if (!currentlyFollowing && [SCIUtils getBoolPref:@"follow_confirm"]) {
[SCIUtils showConfirmation:doIt];
} else if (currentlyFollowing && [SCIUtils getBoolPref:@"unfollow_confirm"]) {
[SCIUtils showConfirmation:doIt];
} else {
doIt();
}
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *info = self.userInfos[indexPath.row];
NSString *username = info[@"username"];
if (!username) return;
[self dismissViewControllerAnimated:YES completion:^{
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]];
if ([[UIApplication sharedApplication] canOpenURL:url])
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}];
}
@end
// ============ Entry points ============
void sciShowStoryMentions(UIViewController *presenter, UIView *anchor) {
if (![SCIUtils getBoolPref:@"view_story_mentions"]) return;
NSArray *mentions = sciCurrentStoryMentions(anchor);
NSMutableArray *infos = [NSMutableArray array];
for (id mention in mentions) {
NSDictionary *info = sciMentionUserInfo(mention);
if (info) [infos addObject:info];
}
SCIStoryMentionsVC *vc = [[SCIStoryMentionsVC alloc] init];
vc.userInfos = [infos copy];
vc.modalPresentationStyle = UIModalPresentationPageSheet;
if (@available(iOS 15.0, *)) {
UISheetPresentationController *sheet = vc.sheetPresentationController;
sheet.detents = @[UISheetPresentationControllerDetent.mediumDetent,
UISheetPresentationControllerDetent.largeDetent];
@try { [sheet setValue:@YES forKey:@"prefersGrabberIndicator"]; } @catch (__unused id e) {}
sheet.prefersScrollingExpandsWhenScrolledToEdge = YES;
}
[presenter presentViewController:vc animated:YES completion:nil];
}
NSArray *sciMaybeAppendStoryMentionsMenuItem(NSArray *items) {
if (!sciActiveStoryViewerVC) return items;
if (![SCIUtils getBoolPref:@"view_story_mentions"]) return items;
BOOL looksLikeStoryHeader = NO;
for (id it in items) {
@try {
NSString *t = [NSString stringWithFormat:@"%@", [it valueForKey:@"title"] ?: @""];
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
[t isEqualToString:@"Hide"]) { looksLikeStoryHeader = YES; break; }
} @catch (__unused id e) {}
}
if (!looksLikeStoryHeader) return items;
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
void (^handler)(void) = ^{
UIViewController *vc = weakVC;
if (!vc) return;
sciShowStoryMentions(vc, vc.view);
};
id newItem = nil;
@try {
typedef id (*Init)(id, SEL, id, id, id);
newItem = ((Init)objc_msgSend)([menuItemCls alloc],
@selector(initWithTitle:image:handler:), @"View mentions", nil, handler);
} @catch (__unused id e) {}
if (!newItem) return items;
NSMutableArray *newItems = [items mutableCopy];
[newItems addObject:newItem];
return [newItems copy];
}