mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-06-08 08:23:54 +02:00
[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:
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 *_) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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();
|
||||
}];
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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(); }];
|
||||
}
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)");
|
||||
|
||||
|
||||
@@ -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]];
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
@@ -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]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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]];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user