[release] RyukGram v1.2.0

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

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

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

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

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

### Known Issues
- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh clears them (warning available in settings)
- "Delete for you" detection uses a ~2s window after the local action — a real unsend landing in that window may be missed (rare)
This commit is contained in:
faroukbmiled
2026-04-16 03:03:30 +01:00
parent 9b2c7dc202
commit 86eaa95019
124 changed files with 11523 additions and 1393 deletions
+37
View File
@@ -0,0 +1,37 @@
// SCIActionButton — wires a UIButton to the RyukGram action menu system.
// Tap fires the default action; long-press opens the full context menu.
#import <UIKit/UIKit.h>
#import "SCIMediaActions.h"
NS_ASSUME_NONNULL_BEGIN
typedef id _Nullable (^SCIActionMediaProvider)(UIView *sourceView);
@interface SCIActionButton : NSObject
/// Key for an optional dismiss callback block (void(^)(void)) stored on
/// the button via objc_setAssociatedObject. Called when the context menu
/// or UIMenu dismisses. Used by stories to resume playback.
extern const void *kSCIDismissKey;
/// Configure an existing UIButton with RyukGram action-menu behavior.
///
/// `prefKey` is the NSUserDefaults key storing the default-tap choice
/// (one of `menu`, `expand`, `download_share`, `download_photos`).
+ (void)configureButton:(UIButton *)button
context:(SCIActionContext)ctx
prefKey:(NSString *)prefKey
mediaProvider:(SCIActionMediaProvider)provider;
/// Build the deferred UIMenu for a given context + provider. Exposed so
/// callers that already have their own UIButton wiring can reuse just the
/// menu construction.
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
fromView:(UIView *)sourceView
mediaProvider:(SCIActionMediaProvider)provider;
@end
NS_ASSUME_NONNULL_END
+165
View File
@@ -0,0 +1,165 @@
#import "SCIActionButton.h"
#import "SCIActionMenu.h"
#import "../Utils.h"
#import <objc/runtime.h>
// Associated-object keys for per-button config.
static const void *kSCICtxKey = &kSCICtxKey;
static const void *kSCIProviderKey = &kSCIProviderKey;
static const void *kSCIPrefKey = &kSCIPrefKey;
const void *kSCIDismissKey = &kSCIDismissKey;
@interface SCIActionButton () <UIContextMenuInteractionDelegate>
@end
@implementation SCIActionButton
// Singleton delegate for UIContextMenuInteraction.
+ (instancetype)shared {
static SCIActionButton *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [SCIActionButton new]; });
return s;
}
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
fromView:(UIView *)sourceView
mediaProvider:(SCIActionMediaProvider)provider {
__weak UIView *weakSource = sourceView;
SCIActionMediaProvider capturedProvider = [provider copy];
UIDeferredMenuElement *deferred = [UIDeferredMenuElement
elementWithUncachedProvider:^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
UIView *view = weakSource;
id media = (view && capturedProvider) ? capturedProvider(view) : nil;
NSArray *actions = [SCIMediaActions actionsForContext:ctx
media:media
fromView:view];
UIMenu *built = [SCIActionMenu buildMenuWithActions:actions];
completion(built.children);
}];
return [UIMenu menuWithTitle:@""
image:nil
identifier:nil
options:0
children:@[deferred]];
}
+ (void)configureButton:(UIButton *)button
context:(SCIActionContext)ctx
prefKey:(NSString *)prefKey
mediaProvider:(SCIActionMediaProvider)provider {
if (!button) return;
// Stash config on the button.
objc_setAssociatedObject(button, kSCICtxKey, @(ctx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
objc_setAssociatedObject(button, kSCIProviderKey, [provider copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(button, kSCIPrefKey, [prefKey copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
// Read default tap mode fresh.
NSString *defaultTap = [SCIUtils getStringPref:prefKey];
if (!defaultTap.length) defaultTap = @"menu";
// Remove previous wiring to stay idempotent.
[button removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
for (id<UIInteraction> it in [button.interactions copy]) {
if ([(id)it isKindOfClass:[UIContextMenuInteraction class]]) {
[button removeInteraction:it];
}
}
if ([defaultTap isEqualToString:@"menu"]) {
// Tap opens menu natively.
button.menu = [self deferredMenuForContext:ctx fromView:button mediaProvider:provider];
button.showsMenuAsPrimaryAction = YES;
return;
}
// Tap fires dedicated action; long-press opens menu.
button.showsMenuAsPrimaryAction = NO;
button.menu = nil;
[button addTarget:[self shared]
action:@selector(sciTapHandler:)
forControlEvents:UIControlEventTouchUpInside];
UIContextMenuInteraction *interaction =
[[UIContextMenuInteraction alloc] initWithDelegate:[self shared]];
[button addInteraction:interaction];
}
// Haptic + scale-bounce feedback.
+ (void)bounceButton:(UIView *)view {
UIImpactFeedbackGenerator *haptic =
[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1
animations:^{ view.transform = CGAffineTransformMakeScale(0.82, 0.82); }
completion:^(BOOL _) {
[UIView animateWithDuration:0.1 animations:^{
view.transform = CGAffineTransformIdentity;
}];
}];
}
// Default-tap handler.
- (void)sciTapHandler:(UIButton *)sender {
[SCIActionButton bounceButton:sender];
NSNumber *ctxNum = objc_getAssociatedObject(sender, kSCICtxKey);
SCIActionMediaProvider provider = objc_getAssociatedObject(sender, kSCIProviderKey);
NSString *prefKey = objc_getAssociatedObject(sender, kSCIPrefKey);
if (!ctxNum || !provider) return;
NSString *tap = [SCIUtils getStringPref:prefKey];
if (!tap.length) tap = @"menu";
id media = provider(sender);
if (media == (id)kCFNull) return;
if ([tap isEqualToString:@"expand"]) {
[SCIMediaActions expandMedia:media fromView:sender caption:nil];
} else if ([tap isEqualToString:@"download_share"]) {
[SCIMediaActions downloadAndShareMedia:media];
} else if ([tap isEqualToString:@"download_photos"]) {
[SCIMediaActions downloadAndSaveMedia:media];
} else {
// Fallback: user can long-press for menu.
}
}
// MARK: - UIContextMenuInteractionDelegate
- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
configurationForMenuAtLocation:(CGPoint)location {
UIView *view = interaction.view;
NSNumber *ctxNum = objc_getAssociatedObject(view, kSCICtxKey);
SCIActionMediaProvider provider = objc_getAssociatedObject(view, kSCIProviderKey);
if (!ctxNum || !provider) return nil;
SCIActionContext ctx = (SCIActionContext)ctxNum.integerValue;
return [UIContextMenuConfiguration
configurationWithIdentifier:nil
previewProvider:nil
actionProvider:^UIMenu * _Nullable(NSArray<UIMenuElement *> * _Nonnull suggested) {
return [SCIActionButton deferredMenuForContext:ctx
fromView:view
mediaProvider:provider];
}];
}
- (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
willEndForConfiguration:(UIContextMenuConfiguration *)configuration
animator:(id<UIContextMenuInteractionAnimating>)animator {
UIView *view = interaction.view;
void (^dismiss)(void) = objc_getAssociatedObject(view, kSCIDismissKey);
if (dismiss) {
if (animator) {
[animator addCompletion:^{ dismiss(); }];
} else {
dismiss();
}
}
}
@end
+48
View File
@@ -0,0 +1,48 @@
// SCIActionMenu — reusable action menu model + UIMenu builder.
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
/// One menu entry. Either a leaf (has handler) or a submenu (has children).
@interface SCIAction : NSObject
@property (nonatomic, copy, readonly) NSString *title;
@property (nonatomic, copy, readonly, nullable) NSString *subtitle;
@property (nonatomic, copy, readonly, nullable) NSString *systemIconName;
@property (nonatomic, copy, readonly, nullable) void (^handler)(void);
@property (nonatomic, copy, readonly, nullable) NSArray<SCIAction *> *children;
@property (nonatomic, assign, readonly) BOOL destructive;
@property (nonatomic, assign, readonly) BOOL isSeparator;
+ (instancetype)actionWithTitle:(NSString *)title
icon:(nullable NSString *)icon
handler:(void(^)(void))handler;
+ (instancetype)actionWithTitle:(NSString *)title
subtitle:(nullable NSString *)subtitle
icon:(nullable NSString *)icon
destructive:(BOOL)destructive
handler:(void(^)(void))handler;
+ (instancetype)actionWithTitle:(NSString *)title
icon:(nullable NSString *)icon
children:(NSArray<SCIAction *> *)children;
/// A visual group break. Rendered as an inline submenu divider in UIMenu.
+ (instancetype)separator;
@end
@interface SCIActionMenu : NSObject
/// Build a UIMenu from an array of SCIAction. Consecutive actions between
/// `separator` markers are grouped into inline submenus so they render as
/// divided sections (standard iOS menu aesthetic).
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions;
/// Build a UIMenu with a header title shown at the top of the menu.
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(nullable NSString *)title;
@end
NS_ASSUME_NONNULL_END
+132
View File
@@ -0,0 +1,132 @@
#import "SCIActionMenu.h"
#pragma mark - SCIAction
@interface SCIAction ()
@property (nonatomic, copy, readwrite) NSString *title;
@property (nonatomic, copy, readwrite, nullable) NSString *subtitle;
@property (nonatomic, copy, readwrite, nullable) NSString *systemIconName;
@property (nonatomic, copy, readwrite, nullable) void (^handler)(void);
@property (nonatomic, copy, readwrite, nullable) NSArray<SCIAction *> *children;
@property (nonatomic, assign, readwrite) BOOL destructive;
@property (nonatomic, assign, readwrite) BOOL isSeparator;
@end
@implementation SCIAction
+ (instancetype)actionWithTitle:(NSString *)title
icon:(NSString *)icon
handler:(void(^)(void))handler {
return [self actionWithTitle:title subtitle:nil icon:icon destructive:NO handler:handler];
}
+ (instancetype)actionWithTitle:(NSString *)title
subtitle:(NSString *)subtitle
icon:(NSString *)icon
destructive:(BOOL)destructive
handler:(void(^)(void))handler {
SCIAction *a = [SCIAction new];
a.title = title ?: @"";
a.subtitle = subtitle;
a.systemIconName = icon;
a.handler = handler;
a.destructive = destructive;
return a;
}
+ (instancetype)actionWithTitle:(NSString *)title
icon:(NSString *)icon
children:(NSArray<SCIAction *> *)children {
SCIAction *a = [SCIAction new];
a.title = title ?: @"";
a.systemIconName = icon;
a.children = [children copy];
return a;
}
+ (instancetype)separator {
SCIAction *a = [SCIAction new];
a.isSeparator = YES;
return a;
}
@end
#pragma mark - SCIActionMenu
@implementation SCIActionMenu
+ (UIImage *)imageForIcon:(NSString *)name {
if (!name.length) return nil;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightRegular];
return [UIImage systemImageNamed:name withConfiguration:cfg];
}
// Convert SCIAction to UIMenuElement.
+ (UIMenuElement *)elementForAction:(SCIAction *)action {
if (action.children.count) {
NSMutableArray<UIMenuElement *> *kids = [NSMutableArray arrayWithCapacity:action.children.count];
for (SCIAction *child in action.children) {
UIMenuElement *el = [self elementForAction:child];
if (el) [kids addObject:el];
}
return [UIMenu menuWithTitle:action.title
image:[self imageForIcon:action.systemIconName]
identifier:nil
options:0
children:kids];
}
UIAction *ua = [UIAction actionWithTitle:action.title
image:[self imageForIcon:action.systemIconName]
identifier:nil
handler:^(__kindof UIAction * _Nonnull a) {
if (action.handler) action.handler();
}];
if (@available(iOS 15.0, *)) {
if (action.subtitle.length) ua.subtitle = action.subtitle;
}
if (action.destructive) ua.attributes = UIMenuElementAttributesDestructive;
return ua;
}
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions {
return [self buildMenuWithActions:actions title:nil];
}
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(NSString *)title {
// Group actions between separators into inline submenus.
NSMutableArray<UIMenuElement *> *top = [NSMutableArray array];
NSMutableArray<UIMenuElement *> *currentGroup = [NSMutableArray array];
void (^flush)(void) = ^{
if (currentGroup.count == 0) return;
UIMenu *group = [UIMenu menuWithTitle:@""
image:nil
identifier:nil
options:UIMenuOptionsDisplayInline
children:[currentGroup copy]];
[top addObject:group];
[currentGroup removeAllObjects];
};
for (SCIAction *a in actions) {
if (a.isSeparator) {
flush();
continue;
}
UIMenuElement *el = [self elementForAction:a];
if (el) [currentGroup addObject:el];
}
flush();
return [UIMenu menuWithTitle:title ?: @""
image:nil
identifier:nil
options:0
children:[top copy]];
}
@end
+98
View File
@@ -0,0 +1,98 @@
// SCIMediaActions — shared media extraction + action handlers for the action menu.
#import <UIKit/UIKit.h>
#import "../InstagramHeaders.h"
#import "SCIActionMenu.h"
NS_ASSUME_NONNULL_BEGIN
/// Where the action is being invoked from. Used to target settings entries
/// and to pick context-specific language in HUDs.
typedef NS_ENUM(NSInteger, SCIActionContext) {
SCIActionContextFeed,
SCIActionContextReels,
SCIActionContextStories,
};
@interface SCIMediaActions : NSObject
// MARK: - Media extraction
/// Return the post's caption string. Tries selectors first, falls back to
/// reading `_fieldCache[@"caption"][@"text"]`.
+ (nullable NSString *)captionForMedia:(id)media;
/// YES if the media is a carousel (multi-photo/video sidecar).
+ (BOOL)isCarouselMedia:(id)media;
/// Ordered children of a carousel IGMedia. Empty array for non-carousels.
+ (NSArray *)carouselChildrenForMedia:(id)media;
/// Best URL for a single (non-carousel) media item. Prefers video URL, falls
/// back to photo URL. Returns nil if nothing extractable.
+ (nullable NSURL *)bestURLForMedia:(id)media;
/// Cover/poster image URL for a video-type media (first frame). Works for
/// reels, feed videos, and story videos.
+ (nullable NSURL *)coverURLForMedia:(id)media;
// MARK: - Primary actions (each directly triggerable from a menu entry)
/// Present the media in the native QLPreview UI. Video URLs download first,
/// images preview directly. Optional caption is shown as a subtitle.
+ (void)expandMedia:(id)media
fromView:(UIView *)sourceView
caption:(nullable NSString *)caption;
/// Download the best URL for the media and hand off via share sheet.
+ (void)downloadAndShareMedia:(id)media;
/// Download the best URL for the media and save to Photos (respects album pref).
+ (void)downloadAndSaveMedia:(id)media;
/// Copy the direct CDN URL for the media to the clipboard.
+ (void)copyURLForMedia:(id)media;
/// Copy the post caption to the clipboard.
+ (void)copyCaptionForMedia:(id)media;
/// Trigger Instagram's native repost flow for the given context's currently
/// visible UFI bar. Uses the existing button ivars to avoid reimplementing.
+ (void)triggerRepostForContext:(SCIActionContext)ctx sourceView:(UIView *)sourceView;
/// Open the RyukGram settings page for the given context.
+ (void)openSettingsForContext:(SCIActionContext)ctx fromView:(UIView *)sourceView;
// MARK: - Carousel bulk actions
/// Download every child of a carousel and share as a batch.
+ (void)downloadAllAndShareMedia:(id)carouselMedia;
/// Download every child of a carousel and save to Photos.
+ (void)downloadAllAndSaveMedia:(id)carouselMedia;
/// Copy newline-joined CDN URLs for every child of a carousel.
+ (void)copyAllURLsForMedia:(id)carouselMedia;
// MARK: - Menu builders
// MARK: - Bulk URL download helpers
/// Download an array of URLs in parallel, show pill, call done with file URLs.
+ (void)bulkDownloadURLs:(NSArray<NSURL *> *)urls
title:(NSString *)title
done:(void(^)(NSArray<NSURL *> *fileURLs))done;
/// Save an array of local file URLs to Photos (sequential, respects album pref).
+ (void)bulkSaveFiles:(NSArray<NSURL *> *)files;
/// Build the full action menu for the given context + media + default tap.
/// If `defaultTap` is provided and non-menu, the builder may reorder or skip
/// its matching leaf so it's visible in the full menu.
+ (NSArray<SCIAction *> *)actionsForContext:(SCIActionContext)ctx
media:(nullable id)media
fromView:(UIView *)sourceView;
@end
NS_ASSUME_NONNULL_END
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
// SCIMediaViewer — full-screen media viewer. Supports single items and carousels.
#import <UIKit/UIKit.h>
/// One media item to display.
@interface SCIMediaViewerItem : NSObject
@property (nonatomic, strong) NSURL *videoURL; // nil for photos
@property (nonatomic, strong) NSURL *photoURL; // nil for videos
@property (nonatomic, copy) NSString *caption;
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
@end
@interface SCIMediaViewer : NSObject
/// Show a single media item.
+ (void)showItem:(SCIMediaViewerItem *)item;
/// Show multiple items (carousel). Starts at the given index.
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index;
/// Convenience: auto-detect video vs photo for a single item.
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
@end
+437
View File
@@ -0,0 +1,437 @@
#import "SCIMediaViewer.h"
#import "../Utils.h"
#import <AVFoundation/AVFoundation.h>
#import <AVKit/AVKit.h>
// ═══════════════════════════════════════════════════════════════════════════
#pragma mark - Data model
// ═══════════════════════════════════════════════════════════════════════════
@implementation SCIMediaViewerItem
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
SCIMediaViewerItem *i = [SCIMediaViewerItem new];
i.videoURL = videoURL;
i.photoURL = photoURL;
i.caption = caption;
return i;
}
@end
// ═══════════════════════════════════════════════════════════════════════════
#pragma mark - Single photo page
// ═══════════════════════════════════════════════════════════════════════════
@interface _SCIPhotoPageVC : UIViewController <UIScrollViewDelegate>
@property (nonatomic, strong) NSURL *photoURL;
@property (nonatomic, strong) UIScrollView *scrollView;
@property (nonatomic, strong) UIImageView *imageView;
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
@end
@implementation _SCIPhotoPageVC
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor blackColor];
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.scrollView.delegate = self;
self.scrollView.minimumZoomScale = 1.0;
self.scrollView.maximumZoomScale = 5.0;
self.scrollView.showsVerticalScrollIndicator = NO;
self.scrollView.showsHorizontalScrollIndicator = NO;
[self.view addSubview:self.scrollView];
self.imageView = [[UIImageView alloc] initWithFrame:self.scrollView.bounds];
self.imageView.contentMode = UIViewContentModeScaleAspectFit;
self.imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.scrollView addSubview:self.imageView];
self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
self.spinner.color = [UIColor whiteColor];
self.spinner.center = self.view.center;
self.spinner.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin
| UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;
[self.view addSubview:self.spinner];
[self.spinner startAnimating];
NSURL *url = [self.photoURL copy];
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:url];
UIImage *img = data ? [UIImage imageWithData:data] : nil;
dispatch_async(dispatch_get_main_queue(), ^{
[self.spinner stopAnimating];
if (img) self.imageView.image = img;
});
});
// Double-tap to zoom
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];
doubleTap.numberOfTapsRequired = 2;
[self.scrollView addGestureRecognizer:doubleTap];
}
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)sv { return self.imageView; }
- (void)handleDoubleTap:(UITapGestureRecognizer *)gr {
if (self.scrollView.zoomScale > 1.0) {
[self.scrollView setZoomScale:1.0 animated:YES];
} else {
CGPoint pt = [gr locationInView:self.imageView];
CGRect rect = CGRectMake(pt.x - 50, pt.y - 50, 100, 100);
[self.scrollView zoomToRect:rect animated:YES];
}
}
- (UIImage *)currentImage { return self.imageView.image; }
@end
// ═══════════════════════════════════════════════════════════════════════════
#pragma mark - Single video page
// ═══════════════════════════════════════════════════════════════════════════
@interface _SCIVideoPageVC : UIViewController
@property (nonatomic, strong) NSURL *videoURL;
@property (nonatomic, strong) AVPlayerViewController *playerVC;
@end
@implementation _SCIVideoPageVC
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor blackColor];
AVPlayer *player = [AVPlayer playerWithURL:self.videoURL];
self.playerVC = [[AVPlayerViewController alloc] init];
self.playerVC.player = player;
self.playerVC.showsPlaybackControls = YES;
[self addChildViewController:self.playerVC];
self.playerVC.view.frame = self.view.bounds;
self.playerVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.playerVC.view];
[self.playerVC didMoveToParentViewController:self];
[player play];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
[self.playerVC.player pause];
}
@end
// ═══════════════════════════════════════════════════════════════════════════
#pragma mark - Container VC (PageViewController-based)
// ═══════════════════════════════════════════════════════════════════════════
@interface _SCIMediaViewerContainerVC : UIViewController <UIPageViewControllerDataSource, UIPageViewControllerDelegate>
@property (nonatomic, strong) NSArray<SCIMediaViewerItem *> *items;
@property (nonatomic, assign) NSUInteger currentIndex;
@property (nonatomic, strong) UIPageViewController *pageVC;
@property (nonatomic, strong) UIView *topBar;
@property (nonatomic, strong) UIButton *closeBtn;
@property (nonatomic, strong) UILabel *counterLabel;
@property (nonatomic, strong) UIButton *shareBtn;
@property (nonatomic, strong) UIView *bottomBar;
@property (nonatomic, strong) UILabel *captionLabel;
@property (nonatomic, assign) BOOL chromeVisible;
@property (nonatomic, assign) BOOL captionExpanded;
@end
@implementation _SCIMediaViewerContainerVC
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor blackColor];
self.chromeVisible = YES;
// Page view controller
self.pageVC = [[UIPageViewController alloc]
initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
options:nil];
self.pageVC.dataSource = self.items.count > 1 ? self : nil;
self.pageVC.delegate = self;
UIViewController *firstPage = [self viewControllerForIndex:self.currentIndex];
if (firstPage) [self.pageVC setViewControllers:@[firstPage] direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
[self addChildViewController:self.pageVC];
self.pageVC.view.frame = self.view.bounds;
self.pageVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
[self.view addSubview:self.pageVC.view];
[self.pageVC didMoveToParentViewController:self];
// Top bar
self.topBar = [[UIView alloc] init];
self.topBar.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.topBar];
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:17 weight:UIImageSymbolWeightSemibold];
self.closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
[self.closeBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:cfg] forState:UIControlStateNormal];
self.closeBtn.tintColor = [UIColor whiteColor];
self.closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
[self.closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
[self.topBar addSubview:self.closeBtn];
self.shareBtn = [UIButton buttonWithType:UIButtonTypeSystem];
[self.shareBtn setImage:[UIImage systemImageNamed:@"square.and.arrow.up" withConfiguration:cfg] forState:UIControlStateNormal];
self.shareBtn.tintColor = [UIColor whiteColor];
self.shareBtn.translatesAutoresizingMaskIntoConstraints = NO;
[self.shareBtn addTarget:self action:@selector(shareTapped) forControlEvents:UIControlEventTouchUpInside];
[self.topBar addSubview:self.shareBtn];
self.counterLabel = [[UILabel alloc] init];
self.counterLabel.textColor = [UIColor whiteColor];
self.counterLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
self.counterLabel.textAlignment = NSTextAlignmentCenter;
self.counterLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self.topBar addSubview:self.counterLabel];
[NSLayoutConstraint activateConstraints:@[
[self.topBar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[self.topBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.topBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.topBar.heightAnchor constraintEqualToConstant:44],
[self.closeBtn.leadingAnchor constraintEqualToAnchor:self.topBar.leadingAnchor constant:16],
[self.closeBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
[self.shareBtn.trailingAnchor constraintEqualToAnchor:self.topBar.trailingAnchor constant:-16],
[self.shareBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
[self.counterLabel.centerXAnchor constraintEqualToAnchor:self.topBar.centerXAnchor],
[self.counterLabel.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
]];
// Bottom bar (caption — tap to expand/collapse)
self.bottomBar = [[UIView alloc] init];
self.bottomBar.backgroundColor = [UIColor colorWithWhite:0 alpha:0.6];
self.bottomBar.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.bottomBar];
self.captionLabel = [[UILabel alloc] init];
self.captionLabel.textColor = [UIColor whiteColor];
self.captionLabel.font = [UIFont systemFontOfSize:14];
self.captionLabel.numberOfLines = 3; // collapsed
self.captionLabel.translatesAutoresizingMaskIntoConstraints = NO;
self.captionLabel.userInteractionEnabled = YES;
[self.bottomBar addSubview:self.captionLabel];
UITapGestureRecognizer *captionTap = [[UITapGestureRecognizer alloc]
initWithTarget:self action:@selector(toggleCaption)];
[self.captionLabel addGestureRecognizer:captionTap];
[NSLayoutConstraint activateConstraints:@[
[self.bottomBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.bottomBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.bottomBar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
[self.captionLabel.topAnchor constraintEqualToAnchor:self.bottomBar.topAnchor constant:12],
[self.captionLabel.leadingAnchor constraintEqualToAnchor:self.bottomBar.leadingAnchor constant:16],
[self.captionLabel.trailingAnchor constraintEqualToAnchor:self.bottomBar.trailingAnchor constant:-16],
[self.captionLabel.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-8],
]];
// Single tap toggles chrome
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleChrome)];
tap.cancelsTouchesInView = NO;
[self.pageVC.view addGestureRecognizer:tap];
// For photos, let double-tap zoom work without triggering single-tap
for (UIGestureRecognizer *gr in self.pageVC.view.gestureRecognizers) {
if ([gr isKindOfClass:[UITapGestureRecognizer class]] && ((UITapGestureRecognizer *)gr).numberOfTapsRequired == 1) {
// Already have our tap
}
}
[self updateChrome];
}
- (void)updateChrome {
SCIMediaViewerItem *item = self.items[self.currentIndex];
// Counter (hide for single items)
if (self.items.count > 1) {
self.counterLabel.text = [NSString stringWithFormat:@"%lu / %lu", (unsigned long)(self.currentIndex + 1), (unsigned long)self.items.count];
self.counterLabel.hidden = NO;
} else {
self.counterLabel.hidden = YES;
}
// Caption
if (item.caption.length) {
self.captionLabel.text = item.caption;
self.bottomBar.hidden = NO;
} else {
self.bottomBar.hidden = YES;
}
}
- (void)toggleChrome {
self.chromeVisible = !self.chromeVisible;
[UIView animateWithDuration:0.25 animations:^{
CGFloat a = self.chromeVisible ? 1.0 : 0.0;
self.topBar.alpha = a;
self.bottomBar.alpha = a;
}];
}
- (void)toggleCaption {
self.captionExpanded = !self.captionExpanded;
[UIView animateWithDuration:0.25 animations:^{
self.captionLabel.numberOfLines = self.captionExpanded ? 0 : 3;
[self.view layoutIfNeeded];
}];
}
- (void)closeTapped {
// Pause any playing video
UIViewController *current = self.pageVC.viewControllers.firstObject;
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
[(((_SCIVideoPageVC *)current).playerVC.player) pause];
}
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)shareTapped {
SCIMediaViewerItem *item = self.items[self.currentIndex];
NSMutableArray *shareItems = [NSMutableArray array];
UIViewController *current = self.pageVC.viewControllers.firstObject;
if ([current isKindOfClass:[_SCIPhotoPageVC class]]) {
UIImage *img = [(_SCIPhotoPageVC *)current currentImage];
if (img) [shareItems addObject:img];
}
// For videos or if no image loaded, share the URL
if (!shareItems.count) {
NSURL *url = item.videoURL ?: item.photoURL;
if (url) [shareItems addObject:url];
}
if (!shareItems.count) return;
UIActivityViewController *vc = [[UIActivityViewController alloc] initWithActivityItems:shareItems applicationActivities:nil];
vc.popoverPresentationController.sourceView = self.shareBtn;
[self presentViewController:vc animated:YES completion:nil];
}
// ─── Page data source ───
- (UIViewController *)viewControllerForIndex:(NSUInteger)idx {
if (idx >= self.items.count) return nil;
SCIMediaViewerItem *item = self.items[idx];
if (item.videoURL) {
_SCIVideoPageVC *vc = [[_SCIVideoPageVC alloc] init];
vc.videoURL = item.videoURL;
vc.view.tag = (NSInteger)idx;
return vc;
} else if (item.photoURL) {
_SCIPhotoPageVC *vc = [[_SCIPhotoPageVC alloc] init];
vc.photoURL = item.photoURL;
vc.view.tag = (NSInteger)idx;
return vc;
}
return nil;
}
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerBeforeViewController:(UIViewController *)vc {
NSInteger idx = vc.view.tag;
if (idx <= 0) return nil;
return [self viewControllerForIndex:idx - 1];
}
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerAfterViewController:(UIViewController *)vc {
NSInteger idx = vc.view.tag;
if (idx + 1 >= (NSInteger)self.items.count) return nil;
return [self viewControllerForIndex:idx + 1];
}
- (void)pageViewController:(UIPageViewController *)pvc didFinishAnimating:(BOOL)finished
previousViewControllers:(NSArray<UIViewController *> *)prev transitionCompleted:(BOOL)completed {
if (!completed) return;
UIViewController *current = pvc.viewControllers.firstObject;
self.currentIndex = (NSUInteger)current.view.tag;
// Pause previous video
for (UIViewController *p in prev) {
if ([p isKindOfClass:[_SCIVideoPageVC class]]) {
[((_SCIVideoPageVC *)p).playerVC.player pause];
}
}
// Play new video
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
[((_SCIVideoPageVC *)current).playerVC.player play];
}
[self updateChrome];
}
- (BOOL)prefersStatusBarHidden { return YES; }
- (BOOL)prefersHomeIndicatorAutoHidden { return YES; }
@end
// ═══════════════════════════════════════════════════════════════════════════
#pragma mark - Public API
// ═══════════════════════════════════════════════════════════════════════════
@implementation SCIMediaViewer
+ (void)presentNativeVideoPlayer:(NSURL *)url {
dispatch_async(dispatch_get_main_queue(), ^{
AVPlayerViewController *playerVC = [[AVPlayerViewController alloc] init];
playerVC.player = [AVPlayer playerWithURL:url];
playerVC.modalPresentationStyle = UIModalPresentationFullScreen;
[topMostController() presentViewController:playerVC animated:YES completion:^{
[playerVC.player play];
}];
});
}
+ (void)showItem:(SCIMediaViewerItem *)item {
if (!item) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
// Single video → native AVPlayerViewController directly (no wrapper)
if (item.videoURL) {
[self presentNativeVideoPlayer:item.videoURL];
return;
}
// Single photo → use our photo viewer container
[self showItems:@[item] startIndex:0];
}
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index {
if (!items.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
if (index >= items.count) index = 0;
// Single video item → native player
if (items.count == 1 && items[0].videoURL) {
[self presentNativeVideoPlayer:items[0].videoURL];
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
_SCIMediaViewerContainerVC *vc = [[_SCIMediaViewerContainerVC alloc] init];
vc.items = items;
vc.currentIndex = index;
vc.modalPresentationStyle = UIModalPresentationFullScreen;
vc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
[topMostController() presentViewController:vc animated:YES completion:nil];
});
}
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
[self showItem:[SCIMediaViewerItem itemWithVideoURL:videoURL photoURL:photoURL caption:caption]];
}
@end
+10
View File
@@ -0,0 +1,10 @@
// SCIRepostSheet — download media, save to Photos, open IG's creation flow.
#import <UIKit/UIKit.h>
@interface SCIRepostSheet : NSObject
/// Download media, save to Photos, open IG's creation flow.
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL;
@end
+109
View File
@@ -0,0 +1,109 @@
#import "SCIRepostSheet.h"
#import "../Utils.h"
#import "../Downloader/Download.h"
#import "../PhotoAlbum.h"
#import <Photos/Photos.h>
@implementation SCIRepostSheet
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL {
NSURL *url = videoURL ?: photoURL;
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media URL")]; return; }
// Show pill
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
[pill resetState];
[pill setText:SCILocalized(@"Preparing repost...")];
[pill setSubtitle:nil];
UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
if (hostView) [pill showInView:hostView];
// Download to temp file
NSString *ext = [[url lastPathComponent] pathExtension];
if (!ext.length) ext = videoURL ? @"mp4" : @"jpg";
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"repost_%@.%@", [[NSUUID UUID] UUIDString], ext]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
if (err || !loc) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill showError:SCILocalized(@"Download failed")];
[pill dismissAfterDelay:2.0];
});
return;
}
NSError *mv = nil;
NSURL *fileURL = [NSURL fileURLWithPath:tmp];
[[NSFileManager defaultManager] moveItemAtURL:loc toURL:fileURL error:&mv];
if (mv) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill showError:SCILocalized(@"Save failed")];
[pill dismissAfterDelay:2.0];
});
return;
}
// Save to Photos and get the localIdentifier
[self saveToPhotosAndOpenCreation:fileURL isVideo:(videoURL != nil) pill:pill];
}];
[task resume];
}
+ (void)saveToPhotosAndOpenCreation:(NSURL *)fileURL isVideo:(BOOL)isVideo pill:(SCIDownloadPillView *)pill {
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
if (status != PHAuthorizationStatusAuthorized) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill showError:SCILocalized(@"Photos access denied")];
[pill dismissAfterDelay:2.0];
});
return;
}
__block NSString *localId = nil;
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHAssetCreationRequest *req;
if (isVideo) {
req = [PHAssetCreationRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
} else {
UIImage *img = [UIImage imageWithContentsOfFile:fileURL.path];
if (img) {
req = [PHAssetCreationRequest creationRequestForAssetFromImage:img];
} else {
req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new];
opts.shouldMoveFile = YES;
[req addResourceWithType:PHAssetResourceTypePhoto fileURL:fileURL options:opts];
}
}
localId = req.placeholderForCreatedAsset.localIdentifier;
} completionHandler:^(BOOL success, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (!success || !localId.length) {
[pill showError:SCILocalized(@"Failed to save")];
[pill dismissAfterDelay:2.0];
return;
}
[pill showSuccess:SCILocalized(@"Opening creator...")];
[pill dismissAfterDelay:1.0];
// Open IG's native creation flow with the saved asset
NSString *urlStr = [NSString stringWithFormat:@"instagram://library?LocalIdentifier=%@",
[localId stringByAddingPercentEncodingWithAllowedCharacters:
[NSCharacterSet URLQueryAllowedCharacterSet]]];
NSURL *igURL = [NSURL URLWithString:urlStr];
if ([[UIApplication sharedApplication] canOpenURL:igURL]) {
[[UIApplication sharedApplication] openURL:igURL options:@{} completionHandler:nil];
} else {
// Fallback: show share sheet
[SCIUtils showShareVC:fileURL];
}
});
}];
}];
}
@end
+20 -2
View File
@@ -8,17 +8,34 @@
#import "Manager.h"
@interface SCIDownloadPillView : UIView
@property (nonatomic, strong) UIProgressView *progressRing;
@property (nonatomic, strong) UIProgressView *progressBar;
@property (nonatomic, strong) UILabel *textLabel;
@property (nonatomic, strong) UILabel *subtitleLabel;
@property (nonatomic, strong) UIButton *cancelButton;
@property (nonatomic, strong) UIImageView *iconView;
@property (nonatomic, copy) void (^onCancel)(void);
- (void)resetState;
- (void)showInView:(UIView *)view;
- (void)dismiss;
- (void)dismissAfterDelay:(NSTimeInterval)delay;
- (void)setProgress:(float)progress;
- (void)setText:(NSString *)text;
- (void)setSubtitle:(NSString *)text;
- (void)showSuccess:(NSString *)text;
- (void)showError:(NSString *)text;
- (void)showBulkProgress:(NSUInteger)completed total:(NSUInteger)total;
// Multi-download ticket API. All methods are safe from any thread.
// Tap-to-cancel pops the most recently pushed ticket.
- (NSString *)beginTicketWithTitle:(NSString *)title onCancel:(void (^)(void))cancel;
- (void)updateTicket:(NSString *)ticketId progress:(float)progress;
- (void)updateTicket:(NSString *)ticketId text:(NSString *)text;
- (void)finishTicket:(NSString *)ticketId successMessage:(NSString *)message;
- (void)finishTicket:(NSString *)ticketId errorMessage:(NSString *)message;
- (void)finishTicket:(NSString *)ticketId cancelled:(NSString *)message;
/// Shared singleton pill — reused across all downloads so only one shows at a time.
+ (instancetype)shared;
@end
@interface SCIDownloadDelegate : NSObject <SCIDownloadDelegateProtocol>
@@ -33,6 +50,7 @@ typedef NS_ENUM(NSUInteger, DownloadAction) {
@property (nonatomic, strong) SCIDownloadManager *downloadManager;
@property (nonatomic, strong) SCIDownloadPillView *pill;
@property (nonatomic, copy) NSString *ticketId;
- (instancetype)initWithAction:(DownloadAction)action showProgress:(BOOL)showProgress;
+334 -103
View File
@@ -2,70 +2,145 @@
#import "../PhotoAlbum.h"
#import <Photos/Photos.h>
#pragma mark - Ticket slot
@interface SCIDownloadSlot : NSObject
@property (nonatomic, copy) NSString *ticketId;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, assign) float progress;
@property (nonatomic, copy) void (^onCancel)(void);
@property (nonatomic, assign) BOOL finished;
@end
@implementation SCIDownloadSlot @end
#pragma mark - SCIDownloadPillView
@interface SCIDownloadPillView ()
@property (nonatomic, strong) NSMutableArray<SCIDownloadSlot *> *slots;
@end
@implementation SCIDownloadPillView
+ (instancetype)shared {
static SCIDownloadPillView *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [[SCIDownloadPillView alloc] init]; });
return s;
}
- (instancetype)init {
self = [super initWithFrame:CGRectZero];
if (self) {
self.backgroundColor = [UIColor colorWithWhite:0.1 alpha:0.92];
self.layer.cornerRadius = 20;
_slots = [NSMutableArray array];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(_sciAppDidBecomeActive)
name:UIApplicationDidBecomeActiveNotification object:nil];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(_sciAppDidEnterBackground)
name:UIApplicationDidEnterBackgroundNotification object:nil];
UIBlurEffect *blur = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemUltraThinMaterialDark];
UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect:blur];
blurView.translatesAutoresizingMaskIntoConstraints = NO;
blurView.layer.cornerRadius = 16;
blurView.clipsToBounds = YES;
[self addSubview:blurView];
[NSLayoutConstraint activateConstraints:@[
[blurView.topAnchor constraintEqualToAnchor:self.topAnchor],
[blurView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
[blurView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[blurView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
]];
self.layer.cornerRadius = 16;
self.clipsToBounds = YES;
self.alpha = 0;
// Circular progress (using a small CAShapeLayer ring)
_progressRing = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
_progressRing.progressTintColor = [UIColor systemBlueColor];
_progressRing.trackTintColor = [UIColor colorWithWhite:0.3 alpha:1.0];
_progressRing.translatesAutoresizingMaskIntoConstraints = NO;
_progressRing.layer.cornerRadius = 2;
_progressRing.clipsToBounds = YES;
[self addSubview:_progressRing];
// Icon
_iconView = [[UIImageView alloc] init];
_iconView.translatesAutoresizingMaskIntoConstraints = NO;
_iconView.tintColor = [UIColor whiteColor];
_iconView.contentMode = UIViewContentModeScaleAspectFit;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
_iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
[self addSubview:_iconView];
// Text
_textLabel = [[UILabel alloc] init];
_textLabel.text = @"Downloading 0%";
_textLabel.text = SCILocalized(@"Downloading...");
_textLabel.textColor = [UIColor whiteColor];
_textLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
_textLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
_textLabel.textAlignment = NSTextAlignmentCenter;
_textLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:_textLabel];
// Subtitle
_subtitleLabel = [[UILabel alloc] init];
_subtitleLabel.text = @"Tap to cancel";
_subtitleLabel.textColor = [UIColor colorWithWhite:0.6 alpha:1.0];
_subtitleLabel.font = [UIFont systemFontOfSize:10 weight:UIFontWeightRegular];
_subtitleLabel.text = SCILocalized(@"Tap to cancel");
_subtitleLabel.textColor = [UIColor colorWithWhite:0.7 alpha:1.0];
_subtitleLabel.font = [UIFont systemFontOfSize:11 weight:UIFontWeightRegular];
_subtitleLabel.textAlignment = NSTextAlignmentCenter;
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:_subtitleLabel];
// Tap gesture for cancel
// Progress bar
_progressBar = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
_progressBar.progressTintColor = [UIColor systemBlueColor];
_progressBar.trackTintColor = [UIColor colorWithWhite:0.3 alpha:0.5];
_progressBar.translatesAutoresizingMaskIntoConstraints = NO;
_progressBar.layer.cornerRadius = 1.5;
_progressBar.clipsToBounds = YES;
[self addSubview:_progressBar];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)];
[self addGestureRecognizer:tap];
// Layout: [progress bar]
// [text centered]
// [subtitle centered]
[NSLayoutConstraint activateConstraints:@[
[_progressRing.topAnchor constraintEqualToAnchor:self.topAnchor constant:12],
[_progressRing.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
[_progressRing.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
[_progressRing.heightAnchor constraintEqualToConstant:4],
[_iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:14],
[_iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor constant:-2],
[_iconView.widthAnchor constraintEqualToConstant:22],
[_iconView.heightAnchor constraintEqualToConstant:22],
[_textLabel.topAnchor constraintEqualToAnchor:_progressRing.bottomAnchor constant:6],
[_textLabel.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[_textLabel.topAnchor constraintEqualToAnchor:self.topAnchor constant:10],
[_textLabel.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:10],
[_textLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-14],
[_subtitleLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor constant:2],
[_subtitleLabel.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[_subtitleLabel.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-10],
[_subtitleLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor constant:1],
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_textLabel.leadingAnchor],
[_subtitleLabel.trailingAnchor constraintEqualToAnchor:_textLabel.trailingAnchor],
[_progressBar.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
[_progressBar.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[_progressBar.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[_progressBar.heightAnchor constraintEqualToConstant:3],
[_subtitleLabel.bottomAnchor constraintEqualToAnchor:_progressBar.topAnchor constant:-8],
]];
}
return self;
}
- (void)handleTap {
if (self.onCancel) self.onCancel();
if (self.slots.count > 0) {
SCIDownloadSlot *top = self.slots.lastObject;
void (^cb)(void) = top.onCancel;
top.onCancel = nil;
if (cb) cb();
return;
}
void (^cb)(void) = self.onCancel;
self.onCancel = nil;
if (cb) cb();
}
- (void)resetState {
self.progressBar.progress = 0;
self.progressBar.hidden = NO;
self.subtitleLabel.hidden = NO;
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
self.textLabel.text = SCILocalized(@"Downloading...");
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
self.iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
self.iconView.tintColor = [UIColor whiteColor];
}
- (void)showInView:(UIView *)view {
@@ -74,23 +149,32 @@
[view addSubview:self];
[NSLayoutConstraint activateConstraints:@[
[self.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor constant:4],
[self.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor constant:8],
[self.centerXAnchor constraintEqualToAnchor:view.centerXAnchor],
[self.widthAnchor constraintGreaterThanOrEqualToConstant:160],
[self.widthAnchor constraintLessThanOrEqualToConstant:220],
[self.widthAnchor constraintGreaterThanOrEqualToConstant:200],
[self.widthAnchor constraintLessThanOrEqualToConstant:300],
]];
[UIView animateWithDuration:0.25 animations:^{
[UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0.5
options:UIViewAnimationOptionCurveEaseOut animations:^{
self.alpha = 1;
}];
} completion:nil];
}
- (void)dismiss {
[UIView animateWithDuration:0.2 animations:^{
self.alpha = 0;
} completion:^(BOOL finished) {
[self removeFromSuperview];
}];
dispatch_async(dispatch_get_main_queue(), ^{
// A new ticket raced in — keep the pill alive.
if (self.slots.count > 0) return;
if (self.alpha <= 0.01 && !self.superview) return;
self.onCancel = nil;
[UIView animateWithDuration:0.25 animations:^{
self.alpha = 0;
self.transform = CGAffineTransformMakeScale(0.9, 0.9);
} completion:^(BOOL finished) {
[self removeFromSuperview];
self.transform = CGAffineTransformIdentity;
}];
});
}
- (void)dismissAfterDelay:(NSTimeInterval)delay {
@@ -100,13 +184,203 @@
}
- (void)setProgress:(float)progress {
[self.progressRing setProgress:progress animated:YES];
self.progressBar.hidden = NO;
[self.progressBar setProgress:progress animated:YES];
}
- (void)setText:(NSString *)text {
self.textLabel.text = text;
}
- (void)setSubtitle:(NSString *)text {
self.subtitleLabel.text = text;
self.subtitleLabel.hidden = (text.length == 0);
}
- (void)showSuccess:(NSString *)text {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
self.iconView.image = [UIImage systemImageNamed:@"checkmark.circle.fill" withConfiguration:cfg];
self.iconView.tintColor = [UIColor systemGreenColor];
self.textLabel.text = text;
self.subtitleLabel.hidden = YES;
self.progressBar.hidden = YES;
self.onCancel = nil;
}
- (void)showError:(NSString *)text {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
self.iconView.image = [UIImage systemImageNamed:@"xmark.circle.fill" withConfiguration:cfg];
self.iconView.tintColor = [UIColor systemRedColor];
self.textLabel.text = text;
self.subtitleLabel.hidden = YES;
self.progressBar.hidden = YES;
self.onCancel = nil;
}
- (void)showBulkProgress:(NSUInteger)completed total:(NSUInteger)total {
self.textLabel.text = [NSString stringWithFormat:@"Downloading %lu of %lu", (unsigned long)completed + 1, (unsigned long)total];
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
self.subtitleLabel.hidden = NO;
self.progressBar.hidden = NO;
[self.progressBar setProgress:(float)completed / (float)total animated:YES];
}
#pragma mark - Ticket API
- (void)_onMain:(dispatch_block_t)block {
if ([NSThread isMainThread]) block();
else dispatch_async(dispatch_get_main_queue(), block);
}
- (SCIDownloadSlot *)_slotForId:(NSString *)ticketId {
if (!ticketId) return nil;
for (SCIDownloadSlot *s in self.slots) {
if ([s.ticketId isEqualToString:ticketId]) return s;
}
return nil;
}
- (void)_renderTop {
SCIDownloadSlot *top = self.slots.lastObject;
if (!top) return;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
self.iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
self.iconView.tintColor = [UIColor whiteColor];
self.textLabel.text = top.title ?: @"Downloading...";
self.progressBar.hidden = NO;
[self.progressBar setProgress:top.progress animated:YES];
self.subtitleLabel.hidden = NO;
if (self.slots.count > 1) {
self.subtitleLabel.text = [NSString stringWithFormat:@"%lu active — tap to cancel",
(unsigned long)self.slots.count];
} else {
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
}
}
- (NSString *)beginTicketWithTitle:(NSString *)title onCancel:(void (^)(void))cancel {
NSString *ticketId = [[NSUUID UUID] UUIDString];
void (^cancelCopy)(void) = [cancel copy];
[self _onMain:^{
SCIDownloadSlot *slot = [SCIDownloadSlot new];
slot.ticketId = ticketId;
slot.title = title ?: @"Downloading...";
slot.progress = 0;
slot.onCancel = cancelCopy;
[self.slots addObject:slot];
// Reset visual state so the prior download's final frame doesn't leak in.
[self.progressBar setProgress:0 animated:NO];
self.alpha = 1;
self.transform = CGAffineTransformIdentity;
if (!self.superview) {
UIView *host = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
if (host) [self showInView:host];
}
[self _renderTop];
}];
return ticketId;
}
- (void)_sciAppDidBecomeActive {
[self _onMain:^{
if (self.slots.count == 0 && (self.superview || self.alpha > 0.01)) {
self.alpha = 0;
self.transform = CGAffineTransformIdentity;
[self removeFromSuperview];
} else if (self.slots.count > 0) {
[self _renderTop];
}
}];
}
// iOS suspends networking + ffmpeg on background — cancel active tickets so the
// pill clears cleanly on return. User re-initiates the download.
- (void)_sciAppDidEnterBackground {
[self _onMain:^{
for (SCIDownloadSlot *slot in [self.slots copy]) {
void (^cb)(void) = slot.onCancel;
slot.onCancel = nil;
if (cb) cb();
}
}];
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)updateTicket:(NSString *)ticketId progress:(float)progress {
[self _onMain:^{
SCIDownloadSlot *s = [self _slotForId:ticketId];
if (!s || s.finished) return;
s.progress = progress;
if (self.slots.lastObject == s) [self.progressBar setProgress:progress animated:YES];
}];
}
- (void)updateTicket:(NSString *)ticketId text:(NSString *)text {
[self _onMain:^{
SCIDownloadSlot *s = [self _slotForId:ticketId];
if (!s || s.finished) return;
s.title = text ?: s.title;
if (self.slots.lastObject == s) self.textLabel.text = s.title;
}];
}
- (void)_removeSlot:(SCIDownloadSlot *)slot
finalText:(NSString *)finalText
finalIcon:(NSString *)finalIcon
iconColor:(UIColor *)iconColor {
if (!slot || slot.finished) return;
slot.finished = YES;
slot.onCancel = nil;
[self.slots removeObject:slot];
if (self.slots.count > 0) {
[self _renderTop];
return;
}
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
self.iconView.image = [UIImage systemImageNamed:finalIcon withConfiguration:cfg];
self.iconView.tintColor = iconColor;
self.textLabel.text = finalText;
self.subtitleLabel.hidden = YES;
self.progressBar.hidden = YES;
[self dismissAfterDelay:1.2];
}
- (void)finishTicket:(NSString *)ticketId successMessage:(NSString *)message {
[self _onMain:^{
SCIDownloadSlot *s = [self _slotForId:ticketId];
[self _removeSlot:s
finalText:message ?: @"Done"
finalIcon:@"checkmark.circle.fill"
iconColor:[UIColor systemGreenColor]];
}];
}
- (void)finishTicket:(NSString *)ticketId errorMessage:(NSString *)message {
[self _onMain:^{
SCIDownloadSlot *s = [self _slotForId:ticketId];
[self _removeSlot:s
finalText:message ?: @"Failed"
finalIcon:@"xmark.circle.fill"
iconColor:[UIColor systemRedColor]];
}];
}
- (void)finishTicket:(NSString *)ticketId cancelled:(NSString *)message {
[self _onMain:^{
SCIDownloadSlot *s = [self _slotForId:ticketId];
[self _removeSlot:s
finalText:message ?: @"Cancelled"
finalIcon:@"xmark.circle.fill"
iconColor:[UIColor systemOrangeColor]];
}];
}
@end
@@ -127,33 +401,13 @@
}
- (void)downloadFileWithURL:(NSURL *)url fileExtension:(NSString *)fileExtension hudLabel:(NSString *)hudLabel {
// Dismiss any existing pill
[self.pill dismiss];
self.pill = [[SCIDownloadPillView alloc] init];
if (hudLabel) {
[self.pill setText:hudLabel];
}
if (!self.showProgress) {
self.pill.progressRing.hidden = YES;
self.pill.subtitleLabel.text = nil;
}
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
self.pill = pill;
__weak typeof(self) weakSelf = self;
self.pill.onCancel = ^{
self.ticketId = [pill beginTicketWithTitle:hudLabel ?: @"Downloading..." onCancel:^{
[weakSelf.downloadManager cancelDownload];
};
// Show on keyWindow so it survives VC transitions (e.g. leaving stories)
UIView *hostView = [UIApplication sharedApplication].keyWindow;
if (!hostView) hostView = topMostController().view;
if (!hostView) {
NSLog(@"[SCInsta] Download: No valid view");
return;
}
[self.pill showInView:hostView];
}];
NSLog(@"[SCInsta] Download: Will start download for url \"%@\" with file extension: \".%@\"", url, fileExtension);
[self.downloadManager downloadFileWithURL:url fileExtension:fileExtension];
@@ -164,46 +418,30 @@
}
- (void)downloadDidCancel {
dispatch_async(dispatch_get_main_queue(), ^{
[self.pill setText:@"Cancelled"];
self.pill.subtitleLabel.text = nil;
self.pill.progressRing.hidden = YES;
[self.pill dismissAfterDelay:0.8];
});
[self.pill finishTicket:self.ticketId cancelled:@"Cancelled"];
NSLog(@"[SCInsta] Download: Download cancelled");
}
- (void)downloadDidProgress:(float)progress {
if (self.showProgress) {
dispatch_async(dispatch_get_main_queue(), ^{
[self.pill setProgress:progress];
[self.pill setText:[NSString stringWithFormat:@"Downloading %d%%", (int)(progress * 100)]];
});
}
if (!self.showProgress) return;
[self.pill updateTicket:self.ticketId progress:progress];
[self.pill updateTicket:self.ticketId text:[NSString stringWithFormat:@"Downloading %d%%", (int)(progress * 100)]];
}
- (void)downloadDidFinishWithError:(NSError *)error {
dispatch_async(dispatch_get_main_queue(), ^{
if (error && error.code != NSURLErrorCancelled) {
NSLog(@"[SCInsta] Download: Download failed with error: \"%@\"", error);
[self.pill setText:@"Download failed"];
self.pill.subtitleLabel.text = error.localizedDescription;
self.pill.progressRing.hidden = YES;
[self.pill dismissAfterDelay:3.0];
} else if (!error) {
// nil error without fileURL callback — dismiss stale pill
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (self.pill.superview) [self.pill dismissAfterDelay:0];
});
}
});
if (error && error.code != NSURLErrorCancelled) {
NSLog(@"[SCInsta] Download: Download failed with error: \"%@\"", error);
[self.pill finishTicket:self.ticketId errorMessage:SCILocalized(@"Download failed")];
}
}
- (void)downloadDidFinishWithFileURL:(NSURL *)fileURL {
dispatch_async(dispatch_get_main_queue(), ^{
[self.pill dismiss];
NSLog(@"[SCInsta] Download: Finished with url: \"%@\"", [fileURL absoluteString]);
// saveToPhotos finishes the ticket after the PH completion fires.
if (self.action != saveToPhotos) {
[self.pill finishTicket:self.ticketId successMessage:SCILocalized(@"Done")];
}
switch (self.action) {
case share:
@@ -218,7 +456,7 @@
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
if (status != PHAuthorizationStatusAuthorized) {
dispatch_async(dispatch_get_main_queue(), ^{
[SCIUtils showErrorHUDWithDescription:@"Photo library access denied"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")];
});
return;
}
@@ -227,17 +465,10 @@
void (^onDone)(BOOL, NSError *) = ^(BOOL success, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success) {
SCIDownloadPillView *donePill = [[SCIDownloadPillView alloc] init];
donePill.progressRing.hidden = YES;
donePill.subtitleLabel.text = nil;
[donePill setText:useAlbum ? @"Saved to RyukGram" : @"Saved to Photos"];
UIView *hostView = topMostController().view;
if (hostView) {
[donePill showInView:hostView];
[donePill dismissAfterDelay:1.5];
}
[self.pill finishTicket:self.ticketId
successMessage:useAlbum ? SCILocalized(@"Saved to RyukGram") : SCILocalized(@"Saved to Photos")];
} else {
[SCIUtils showErrorHUDWithDescription:@"Failed to save to Photos"];
[self.pill finishTicket:self.ticketId errorMessage:SCILocalized(@"Failed to save")];
}
});
};
@@ -0,0 +1,259 @@
// Feed action button — hooks IGUFIInteractionCountsView.
// Media lives on sibling cells (IGFeedItemPhotoCell, IGModernFeedVideoCell)
// in the same collection view section, NOT on the UFI cell itself.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import <objc/runtime.h>
#import <objc/message.h>
static const NSInteger kFeedActionBtnTag = 13370;
static const void *kFeedPageIndexKey = &kFeedPageIndexKey;
// Read _currentMediaPK from IGFeedItemUFICell.
static NSString *sciFeedCurrentMediaPK(UIView *button) {
UIResponder *r = button;
Class ufiCls = NSClassFromString(@"IGFeedItemUFICell");
while (r && !(ufiCls && [r isKindOfClass:ufiCls])) r = [r nextResponder];
if (!r) return nil;
Ivar iv = class_getInstanceVariable(object_getClass(r), "_currentMediaPK");
if (!iv) return nil;
id val = object_getIvar(r, iv);
return [val isKindOfClass:[NSString class]] ? val : nil;
}
// Current carousel page index. Returns -1 if not found.
static NSInteger sciFeedCarouselPageIndex(UIView *button) {
// Walk up to collection view
UIView *v = button;
UICollectionViewCell *ufiCell = nil;
UICollectionView *cv = nil;
while (v) {
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
ufiCell = (UICollectionViewCell *)v;
}
if ([v isKindOfClass:[UICollectionView class]]) {
cv = (UICollectionView *)v;
break;
}
v = v.superview;
}
if (!ufiCell || !cv) return -1;
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
if (!ufiPath) return -1;
NSInteger section = ufiPath.section;
// Find IGFeedItemPageCell in same section
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
NSString *cls = NSStringFromClass([cell class]);
if (![cls containsString:@"Page"]) continue;
// BFS for IGPageMediaView
Class pmvCls = NSClassFromString(@"IGPageMediaView");
if (pmvCls) {
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
int scanned = 0;
UIView *pmv = nil;
while (queue.count && scanned < 50) {
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:pmvCls]) { pmv = cur; break; }
for (UIView *s in cur.subviews) [queue addObject:s];
}
if (pmv && [pmv respondsToSelector:@selector(currentMediaItem)] && [pmv respondsToSelector:@selector(items)]) {
@try {
id current = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(currentMediaItem));
NSArray *items = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(items));
if (current && items.count) {
NSUInteger idx = [items indexOfObjectIdenticalTo:current];
if (idx != NSNotFound) return (NSInteger)idx;
}
} @catch (__unused id e) {}
}
}
// Fallback: _currentIndex ivar on the page cell
Ivar idxIvar = class_getInstanceVariable([cell class], "_currentIndex");
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentPage");
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentMediaIndex");
if (idxIvar) {
ptrdiff_t offset = ivar_getOffset(idxIvar);
NSInteger idx = *(NSInteger *)((char *)(__bridge void *)cell + offset);
return idx;
}
// Fallback: compute page from scroll view content offset
{
NSMutableArray *sq = [NSMutableArray arrayWithObject:cell];
int sc = 0;
while (sq.count && sc < 100) {
UIView *cur = sq.firstObject; [sq removeObjectAtIndex:0]; sc++;
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
UIScrollView *sv = (UIScrollView *)cur;
CGFloat pageW = sv.bounds.size.width;
// Horizontal paging scroll view
if (pageW > 100 && sv.contentSize.width > pageW * 1.5) {
NSInteger idx = (NSInteger)round(sv.contentOffset.x / pageW);
return idx;
}
}
for (UIView *s in cur.subviews) [sq addObject:s];
}
}
}
return -1;
}
// Resolve current carousel child using page index.
static id sciFeedResolveCarouselChild(id parentMedia, UIView *button) {
if (!parentMedia) return nil;
if (![SCIMediaActions isCarouselMedia:parentMedia]) return parentMedia;
NSInteger idx = sciFeedCarouselPageIndex(button);
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
if (idx >= 0 && (NSUInteger)idx < children.count) {
return children[idx];
}
return parentMedia;
}
// Extract IGMedia from sibling cells in the same collection view section.
static IGMedia *sciFeedMediaFromButton(UIView *button) {
if (!button) return nil;
Class mediaClass = NSClassFromString(@"IGMedia");
if (!mediaClass) return nil;
// Walk up to find UFI cell and collection view
UIView *v = button;
UICollectionViewCell *ufiCell = nil;
UICollectionView *cv = nil;
while (v) {
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
ufiCell = (UICollectionViewCell *)v;
}
if ([v isKindOfClass:[UICollectionView class]]) {
cv = (UICollectionView *)v;
break;
}
v = v.superview;
}
if (!ufiCell || !cv) return nil;
// Get section
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
if (!ufiPath) return nil;
NSInteger section = ufiPath.section;
// Search sibling cells for IGMedia
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
if (cell == ufiCell) continue;
// Filter to media cell classes
NSString *cls = NSStringFromClass([cell class]);
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
// Scan ivars for IGMedia
unsigned int count = 0;
Class c = object_getClass(cell);
while (c && c != [UICollectionViewCell class]) {
Ivar *ivars = class_copyIvarList(c, &count);
for (unsigned int i = 0; i < count; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(cell, ivars[i]);
if (val && [val isKindOfClass:mediaClass]) {
free(ivars);
return (IGMedia *)val;
}
// Try .media selector on wrapper objects
if (val && [val respondsToSelector:@selector(media)]) {
id m = ((id(*)(id,SEL))objc_msgSend)(val, @selector(media));
if (m && [m isKindOfClass:mediaClass]) {
free(ivars);
return (IGMedia *)m;
}
}
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
c = class_getSuperclass(c);
}
// Try mediaCellFeedItem (video cells)
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
@try {
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
if (m && [m isKindOfClass:mediaClass]) {
return (IGMedia *)m;
}
} @catch (__unused id e) {}
}
}
return nil;
}
%hook IGUFIInteractionCountsView
- (void)updateUFIWithButtonsConfig:(id)config interactionCountProvider:(id)provider {
%orig;
if (![SCIUtils getBoolPref:@"feed_action_button"]) return;
UIButton *btn = (UIButton *)[self viewWithTag:kFeedActionBtnTag];
if (!btn) {
btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = kFeedActionBtnTag;
UIImageSymbolConfiguration *cfg =
[UIImageSymbolConfiguration configurationWithPointSize:21 weight:UIImageSymbolWeightRegular];
[btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = [UIColor labelColor];
btn.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:btn];
// Position: right side, left of bookmark. Shifted up 4pt to
// align with the native like/comment/share icons.
[NSLayoutConstraint activateConstraints:@[
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-44],
[btn.centerYAnchor constraintEqualToAnchor:self.centerYAnchor constant:-6],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36],
]];
}
// Reconfigure with fresh media provider.
[SCIActionButton configureButton:btn
context:SCIActionContextFeed
prefKey:@"feed_action_default"
mediaProvider:^id (UIView *sourceView) {
id parentMedia = sciFeedMediaFromButton(sourceView);
if (!parentMedia) return nil;
if ([SCIMediaActions isCarouselMedia:parentMedia]) {
NSInteger idx = sciFeedCarouselPageIndex(sourceView);
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
if (idx >= 0 && (NSUInteger)idx < children.count) {
// Stash page index for the menu builder to find the parent.
objc_setAssociatedObject(sourceView, kFeedPageIndexKey,
@(idx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
return children[idx];
}
}
return parentMedia;
}];
}
%end
@@ -0,0 +1,171 @@
// Reels action button — injects a RyukGram action button above the reel's
// vertical like/comment/share sidebar (IGSundialViewerVerticalUFI).
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import <objc/runtime.h>
#import <objc/message.h>
static const NSInteger kReelActionBtnTag = 1337;
static UIView *sciFindSuperviewOfClass(UIView *view, NSString *className) {
Class cls = NSClassFromString(className);
if (!cls) return nil;
UIView *current = view.superview;
for (int depth = 0; current && depth < 20; depth++) {
if ([current isKindOfClass:cls]) return current;
current = current.superview;
}
return nil;
}
static id sciFindMediaIvar(UIView *view) {
if (!view) return nil;
Class mediaClass = NSClassFromString(@"IGMedia");
if (!mediaClass) return nil;
unsigned int count = 0;
Ivar *ivars = class_copyIvarList([view class], &count);
id found = nil;
for (unsigned int i = 0; i < count; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(view, ivars[i]);
if (val && [val isKindOfClass:mediaClass]) { found = val; break; }
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
return found;
}
// Resolve the current carousel child from _currentIndex.
static id sciCurrentCarouselChildMedia(UIView *carouselCell, id parentMedia) {
if (!carouselCell || !parentMedia) return parentMedia;
// Try _currentIndex ivar
Ivar idxIvar = class_getInstanceVariable([carouselCell class], "_currentIndex");
NSInteger currentIdx = 0;
if (idxIvar) {
ptrdiff_t offset = ivar_getOffset(idxIvar);
currentIdx = *(NSInteger *)((char *)(__bridge void *)carouselCell + offset);
}
// Fallback: _currentFractionalIndex
if (!idxIvar || currentIdx == 0) {
Ivar fracIvar = class_getInstanceVariable([carouselCell class], "_currentFractionalIndex");
if (fracIvar) {
ptrdiff_t fOffset = ivar_getOffset(fracIvar);
double fracIdx = *(double *)((char *)(__bridge void *)carouselCell + fOffset);
NSInteger roundedIdx = (NSInteger)round(fracIdx);
if (roundedIdx > 0) currentIdx = roundedIdx;
}
}
// Fallback: inner collection view content offset
Ivar cvIvar = class_getInstanceVariable([carouselCell class], "_collectionView");
if (cvIvar) {
UICollectionView *cv = object_getIvar(carouselCell, cvIvar);
if (cv) {
CGFloat pageWidth = cv.bounds.size.width;
if (pageWidth > 0) {
NSInteger cvIdx = (NSInteger)round(cv.contentOffset.x / pageWidth);
if (cvIdx > currentIdx) currentIdx = cvIdx;
}
}
}
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
if (currentIdx >= 0 && (NSUInteger)currentIdx < children.count) {
return children[currentIdx];
}
return parentMedia;
}
// Media provider for reels. Returns current page's child for carousels.
static id sciReelsMediaProvider(UIView *sourceView) {
// Video reel
UIView *videoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerVideoCell");
if (videoCell) {
id m = sciFindMediaIvar(videoCell);
if (m) return m;
}
// Photo reel
UIView *photoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerPhotoCell");
if (photoCell) {
id m = sciFindMediaIvar(photoCell);
if (m) return m;
}
// Carousel reel
UIView *carouselCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerCarouselCell");
if (carouselCell) {
id parentMedia = sciFindMediaIvar(carouselCell);
if (parentMedia) {
return sciCurrentCarouselChildMedia(carouselCell, parentMedia);
}
}
return nil;
}
%hook IGSundialViewerVerticalUFI
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"reels_action_button"]) return;
if (!self.superview) return;
UIButton *btn = (UIButton *)[self viewWithTag:kReelActionBtnTag];
if (!btn) {
btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = kReelActionBtnTag;
UIImageSymbolConfiguration *symCfg =
[UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold];
UIImage *base = [UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:symCfg];
// Bake the drop shadow into a single UIImage so no CALayer shadow is
// applied to the button itself.
CGFloat pad = 8;
CGSize sz = CGSizeMake(base.size.width + pad * 2, base.size.height + pad * 2);
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz];
UIImage *icon = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
CGContextRef c = ctx.CGContext;
CGContextSaveGState(c);
CGContextSetShadowWithColor(c, CGSizeMake(0, 1), 3,
[UIColor colorWithWhite:0 alpha:0.55].CGColor);
UIImage *tinted = [base imageWithTintColor:[UIColor whiteColor]
renderingMode:UIImageRenderingModeAlwaysOriginal];
[tinted drawInRect:CGRectMake(pad, pad, base.size.width, base.size.height)];
CGContextRestoreGState(c);
}];
[btn setImage:icon forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
self.clipsToBounds = NO;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[btn.bottomAnchor constraintEqualToAnchor:self.topAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:40],
[btn.heightAnchor constraintEqualToConstant:40]
]];
}
// Reconfigure with fresh media provider.
[SCIActionButton configureButton:btn
context:SCIActionContextReels
prefKey:@"reels_action_default"
mediaProvider:^id (UIView *sourceView) {
return sciReelsMediaProvider(sourceView);
}];
}
%end
+12 -7
View File
@@ -18,17 +18,22 @@
// Follow button on profile page
%hook IGFollowController
- (void)_didPressFollowButton {
// Get user follow status (check if already following user)
NSInteger UserFollowStatus = self.user.followStatus;
// Only show confirm dialog if user is not following
if (UserFollowStatus == 2) {
NSInteger status = self.user.followStatus;
if (status == 2) {
CONFIRMFOLLOW(%orig);
}
else {
} else {
return %orig;
}
}
// Unfollow from profile action sheet
- (void)_performUnfollow {
if ([SCIUtils getBoolPref:@"unfollow_confirm"]) {
[SCIUtils showConfirmation:^(void) { %orig; } title:SCILocalized(@"Unfollow?")];
} else {
%orig;
}
}
%end
// Follow button on discover people page
+22 -27
View File
@@ -1,33 +1,28 @@
#import "../../Utils.h"
%hook IGDirectDisappearingModeSwipeHandler
- (void)handleBottomSwipeableScrollUpdate {
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return;
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
[SCIUtils showConfirmation:^(void) { %orig; }];
else %orig;
}
- (id)getSwipeableScrollHintTextInfo {
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return nil;
return %orig;
}
%end
%hook IGDirectThreadViewController
- (void)swipeableScrollManagerDidEndDraggingAboveSwipeThreshold:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
NSLog(@"[SCInsta] Confirm shh mode triggered");
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
- (void)shhModeTransitionButtonDidTap:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
NSLog(@"[SCInsta] Confirm shh mode triggered");
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
- (void)messageListViewControllerDidToggleShhMode:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
NSLog(@"[SCInsta] Confirm shh mode triggered");
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
else %orig;
}
%end
- (void)messageListViewControllerDidReplayInShhMode:(id)arg1 {
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
[SCIUtils showConfirmation:^(void) { %orig; }];
else %orig;
}
%end
+185
View File
@@ -0,0 +1,185 @@
// Story tray long-press actions — adds "View profile picture" to the action sheet.
// Fetches HD profile pic via /api/v1/users/{pk}/info/.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import "../../Networking/SCIInstagramAPI.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static __weak id sciLongPressedTrayCell = nil;
// ── Helpers ──
static UIImage *sciProfileImageFromCell(id cell) {
Ivar avIvar = class_getInstanceVariable([cell class], "_avatarView");
if (!avIvar) return nil;
UIView *avatarView = object_getIvar(cell, avIvar);
if (!avatarView) return nil;
Ivar imgIvar = class_getInstanceVariable([avatarView class], "_ownerImageView");
if (!imgIvar) return nil;
UIImageView *imgView = object_getIvar(avatarView, imgIvar);
if ([imgView isKindOfClass:[UIImageView class]]) return imgView.image;
return nil;
}
static NSString *sciUsernameFromCell(id cell) {
@try {
Ivar mi = class_getInstanceVariable([cell class], "_model");
if (!mi) return nil;
id model = object_getIvar(cell, mi);
id title = [model valueForKey:@"title"];
if ([title isKindOfClass:[NSAttributedString class]])
return [[(NSAttributedString *)title string] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
} @catch (NSException *e) {}
return nil;
}
static NSString *sciFullNameFromCell(id cell) {
@try {
Ivar mi = class_getInstanceVariable([cell class], "_model");
if (!mi) return nil;
id model = object_getIvar(cell, mi);
id owner = [model valueForKey:@"reelOwner"];
if (!owner) return nil;
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!ui) return nil;
id igUser = object_getIvar(owner, ui);
Ivar fi = NULL;
for (Class c = [igUser class]; c && !fi; c = class_getSuperclass(c))
fi = class_getInstanceVariable(c, "_fieldCache");
if (!fi) return nil;
id fc = object_getIvar(igUser, fi);
if (![fc isKindOfClass:[NSDictionary class]]) return nil;
id name = [(NSDictionary *)fc objectForKey:@"full_name"];
if ([name isKindOfClass:[NSString class]] && [(NSString *)name length] > 0) return name;
} @catch (NSException *e) {}
return nil;
}
static NSString *sciCaptionFromCell(id cell) {
NSString *username = sciUsernameFromCell(cell);
NSString *fullName = sciFullNameFromCell(cell);
if (username && fullName) return [NSString stringWithFormat:@"%@\n%@", username, fullName];
return username ?: fullName;
}
static NSString *sciUserPKFromCell(id cell) {
@try {
Ivar mi = class_getInstanceVariable([cell class], "_model");
if (!mi) return nil;
id model = object_getIvar(cell, mi);
id owner = [model valueForKey:@"reelOwner"];
if (!owner) return nil;
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!ui) return nil;
id igUser = object_getIvar(owner, ui);
Ivar pi = NULL;
for (Class c = [igUser class]; c && !pi; c = class_getSuperclass(c))
pi = class_getInstanceVariable(c, "_pk");
if (!pi) return nil;
return [object_getIvar(igUser, pi) description];
} @catch (NSException *e) {}
return nil;
}
// Fetch HD profile pic via API, fallback to local avatar
static void sciShowHDProfilePic(NSString *pk, NSString *caption, UIImage *fallback) {
NSString *path = [NSString stringWithFormat:@"users/%@/info/", pk];
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *response, NSError *error) {
if (error || !response) {
if (fallback) {
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
[d writeToFile:p atomically:YES];
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
}
return;
}
NSDictionary *user = response[@"user"];
NSString *hdURL = nil;
NSDictionary *hdInfo = user[@"hd_profile_pic_url_info"];
if ([hdInfo isKindOfClass:[NSDictionary class]]) hdURL = hdInfo[@"url"];
if (!hdURL) {
NSArray *versions = user[@"hd_profile_pic_versions"];
if ([versions isKindOfClass:[NSArray class]] && versions.count > 0)
hdURL = [versions.lastObject objectForKey:@"url"];
}
if (!hdURL) hdURL = user[@"profile_pic_url"];
if (hdURL) {
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL URLWithString:hdURL] caption:caption];
} else if (fallback) {
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
[d writeToFile:p atomically:YES];
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
}
}];
}
// ── Capture long-pressed cell ──
static void (*orig_didLongPressCell)(id, SEL, UIGestureRecognizer *);
static void hook_didLongPressCell(id self, SEL _cmd, UIGestureRecognizer *gesture) {
if (gesture.state == UIGestureRecognizerStateBegan)
sciLongPressedTrayCell = gesture.view;
orig_didLongPressCell(self, _cmd, gesture);
}
// ── Inject action into the sheet ──
static void (*orig_present)(id, SEL, id, BOOL, id);
static void hook_present(id self, SEL _cmd, id vc, BOOL animated, id completion) {
if (sciLongPressedTrayCell && [SCIUtils getBoolPref:@"story_tray_actions"]) {
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
NSArray *actions = actIvar ? object_getIvar(vc, actIvar) : nil;
if (actions) {
id cell = sciLongPressedTrayCell;
sciLongPressedTrayCell = nil;
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
NSString *pk = sciUserPKFromCell(cell);
if (actionCls && pk) {
NSString *caption = sciCaptionFromCell(cell);
UIImage *localPic = sciProfileImageFromCell(cell);
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
void (^handler)(void) = ^{ sciShowHDProfilePic(pk, caption, localPic); };
id action = ((InitFn)objc_msgSend)([actionCls alloc],
@selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:),
@"View profile picture", nil, (NSInteger)0, handler, nil, nil);
if (action) {
NSMutableArray *newActions = [actions mutableCopy];
[newActions insertObject:action atIndex:0];
object_setIvar(vc, actIvar, [newActions copy]);
}
}
}
}
if (sciLongPressedTrayCell) sciLongPressedTrayCell = nil;
orig_present(self, _cmd, vc, animated, completion);
}
%ctor {
Class scCls = NSClassFromString(@"IGStorySectionController");
if (scCls) {
SEL sel = NSSelectorFromString(@"_didLongPressCell:");
if (class_getInstanceMethod(scCls, sel))
MSHookMessageEx(scCls, sel, (IMP)hook_didLongPressCell, (IMP *)&orig_didLongPressCell);
}
MSHookMessageEx([UIViewController class], @selector(presentViewController:animated:completion:),
(IMP)hook_present, (IMP *)&orig_present);
}
+2 -2
View File
@@ -59,7 +59,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
NSMutableArray *extra = [NSMutableArray array];
if (hasText && [SCIUtils getBoolPref:@"copy_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Copy"
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Copy")
image:[UIImage systemImageNamed:@"doc.on.doc"]
identifier:nil
handler:^(__kindof UIAction *_) {
@@ -68,7 +68,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
}
if (hasGif && [SCIUtils getBoolPref:@"download_gif_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Download GIF"
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Download GIF")
image:[UIImage systemImageNamed:@"arrow.down.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
+1 -1
View File
@@ -41,7 +41,7 @@
// Notify user
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
HUD.textLabel.text = @"Copied text to clipboard";
HUD.textLabel.text = SCILocalized(@"Copied text to clipboard");
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
[HUD showInView:topMostController().view];
+1 -1
View File
@@ -27,7 +27,7 @@
UIColorPickerViewController *colorPickerController = [[UIColorPickerViewController alloc] init];
colorPickerController.delegate = (id<UIColorPickerViewControllerDelegate>)self; // cast to suppress warnings
colorPickerController.title = @"Select color";
colorPickerController.title = SCILocalized(@"Select color");
colorPickerController.modalPresentationStyle = UIModalPresentationPopover;
colorPickerController.supportsAlpha = NO;
colorPickerController.selectedColor = self.color;
@@ -0,0 +1,210 @@
// Disable feed refresh — background refresh and home tab refresh.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
static BOOL sciDisableBgRefresh(void) {
return [SCIUtils getBoolPref:@"disable_bg_refresh"];
}
static BOOL sciDisableHomeRefresh(void) {
return [SCIUtils getBoolPref:@"disable_home_refresh"];
}
static BOOL sciDisableHomeScroll(void) {
return [SCIUtils getBoolPref:@"disable_home_scroll"];
}
static BOOL sciDisableReelsRefresh(void) {
return [SCIUtils getBoolPref:@"disable_reels_tab_refresh"];
}
// Returns 999999s when disabled (effectively never), -1 to keep IG's value.
static double sciOverrideInterval(void) {
if (sciDisableBgRefresh()) return 999999;
return -1;
}
// MARK: - Refresh-utility class-method overrides
// IGMainFeedRefreshUtility recomputes the intervals at runtime, ignoring the
// init args on IGMainFeedNetworkSource — override the 4 class methods too.
static double (*orig_wsRefresh)(id, SEL, id, id);
static double new_wsRefresh(id self, SEL _cmd, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_wsRefresh(self, _cmd, ls, store);
}
static double (*orig_wsBgRefresh)(id, SEL, id, id);
static double new_wsBgRefresh(id self, SEL _cmd, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_wsBgRefresh(self, _cmd, ls, store);
}
static double (*orig_peakWsRefresh)(id, SEL, double, id, id);
static double new_peakWsRefresh(id self, SEL _cmd, double iv, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_peakWsRefresh(self, _cmd, iv, ls, store);
}
static double (*orig_peakWsBgRefresh)(id, SEL, id, id);
static double new_peakWsBgRefresh(id self, SEL _cmd, id ls, id store) {
double o = sciOverrideInterval();
return o > 0 ? o : orig_peakWsBgRefresh(self, _cmd, ls, store);
}
%ctor {
Class c = NSClassFromString(@"IGMainFeedViewModelUtility.IGMainFeedRefreshUtility");
if (!c) return;
Class meta = object_getClass(c);
SEL s1 = NSSelectorFromString(@"warmStartRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s1))
MSHookMessageEx(meta, s1, (IMP)new_wsRefresh, (IMP *)&orig_wsRefresh);
SEL s2 = NSSelectorFromString(@"warmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s2))
MSHookMessageEx(meta, s2, (IMP)new_wsBgRefresh, (IMP *)&orig_wsBgRefresh);
SEL s3 = NSSelectorFromString(@"onPeakWarmStartRefreshIntervalWithWarmStartFetchInterval:launcherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s3))
MSHookMessageEx(meta, s3, (IMP)new_peakWsRefresh, (IMP *)&orig_peakWsRefresh);
SEL s4 = NSSelectorFromString(@"onPeakWarmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
if (class_getInstanceMethod(meta, s4))
MSHookMessageEx(meta, s4, (IMP)new_peakWsBgRefresh, (IMP *)&orig_peakWsBgRefresh);
}
// MARK: - Background refresh
%hook IGMainFeedNetworkSource
- (instancetype)initWithDeps:(id)a1
posts:(id)a2
nextMaxID:(id)a3
initialPaginationSource:(id)a4
contentCoordinator:(id)a5
dataSourceSupplementaryItemsProvider:(id)a6
disableAutomaticRefresh:(BOOL)disable
disableSerialization:(BOOL)a8
sessionId:(id)a9
analyticsModule:(id)a10
serializationSuffix:(id)a11
disableFlashFeedTLI:(BOOL)a12
disableFlashFeedOnColdStart:(BOOL)a13
disableResponseDeferral:(BOOL)a14
hidesStoriesTray:(BOOL)a15
isSecondaryFeed:(BOOL)a16
collectionViewBackgroundColorOverride:(id)a17
minWarmStartFetchInterval:(double)a18
peakMinWarmStartFetchInterval:(double)a19
minimumWarmStartBackgroundedInterval:(double)a20
peakMinimumWarmStartBackgroundedInterval:(double)a21
supplementalFeedHoistedMediaID:(id)a22
headerTitleOverride:(id)a23
isInFollowingTab:(BOOL)a24
useShimmerLoadingWhenNoStoriesTray:(BOOL)a25 {
double override = sciOverrideInterval();
if (sciDisableBgRefresh()) disable = YES;
if (override > 0) { a18 = override; a19 = override; a20 = override; a21 = override; }
return %orig(a1, a2, a3, a4, a5, a6, disable, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24, a25);
}
// Getter overrides for instances created before the class hooks landed.
- (double)minWarmStartFetchInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
- (double)peakMinWarmStartFetchInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
- (double)minimumWarmStartBackgroundedInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
- (double)peakMinimumWarmStartBackgroundedInterval {
double o = sciOverrideInterval();
return o > 0 ? o : %orig;
}
%end
// MARK: - Hot start refresh
%hook IGMainFeedViewController
- (void)hotStartRefresh {
if (sciDisableBgRefresh()) return;
%orig;
}
%end
// MARK: - Home tab refresh
%hook IGTabBarController
- (void)_timelineButtonPressed {
BOOL noRefresh = sciDisableHomeRefresh();
BOOL noScroll = sciDisableHomeScroll();
if (!noRefresh && !noScroll) { %orig; return; }
UIViewController *selected = nil;
if ([self respondsToSelector:@selector(selectedViewController)])
selected = [self valueForKey:@"selectedViewController"];
BOOL onFeedTab = NO;
if (selected) {
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
? [(UINavigationController *)selected topViewController] : selected;
onFeedTab = [NSStringFromClass([top class]) containsString:@"MainFeed"];
}
if (!onFeedTab) { %orig; return; }
if (noScroll) return;
// noRefresh only — scroll to top without refreshing.
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
? [(UINavigationController *)selected topViewController] : selected;
NSMutableArray *queue = [NSMutableArray arrayWithObject:top.view];
int scanned = 0;
while (queue.count && scanned < 30) {
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:[UICollectionView class]]) {
UIScrollView *sv = (UIScrollView *)cur;
[sv setContentOffset:CGPointMake(0, -sv.adjustedContentInset.top) animated:YES];
return;
}
for (UIView *s in cur.subviews) [queue addObject:s];
}
}
// MARK: - Reels tab refresh
- (void)_discoverVideoButtonPressed {
if (!sciDisableReelsRefresh()) { %orig; return; }
UIViewController *selected = nil;
if ([self respondsToSelector:@selector(selectedViewController)])
selected = [self valueForKey:@"selectedViewController"];
BOOL onReelsTab = NO;
if (selected) {
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
? [(UINavigationController *)selected topViewController] : selected;
NSString *cls = NSStringFromClass([top class]);
onReelsTab = [cls containsString:@"Sundial"] || [cls containsString:@"Reels"]
|| [cls containsString:@"DiscoverVideo"];
}
if (!onReelsTab) { %orig; return; }
}
%end
+33
View File
@@ -0,0 +1,33 @@
#import "../../Utils.h"
%hook UIImpactFeedbackGenerator
- (void)impactOccurred {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
}
- (void)impactOccurredWithIntensity:(CGFloat)intensity {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(intensity);
}
%end
%hook UINotificationFeedbackGenerator
- (void)notificationOccurred:(UINotificationFeedbackType)notificationType {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(notificationType);
}
%end
%hook UISelectionFeedbackGenerator
- (void)selectionChanged {
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
}
%end
%hook CHHapticEngine
- (BOOL)startAndReturnError:(NSError **)outError {
if (![SCIUtils getBoolPref:@"disable_haptics"]) {
return %orig(outError);
}
else {
return NO;
}
}
%end
+49
View File
@@ -0,0 +1,49 @@
// Fake location — overrides CLLocationManager so any IG location read returns our coord.
#import "../../Utils.h"
#import <CoreLocation/CoreLocation.h>
#import <objc/message.h>
static BOOL sciFakeLocOn(void) {
return [SCIUtils getBoolPref:@"fake_location_enabled"];
}
static CLLocation *sciFakeLocation(void) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
double lat = [[d objectForKey:@"fake_location_lat"] doubleValue];
double lon = [[d objectForKey:@"fake_location_lon"] doubleValue];
return [[CLLocation alloc] initWithCoordinate:CLLocationCoordinate2DMake(lat, lon)
altitude:35
horizontalAccuracy:5
verticalAccuracy:5
timestamp:[NSDate date]];
}
static void sciFeedFake(CLLocationManager *mgr) {
id<CLLocationManagerDelegate> d = mgr.delegate;
if (![d respondsToSelector:@selector(locationManager:didUpdateLocations:)]) return;
CLLocation *loc = sciFakeLocation();
NSArray *locs = @[ loc ];
dispatch_async(dispatch_get_main_queue(), ^{
[d locationManager:mgr didUpdateLocations:locs];
});
}
%hook CLLocationManager
- (CLLocation *)location {
if (sciFakeLocOn()) return sciFakeLocation();
return %orig;
}
- (void)startUpdatingLocation {
%orig;
if (sciFakeLocOn()) sciFeedFake(self);
}
- (void)requestLocation {
if (sciFakeLocOn()) { sciFeedFake(self); return; }
%orig;
}
%end
@@ -0,0 +1,260 @@
// Quick fake-location toggle injected into IG's Friends Map (DMs > Maps).
#import "../../Utils.h"
#import "../../Settings/SCIFakeLocationSettingsVC.h"
#import "../../Settings/SCIFakeLocationPickerVC.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static const NSInteger kSciMapBtnTag = 0x5C1F4B;
static UIViewController *sciTopMost(void) {
UIWindow *win = nil;
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *w in ((UIWindowScene *)sc).windows) if (w.isKeyWindow) { win = w; break; }
if (win) break;
}
UIViewController *v = win.rootViewController;
while (v.presentedViewController) v = v.presentedViewController;
return v;
}
static void sciRefreshMapButton(UIView *mapView);
static void sciAddMapButton(UIView *mapView);
static void sciRemoveMapButton(UIView *mapView);
static UIMenu *sciBuildMapMenu(void);
static void sciWalkMapViews(UIView *root, Class mapCls, void (^block)(UIView *)) {
if (!root) return;
if (mapCls && [root isKindOfClass:mapCls]) block(root);
for (UIView *s in root.subviews) sciWalkMapViews(s, mapCls, block);
}
static void sciRefreshActiveMapButton(void) {
Class mapCls = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *w in ((UIWindowScene *)sc).windows) {
sciWalkMapViews(w, mapCls, ^(UIView *mv) {
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
sciRemoveMapButton(mv);
} else {
sciAddMapButton(mv);
sciRefreshMapButton(mv);
}
});
}
}
}
static void sciOpenPickerForCurrent(void) {
UIViewController *top = sciTopMost();
if (!top) return;
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
[[d objectForKey:@"fake_location_lon"] doubleValue]);
vc.titleText = SCILocalized(@"Set location");
vc.onPick = ^(double lat, double lon, NSString *name) {
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
[u setObject:@(lat) forKey:@"fake_location_lat"];
[u setObject:@(lon) forKey:@"fake_location_lon"];
[u setObject:(name ?: @"") forKey:@"fake_location_name"];
if (![u boolForKey:@"fake_location_enabled"]) [u setBool:YES forKey:@"fake_location_enabled"];
sciRefreshActiveMapButton();
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
[top presentViewController:nav animated:YES completion:nil];
}
static void sciOpenPickerForNewPreset(void) {
UIViewController *top = sciTopMost();
if (!top) return;
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
[[d objectForKey:@"fake_location_lon"] doubleValue]);
vc.titleText = SCILocalized(@"Add preset");
vc.onPick = ^(double lat, double lon, NSString *name) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Save preset")
message:nil
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"Name"); tf.text = name; }];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
NSString *n = alert.textFields.firstObject.text.length ? alert.textFields.firstObject.text : name;
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
NSArray *raw = [u objectForKey:@"fake_location_presets"];
NSMutableArray *presets = [raw isKindOfClass:[NSArray class]] ? [raw mutableCopy] : [NSMutableArray array];
[presets addObject:@{@"name": n ?: @"", @"lat": @(lat), @"lon": @(lon)}];
[u setObject:presets forKey:@"fake_location_presets"];
sciRefreshActiveMapButton();
}]];
[sciTopMost() presentViewController:alert animated:YES completion:nil];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
[top presentViewController:nav animated:YES completion:nil];
}
static UIMenu *sciBuildMapMenu(void) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
BOOL enabled = [d boolForKey:@"fake_location_enabled"];
NSString *name = [d objectForKey:@"fake_location_name"] ?: @"(unset)";
// Header section: current location (disabled), enable/disable, change location
UIAction *header = [UIAction actionWithTitle:[NSString stringWithFormat:SCILocalized(@"Current: %@"), name]
image:[UIImage systemImageNamed:@"mappin.and.ellipse"]
identifier:nil handler:^(__unused UIAction *a) {}];
header.attributes = UIMenuElementAttributesDisabled;
UIAction *toggle = [UIAction actionWithTitle:enabled ? SCILocalized(@"Disable") : SCILocalized(@"Enable")
image:[UIImage systemImageNamed:enabled ? @"location.slash.fill" : @"location.fill"]
identifier:nil
handler:^(__unused UIAction *a) {
[d setBool:!enabled forKey:@"fake_location_enabled"];
sciRefreshActiveMapButton();
}];
if (enabled) toggle.attributes = UIMenuElementAttributesDestructive;
UIAction *change = [UIAction actionWithTitle:SCILocalized(@"Change location")
image:[UIImage systemImageNamed:@"map"]
identifier:nil
handler:^(__unused UIAction *a) { sciOpenPickerForCurrent(); }];
UIMenu *headerSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:@[header, toggle, change]];
// Presets + Add
NSMutableArray<UIMenuElement *> *presetItems = [NSMutableArray array];
NSArray *presets = [d objectForKey:@"fake_location_presets"];
if ([presets isKindOfClass:[NSArray class]]) {
for (NSDictionary *p in presets) {
if (![p isKindOfClass:[NSDictionary class]]) continue;
NSString *pname = p[@"name"] ?: @"Preset";
BOOL active = [p[@"name"] isEqualToString:name];
UIAction *act = [UIAction actionWithTitle:pname
image:[UIImage systemImageNamed:@"mappin.circle.fill"]
identifier:nil
handler:^(__unused UIAction *x) {
[d setObject:p[@"lat"] forKey:@"fake_location_lat"];
[d setObject:p[@"lon"] forKey:@"fake_location_lon"];
[d setObject:p[@"name"] ?: @"" forKey:@"fake_location_name"];
if (![d boolForKey:@"fake_location_enabled"]) [d setBool:YES forKey:@"fake_location_enabled"];
sciRefreshActiveMapButton();
}];
if (active) act.state = UIMenuElementStateOn;
[presetItems addObject:act];
}
}
[presetItems addObject:[UIAction actionWithTitle:SCILocalized(@"Add location")
image:[UIImage systemImageNamed:@"plus.circle.fill"]
identifier:nil
handler:^(__unused UIAction *x) { sciOpenPickerForNewPreset(); }]];
UIMenu *presetSection = [UIMenu menuWithTitle:SCILocalized(@"Saved locations") image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:presetItems];
// Settings
UIAction *openSettings = [UIAction actionWithTitle:SCILocalized(@"Settings…")
image:[UIImage systemImageNamed:@"gearshape.fill"]
identifier:nil
handler:^(__unused UIAction *x) {
UIViewController *top = sciTopMost();
if (!top) return;
SCIFakeLocationSettingsVC *vc = [SCIFakeLocationSettingsVC new];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFormSheet;
[top presentViewController:nav animated:YES completion:nil];
}];
UIMenu *settingsSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:@[openSettings]];
return [UIMenu menuWithTitle:SCILocalized(@"Fake location") image:nil identifier:nil options:0
children:@[headerSection, presetSection, settingsSection]];
}
static void sciRemoveMapButton(UIView *mapView) {
UIView *btn = [mapView viewWithTag:kSciMapBtnTag];
if (btn) [btn removeFromSuperview];
}
static void sciAddMapButton(UIView *mapView) {
if (!mapView) return;
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) { sciRemoveMapButton(mapView); return; }
if ([mapView viewWithTag:kSciMapBtnTag]) return;
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = kSciMapBtnTag;
btn.translatesAutoresizingMaskIntoConstraints = NO;
btn.backgroundColor = [UIColor secondarySystemBackgroundColor];
btn.layer.cornerRadius = 24;
btn.layer.shadowColor = [UIColor blackColor].CGColor;
btn.layer.shadowOpacity = 0.18;
btn.layer.shadowRadius = 5;
btn.layer.shadowOffset = CGSizeMake(0, 2);
btn.showsMenuAsPrimaryAction = YES;
btn.menu = sciBuildMapMenu();
// Refresh menu on each press so toggle/preset state is current.
[btn addAction:[UIAction actionWithHandler:^(__unused UIAction *a) {
btn.menu = sciBuildMapMenu();
}] forControlEvents:UIControlEventMenuActionTriggered];
[mapView addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16],
[btn.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78],
[btn.widthAnchor constraintEqualToConstant:48],
[btn.heightAnchor constraintEqualToConstant:48],
]];
}
static void sciRefreshMapButton(UIView *mapView) {
UIButton *btn = (UIButton *)[mapView viewWithTag:kSciMapBtnTag];
if (!btn) return;
BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"];
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:on ? @"location.fill" : @"location.slash" withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = on ? [UIColor systemGreenColor] : [UIColor labelColor];
btn.menu = sciBuildMapMenu();
}
static void (*orig_mapLayout)(UIView *, SEL);
static void new_mapLayout(UIView *self, SEL _cmd) {
orig_mapLayout(self, _cmd);
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
sciRemoveMapButton(self);
return;
}
sciAddMapButton(self);
sciRefreshMapButton(self);
UIView *btn = [self viewWithTag:kSciMapBtnTag];
if (btn) [self bringSubviewToFront:btn];
}
static void sciInstallMapHooks(void) {
static BOOL installed = NO;
if (installed) return;
Class c = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
if (!c) return;
installed = YES;
SEL sel = @selector(layoutSubviews);
if (class_getInstanceMethod(c, sel))
MSHookMessageEx(c, sel, (IMP)new_mapLayout, (IMP *)&orig_mapLayout);
}
%ctor {
sciInstallMapHooks();
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciInstallMapHooks();
});
[[NSNotificationCenter defaultCenter] addObserverForName:@"SCIFakeLocationMapBtnPrefChanged"
object:nil
queue:[NSOperationQueue mainQueue]
usingBlock:^(__unused NSNotification *n) {
sciRefreshActiveMapButton();
}];
}
+115
View File
@@ -0,0 +1,115 @@
// Date format hooks — replace IG's relative timestamps with a custom format.
// Each NSDate formatter selector is independently toggleable via prefs
// (date_fmt_<name>) so users can apply the format surface-by-surface.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "SCIDateFormatEntries.h"
#import <substrate.h>
static NSDictionary *sciDateFormats(BOOL sec) {
return sec ? @{
@"short": @"MMM d",
@"medium": @"MMM d, yyyy",
@"full": @"MMM d, yyyy 'at' h:mm:ss a",
@"time_12": @"MMM d 'at' h:mm:ss a",
@"time_24": @"MMM d 'at' HH:mm:ss",
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm:ss a",
@"day_slash": @"dd/MM/yyyy h:mm:ss a",
@"month_slash": @"MM/dd/yyyy h:mm:ss a",
@"euro": @"dd.MM.yyyy HH:mm:ss",
@"iso": @"yyyy-MM-dd",
@"iso_time": @"yyyy-MM-dd HH:mm:ss",
} : @{
@"short": @"MMM d",
@"medium": @"MMM d, yyyy",
@"full": @"MMM d, yyyy 'at' h:mm a",
@"time_12": @"MMM d 'at' h:mm a",
@"time_24": @"MMM d 'at' HH:mm",
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm a",
@"day_slash": @"dd/MM/yyyy h:mm a",
@"month_slash": @"MM/dd/yyyy h:mm a",
@"euro": @"dd.MM.yyyy HH:mm",
@"iso": @"yyyy-MM-dd",
@"iso_time": @"yyyy-MM-dd HH:mm",
};
}
static NSString *sciFormat(NSDate *date) {
NSString *fmt = [SCIUtils getStringPref:@"feed_date_format"];
if (!fmt.length || [fmt isEqualToString:@"default"]) return nil;
BOOL sec = [[NSUserDefaults standardUserDefaults] boolForKey:@"feed_date_show_seconds"];
NSString *pattern = sciDateFormats(sec)[fmt];
if (!pattern) return nil;
static NSDateFormatter *df = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{ df = [NSDateFormatter new]; });
df.dateFormat = pattern;
return [df stringFromDate:date];
}
// Per-arity hook generators. When the entry's pref is on, return the custom
// format; otherwise forward to orig with the original arguments.
#define SCI_HOOK0(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd); \
}
#define SCI_HOOK1(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1); \
}
#define SCI_HOOK2(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1, a2); \
}
#define SCI_HOOK3(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1, a2, a3); \
}
#define SCI_HOOK4(NAME, SEL_, LABEL, PREF) \
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger, NSInteger); \
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3, NSInteger a4) { \
if ([SCIUtils getBoolPref:@PREF]) { \
NSString *r = sciFormat(self); \
if (r) return r; \
} \
return orig_##NAME(self, _cmd, a1, a2, a3, a4); \
}
#define SCI_EMIT_HOOK(NAME, SEL_, LABEL, ARITY, PREF) SCI_HOOK##ARITY(NAME, SEL_, LABEL, PREF)
SCI_DATE_FORMAT_ENTRIES(SCI_EMIT_HOOK)
#define SCI_INSTALL_HOOK(NAME, SEL_, LABEL, ARITY, PREF) do { \
SEL s = sel_registerName(SEL_); \
if ([[NSDate class] instancesRespondToSelector:s]) \
MSHookMessageEx([NSDate class], s, (IMP)hook_##NAME, (IMP *)&orig_##NAME); \
} while (0);
%ctor {
SCI_DATE_FORMAT_ENTRIES(SCI_INSTALL_HOOK)
}
+55 -10
View File
@@ -135,22 +135,35 @@
// Write with meta ai in message composer
%hook IGDirectComposer
- (id)initWithLayoutSpecProvider:(id)arg1
userLauncherSetProviding:(id)arg2
userSession:(id)arg2
userLauncherSet:(id)arg3
config:(IGDirectComposerConfig *)config
style:(id)arg4
text:(id)arg5
style:(id)arg5
text:(id)arg6
{
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5);
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6);
}
- (id)initWithLayoutSpecProvider:(id)arg1
userLauncherSetProviding:(id)arg2
userSession:(id)arg2
userLauncherSet:(id)arg3
config:(IGDirectComposerConfig *)config
style:(id)arg4
text:(id)arg5
shouldUpdateModeLater:(BOOL)arg6
style:(id)arg5
text:(id)arg6
shouldUpdateModeLater:(BOOL)arg7
{
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5, arg6);
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
}
- (id)_initializeWithLayoutSpecProvider:(id)arg1
userSession:(id)arg2
userLauncherSet:(id)arg3
config:(IGDirectComposerConfig *)config
style:(id)arg5
text:(id)arg6
shouldUpdateModeLater:(BOOL)arg7
{
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
}
- (void)setConfig:(IGDirectComposerConfig *)config {
@@ -178,6 +191,20 @@
}
%end
// Demangled name: IGAIRewrite.IGAIRewriteStoryRepliesPresenter
%hook _TtC11IGAIRewrite32IGAIRewriteStoryRepliesPresenter
- (BOOL)shouldShowAIRewriteButton:(id)arg1 input:(id)arg2 {
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
NSLog(@"[SCInsta] Hiding meta ai: disable ai rewrite story reply presenter");
return NO;
}
return %orig(arg1, arg2);
}
%end
// Direct sticker tray picker view
%hook IGStickerTrayListAdapterDataSource
- (id)objectsForListAdapter:(id)arg1 {
@@ -346,6 +373,24 @@
// Reels/Sundial
// Suggested AI searches in comment section
%hook IGCommentConfig
- (id)initWithUserSession:(id)session
commentThreadConfiguration:(IGCommentThreadConfiguration *)threadConfig
sponsoredSupportConfiguration:(id)supportConfig
CTAPresenterContext:(id)context
replyText:(id)text
loggingDelegate:(id)loggingDelegate
presentingViewController:(id)vc
childCommentThreadDelegate:(id)threadDelegate
{
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
[threadConfig setValue:@(YES) forKey:@"disableMetaAICarousel"];
}
return %orig(session, threadConfig, supportConfig, context, text, loggingDelegate, vc, threadDelegate);
}
%end
// Suggested AI searches in comment section (workaround if setting comment thread config fails)
%hook IGCommentThreadAICarousel
- (id)initWithLauncherSet:(id)arg1 hasSearchPrefix:(BOOL)arg2 {
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
@@ -383,7 +428,7 @@
NSLog(@"[SCInsta] Hiding meta ai: ai images add to story suggestion");
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(10), @(11) ]];
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(9), @(10), @(11) ]];
newTools = [tools filteredArrayUsingPredicate:predicate];
}
+25
View File
@@ -0,0 +1,25 @@
#import "../../Utils.h"
%hook IGSundialViewerVerticalUFI
- (void)setNumLikes:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumReshares:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumComments:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumReposts:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
- (void)setNumSaves:(NSInteger)num {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
}
%end
%hook IGUFIButtonWithCountsView
- (void)setCountString:(id)string showButton:(BOOL)showButton {
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? @"" : string, showButton);
}
%end
@@ -0,0 +1,90 @@
// Hide suggested stories from the tray. Drops items the user doesn't follow
// (friendship_status.following=0 or empty fieldCache); highlights pass through.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// IGListAdapter declared in InstagramHeaders.h
static __weak id sciTrayAdapter = nil;
// ── Suggested item detection ──
// Returns YES if the item should be kept. Highlights / non-tray rows pass
// through; followed reels keep; empty fieldCache (freshly-streamed suggested
// users) drops; otherwise check friendship_status.following.
static BOOL sciIsFollowedTrayItem(id obj) {
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return YES;
@try {
if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) return YES;
id owner = [obj valueForKey:@"reelOwner"];
if (!owner) return YES;
Ivar userIvar = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!userIvar) return YES;
id igUser = object_getIvar(owner, userIvar);
if (!igUser) return YES;
Ivar fcIvar = NULL;
for (Class c = [igUser class]; c && !fcIvar; c = class_getSuperclass(c))
fcIvar = class_getInstanceVariable(c, "_fieldCache");
if (!fcIvar) return YES;
const char *fcType = ivar_getTypeEncoding(fcIvar);
if (!fcType || fcType[0] != '@') return YES;
id fc = object_getIvar(igUser, fcIvar);
if (![fc isKindOfClass:[NSDictionary class]]) return YES;
if ([(NSDictionary *)fc count] == 0) return NO;
id fs = [(NSDictionary *)fc objectForKey:@"friendship_status"];
if (!fs) return YES;
return [[fs valueForKey:@"following"] boolValue];
} @catch (__unused NSException *e) {
return YES;
}
}
// ── Data source filter ──
static NSArray *(*orig_objectsForListAdapter)(id, SEL, id);
static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
NSArray *objects = orig_objectsForListAdapter(self, _cmd, adapter);
sciTrayAdapter = adapter;
if (![SCIUtils getBoolPref:@"hide_suggested_stories"]) return objects;
NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count];
for (id obj in objects) {
if (sciIsFollowedTrayItem(obj)) [filtered addObject:obj];
}
return [filtered copy];
}
// ── Reload tray on pref change ──
static void sciReloadTray(void) {
dispatch_async(dispatch_get_main_queue(), ^{
IGListAdapter *adapter = sciTrayAdapter;
if (adapter) [adapter performUpdatesAnimated:YES completion:nil];
});
}
%ctor {
Class dsCls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
if (!dsCls) return;
SEL sel = NSSelectorFromString(@"objectsForListAdapter:");
if (class_getInstanceMethod(dsCls, sel))
MSHookMessageEx(dsCls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
[[NSNotificationCenter defaultCenter] addObserverForName:@"SCISuggestedStoriesReload"
object:nil queue:nil
usingBlock:^(NSNotification *n) { sciReloadTray(); }];
}
+14 -27
View File
@@ -1,15 +1,12 @@
// Download highlight cover image from the profile long-press menu.
// Captures the long-pressed IGStoryTrayCell, finds the IGImageView inside it,
// and saves the cover using the user's download settings.
// View highlight cover — opens the cover image in the full-screen media viewer.
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static SCIDownloadDelegate *sciHighlightDl = nil;
// Find the IGStoryTrayCell with an active long-press gesture
static UIView *sciFindLongPressedCell(UIView *root) {
Class cellCls = NSClassFromString(@"IGStoryTrayCell");
@@ -46,29 +43,20 @@ static UIImage *sciCoverImageFromCell(UIView *cell) {
return nil;
}
static void sciSaveCoverImage(UIImage *image, UIViewController *presenter) {
static void sciViewCoverImage(UIImage *image) {
if (!image) {
[SCIUtils showErrorHUDWithDescription:@"Could not find cover image"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find cover image")];
return;
}
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
if ([method isEqualToString:@"photos"]) {
// Save to Photos (respects RyukGram album pref)
NSData *data = UIImageJPEGRepresentation(image, 1.0);
if (!data) return;
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]]];
[data writeToFile:tmpPath atomically:YES];
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
sciHighlightDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
[sciHighlightDl downloadDidFinishWithFileURL:tmpURL];
} else {
// Share sheet
UIActivityViewController *activityVC = [[UIActivityViewController alloc]
initWithActivityItems:@[image] applicationActivities:nil];
if (presenter) [presenter presentViewController:activityVC animated:YES completion:nil];
}
// Save to temp and open in the media viewer
NSData *data = UIImageJPEGRepresentation(image, 1.0);
if (!data) return;
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"cover_%@.jpg", [[NSUUID UUID] UUIDString]]];
[data writeToFile:tmpPath atomically:YES];
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
[SCIMediaViewer showWithVideoURL:nil photoURL:tmpURL caption:nil];
}
// Stored reference to the long-pressed cell (captured at presentation time)
@@ -90,16 +78,15 @@ static void new_present(id self, SEL _cmd, id vc, BOOL animated, id completion)
if (actions && actions.count >= 2 && actions.count <= 6) {
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
if (actionCls) {
__weak UIViewController *weakSelf = (UIViewController *)self;
void (^handler)(void) = ^{
UIImage *cover = sciCoverImageFromCell(sciLongPressedHighlightCell);
sciSaveCoverImage(cover, weakSelf);
sciViewCoverImage(cover);
};
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
id newAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
@"Download cover", nil, 0, handler, nil, nil);
@"View cover", nil, 0, handler, nil, nil);
if (newAction) {
NSMutableArray *newActions = [actions mutableCopy];
+33
View File
@@ -0,0 +1,33 @@
// Force launch into a chosen tab. Ignored while messages_only is active.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/message.h>
static NSString *sciSelectorForLaunchPref(NSString *p) {
if ([p isEqualToString:@"feed"]) return @"_timelineButtonPressed";
if ([p isEqualToString:@"explore"]) return @"_exploreButtonPressed";
if ([p isEqualToString:@"reels"]) return @"_discoverVideoButtonPressed";
if ([p isEqualToString:@"inbox"]) return @"_directInboxButtonPressed";
if ([p isEqualToString:@"profile"]) return @"_profileButtonPressed";
return nil;
}
%hook IGTabBarController
- (void)viewWillAppear:(BOOL)animated {
if (![SCIUtils getBoolPref:@"messages_only"]) {
static BOOL fired = NO;
if (!fired) {
fired = YES;
NSString *pref = [SCIUtils getStringPref:@"launch_tab"];
NSString *selName = sciSelectorForLaunchPref(pref);
if (selName) {
SEL s = NSSelectorFromString(selName);
if ([self respondsToSelector:s])
((void(*)(id, SEL))objc_msgSend)(self, s);
}
}
}
%orig;
}
%end
+198
View File
@@ -0,0 +1,198 @@
// Media zoom — long press on feed media to expand in full-screen viewer.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../ActionButton/SCIMediaActions.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import <objc/runtime.h>
#import <objc/message.h>
// IGFeedItemPageVideoCell declared in InstagramHeaders.h
static const void *kZoomGestureKey = &kZoomGestureKey;
static BOOL sciZoomEnabled(void) {
return [SCIUtils getBoolPref:@"feed_media_zoom"];
}
// Walk up to the feed's outer collection view (skip carousel inner CVs)
static UICollectionView *sciFeedCollectionView(UIView *view) {
UIView *v = view;
while (v) {
if ([v isKindOfClass:[UICollectionView class]]) {
NSString *cls = NSStringFromClass([v class]);
if (![cls containsString:@"Carousel"] && ![cls containsString:@"Page"])
return (UICollectionView *)v;
}
v = v.superview;
}
return nil;
}
static NSInteger sciFeedSectionForView(UIView *view, UICollectionView *cv) {
UIView *v = view;
while (v) {
if ([v isKindOfClass:[UICollectionViewCell class]]) {
NSIndexPath *ip = [cv indexPathForCell:(UICollectionViewCell *)v];
if (ip) return ip.section;
}
v = v.superview;
}
return -1;
}
// Extract IGMedia from sibling cells in the same section
static IGMedia *sciZoomFeedMedia(UIView *view) {
Class mediaClass = NSClassFromString(@"IGMedia");
if (!mediaClass) return nil;
UICollectionView *cv = sciFeedCollectionView(view);
if (!cv) return nil;
NSInteger section = sciFeedSectionForView(view, cv);
if (section < 0) return nil;
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
NSString *cls = NSStringFromClass([cell class]);
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
unsigned int count = 0;
Class c = object_getClass(cell);
while (c && c != [UICollectionViewCell class]) {
Ivar *ivars = class_copyIvarList(c, &count);
for (unsigned int i = 0; i < count; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(cell, ivars[i]);
if (val && [val isKindOfClass:mediaClass]) { free(ivars); return (IGMedia *)val; }
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
c = class_getSuperclass(c);
}
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
if (m && [m isKindOfClass:mediaClass]) return (IGMedia *)m;
}
}
return nil;
}
// Carousel page index from the horizontal scroll view in the Page cell
static NSInteger sciZoomPageIndex(UIView *view) {
UICollectionView *cv = sciFeedCollectionView(view);
if (!cv) return 0;
NSInteger section = sciFeedSectionForView(view, cv);
if (section < 0) return 0;
for (UICollectionViewCell *cell in cv.visibleCells) {
NSIndexPath *path = [cv indexPathForCell:cell];
if (!path || path.section != section) continue;
if (![NSStringFromClass([cell class]) containsString:@"Page"]) continue;
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
int scanned = 0;
while (queue.count && scanned < 100) {
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
UIScrollView *sv = (UIScrollView *)cur;
CGFloat pageW = sv.bounds.size.width;
if (pageW > 100 && sv.contentSize.width > pageW * 1.5)
return (NSInteger)round(sv.contentOffset.x / pageW);
}
for (UIView *s in cur.subviews) [queue addObject:s];
}
}
return 0;
}
static void sciZoomFired(UILongPressGestureRecognizer *g) {
if (g.state != UIGestureRecognizerStateBegan) return;
if (!sciZoomEnabled()) return;
UIView *view = g.view;
IGMedia *media = sciZoomFeedMedia(view);
if (!media) return;
NSString *caption = [SCIMediaActions captionForMedia:media];
if ([SCIMediaActions isCarouselMedia:media]) {
NSArray *children = [SCIMediaActions carouselChildrenForMedia:media];
NSMutableArray *items = [NSMutableArray array];
for (id child in children) {
NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)child];
NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)child];
if (!v && !p) p = [SCIMediaActions bestURLForMedia:child];
if (v || p) [items addObject:[SCIMediaViewerItem itemWithVideoURL:v photoURL:p caption:caption]];
}
if (items.count) {
NSInteger idx = sciZoomPageIndex(view);
if (idx < 0 || idx >= (NSInteger)items.count) idx = 0;
[SCIMediaViewer showItems:items startIndex:idx];
return;
}
}
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
if (!videoUrl && !photoUrl) photoUrl = [SCIMediaActions bestURLForMedia:media];
if (!videoUrl && !photoUrl) return;
[SCIMediaViewer showWithVideoURL:videoUrl photoURL:photoUrl caption:caption];
}
// MARK: - Gesture setup
@interface _SCIZoomTarget : NSObject @end
@implementation _SCIZoomTarget
- (void)fired:(UILongPressGestureRecognizer *)g { sciZoomFired(g); }
@end
static void sciAddZoomGesture(UIView *view) {
if (objc_getAssociatedObject(view, kZoomGestureKey)) return;
_SCIZoomTarget *target = [_SCIZoomTarget new];
objc_setAssociatedObject(view, kZoomGestureKey, target, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
UILongPressGestureRecognizer *gesture = [[UILongPressGestureRecognizer alloc]
initWithTarget:target action:@selector(fired:)];
gesture.minimumPressDuration = 0.5;
[view addGestureRecognizer:gesture];
}
// MARK: - Hooks
%hook IGFeedPhotoView
- (void)didMoveToSuperview {
%orig;
if (self.superview) sciAddZoomGesture(self);
}
%end
%hook IGModernFeedVideoCell.IGModernFeedVideoCell
- (void)didMoveToSuperview {
%orig;
if (((UIView *)self).superview) sciAddZoomGesture((UIView *)self);
}
%end
%hook IGFeedItemPagePhotoCell
- (void)didMoveToSuperview {
%orig;
if (self.superview) sciAddZoomGesture((UIView *)self);
}
%end
%hook IGFeedItemPageVideoCell
- (void)didMoveToSuperview {
%orig;
if (self.superview) sciAddZoomGesture((UIView *)self);
}
%end
+65
View File
@@ -0,0 +1,65 @@
// Messages-only mode — no-op the tab creators we don't want, force inbox at launch.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/runtime.h>
#import <objc/message.h>
static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
%hook IGTabBarController
// Block tab creation entirely so they never enter the buttons array (no gaps).
- (void)_createAndConfigureTimelineButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureReelsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureExploreButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureCameraButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureDynamicTabButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureNewsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
- (void)_createAndConfigureStreamsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
// Force initial selection to inbox once after the tab bar has fully laid out.
- (void)viewDidAppear:(BOOL)animated {
%orig;
static BOOL launched = NO;
if (sciMsgOnly() && !launched) {
launched = YES;
SEL s = NSSelectorFromString(@"_directInboxButtonPressed");
if ([self respondsToSelector:s])
((void(*)(id, SEL))objc_msgSend)(self, s);
}
}
// Surface enum no longer maps cleanly to the trimmed _buttons array, so flip
// the selected state ourselves and nudge the liquid-glass indicator.
%new - (void)sciSyncTabBarSelection:(NSString *)which {
Class c = [self class];
Ivar ibIv = class_getInstanceVariable(c, "_directInboxButton");
Ivar pbIv = class_getInstanceVariable(c, "_profileButton");
UIButton *inbox = ibIv ? object_getIvar(self, ibIv) : nil;
UIButton *profile = pbIv ? object_getIvar(self, pbIv) : nil;
BOOL profileActive = [which isEqualToString:@"profile"];
if ([inbox respondsToSelector:@selector(setSelected:)]) inbox.selected = !profileActive;
if ([profile respondsToSelector:@selector(setSelected:)]) profile.selected = profileActive;
// No-op on classic tab bar (selector only exists on IGLiquidGlassInteractiveTabBar).
Ivar tbIv = class_getInstanceVariable(c, "_tabBar");
id tabBar = tbIv ? object_getIvar(self, tbIv) : nil;
NSInteger idx = profileActive ? 1 : 0;
SEL setIdx = NSSelectorFromString(@"setSelectedTabBarItemIndex:animateIndicator:");
if ([tabBar respondsToSelector:setIdx])
((void(*)(id, SEL, NSInteger, BOOL))objc_msgSend)(tabBar, setIdx, idx, YES);
}
- (void)_directInboxButtonPressed {
%orig;
if (sciMsgOnly())
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"inbox");
}
- (void)_profileButtonPressed {
%orig;
if (sciMsgOnly())
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"profile");
}
%end
+20
View File
@@ -13,6 +13,11 @@ BOOL isSurfaceShown(IGMainAppSurfaceIntent *surface) {
isShown = NO;
}
// Messages
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"DIRECT"] && [SCIUtils getBoolPref:@"hide_messages_tab"]) {
isShown = NO;
}
// Explore
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"SEARCH"] && [SCIUtils getBoolPref:@"hide_explore_tab"]) {
isShown = NO;
@@ -97,4 +102,19 @@ NSArray *filterSurfacesArray(NSArray *surfaces) {
- (void)setIsTabSwipingEnabled:(BOOL)arg1 {
return;
}
%end
%hook IGHomeFeedHeaderView
- (void)didMoveToWindow {
%orig;
if ([SCIUtils getBoolPref:@"hide_messages_tab"]) {
UIButton *rightButton = [self valueForKey:@"rightButton"];
if (rightButton) {
NSLog(@"[SCInsta] Hiding messages tab (on feed)");
[rightButton removeFromSuperview];
}
}
}
%end
+2 -2
View File
@@ -38,13 +38,13 @@
// Recent dm message recipients search bar
%hook IGDirectRecipientRecentSearchStorage
- (id)initWithDiskManager:(id)arg1 directCache:(id)arg2 userStore:(id)arg3 currentUser:(id)arg4 featureSets:(id)arg5 {
- (id)initWithDiskManager:(id)arg1 directRepo:(id)arg2 userMap:(id)arg3 currentUser:(id)arg4 launcherSet:(id)arg5 {
if ([SCIUtils getBoolPref:@"no_recent_searches"]) {
NSLog(@"[SCInsta] Disabling recent searches");
return nil;
}
return %orig;
return %orig(arg1, arg2, arg3, arg4, arg5);
}
%end
+1 -1
View File
@@ -64,7 +64,7 @@
// Section header
if ([obj isKindOfClass:%c(IGLabelItemViewModel)]) {
// Suggested for you
if ([[obj labelTitle] isEqualToString:@"Suggested for you"]) {
if ([[obj valueForKey:@"tag"] intValue] == 2) { // 2 == Suggested Users
if ([SCIUtils getBoolPref:@"no_suggested_users"]) {
NSLog(@"[SCInsta] Hiding suggested users (header: activity feed)");
+4 -4
View File
@@ -130,12 +130,12 @@ static char targetStaticRef[] = "target";
[rightButton sizeToFit];
[rightButton addAction:[UIAction actionWithHandler:^(__kindof UIAction * _Nonnull action) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Enter Emoji Text"
message:@"Click the Apply button after this to see the emoji"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Enter Emoji Text")
message:SCILocalized(@"Click the Apply button after this to see the emoji")
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *textField) {
textField.placeholder = @"Type emoji...";
textField.placeholder = SCILocalized(@"Type emoji...");
}];
[alert addAction:[UIAlertAction actionWithTitle:@"OK"
@@ -145,7 +145,7 @@ static char targetStaticRef[] = "target";
[self applySCICustomTheme:@"Emoji"];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel"
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel")
style:UIAlertActionStyleCancel
handler:nil]];
+7 -8
View File
@@ -8,8 +8,7 @@
// a copy button alongside IG's own buttons, then opens a menu to copy
// username/name/bio.
@interface IGProfileViewController : UIViewController
@end
// IGProfileViewController declared in InstagramHeaders.h
static id sci_safeValueForKey(id obj, NSString *key) {
@try { return [obj valueForKey:key]; }
@@ -107,7 +106,7 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
username, fullName, (unsigned long)biography.length);
UIAlertController *menu = [UIAlertController alertControllerWithTitle:@"Copy from profile"
UIAlertController *menu = [UIAlertController alertControllerWithTitle:SCILocalized(@"Copy from profile")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
@@ -117,12 +116,12 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]];
}
if (fullName.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy name"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy name")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]];
}
if (biography.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy bio"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy bio")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]];
}
@@ -134,16 +133,16 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
if (parts.count >= 2) {
NSString *combined = [parts componentsJoinedByString:@"\n\n"];
[menu addAction:[UIAlertAction actionWithTitle:@"Copy all"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy all")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]];
}
if (menu.actions.count == 0) {
[menu addAction:[UIAlertAction actionWithTitle:@"Nothing to copy" style:UIAlertActionStyleDefault handler:nil]];
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Nothing to copy") style:UIAlertActionStyleDefault handler:nil]];
}
[menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
if (sender) {
menu.popoverPresentationController.sourceView = sender;
@@ -0,0 +1,26 @@
// Single source of truth for date-format hook entries.
// Format: X(name, selector_cstring, label, arity, pref_key)
// Entries sharing a pref_key are toggled together; label is shown in the
// picker for the first entry sharing a given pref_key (use "" for others).
#define SCI_DATE_FORMAT_ENTRIES(X) \
X(mixed, "formattedDateInMixedFormat", "Feed posts", 0, "date_fmt_mixed") \
X(rel, "formattedDateRelativeToNow", "Notes, comments, stories",0, "date_fmt_notes_comments_stories") \
X(shortRel, "shortenedFormattedDateRelativeToNow", "", 0, "date_fmt_notes_comments_stories") \
X(shortRelHs, "shortenedFormattedDateRelativeToNowHideSeconds:", "DMs", 1, "date_fmt_dms")
// Kept for future use — other NSDate relative formatters IG uses across
// surfaces. Enable by adding to SCI_DATE_FORMAT_ENTRIES above.
//
// X(partialRel, "partiallyShortenedFormattedDateRelativeToNow", "Partially shortened relative", 0, "date_fmt_partialRel")
// X(shortRelYears, "shortenedFormattedDateRelativeToNowIncludeYears", "Shortened relative (incl. years)", 0, "date_fmt_shortRelYears")
// X(shortRelOpts, "shortenedFormattedDateRelativeToNowWithOptions:", "Shortened relative (options)", 1, "date_fmt_shortRelOpts")
// X(shortRelFloor, "shortenedFormattedDateRelativeToNowWithFloorDaysWeeks:", "Shortened rel. (floor days/weeks)", 1, "date_fmt_shortRelFloor")
// X(mixedShortRelMDY, "formattedDateInMixedShortenedRelativeAndMonthDayYearFormatWithThreshold:", "Mixed shortened + M/D/Y", 1, "date_fmt_mixedShortRelMDY")
// X(relHs, "formattedDateRelativeToNowHideSeconds:", "Relative (hide seconds)", 1, "date_fmt_relHs")
// X(relYearsHs, "formattedDateRelativeToNowIncludingYearsHideSeconds:", "Rel. incl. years (hide seconds)", 1, "date_fmt_relYearsHs")
// X(partialRelHsOpts, "partiallyShortenedFormattedDateRelativeToNowHideSeconds:options:", "Partial rel. (hide secs, opts)", 2, "date_fmt_partialRelHsOpts")
// X(relHsFloor, "formattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:", "Relative (hide secs, floor)", 2, "date_fmt_relHsFloor")
// X(shortRelHsFloor, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:", "Shortened rel. (hide secs, floor)", 2, "date_fmt_shortRelHsFloor")
// X(shortRelHsFloorOpts, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:options:", "Shortened rel. (hide secs, floor, opts)", 3, "date_fmt_shortRelHsFloorOpts")
// X(shortRelHsFloorYearsOpts, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:includeYears:options:","Shortened rel. (full signature)", 4, "date_fmt_shortRelHsFloorYearsOpts")
+6 -4
View File
@@ -30,14 +30,16 @@
}
%end
// Quick access to tweak settings by holding on home tab button
// Quick access to tweak settings by holding on the home tab button.
// In messages-only mode the home tab is gone — fall back to the inbox tab.
%hook IGTabBarButton
- (void)didMoveToSuperview {
%orig;
// Only work on home/feed tab
if (![self.accessibilityIdentifier isEqualToString:@"mainfeed-tab"]) return;
BOOL msgOnly = [SCIUtils getBoolPref:@"messages_only"];
NSString *target = msgOnly ? SCILocalized(@"direct-inbox-tab") : SCILocalized(@"mainfeed-tab");
if (![self.accessibilityIdentifier isEqualToString:target]) return;
if ([SCIUtils getBoolPref:@"settings_shortcut"]) {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = 0.3;
+210 -511
View File
@@ -1,6 +1,21 @@
// Legacy download gestures — off by default, kept for users who prefer the
// old multi-finger long-press workflow over the action button menu.
//
// The modern flow lives in:
// src/ActionButton/ — menu + handlers
// src/Features/ActionButton/ — per-context button injection
// src/Features/StoriesAndMessages/OverlayButtons.xm — stories action button
//
// This file only contains:
// 1. Long-press gesture recognizers on feed/story/reel media views, gated
// by `dw_legacy_gesture`. When on, they reuse the old sciDownload* path
// and save via the user's `dw_save_action` preference.
// 2. The profile-picture long-press gesture (always on when `save_profile`).
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import <objc/runtime.h>
static SCIDownloadDelegate *imageDownloadDelegate;
@@ -12,220 +27,25 @@ static DownloadAction sciGetDownloadAction() {
return share;
}
static void initDownloaders () {
// Re-init each time to pick up the current save action preference
static void initDownloaders() {
DownloadAction action = sciGetDownloadAction();
DownloadAction imgAction = (action == saveToPhotos) ? saveToPhotos : quickLook;
imageDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO];
videoDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES];
}
// Helper: run a download block with optional confirmation dialog
static void sciConfirmAndDownload(NSString *title, void(^downloadBlock)(void)) {
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
[SCIUtils showConfirmation:downloadBlock title:title];
} else {
downloadBlock();
}
}
// Helper: recursively search within a view tree for downloadable media (bounded to one post)
static BOOL sciFindAndDownloadMediaInView(UIView *root) {
if (!root) return NO;
// Check for video media via mediaCellFeedItem
if ([root respondsToSelector:@selector(mediaCellFeedItem)]) {
IGMedia *media = [root performSelector:@selector(mediaCellFeedItem)];
if (media) {
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (videoUrl) {
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
if (photoUrl) {
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
}
}
// Check for IGFeedPhotoView with delegate chain
if ([root isKindOfClass:NSClassFromString(@"IGFeedPhotoView")] && [root respondsToSelector:@selector(delegate)]) {
id delegate = [root performSelector:@selector(delegate)];
if ([delegate isKindOfClass:NSClassFromString(@"IGFeedItemPhotoCell")]) {
@try {
Ivar cfgIvar = class_getInstanceVariable([delegate class], "_configuration");
if (cfgIvar) {
id cfg = object_getIvar(delegate, cfgIvar);
if (cfg) {
Ivar photoIvar = class_getInstanceVariable([cfg class], "_photo");
if (photoIvar) {
IGPhoto *photo = object_getIvar(cfg, photoIvar);
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
if (photoUrl) {
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
}
}
}
} @catch (NSException *e) {}
}
if ([delegate isKindOfClass:NSClassFromString(@"IGFeedItemPagePhotoCell")]) {
@try {
if ([delegate respondsToSelector:@selector(pagePhotoPost)]) {
id pagePhotoPost = [delegate performSelector:@selector(pagePhotoPost)];
if (pagePhotoPost && [pagePhotoPost respondsToSelector:@selector(photo)]) {
IGPhoto *photo = [pagePhotoPost performSelector:@selector(photo)];
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
if (photoUrl) {
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return YES;
}
}
}
} @catch (NSException *e) {}
}
}
// Recurse into subviews
for (UIView *sub in root.subviews) {
if (sciFindAndDownloadMediaInView(sub)) return YES;
}
return NO;
}
// Helper: find IGMedia from a cell using runtime ivar scanning
// Avoids property getters which can cause EXC_BAD_ACCESS on certain IG versions
static IGMedia * _Nullable sciGetMediaFromView(UIView *view) {
if (!view) return nil;
unsigned int ivarCount = 0;
Ivar *ivars = class_copyIvarList([view class], &ivarCount);
if (!ivars) return nil;
IGMedia *found = nil;
Class mediaClass = NSClassFromString(@"IGMedia");
for (unsigned int i = 0; i < ivarCount; i++) {
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *ivarName = [NSString stringWithUTF8String:name];
NSString *lower = [ivarName lowercaseString];
if ([lower containsString:@"video"] || [lower containsString:@"media"] || [lower containsString:@"item"]) {
id value = object_getIvar(view, ivars[i]);
if (value && mediaClass && [value isKindOfClass:mediaClass]) {
found = (IGMedia *)value;
NSLog(@"[SCInsta] Found IGMedia in ivar '%@' of %@", ivarName, NSStringFromClass([view class]));
break;
}
}
}
free(ivars);
return found;
}
// Helper: walk superview chain to find a view of a given class
static UIView * _Nullable sciFindSuperviewOfClass(UIView *view, NSString *className) {
Class cls = NSClassFromString(className);
if (!cls) return nil;
UIView *current = view.superview;
int depth = 0;
while (current && depth < 15) {
if ([current isKindOfClass:cls]) return current;
current = current.superview;
depth++;
}
return nil;
}
// Helper: show debug ivar dump when media extraction fails (survives IG updates)
static void sciShowDebugIvarDump(UIView *cell) {
NSMutableString *debug = [NSMutableString stringWithFormat:@"No IGMedia found in %@\n\nIvars:\n", NSStringFromClass([cell class])];
unsigned int count = 0;
Ivar *ivars = class_copyIvarList([cell class], &count);
for (unsigned int i = 0; i < count && i < 50; i++) {
const char *name = ivar_getName(ivars[i]);
const char *type = ivar_getTypeEncoding(ivars[i]);
if (name) [debug appendFormat:@"%s (%s)\n", name, type ? type : "?"];
}
if (ivars) free(ivars);
NSLog(@"[SCInsta] Debug: %@", debug);
dispatch_async(dispatch_get_main_queue(), ^{
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"RyukGram Debug"
message:debug
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Copy & Close" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[[UIPasteboard generalPasteboard] setString:debug];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleCancel handler:nil]];
UIViewController *topVC = topMostController();
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
});
}
// Whether download buttons (not long-press) are enabled
static BOOL sciUseDownloadButtons() {
return [[SCIUtils getStringPref:@"dw_method"] isEqualToString:@"button"];
static BOOL sciLegacyGestureEnabled() {
return [SCIUtils getBoolPref:@"dw_legacy_gesture"];
}
/* * Feed * */
/* * Feed (legacy gesture) * */
// Download feed images
%hook IGFeedPhotoView
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"dw_feed_posts"]) return;
if (sciUseDownloadButtons()) {
[self sciAddDownloadButton];
} else {
[self addLongPressGestureRecognizer];
}
}
%new - (void)sciAddDownloadButton {
if ([self viewWithTag:1338]) return;
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1338;
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:@"arrow.down.to.line" withConfiguration:config] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 12;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciDownloadBtnTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:10],
[btn.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:24],
[btn.heightAnchor constraintEqualToConstant:24]
]];
}
%new - (void)sciDownloadBtnTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.75, 0.75); }
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
sciConfirmAndDownload(@"Download photo?", ^{
[self handleLongPress:nil];
});
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
@@ -237,75 +57,30 @@ static BOOL sciUseDownloadButtons() {
if (sender && sender.state != UIGestureRecognizerStateBegan) return;
IGPhoto *photo;
if ([self.delegate isKindOfClass:%c(IGFeedItemPhotoCell)]) {
IGFeedItemPhotoCellConfiguration *_configuration = MSHookIvar<IGFeedItemPhotoCellConfiguration *>(self.delegate, "_configuration");
if (!_configuration) return;
photo = MSHookIvar<IGPhoto *>(_configuration, "_photo");
}
else if ([self.delegate isKindOfClass:%c(IGFeedItemPagePhotoCell)]) {
} else if ([self.delegate isKindOfClass:%c(IGFeedItemPagePhotoCell)]) {
IGFeedItemPagePhotoCell *pagePhotoCell = self.delegate;
photo = pagePhotoCell.pagePhotoPost.photo;
}
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
if (!photoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from post"];
return;
}
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from post")]; return; }
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent]pathExtension]
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
}
%end
// Download feed videos
%hook IGModernFeedVideoCell.IGModernFeedVideoCell
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"dw_feed_posts"]) return;
if (sciUseDownloadButtons()) {
[self sciAddDownloadButton];
} else {
[self addLongPressGestureRecognizer];
}
}
%new - (void)sciAddDownloadButton {
UIView *selfView = (UIView *)self;
if ([selfView viewWithTag:1338]) return;
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1338;
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:@"arrow.down.to.line" withConfiguration:config] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 12;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciDownloadBtnTapped:) forControlEvents:UIControlEventTouchUpInside];
[selfView addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.leadingAnchor constraintEqualToAnchor:selfView.leadingAnchor constant:10],
[btn.bottomAnchor constraintEqualToAnchor:selfView.bottomAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:24],
[btn.heightAnchor constraintEqualToConstant:24]
]];
}
%new - (void)sciDownloadBtnTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.75, 0.75); }
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
sciConfirmAndDownload(@"Download video?", ^{
[self handleLongPress:nil];
});
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
@@ -317,10 +92,7 @@ static BOOL sciUseDownloadButtons() {
if (sender && sender.state != UIGestureRecognizerStateBegan) return;
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:[self mediaCellFeedItem]];
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from post"];
return;
}
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from post")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
@@ -330,277 +102,50 @@ static BOOL sciUseDownloadButtons() {
%end
/* * Stories (legacy gesture) * */
/* * Reels * */
// Download reels (photos) — long press only when gesture mode selected
%hook IGSundialViewerPhotoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_reels"] && !sciUseDownloadButtons()) {
[self addLongPressGestureRecognizer];
}
return;
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
IGPhoto *_photo = nil;
@try {
_photo = MSHookIvar<IGPhoto *>(self, "_photo");
} @catch (NSException *e) {}
if (!_photo) {
[SCIUtils showErrorHUDWithDescription:@"Could not access reel photo"];
return;
}
NSURL *photoUrl = [SCIUtils getPhotoUrl:_photo];
if (!photoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from reel"];
return;
}
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent]pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel photo download error: %@", exception);
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Reel photo download failed: %@", exception.reason]];
}
}
%end
// Download reels (videos) — long press only when gesture mode selected
%hook IGSundialViewerVideoCell
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_reels"] && !sciUseDownloadButtons()) {
[self addLongPressGestureRecognizer];
}
return;
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
IGMedia *media = sciGetMediaFromView(self);
if (!media) {
[SCIUtils showErrorHUDWithDescription:@"Could not access reel media"];
return;
}
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from reel"];
return;
}
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
fileExtension:[[videoUrl lastPathComponent] pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel download error: %@", exception);
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Reel download failed: %@", exception.reason]];
}
}
%end
// Download button on reels vertical UFI (like/comment/share sidebar)
%hook IGSundialViewerVerticalUFI
- (void)didMoveToSuperview {
%orig;
if (![SCIUtils getBoolPref:@"dw_reels"]) return;
if (!sciUseDownloadButtons()) return;
if (!self.superview) return;
// Add to superview so we're not clipped by the narrow 29pt UFI
UIView *parent = self.superview;
if ([parent viewWithTag:1337]) return;
UIButton *downloadBtn = [UIButton buttonWithType:UIButtonTypeCustom];
downloadBtn.tag = 1337;
// Match IG reel sidebar style: outline icon, semi-transparent white
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold];
UIImage *icon = [UIImage systemImageNamed:@"arrow.down" withConfiguration:config];
[downloadBtn setImage:icon forState:UIControlStateNormal];
downloadBtn.tintColor = [UIColor colorWithWhite:1.0 alpha:0.9];
downloadBtn.layer.shadowColor = [UIColor blackColor].CGColor;
downloadBtn.layer.shadowOffset = CGSizeMake(0, 1);
downloadBtn.layer.shadowOpacity = 0.5;
downloadBtn.layer.shadowRadius = 3;
downloadBtn.translatesAutoresizingMaskIntoConstraints = NO;
[downloadBtn addTarget:self action:@selector(sciDownloadTapped:) forControlEvents:UIControlEventTouchUpInside];
[parent addSubview:downloadBtn];
[NSLayoutConstraint activateConstraints:@[
[downloadBtn.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[downloadBtn.bottomAnchor constraintEqualToAnchor:self.topAnchor constant:-10],
[downloadBtn.widthAnchor constraintEqualToConstant:40],
[downloadBtn.heightAnchor constraintEqualToConstant:40]
]];
}
%new - (void)sciDownloadTapped:(UIButton *)sender {
NSLog(@"[SCInsta] Reel download button tapped");
// Haptic + visual feedback
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{
sender.transform = CGAffineTransformMakeScale(0.75, 0.75);
} completion:^(BOOL finished) {
[UIView animateWithDuration:0.1 animations:^{
sender.transform = CGAffineTransformIdentity;
}];
}];
sciConfirmAndDownload(@"Download reel?", ^{
// Find IGSundialViewerVideoCell in superview chain
UIView *videoCell = sciFindSuperviewOfClass(self, @"IGSundialViewerVideoCell");
if (videoCell) {
IGMedia *media = sciGetMediaFromView(videoCell);
if (media) {
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (videoUrl) {
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
fileExtension:[[videoUrl lastPathComponent] pathExtension]
hudLabel:nil];
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not extract video URL from reel"];
return;
}
sciShowDebugIvarDump(videoCell);
return;
}
// Try photo reel
UIView *photoView = sciFindSuperviewOfClass(self, @"IGSundialViewerPhotoView");
if (photoView) {
unsigned int count = 0;
Ivar *ivars = class_copyIvarList([photoView class], &count);
Class photoClass = NSClassFromString(@"IGPhoto");
for (unsigned int i = 0; i < count; i++) {
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *ivarName = [NSString stringWithUTF8String:name];
if ([[ivarName lowercaseString] containsString:@"photo"]) {
id value = object_getIvar(photoView, ivars[i]);
if (value && photoClass && [value isKindOfClass:photoClass]) {
NSURL *photoUrl = [SCIUtils getPhotoUrl:(IGPhoto *)value];
if (photoUrl) {
free(ivars);
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
return;
}
}
}
}
if (ivars) free(ivars);
sciShowDebugIvarDump(photoView);
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not find reel cell in view hierarchy"];
});
}
%end
/* * Stories * */
// Download story (images)
%hook IGStoryPhotoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_story"]) {
[self addLongPressGestureRecognizer];
}
return;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:[self item]];
if (!photoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from story"];
return;
}
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from story")]; return; }
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent]pathExtension]
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
}
%end
// Download story (videos)
%hook IGStoryModernVideoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_story"]) {
[self addLongPressGestureRecognizer];
}
return;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:self.item];
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from story"];
return;
}
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from story")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
@@ -609,35 +154,26 @@ static BOOL sciUseDownloadButtons() {
}
%end
// Download story (videos, legacy)
%hook IGStoryVideoView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"dw_story"]) {
[self addLongPressGestureRecognizer];
}
return;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
NSURL *videoUrl;
IGStoryFullscreenSectionController *captionDelegate = self.captionDelegate;
if (captionDelegate) {
videoUrl = [SCIUtils getVideoUrlForMedia:captionDelegate.currentStoryItem];
}
else {
// Direct messages video player
} else {
id parentVC = [SCIUtils nearestViewControllerForView:self];
if (!parentVC || ![parentVC isKindOfClass:%c(IGDirectVisualMessageViewerController)]) return;
@@ -653,11 +189,7 @@ static BOOL sciUseDownloadButtons() {
videoUrl = [SCIUtils getVideoUrl:rawVideo];
}
if (!videoUrl) {
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from story"];
return;
}
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from story")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
@@ -667,17 +199,176 @@ static BOOL sciUseDownloadButtons() {
%end
/* * Reels (legacy gesture) * */
%hook IGSundialViewerPhotoView
- (void)didMoveToSuperview {
%orig;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
IGPhoto *_photo = MSHookIvar<IGPhoto *>(self, "_photo");
if (!_photo) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not access reel photo")]; return; }
NSURL *photoUrl = [SCIUtils getPhotoUrl:_photo];
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from reel")]; return; }
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:photoUrl
fileExtension:[[photoUrl lastPathComponent] pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel photo download error: %@", exception);
}
}
%end
%hook IGSundialViewerVideoCell
- (void)didMoveToSuperview {
%orig;
if (!sciLegacyGestureEnabled()) return;
[self addLongPressGestureRecognizer];
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
[self addGestureRecognizer:longPress];
}
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
if (sender.state != UIGestureRecognizerStateBegan) return;
@try {
// Runtime ivar scan: the exact name varies across IG releases.
unsigned int ivarCount = 0;
Ivar *ivars = class_copyIvarList([self class], &ivarCount);
Class mediaClass = NSClassFromString(@"IGMedia");
IGMedia *media = nil;
for (unsigned int i = 0; i < ivarCount; i++) {
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *lower = [[NSString stringWithUTF8String:name] lowercaseString];
if ([lower containsString:@"video"] || [lower containsString:@"media"] || [lower containsString:@"item"]) {
id val = object_getIvar(self, ivars[i]);
if (val && mediaClass && [val isKindOfClass:mediaClass]) { media = val; break; }
}
}
if (ivars) free(ivars);
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not access reel media")]; return; }
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from reel")]; return; }
initDownloaders();
[videoDownloadDelegate downloadFileWithURL:videoUrl
fileExtension:[[videoUrl lastPathComponent] pathExtension]
hudLabel:nil];
} @catch (NSException *exception) {
NSLog(@"[SCInsta] Reel download error: %@", exception);
}
}
%end
/* * Profile pictures * */
// Get profile info by walking up to IGProfileViewController
static NSString *sciProfileCaption(UIView *view) {
Class profileCls = NSClassFromString(@"IGProfileViewController");
Class userCls = NSClassFromString(@"IGUser");
UIResponder *r = view;
while (r) {
if (profileCls && [r isKindOfClass:profileCls]) {
id user = nil;
for (NSString *key in @[@"user", @"userGQL", @"profileUser"]) {
@try { user = [(UIViewController *)r valueForKey:key]; } @catch (__unused id e) {}
if (user) break;
}
if (!user && userCls) {
unsigned int cnt = 0;
Ivar *ivars = class_copyIvarList([r class], &cnt);
for (unsigned int i = 0; i < cnt; i++) {
id v = object_getIvar(r, ivars[i]);
if (v && [v isKindOfClass:userCls]) { user = v; break; }
}
if (ivars) free(ivars);
}
if (user) {
NSString *name = nil, *username = nil, *bio = nil;
@try { username = [user valueForKey:@"username"]; } @catch (__unused id e) {}
@try { name = [user valueForKey:@"fullName"]; } @catch (__unused id e) {}
if (!name) @try { name = [user valueForKey:@"name"]; } @catch (__unused id e) {}
@try { bio = [user valueForKey:@"biography"]; } @catch (__unused id e) {}
NSMutableString *caption = [NSMutableString string];
if (name.length) [caption appendString:name];
if (username.length) {
if (caption.length) [caption appendString:@"\n"];
[caption appendFormat:@"@%@", username];
}
if (bio.length) {
if (caption.length) [caption appendString:@"\n\n"];
[caption appendString:bio];
}
return caption.length ? caption : nil;
}
}
r = [r nextResponder];
}
return nil;
}
// Profile photo zoom — intercepts IG's profile pic long press
%hook IGProfilePhotoCoinFlipUI.IGProfilePhotoCoinFlipView
- (void)viewLongPressedWithGesture:(UILongPressGestureRecognizer *)gesture {
if (![SCIUtils getBoolPref:@"zoom_profile_photo"]) { %orig; return; }
if (gesture.state != UIGestureRecognizerStateBegan) { %orig; return; }
// Find the IGProfilePictureImageView inside us
UIView *source = gesture.view;
NSMutableArray *q = [NSMutableArray arrayWithObject:source];
int scanned = 0;
while (q.count && scanned < 30) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
if ([cur isKindOfClass:NSClassFromString(@"IGProfilePictureImageView")]) {
IGImageView *imgView = MSHookIvar<IGImageView *>(cur, "_imageView");
if (imgView) {
IGImageSpecifier *spec = imgView.imageSpecifier;
NSURL *url = spec ? spec.url : nil;
if (url) {
NSString *caption = sciProfileCaption(cur);
[SCIMediaViewer showWithVideoURL:nil photoURL:url caption:caption];
return;
}
}
}
for (UIView *s in cur.subviews) [q addObject:s];
}
%orig;
}
%end
%hook IGProfilePictureImageView
- (void)didMoveToSuperview {
%orig;
if ([SCIUtils getBoolPref:@"save_profile"]) {
if ([SCIUtils getBoolPref:@"save_profile"] || [SCIUtils getBoolPref:@"zoom_profile_photo"]) {
[self addLongPressGestureRecognizer];
}
return;
}
%new - (void)addLongPressGestureRecognizer {
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
@@ -695,6 +386,14 @@ static BOOL sciUseDownloadButtons() {
NSURL *imageUrl = imageSpecifier.url;
if (!imageUrl) return;
// Zoom: open in full-screen viewer with profile info
if ([SCIUtils getBoolPref:@"zoom_profile_photo"]) {
NSString *caption = sciProfileCaption(self);
[SCIMediaViewer showWithVideoURL:nil photoURL:imageUrl caption:caption];
return;
}
// Legacy: direct download
initDownloaders();
[imageDownloadDelegate downloadFileWithURL:imageUrl
fileExtension:[[imageUrl lastPathComponent] pathExtension]
+125
View File
@@ -0,0 +1,125 @@
// Follow indicator — shows whether the profile user follows you.
// Fetches via /api/v1/friendships/show/{pk}/, renders inside the stats container.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../Networking/SCIInstagramAPI.h"
#import <objc/runtime.h>
#import <objc/message.h>
// IGProfileViewController declared in InstagramHeaders.h
static const NSInteger kFollowBadgeTag = 99788;
static NSString *sciPKFromUser(id igUser) {
if (!igUser) return nil;
Ivar pkIvar = NULL;
for (Class c = [igUser class]; c && !pkIvar; c = class_getSuperclass(c))
pkIvar = class_getInstanceVariable(c, "_pk");
if (!pkIvar) return nil;
return [object_getIvar(igUser, pkIvar) description];
}
static NSString *sciCurrentUserPK(void) {
@try {
for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) {
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *window in scene.windows) {
id session = [window valueForKey:@"userSession"];
if (!session) continue;
id su = [session valueForKey:@"user"];
if (su) return sciPKFromUser(su);
}
}
} @catch (NSException *e) {}
return nil;
}
// Cache follow status on the VC to avoid re-fetching
static const char kFollowStatusKey;
static NSNumber *sciGetFollowStatus(id vc) {
return objc_getAssociatedObject(vc, &kFollowStatusKey);
}
static void sciSetFollowStatus(id vc, NSNumber *status) {
objc_setAssociatedObject(vc, &kFollowStatusKey, status, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
static void sciRenderBadge(UIViewController *vc) {
NSNumber *status = sciGetFollowStatus(vc);
if (!status) return;
BOOL followedBy = [status boolValue];
UIView *statContainer = nil;
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
if ([NSStringFromClass([v class]) containsString:@"StatButtonContainerView"]) {
statContainer = v;
break;
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
if (!statContainer) return;
UIView *old = [statContainer viewWithTag:kFollowBadgeTag];
if (old) [old removeFromSuperview];
UILabel *badge = [[UILabel alloc] init];
badge.tag = kFollowBadgeTag;
badge.text = followedBy ? SCILocalized(@"Follows you") : SCILocalized(@"Doesn't follow you");
badge.font = [UIFont systemFontOfSize:11 weight:UIFontWeightMedium];
badge.textColor = followedBy
? [UIColor colorWithRed:0.3 green:0.75 blue:0.4 alpha:1.0]
: [UIColor colorWithRed:0.85 green:0.3 blue:0.3 alpha:1.0];
[badge sizeToFit];
CGFloat x = 0;
for (UIView *sub in statContainer.subviews) {
if (!sub.isHidden && sub.frame.size.width > 0) {
x = sub.frame.origin.x;
break;
}
}
badge.frame = CGRectMake(x, statContainer.bounds.size.height - badge.frame.size.height - 2,
badge.frame.size.width, badge.frame.size.height);
[statContainer addSubview:badge];
}
%hook IGProfileViewController
- (void)viewDidAppear:(BOOL)animated {
%orig;
if (![SCIUtils getBoolPref:@"follow_indicator"]) return;
// Already fetched — just re-render
if (sciGetFollowStatus(self)) {
sciRenderBadge(self);
return;
}
id igUser = nil;
@try { igUser = [self valueForKey:@"user"]; } @catch (NSException *e) {}
if (!igUser) return;
NSString *profilePK = sciPKFromUser(igUser);
NSString *myPK = sciCurrentUserPK();
if (!profilePK || !myPK || [profilePK isEqualToString:myPK]) return;
__weak UIViewController *weakSelf = self;
NSString *path = [NSString stringWithFormat:@"friendships/show/%@/", profilePK];
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *response, NSError *error) {
if (error || !response) return;
BOOL followedBy = [response[@"followed_by"] boolValue];
dispatch_async(dispatch_get_main_queue(), ^{
UIViewController *vc = weakSelf;
if (!vc) return;
sciSetFollowStatus(vc, @(followedBy));
sciRenderBadge(vc);
});
}];
}
%end
+40
View File
@@ -0,0 +1,40 @@
// Copy note text on long press — long-press the note bubble to copy text.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
// IGDirectNotesThoughtBubbleView declared in InstagramHeaders.h
%hook IGDirectNotesThoughtBubbleView
- (void)layoutSubviews {
%orig;
if (![SCIUtils getBoolPref:@"profile_note_copy"]) return;
// Only add once
static const NSInteger kCopyGestureTag = 99791;
for (UIGestureRecognizer *gr in self.gestureRecognizers) {
if (gr.view.tag == kCopyGestureTag) return;
}
self.tag = kCopyGestureTag;
UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc]
initWithTarget:self action:@selector(sciCopyNoteLongPress:)];
lp.minimumPressDuration = 0.5;
[self addGestureRecognizer:lp];
}
%new - (void)sciCopyNoteLongPress:(UILongPressGestureRecognizer *)gesture {
if (gesture.state != UIGestureRecognizerStateBegan) return;
Ivar textIvar = class_getInstanceVariable([self class], "_noteText");
if (!textIvar) return;
NSString *text = object_getIvar(self, textIvar);
if (!text.length) return;
[[UIPasteboard generalPasteboard] setString:text];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note copied")];
}
%end
+5 -5
View File
@@ -136,13 +136,13 @@ static UIView * _Nullable sciFindSubmitButton(UIView *root) {
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
return;
}
UITextField *textField = sciFindTextField(self);
if (!textField) {
[SCIUtils showErrorHUDWithDescription:@"No text field found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No text field found")];
return;
}
@@ -172,16 +172,16 @@ static UIView * _Nullable sciFindSubmitButton(UIView *root) {
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
return;
}
[[UIPasteboard generalPasteboard] setString:password];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Password"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Password")
message:password
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Copied!" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copied!") style:UIAlertActionStyleCancel handler:nil]];
UIViewController *topVC = topMostController();
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
}
+3 -3
View File
@@ -54,12 +54,12 @@ static BOOL sciReelRefreshBypassing = NO;
((void(*)(id,SEL))objc_msgSend)(rc, @selector(endRefreshing));
[self refreshControlDidEndFinishLoadingAnimation:rc];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Refresh Reels?"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Refresh Reels?")
message:nil
preferredStyle:UIAlertControllerStyleAlert];
__weak id weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Refresh") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
sciReelRefreshBypassing = YES;
SEL rSel = @selector(_refreshReelsWithParamsForNetworkRequest:userDidPullToRefresh:);
((void(*)(id,SEL,NSInteger,BOOL))objc_msgSend)(weakSelf, rSel, arg1, arg2);
@@ -123,7 +123,7 @@ static BOOL sciShouldBlockSeenVisualForObj(id obj) {
// Hooks all known like entry points to trigger mark-seen and auto-advance on like.
// Uses sciMarkSeenTapped: from OverlayButtons.xm for the actual seen flow.
static __weak UIViewController *sciActiveStoryVC = nil;
__weak UIViewController *sciActiveStoryVC = nil;
%hook IGStoryViewerViewController
- (void)viewDidAppear:(BOOL)animated {
@@ -72,27 +72,27 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
id directAudio = nil;
@try { directAudio = [capturedVM valueForKey:@"audio"]; } @catch (NSException *e) {}
if (!directAudio) {
[SCIUtils showErrorHUDWithDescription:@"Could not get audio data. Try again after refreshing the chat."];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not get audio data. Try again after refreshing the chat.")];
return;
}
Ivar serverAudioIvar = class_getInstanceVariable([directAudio class], "_server_audio");
id serverAudio = serverAudioIvar ? object_getIvar(directAudio, serverAudioIvar) : nil;
if (!serverAudio) {
[SCIUtils showErrorHUDWithDescription:@"Audio not loaded yet. Play the message first and try again."];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Audio not loaded yet. Play the message first and try again.")];
return;
}
NSURL *playbackURL = sciDAF(serverAudio, @selector(playbackURL));
if (!playbackURL) playbackURL = sciDAF(serverAudio, @selector(fallbackURL));
if (!playbackURL) {
[SCIUtils showErrorHUDWithDescription:@"No audio URL found. Try again after refreshing the chat."];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio URL found. Try again after refreshing the chat.")];
return;
}
UIView *topView = [UIApplication sharedApplication].keyWindow;
SCIDownloadPillView *pill = [[SCIDownloadPillView alloc] init];
[pill setText:@"Downloading audio..."];
[pill setText:SCILocalized(@"Downloading audio...")];
[pill showInView:topView];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
@@ -119,7 +119,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
void (^present)(NSURL *) = ^(NSURL *url) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill setText:@"Done!"];
[pill setText:SCILocalized(@"Done!")];
[pill dismissAfterDelay:0.5];
[SCIUtils showShareVC:url];
});
@@ -74,8 +74,8 @@ static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
UIMenu *base = origProvider ? origProvider(suggested) : [UIMenu menuWithChildren:suggested];
BOOL inList = [SCIExcludedThreads isInList:tid];
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude chat");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude chat");
NSString *title = inList ? removeLabel : addLabel;
UIImage *img = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil
@@ -221,22 +221,22 @@ NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) {
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen");
NSString *title = inList ? removeLabel : addLabel;
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
void (^handler)(void) = ^{
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
// Removing in block_selected = normal behavior → mark seen
if (blockSelected) sciTriggerStoryMarkSeen(weakVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": pk, @"username": username, @"fullName": fullName
}];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
// Adding in block_all = normal behavior → mark seen
if (!blockSelected) sciTriggerStoryMarkSeen(weakVC);
}
@@ -0,0 +1,108 @@
// Full last active — replaces "Active Xm ago" with full date in DM chats.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
static NSDateFormatter *sciDMDateFormatter(void) {
static NSDateFormatter *df = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
df = [NSDateFormatter new];
df.dateFormat = @"MMM d 'at' h:mm a";
});
return df;
}
// Replace "Active Xm/h ago" with full date using _lastActiveDate from the thread
static void sciUpdateSubtitleLabel(UIView *titleView) {
if (![SCIUtils getBoolPref:@"dm_full_last_active"]) return;
// Get _subtitleLabel
Ivar subIvar = class_getInstanceVariable([titleView class], "_subtitleLabel");
if (!subIvar) return;
UILabel *label = object_getIvar(titleView, subIvar);
if (![label isKindOfClass:[UILabel class]]) return;
NSString *text = label.text;
if (!text.length) return;
// Only replace "Active X ago" patterns, not "Active now" or "Typing..."
if (![text hasPrefix:@"Active "] || ![text hasSuffix:@"ago"]) return;
// Get the _titleViewModel to find lastActiveDate
Ivar vmIvar = class_getInstanceVariable([titleView class], "_titleViewModel");
if (!vmIvar) return;
id vm = object_getIvar(titleView, vmIvar);
if (!vm) return;
// Try to get lastActiveDate from the view model
NSDate *activeDate = nil;
// Check vm for lastActiveDate / lastActive / activeDate
for (NSString *sel in @[@"lastActiveDate", @"lastActive", @"activeDate"]) {
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
id val = [vm valueForKey:sel];
if ([val isKindOfClass:[NSDate class]]) { activeDate = val; break; }
if ([val isKindOfClass:[NSNumber class]]) {
activeDate = [NSDate dateWithTimeIntervalSince1970:[(NSNumber *)val doubleValue]];
break;
}
}
}
// If no date on VM, parse from the label text as fallback
if (!activeDate) {
// "Active 8m ago" → 8 minutes ago
// "Active 2h ago" → 2 hours ago
NSTimeInterval delta = 0;
NSScanner *scanner = [NSScanner scannerWithString:text];
[scanner scanString:@"Active " intoString:nil];
double val = 0;
if ([scanner scanDouble:&val]) {
NSString *rest = [text substringFromIndex:scanner.scanLocation];
if ([rest hasPrefix:@"m"]) delta = val * 60;
else if ([rest hasPrefix:@"h"]) delta = val * 3600;
else if ([rest hasPrefix:@"d"]) delta = val * 86400;
}
if (delta > 0) {
activeDate = [NSDate dateWithTimeIntervalSinceNow:-delta];
}
}
if (!activeDate) return;
NSString *formatted = [sciDMDateFormatter() stringFromDate:activeDate];
if (formatted.length) {
label.text = formatted;
// Also update _subtitleView and _transitionalSubtitleLabel if they exist
Ivar svIvar = class_getInstanceVariable([titleView class], "_subtitleView");
if (svIvar) {
id sv = object_getIvar(titleView, svIvar);
if ([sv isKindOfClass:[UILabel class]])
[(UILabel *)sv setText:label.text];
}
Ivar tsIvar = class_getInstanceVariable([titleView class], "_transitionalSubtitleLabel");
if (tsIvar) {
id ts = object_getIvar(titleView, tsIvar);
if ([ts isKindOfClass:[UILabel class]])
[(UILabel *)ts setText:label.text];
}
}
}
%hook IGDirectLeftAlignedTitleView
- (void)setTitleViewModel:(id)vm {
%orig;
sciUpdateSubtitleLabel(self);
}
- (void)animationCoordinatorDidUpdate:(id)coordinator {
%orig;
sciUpdateSubtitleLabel(self);
}
%end
@@ -0,0 +1,90 @@
// Hide voice/video call buttons in DM thread header.
#import "../../Utils.h"
// IGDirectThreadCallButtonsCoordinator / IGDirectCallButton / IGNavigationBar
// declared in InstagramHeaders.h
static BOOL sciShouldHide(UIView *b) {
if (![b isKindOfClass:NSClassFromString(@"IGDirectCallButton")]) return NO;
NSString *axId = b.accessibilityIdentifier;
if ([axId isEqualToString:@"audio-call"]) return [SCIUtils getBoolPref:@"hide_voice_call_button"];
if ([axId isEqualToString:@"video-chat"]) return [SCIUtils getBoolPref:@"hide_video_call_button"];
return NO;
}
static BOOL sciPlatterContainsHiddenButton(UIView *platter) {
NSMutableArray *q = [NSMutableArray arrayWithObject:platter];
while (q.count) {
UIView *v = q.firstObject;
[q removeObjectAtIndex:0];
if (sciShouldHide(v)) return YES;
[q addObjectsFromArray:v.subviews];
}
return NO;
}
// Block taps in case a hidden button still receives hit-test events during transitions.
%hook IGDirectThreadCallButtonsCoordinator
- (void)_didTapAudioButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"hide_voice_call_button"]) return;
%orig;
}
- (void)_didTapVideoButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"hide_video_call_button"]) return;
%orig;
}
%end
%hook IGDirectCallButton
- (void)didMoveToWindow {
%orig;
if (!self.window) return;
if (sciShouldHide((UIView *)self)) self.hidden = YES;
}
%end
// Re-pack platters on each layout: shift every non-back platter right by the
// total width of the hidden call platters to eliminate the gap.
static void sciRepackPlatters(UIView *container) {
NSMutableArray *platters = [NSMutableArray array];
for (UIView *sv in container.subviews)
if ([NSStringFromClass([sv class]) isEqualToString:@"_UINavigationBarPlatterView"])
[platters addObject:sv];
CGFloat hiddenWidth = 0;
NSMutableArray *alive = [NSMutableArray array];
for (UIView *p in platters) {
if (sciPlatterContainsHiddenButton(p)) {
hiddenWidth += p.frame.size.width;
p.hidden = YES;
} else {
p.hidden = NO;
[alive addObject:p];
}
}
if (!alive.count || hiddenWidth == 0) {
for (UIView *p in alive) p.transform = CGAffineTransformIdentity;
return;
}
for (UIView *p in alive) {
if (p.frame.origin.x < 60) { p.transform = CGAffineTransformIdentity; continue; }
p.transform = CGAffineTransformMakeTranslation(hiddenWidth, 0);
}
}
%hook IGNavigationBar
- (void)layoutSubviews {
%orig;
NSMutableArray *q = [NSMutableArray arrayWithObject:self];
while (q.count) {
UIView *v = q.firstObject;
[q removeObjectAtIndex:0];
if ([NSStringFromClass([v class]) containsString:@"NavigationBarPlatterContainer"]) {
sciRepackPlatters(v);
break;
}
[q addObjectsFromArray:v.subviews];
}
}
%end
@@ -97,18 +97,18 @@ static void new_pullToRefresh(id self, SEL _cmd) {
@"Refreshing the DMs tab will clear %lu preserved unsent message%@. This cannot be undone.",
(unsigned long)count, count == 1 ? @"" : @"s"];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Clear preserved messages?"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Clear preserved messages?")
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakSelf = vc;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel
handler:^(UIAlertAction *a) {
sciCancelRefresh(weakSelf);
sciRefreshAlertVisible = NO;
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDestructive
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Refresh") style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *a) {
sciRefreshAlertVisible = NO;
id strongSelf = weakSelf;
@@ -356,7 +356,7 @@ static void sciShowUnsentToast() {
pill.alpha = 0;
UILabel *label = [[UILabel alloc] init];
label.text = @"A message was unsent";
label.text = SCILocalized(@"A message was unsent");
label.textColor = [UIColor whiteColor];
label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
label.textAlignment = NSTextAlignmentCenter;
@@ -606,7 +606,7 @@ static void sciUpdateCellIndicator(id cell) {
UIView *parent = bubble ?: view;
UILabel *label = [[UILabel alloc] init];
label.tag = SCI_PRESERVED_TAG;
label.text = @"Unsent";
label.text = SCILocalized(@"Unsent");
label.font = [UIFont italicSystemFontOfSize:10];
label.textColor = [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.9];
label.translatesAutoresizingMaskIntoConstraints = NO;
@@ -0,0 +1,292 @@
// Notes actions — copy text, download GIF/audio from notes long-press menu.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
@interface SCIDownloadDelegate (NotesExt)
- (void)downloadDidFinishWithFileURL:(NSURL *)fileURL;
@end
// Find the note model matching a username from visible tray cells
static id sciFindNoteForUser(UIView *root, NSString *username) {
NSMutableArray *q = [NSMutableArray arrayWithObject:root];
int scanned = 0;
while (q.count && scanned < 500) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
NSString *cls = NSStringFromClass([cur class]);
if (![cls containsString:@"NotesTray"] && ![cls containsString:@"NotesUser"]) {
for (UIView *s in cur.subviews) [q addObject:s];
continue;
}
unsigned int cnt = 0;
Ivar *ivars = class_copyIvarList([cur class], &cnt);
for (unsigned int i = 0; i < cnt; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(cur, ivars[i]);
if (!val || ![val respondsToSelector:NSSelectorFromString(@"note")]) continue;
id note = [val valueForKey:@"note"];
if (!note || ![note respondsToSelector:@selector(text)]) continue;
NSString *noteUser = nil;
@try {
id uf = [note valueForKey:@"userFields"];
if ([uf respondsToSelector:NSSelectorFromString(@"username")])
noteUser = [uf valueForKey:@"username"];
} @catch (__unused id e) {}
if (!username || [noteUser isEqualToString:username])
{ free(ivars); return note; }
} @catch (__unused id e) {}
}
if (ivars) free(ivars);
for (UIView *s in cur.subviews) [q addObject:s];
}
return nil;
}
// Find the cell view model for a specific note, return the cell view
static UIView *sciFindCellForNote(UIView *root, id targetNote) {
NSMutableArray *q = [NSMutableArray arrayWithObject:root];
int scanned = 0;
while (q.count && scanned < 300) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
if (![NSStringFromClass([cur class]) containsString:@"Notes"]) {
for (UIView *s in cur.subviews) [q addObject:s];
continue;
}
Ivar vmIvar = class_getInstanceVariable([cur class], "viewModel");
if (!vmIvar) vmIvar = class_getInstanceVariable([cur class], "_viewModel");
if (!vmIvar) { for (UIView *s in cur.subviews) [q addObject:s]; continue; }
id vm = object_getIvar(cur, vmIvar);
if (!vm || ![vm respondsToSelector:NSSelectorFromString(@"note")]) {
for (UIView *s in cur.subviews) [q addObject:s]; continue;
}
if ([vm valueForKey:@"note"] == targetNote) return cur;
for (UIView *s in cur.subviews) [q addObject:s];
}
return nil;
}
// Get GIF image from a cell's IGGIFView only
static UIImage *sciGIFImageFromCell(UIView *cell) {
if (!cell) return nil;
NSMutableArray *q = [NSMutableArray arrayWithObject:cell];
int s = 0;
while (q.count && s < 100) {
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; s++;
// Only match IGGIFView — not profile pics or other image views
if ([NSStringFromClass([cur class]) containsString:@"GIFView"]) {
if ([cur isKindOfClass:[UIImageView class]]) {
UIImage *img = [(UIImageView *)cur image];
if (img && img.size.width > 20) return img;
}
// Check subviews of GIFView for the actual image view
for (UIView *sub in cur.subviews) {
if ([sub isKindOfClass:[UIImageView class]]) {
UIImage *img = [(UIImageView *)sub image];
if (img && img.size.width > 20) return img;
}
}
}
for (UIView *sub in cur.subviews) [q addObject:sub];
}
return nil;
}
// Get audio URL from the cell's view model
static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) {
if (!cell) return nil;
Ivar vmIvar = class_getInstanceVariable([cell class], "viewModel");
if (!vmIvar) vmIvar = class_getInstanceVariable([cell class], "_viewModel");
if (!vmIvar) return nil;
id vm = object_getIvar(cell, vmIvar);
if (!vm) return nil;
SEL audioSel = NSSelectorFromString(@"audioTrackWithUserMap:");
if (![vm respondsToSelector:audioSel]) return nil;
@try {
id track = ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel, nil);
if (!track) return nil;
// audioFileURL is an IGAsyncTask — try to resolve it
if ([track respondsToSelector:NSSelectorFromString(@"audioFileURL")]) {
id urlOrTask = [track valueForKey:@"audioFileURL"];
if ([urlOrTask isKindOfClass:[NSURL class]]) return urlOrTask;
// IGAsyncTask — try .result, .value, .get
for (NSString *prop in @[@"result", @"value", @"get", @"cachedResult"]) {
if ([urlOrTask respondsToSelector:NSSelectorFromString(prop)]) {
@try {
id resolved = [urlOrTask valueForKey:prop];
if ([resolved isKindOfClass:[NSURL class]]) return resolved;
} @catch (__unused id e) {}
}
}
SEL awaitSel = NSSelectorFromString(@"await");
if ([urlOrTask respondsToSelector:awaitSel]) {
@try {
id resolved = ((id(*)(id,SEL))objc_msgSend)(urlOrTask, awaitSel);
if ([resolved isKindOfClass:[NSURL class]]) return resolved;
} @catch (__unused id e) {}
}
}
} @catch (__unused id e) {}
return nil;
}
static SCIDownloadDelegate *sciNoteDl = nil;
static void (*orig_present)(UIViewController *, SEL, UIViewController *, BOOL, id);
static void hook_present(UIViewController *self, SEL _cmd, UIViewController *vc, BOOL animated, id completion) {
if (![NSStringFromClass([vc class]) isEqualToString:@"IGActionSheetController"]) {
orig_present(self, _cmd, vc, animated, completion);
return;
}
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
if (!actIvar) { orig_present(self, _cmd, vc, animated, completion); return; }
NSArray *actions = object_getIvar(vc, actIvar);
BOOL isNotes = NO;
for (id a in actions) {
if (![a respondsToSelector:@selector(title)]) continue;
NSString *t = [a valueForKey:@"title"];
if ([t isKindOfClass:[NSString class]] && [t containsString:@"Mute notes"])
{ isNotes = YES; break; }
}
if (!isNotes) { orig_present(self, _cmd, vc, animated, completion); return; }
BOOL copyOnHold = [SCIUtils getBoolPref:@"note_copy_on_hold"];
BOOL noteActions = [SCIUtils getBoolPref:@"note_actions"];
if (!copyOnHold && !noteActions) {
orig_present(self, _cmd, vc, animated, completion);
return;
}
// Copy text immediately on long press, then let the menu open normally
if (copyOnHold) {
id note = sciFindNoteForUser(self.view, nil);
NSString *text = nil;
@try { text = [note valueForKey:@"text"]; } @catch (__unused id e) {}
if (text.length) {
[[UIPasteboard generalPasteboard] setString:text];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note text copied")];
}
}
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
if (!actionCls || ![actionCls instancesRespondToSelector:initSel]) {
orig_present(self, _cmd, vc, animated, completion);
return;
}
__weak UIViewController *weakSelf = self;
__weak UIViewController *weakVC = vc;
void (^handler)(void) = ^{
UIViewController *sheet = weakVC;
UIViewController *presenter = weakSelf;
if (!presenter) return;
// Read username from the visible sheet
NSString *user = nil;
if (sheet && sheet.isViewLoaded) {
NSMutableArray *lq = [NSMutableArray arrayWithObject:sheet.view];
int ls = 0;
while (lq.count && ls < 100) {
UIView *cur = lq.firstObject; [lq removeObjectAtIndex:0]; ls++;
if ([cur isKindOfClass:[UILabel class]]) {
NSString *t = [(UILabel *)cur text];
if (t.length > 0 && t.length < 30
&& ![t isEqualToString:@"Cancel"]
&& ![t isEqualToString:@"Report"]
&& ![t isEqualToString:@"Mute notes"]
&& ![t isEqualToString:@"View profile"]
&& ![t isEqualToString:@"Note actions"]) {
user = t; break;
}
}
for (UIView *s in cur.subviews) [lq addObject:s];
}
}
id note = sciFindNoteForUser(presenter.view, user);
if (!note) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Note not found")]; return; }
NSString *text = nil;
@try { text = [note valueForKey:@"text"]; } @catch (__unused id e) {}
UIView *cell = sciFindCellForNote(presenter.view, note);
// Build submenu
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:nil message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
if (text.length) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy text")
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[[UIPasteboard generalPasteboard] setString:text];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note text copied")];
}]];
}
// GIF: save via downloader (respects RyukGram album)
UIImage *gifImage = sciGIFImageFromCell(cell);
if (gifImage) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save GIF")
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
NSData *data = UIImagePNGRepresentation(gifImage);
if (!data) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Failed to encode GIF")]; return; }
NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"note_gif_%@.png", [[NSUUID UUID] UUIDString]]];
[data writeToFile:path atomically:YES];
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
[sciNoteDl downloadDidFinishWithFileURL:[NSURL fileURLWithPath:path]];
}]];
}
// Audio (style=1): download from audioFileURL
NSURL *audioURL = sciAudioURLFromCell(cell, note);
if (audioURL) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Download audio")
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:NO];
[sciNoteDl downloadFileWithURL:audioURL fileExtension:@"m4a" hudLabel:nil];
}]];
}
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel")
style:UIAlertActionStyleCancel handler:nil]];
[sheet dismissViewControllerAnimated:YES completion:^{
[presenter presentViewController:alert animated:YES completion:nil];
}];
};
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
id noteAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
@"Note actions", nil, (NSInteger)0, handler, nil, nil);
if (noteActions && noteAction) {
NSMutableArray *newActions = [actions mutableCopy];
[newActions insertObject:noteAction atIndex:0];
object_setIvar(vc, actIvar, [newActions copy]);
}
orig_present(self, _cmd, vc, animated, completion);
}
%ctor {
MSHookMessageEx([UIViewController class],
@selector(presentViewController:animated:completion:),
(IMP)hook_present, (IMP *)&orig_present);
}
+248 -134
View File
@@ -1,7 +1,14 @@
// Download + mark seen buttons on story/DM visual message overlay
// Action + mark-seen buttons on story/DM visual message overlay
// Tags: [1339] eye [1340] action [1341] audio
#import "StoryHelpers.h"
#import "SCIExcludedThreads.h"
#import "SCIExcludedStoryUsers.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import "../../ActionButton/SCIActionMenu.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import "../../Downloader/Download.h"
extern "C" BOOL sciSeenBypassActive;
extern "C" BOOL sciAdvanceBypassActive;
@@ -18,92 +25,110 @@ extern "C" void sciToggleStoryAudio(void);
extern "C" BOOL sciIsStoryAudioEnabled(void);
extern "C" void sciInitStoryAudioState(void);
extern "C" void sciResetStoryAudioState(void);
extern "C" void sciShowStoryMentions(UIViewController *, UIView *);
static SCIDownloadDelegate *sciStoryVideoDl = nil;
static SCIDownloadDelegate *sciStoryImageDl = nil;
static void sciInitStoryDownloaders() {
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
DownloadAction action = [method isEqualToString:@"photos"] ? saveToPhotos : share;
DownloadAction imgAction = [method isEqualToString:@"photos"] ? saveToPhotos : quickLook;
sciStoryVideoDl = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES];
sciStoryImageDl = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO];
}
static void sciDownloadMedia(IGMedia *media) {
sciInitStoryDownloaders();
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
if (videoUrl) {
[sciStoryVideoDl downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil];
return;
}
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
if (photoUrl) {
[sciStoryImageDl downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not extract URL"];
}
static void sciDownloadWithConfirm(void(^block)(void)) {
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
[SCIUtils showConfirmation:block title:@"Download?"];
} else {
block();
}
}
static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// ── Disappearing DM media ──
static NSURL *sciDisappearingMediaURL(UIViewController *dmVC, BOOL *outIsVideo) {
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
if (!ds) return;
Ivar msgIvar = class_getInstanceVariable([ds class], "_currentMessage");
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
if (!msg) return;
id rawVideo = sciCall(msg, @selector(rawVideo));
if (rawVideo) {
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryVideoDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
id rawPhoto = sciCall(msg, @selector(rawPhoto));
if (rawPhoto) {
NSURL *url = [SCIUtils getPhotoUrl:rawPhoto];
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
id imgSpec = sciCall(msg, NSSelectorFromString(@"imageSpecifier"));
if (imgSpec) {
NSURL *url = sciCall(imgSpec, @selector(url));
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
if (!msg) return nil;
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
if (vmi) {
Ivar mediaIvar = class_getInstanceVariable([vmi class], "_media");
id mediaObj = mediaIvar ? object_getIvar(vmi, mediaIvar) : nil;
if (mediaObj) {
IGMedia *media = sciExtractMediaFromItem(mediaObj);
if (!media && [mediaObj isKindOfClass:NSClassFromString(@"IGMedia")]) media = (IGMedia *)mediaObj;
if (media) { sciDownloadWithConfirm(^{ sciDownloadMedia(media); }); return; }
}
}
Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil;
id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil;
if (!visMedia) return nil;
[SCIUtils showErrorHUDWithDescription:@"Could not find media"];
// Video
@try {
id rawVideo = [msg valueForKey:@"rawVideo"];
if (rawVideo) {
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
if (url) { if (outIsVideo) *outIsVideo = YES; return url; }
}
} @catch (NSException *e) {}
// Photo
Ivar pi = class_getInstanceVariable([visMedia class], "_photo_photo");
id photo = pi ? object_getIvar(visMedia, pi) : nil;
if (photo) {
if (outIsVideo) *outIsVideo = NO;
return [SCIUtils getPhotoUrl:photo];
}
return nil;
}
static SCIDownloadDelegate *sciDMDownloadDelegate = nil;
static void sciDownloadDisappearingMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
sciDMDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:YES];
[sciDMDownloadDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
}
static SCIDownloadDelegate *sciDMShareDelegate = nil;
static void sciShareDisappearingMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
sciDMShareDelegate = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:YES];
[sciDMShareDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
}
static void sciExpandDisappearingMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
if (isVideo) {
[SCIMediaViewer showWithVideoURL:url photoURL:nil caption:nil];
} else {
[SCIMediaViewer showWithVideoURL:nil photoURL:url caption:nil];
}
}
// ── Story playback control ──
static void sciPauseStoryPlayback(UIView *sourceView) {
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
if (!storyVC) return;
id sc = sciFindSectionController(storyVC);
SEL pauseSel = NSSelectorFromString(@"pauseWithReason:");
if (sc && [sc respondsToSelector:pauseSel]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, pauseSel, 10);
return;
}
if ([storyVC respondsToSelector:pauseSel]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, pauseSel, 10);
return;
}
}
static void sciResumeStoryPlayback(UIView *sourceView) {
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
if (!storyVC) return;
id sc = sciFindSectionController(storyVC);
SEL resumeSel1 = NSSelectorFromString(@"tryResumePlaybackWithReason:");
SEL resumeSel2 = NSSelectorFromString(@"tryResumePlayback");
if (sc && [sc respondsToSelector:resumeSel1]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, resumeSel1, 0);
return;
}
if ([storyVC respondsToSelector:resumeSel2]) {
((void(*)(id, SEL))objc_msgSend)(storyVC, resumeSel2);
return;
}
if ([storyVC respondsToSelector:resumeSel1]) {
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, resumeSel1, 0);
return;
}
}
%hook IGStoryFullscreenOverlayView
@@ -114,18 +139,17 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
%orig;
if (!self.superview) return;
// Download button
if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) {
// Action button
if ([SCIUtils getBoolPref:@"stories_action_button"] && ![self viewWithTag:1340]) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1340;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
[btn setImage:[UIImage systemImageNamed:@"arrow.down" withConfiguration:cfg] forState:UIControlStateNormal];
[btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 18;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciDownloadTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
@@ -133,9 +157,108 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
[SCIActionButton configureButton:btn
context:SCIActionContextStories
prefKey:@"stories_action_default"
mediaProvider:^id (UIView *sourceView) {
// DM disappearing message — handle directly
UIViewController *dmVC = sciFindVC(sourceView, @"IGDirectVisualMessageViewerController");
if (dmVC) {
sciDownloadDisappearingMedia(dmVC);
return (id)kCFNull;
}
// Story path
sciPauseStoryPlayback(sourceView);
id item = sciGetCurrentStoryItem(sourceView);
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) return item;
return sciExtractMediaFromItem(item);
}];
// For DM visual messages: override menu with download/share/expand
btn.menu = [UIMenu menuWithChildren:@[
[UIDeferredMenuElement elementWithUncachedProvider:^(void (^completion)(NSArray<UIMenuElement *> *)) {
UIViewController *dmVC = sciFindVC(btn, @"IGDirectVisualMessageViewerController");
if (dmVC) {
completion(@[
[UIAction actionWithTitle:SCILocalized(@"Expand") image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"]
identifier:nil handler:^(UIAction *a) { sciExpandDisappearingMedia(dmVC); }],
[UIAction actionWithTitle:SCILocalized(@"Share") image:[UIImage systemImageNamed:@"square.and.arrow.up"]
identifier:nil handler:^(UIAction *a) { sciShareDisappearingMedia(dmVC); }],
[UIAction actionWithTitle:SCILocalized(@"Save to Photos") image:[UIImage systemImageNamed:@"square.and.arrow.down"]
identifier:nil handler:^(UIAction *a) { sciDownloadDisappearingMedia(dmVC); }],
]);
} else {
// Story — use normal action menu
id media = nil;
sciPauseStoryPlayback(btn);
id item = sciGetCurrentStoryItem(btn);
media = [item isKindOfClass:NSClassFromString(@"IGMedia")] ? item : sciExtractMediaFromItem(item);
NSArray *actions = [SCIMediaActions actionsForContext:SCIActionContextStories media:media fromView:btn];
UIMenu *built = [SCIActionMenu buildMenuWithActions:actions];
completion(built.children);
}
}]
]];
btn.showsMenuAsPrimaryAction = YES;
// KVO highlighted → resume playback when menu dismisses.
[btn addObserver:self forKeyPath:@"highlighted"
options:NSKeyValueObservingOptionNew context:NULL];
// Story reel items provider for "download all" detection.
static const void *kStoryReelItemsProvider = &kStoryReelItemsProvider;
objc_setAssociatedObject(btn, kStoryReelItemsProvider, ^NSArray *(UIView *src) {
UIViewController *storyVC = sciFindVC(src, @"IGStoryViewerViewController");
if (!storyVC) return nil;
id vm = sciCall(storyVC, @selector(currentViewModel));
if (!vm) return nil;
// Try known selectors
for (NSString *sel in @[@"items", @"storyItems", @"reelItems", @"mediaItems", @"allItems"]) {
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
@try {
id val = ((id(*)(id,SEL))objc_msgSend)(vm, NSSelectorFromString(sel));
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
return val;
}
} @catch (__unused id e) {}
}
}
// Scan vm ivars for arrays of IGMedia
Class mc = NSClassFromString(@"IGMedia");
unsigned int cnt = 0;
Ivar *ivs = class_copyIvarList(object_getClass(vm), &cnt);
for (unsigned int i = 0; i < cnt; i++) {
const char *type = ivar_getTypeEncoding(ivs[i]);
if (!type || type[0] != '@') continue;
@try {
id val = object_getIvar(vm, ivs[i]);
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
id first = [(NSArray *)val firstObject];
if (mc && [first isKindOfClass:mc]) {
free(ivs);
return val;
}
// Items might be wrapped — try extracting media from first
IGMedia *extracted = sciExtractMediaFromItem(first);
if (extracted) {
free(ivs);
return val;
}
}
} @catch (__unused id e) {}
}
if (ivs) free(ivs);
return nil;
}, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
// Audio toggle button (left side, small)
// Audio toggle button
sciInitStoryAudioState();
if ([SCIUtils getBoolPref:@"story_audio_toggle"] && ![self viewWithTag:1341]) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
@@ -168,6 +291,17 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// ============ Seen button lifecycle ============
// KVO: action button highlighted → NO means UIMenu dismissed → resume.
%new - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
change:(NSDictionary *)change context:(void *)context {
if ([keyPath isEqualToString:@"highlighted"]) {
BOOL highlighted = [change[NSKeyValueChangeNewKey] boolValue];
if (!highlighted) {
sciResumeStoryPlayback(self);
}
}
}
// Refresh the audio toggle icon (tag 1341) to match current state.
%new - (void)sciRefreshAudioButton {
UIButton *btn = (UIButton *)[self viewWithTag:1341];
@@ -304,33 +438,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
[sender setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
}
// ============ Download handler ============
%new - (void)sciDownloadTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); }
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
@try {
id item = sciGetCurrentStoryItem(self);
IGMedia *media = sciExtractMediaFromItem(item);
if (media) {
sciDownloadWithConfirm(^{ sciDownloadMedia(media); });
return;
}
UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController");
if (dmVC) {
sciDownloadDMVisualMessage(dmVC);
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not find media"];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
}
}
// ============ Seen button tap ============
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
@@ -343,19 +450,19 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
if (bs && !inList && ownerPK) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:[NSString stringWithFormat:@"Story seen receipts will be blocked for @%@.", ownerInfo[@"username"] ?: @""]
alertControllerWithTitle:SCILocalized(@"Add to block list?")
message:[NSString stringWithFormat:SCILocalized(@"Story seen receipts will be blocked for @%@."), ownerInfo[@"username"] ?: @""]
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": ownerPK,
@"username": ownerInfo[@"username"] ?: @"",
@"fullName": ownerInfo[@"fullName"] ?: @""
}];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
@@ -369,18 +476,18 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// Block all + in list: tap to remove from exclude list
if (inList) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude story seen?";
NSString *alertTitle = bs ? SCILocalized(@"Remove from block list?") : SCILocalized(@"Un-exclude story seen?");
NSString *alertMsg = bs ? [NSString stringWithFormat:@"@%@ will no longer have seen receipts blocked.", ownerInfo[@"username"] ?: @""]
: [NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:bs ? @"Unblock" : @"Un-exclude" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:bs ? SCILocalized(@"Unblock") : SCILocalized(@"Un-exclude") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers removePK:ownerPK];
[SCIUtils showToastForDuration:2.0 title:bs ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
if (bs) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
@@ -391,7 +498,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
[sender setImage:[UIImage systemImageNamed:(sciStorySeenToggleEnabled ? @"eye.fill" : @"eye") withConfiguration:cfg] forState:UIControlStateNormal];
sender.tintColor = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? @"Story read receipts enabled" : @"Story read receipts disabled"];
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")];
return;
}
@@ -406,6 +513,9 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
UIView *btn = gr.view;
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
if (!host) return;
// Pause story while the sheet is open
sciPauseStoryPlayback(self);
UIWindow *capturedWin = btn.window ?: self.window;
if (!capturedWin) {
for (UIWindow *w in [UIApplication sharedApplication].windows) { if (w.isKeyWindow) { capturedWin = w; break; } }
@@ -417,31 +527,35 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk];
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
__weak UIView *weakSelf = self;
void (^resume)(void) = ^{ if (weakSelf) sciResumeStoryPlayback(weakSelf); };
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
[sheet addAction:[UIAlertAction actionWithTitle:@"Mark seen" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Mark seen") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn);
resume();
}]];
if (pk) {
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen");
NSString *t = inList ? removeLabel : addLabel;
[sheet addAction:[UIAlertAction actionWithTitle:t style:inList ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
resume();
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Stories settings" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIUtils showSettingsVC:capturedWin atTopLevelEntry:@"Stories"];
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:^(UIAlertAction *_) {
resume();
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.sourceView = btn;
sheet.popoverPresentationController.sourceRect = btn.bounds;
[host presentViewController:sheet animated:YES completion:nil];
@@ -466,7 +580,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
if (!storyItem) storyItem = sciGetCurrentStoryItem(self);
IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem);
if (!media) { [SCIUtils showErrorHUDWithDescription:@"Could not find story media"]; return; }
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; }
sciAllowSeenForPK(media);
sciSeenBypassActive = YES;
@@ -496,7 +610,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
}
}
sciSeenBypassActive = NO;
[SCIUtils showToastForDuration:2.0 title:@"Marked as seen" subtitle:@"Will sync when leaving stories"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked as seen") subtitle:SCILocalized(@"Will sync when leaving stories")];
// Advance to next story if enabled (skip when triggered programmatically via exclude)
if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) {
@@ -561,13 +675,13 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
dmVisualMsgsViewedButtonEnabled = wasEnabled;
});
[SCIUtils showToastForDuration:1.5 title:@"Marked as viewed"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")];
return;
}
[SCIUtils showErrorHUDWithDescription:@"VC not found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]];
}
}
+108 -64
View File
@@ -73,7 +73,7 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
// Re-runs setRightBarButtonItems with the live items. The hook tags its own
// buttons so they get stripped and rebuilt against the new exclusion state.
static void sciRefreshNavBarItems(UIView *anchor) {
void sciRefreshNavBarItems(UIView *anchor) {
if (!anchor || ![anchor respondsToSelector:@selector(setRightBarButtonItems:)]) return;
NSArray *cur = [(id)anchor performSelector:@selector(rightBarButtonItems)];
[(id)anchor performSelector:@selector(setRightBarButtonItems:) withObject:cur];
@@ -92,37 +92,50 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (seenFeatureOn && !excluded) {
BOOL toggleMode = sciIsSeenToggleMode();
NSString *title;
UIImage *img;
// Toggle mode: show toggle action + one-shot mark seen
if (toggleMode) {
title = dmSeenToggleEnabled ? @"Disable read receipts" : @"Enable read receipts";
img = [UIImage systemImageNamed:dmSeenToggleEnabled ? @"eye.slash" : @"eye"];
} else {
title = @"Mark messages as seen";
img = [UIImage systemImageNamed:@"eye"];
}
UIAction *seenAction = [UIAction actionWithTitle:title image:img identifier:nil
handler:^(__kindof UIAction *_) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if (![nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) return;
if (toggleMode) {
NSString *toggleTitle = dmSeenToggleEnabled ? SCILocalized(@"Disable read receipts") : SCILocalized(@"Enable read receipts");
UIImage *toggleImg2 = [UIImage systemImageNamed:@"arrow.triangle.2.circlepath"];
UIAction *toggleAction = [UIAction actionWithTitle:toggleTitle image:toggleImg2 identifier:nil
handler:^(__kindof UIAction *_) {
dmSeenToggleEnabled = !dmSeenToggleEnabled;
if (dmSeenToggleEnabled) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if (dmSeenToggleEnabled && [nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:@"Read receipts enabled"];
} else {
[SCIUtils showToastForDuration:2.0 title:@"Read receipts disabled"];
}
} else {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:@"Marked messages as seen"];
}
}];
[items addObject:seenAction];
[SCIUtils showToastForDuration:2.0 title:dmSeenToggleEnabled ? SCILocalized(@"Read receipts enabled") : SCILocalized(@"Read receipts disabled")];
sciRefreshNavBarItems(anchor);
}];
toggleAction.state = dmSeenToggleEnabled ? UIMenuElementStateOn : UIMenuElementStateOff;
[items addObject:toggleAction];
UIAction *markSeen = [UIAction actionWithTitle:SCILocalized(@"Mark messages as seen")
image:[UIImage systemImageNamed:@"checkmark.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked messages as seen")];
}];
[items addObject:markSeen];
} else {
// Button mode: just mark seen
UIAction *seenAction = [UIAction actionWithTitle:SCILocalized(@"Mark messages as seen")
image:[UIImage systemImageNamed:@"checkmark.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked messages as seen")];
}];
[items addObject:seenAction];
}
}
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude chat");
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude chat");
NSString *toggleTitle = inList ? removeLabel : addLabel;
UIImage *toggleImg = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
__weak UIView *weakAnchor = anchor;
@@ -131,7 +144,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (!threadId) return;
if (inList) {
[SCIExcludedThreads removeThreadId:threadId];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
// In block_selected, removing = normal behavior → mark seen
if (blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
@@ -143,7 +156,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
NSDictionary *entry = sciEntryFromThreadVC(anchorVC);
if (!entry) entry = @{ @"threadId": threadId, @"threadName": @"", @"isGroup": @NO, @"users": @[] };
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
// In block_all, excluding = normal behavior → mark seen
if (!blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
@@ -156,7 +169,25 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (excluded) toggle.attributes = UIMenuElementAttributesDestructive;
[items addObject:toggle];
UIAction *openSettings = [UIAction actionWithTitle:@"Messages settings"
// Unlimited replay toggle
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !excluded) {
NSString *replayTitle = dmVisualMsgsViewedButtonEnabled
? SCILocalized(@"Visual messages: expiring")
: SCILocalized(@"Visual messages: unlimited replay");
UIImage *replayImg = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled
? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
UIAction *replayAction = [UIAction actionWithTitle:replayTitle image:replayImg identifier:nil
handler:^(__kindof UIAction *_) {
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
sciRefreshNavBarItems(anchor);
}];
replayAction.state = dmVisualMsgsViewedButtonEnabled ? UIMenuElementStateOff : UIMenuElementStateOn;
[items addObject:replayAction];
}
UIAction *openSettings = [UIAction actionWithTitle:SCILocalized(@"Messages settings")
image:[UIImage systemImageNamed:@"gear"]
identifier:nil
handler:^(__kindof UIAction *_) {
@@ -213,16 +244,16 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
NSDictionary *entry = sciEntryFromThreadVC(nearestVC);
if (!entry) return;
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:@"Read receipts will be blocked for this chat."
alertControllerWithTitle:SCILocalized(@"Add to block list?")
message:SCILocalized(@"Read receipts will be blocked for this chat.")
preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[nearestVC presentViewController:alert animated:YES completion:nil];
}
@@ -232,30 +263,40 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
if (!tid) return;
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude chat?";
NSString *alertMsg = bs ? @"Read receipts will no longer be blocked for this chat."
: @"This chat will resume normal read-receipt behavior.";
NSString *alertTitle = bs ? SCILocalized(@"Remove from block list?") : SCILocalized(@"Un-exclude chat?");
NSString *alertMsg = bs ? SCILocalized(@"Read receipts will no longer be blocked for this chat.")
: SCILocalized(@"This chat will resume normal read-receipt behavior.");
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Remove") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedThreads removeThreadId:tid];
[SCIUtils showToastForDuration:2.0 title:@"Removed"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Removed")];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[nearestVC presentViewController:alert animated:YES completion:nil];
}
- (void)setRightBarButtonItems:(NSArray <UIBarButtonItem *> *)items {
// Strip our own injected buttons so re-running this hook doesn't dupe them.
// Strip our own injected buttons (so re-runs don't dupe) and drop
// IGDirectCallButton-backed items when their hide pref is on — some
// account variants bundle them into the same platter as our eye btn.
BOOL hideVoice = [SCIUtils getBoolPref:@"hide_voice_call_button"];
BOOL hideVideo = [SCIUtils getBoolPref:@"hide_video_call_button"];
BOOL hideBlend = [SCIUtils getBoolPref:@"hide_reels_blend"];
NSMutableArray *new_items = [[items filteredArrayUsingPredicate:
[NSPredicate predicateWithBlock:^BOOL(UIBarButtonItem *value, NSDictionary *_) {
NSString *aid = value.accessibilityIdentifier;
if ([aid isEqualToString:@"sci-seen-btn"] ||
[aid isEqualToString:@"sci-unex-btn"] ||
[aid isEqualToString:@"sci-visual-btn"]) return NO;
if ([SCIUtils getBoolPref:@"hide_reels_blend"])
return ![aid isEqualToString:@"blend-button"];
if (hideBlend && [aid isEqualToString:@"blend-button"]) return NO;
UIView *cv = value.customView;
if (cv && [cv isKindOfClass:NSClassFromString(@"IGDirectCallButton")]) {
NSString *cvAx = cv.accessibilityIdentifier;
if (hideVoice && [cvAx isEqualToString:@"audio-call"]) return NO;
if (hideVideo && [cvAx isEqualToString:@"video-chat"]) return NO;
}
return YES;
}]
] mutableCopy];
@@ -298,11 +339,15 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
[new_items addObject:listBtn];
}
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded) {
UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)];
dmVisualMsgsViewedButton.accessibilityIdentifier = @"sci-visual-btn";
[new_items addObject:dmVisualMsgsViewedButton];
[dmVisualMsgsViewedButton setTintColor:dmVisualMsgsViewedButtonEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
// Replay toggle: in eye menu when eye button exists, standalone button otherwise
BOOL eyeButtonOn = [SCIUtils getBoolPref:@"remove_lastseen"];
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded && !eyeButtonOn) {
UIBarButtonItem *replayBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"]
style:UIBarButtonItemStylePlain target:self action:@selector(sciReplayToggleHandler:)];
replayBtn.accessibilityIdentifier = @"sci-visual-btn";
replayBtn.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
[new_items addObject:replayBtn];
}
%orig([new_items copy]);
@@ -318,32 +363,31 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.5 title:@"Read receipts enabled"];
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Read receipts enabled")];
} else {
[SCIUtils showToastForDuration:2.5 title:@"Read receipts disabled"];
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Read receipts disabled")];
}
} else {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.5 title:@"Marked messages as seen"];
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Marked messages as seen")];
}
}
// Rebuild menu so toggle text updates
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *tid = sciThreadIdForVC(navNearestVC);
sender.menu = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window);
}
// ============ DM VISUAL MESSAGES VIEWED BUTTON ============
%new - (void)dmVisualMsgsViewedButtonHandler:(UIBarButtonItem *)sender {
if (dmVisualMsgsViewedButtonEnabled) {
dmVisualMsgsViewedButtonEnabled = false;
[sender setTintColor:UIColor.labelColor];
[SCIUtils showToastForDuration:4.5 title:@"Visual messages can be replayed without expiring"];
} else {
dmVisualMsgsViewedButtonEnabled = true;
[sender setTintColor:SCIUtils.SCIColor_Primary];
[SCIUtils showToastForDuration:4.5 title:@"Visual messages will now expire after viewing"];
}
%new - (void)sciReplayToggleHandler:(UIBarButtonItem *)sender {
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
sender.image = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
sender.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
}
%end
// ============ SEEN BLOCKING LOGIC ============
@@ -0,0 +1,148 @@
// Mark seen + advance when replying or reacting to a story.
#import "../../Utils.h"
#import "StoryHelpers.h"
#import <objc/message.h>
#import <objc/runtime.h>
#import <substrate.h>
extern __weak UIViewController *sciActiveStoryVC;
extern BOOL sciAdvanceBypassActive;
static UIView *sciFindOverlayForStoryVC(UIViewController *vc) {
if (!vc) return nil;
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) return nil;
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
while (stack.count) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ([v isKindOfClass:overlayCls]) return v;
for (UIView *s in v.subviews) [stack addObject:s];
}
return nil;
}
static void sciMarkSeenOnReply(void) {
if (![SCIUtils getBoolPref:@"seen_on_story_reply"]) return;
UIView *overlay = sciFindOverlayForStoryVC(sciActiveStoryVC);
if (!overlay) return;
SEL sel = @selector(sciMarkSeenTapped:);
if ([overlay respondsToSelector:sel])
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
}
static uint64_t sciLastReplyAdvanceTime = 0;
static void sciAdvanceOnReply(void) {
if (![SCIUtils getBoolPref:@"advance_on_story_reply"]) return;
UIViewController *storyVC = sciActiveStoryVC;
if (!storyVC) return;
id sectionCtrl = sciFindSectionController(storyVC);
if (!sectionCtrl) return;
// Dedup across multiple hooks firing for the same event
uint64_t now = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
if (now - sciLastReplyAdvanceTime < 500000000ULL) return;
sciLastReplyAdvanceTime = now;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciAdvanceBypassActive = YES;
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
if ([sectionCtrl respondsToSelector:advSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sectionCtrl, advSel, 1);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
id sc2 = storyVC ? sciFindSectionController(storyVC) : nil;
if (sc2) {
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
if ([sc2 respondsToSelector:resumeSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
}
sciAdvanceBypassActive = NO;
});
});
}
static void sciOnStoryReply(void) {
sciMarkSeenOnReply();
sciAdvanceOnReply();
}
// Text reply — IGDirectComposer is shared with DMs, gate by active story VC.
%hook IGDirectComposer
- (void)_didTapSend:(id)arg {
%orig;
if (sciActiveStoryVC) sciOnStoryReply();
}
- (void)_send {
%orig;
if (sciActiveStoryVC) sciOnStoryReply();
}
%end
// Composer emoji reaction buttons (forwarded to the Swift footer delegate)
static void (*orig_footerEmojiQuick)(id, SEL, id, id);
static void new_footerEmojiQuick(id self, SEL _cmd, id inputView, id btn) {
orig_footerEmojiQuick(self, _cmd, inputView, btn);
sciOnStoryReply();
}
static void (*orig_footerEmojiReaction)(id, SEL, id, id);
static void new_footerEmojiReaction(id self, SEL _cmd, id inputView, id btn) {
orig_footerEmojiReaction(self, _cmd, inputView, btn);
sciOnStoryReply();
}
// Swipe-up quick reactions tray
static void (*orig_qrCtrlDidTapEmoji)(id, SEL, id, id, id);
static void new_qrCtrlDidTapEmoji(id self, SEL _cmd, id view, id sourceBtn, id emoji) {
orig_qrCtrlDidTapEmoji(self, _cmd, view, sourceBtn, emoji);
sciOnStoryReply();
}
static void (*orig_qrDelegateDidTapEmoji)(id, SEL, id, id, id);
static void new_qrDelegateDidTapEmoji(id self, SEL _cmd, id ctrl, id sourceBtn, id emoji) {
orig_qrDelegateDidTapEmoji(self, _cmd, ctrl, sourceBtn, emoji);
sciOnStoryReply();
}
// Swift classes aren't guaranteed to be registered at %ctor time — install
// lazily on first overlay appearance as a fallback.
static void sciInstallReplyHooks(void) {
static BOOL installed = NO;
if (installed) return;
Class footerCls = NSClassFromString(@"IGStoryDefaultFooter.IGStoryFullscreenDefaultFooterView");
Class qrCtrl = NSClassFromString(@"IGStoryQuickReactions.IGStoryQuickReactionsController");
Class qrDelegate = NSClassFromString(@"IGStoryQuickReactionsDelegate.IGStoryQuickReactionsDelegateImpl");
if (!footerCls || !qrCtrl || !qrDelegate) return;
installed = YES;
SEL quick = NSSelectorFromString(@"inputView:didTapEmojiQuickReactionButton:");
if (class_getInstanceMethod(footerCls, quick))
MSHookMessageEx(footerCls, quick, (IMP)new_footerEmojiQuick, (IMP *)&orig_footerEmojiQuick);
SEL reaction = NSSelectorFromString(@"inputView:didTapEmojiReactionButton:");
if (class_getInstanceMethod(footerCls, reaction))
MSHookMessageEx(footerCls, reaction, (IMP)new_footerEmojiReaction, (IMP *)&orig_footerEmojiReaction);
SEL qrSel = NSSelectorFromString(@"quickReactionsView:sourceEmojiButton:didTapEmoji:");
if (class_getInstanceMethod(qrCtrl, qrSel))
MSHookMessageEx(qrCtrl, qrSel, (IMP)new_qrCtrlDidTapEmoji, (IMP *)&orig_qrCtrlDidTapEmoji);
SEL qrdSel = NSSelectorFromString(@"storyQuickReactionsController:sourceEmojiButton:didTapEmoji:");
if (class_getInstanceMethod(qrDelegate, qrdSel))
MSHookMessageEx(qrDelegate, qrdSel, (IMP)new_qrDelegateDidTapEmoji, (IMP *)&orig_qrDelegateDidTapEmoji);
}
%hook IGStoryFullscreenOverlayView
- (void)didMoveToWindow {
%orig;
sciInstallReplyHooks();
}
%end
%ctor {
sciInstallReplyHooks();
}
@@ -5,6 +5,7 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../SCIFFmpeg.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <AVFoundation/AVFoundation.h>
@@ -77,26 +78,26 @@ static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
if ([threadVC respondsToSelector:vmSel]) {
typedef void (*Fn)(id, SEL, id, id, double, NSInteger, id);
((Fn)objc_msgSend)(threadVC, vmSel, audioURL, waveform, duration, (NSInteger)2, nil);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
SEL s7 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:aiVoiceEffectApplied:sendButtonTypeTapped:);
if ([threadVC respondsToSelector:s7]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger, id, id);
((Fn)objc_msgSend)(threadVC, s7, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2, nil, nil);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
SEL s5 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:);
if ([threadVC respondsToSelector:s5]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger);
((Fn)objc_msgSend)(threadVC, s5, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
[SCIUtils showErrorHUDWithDescription:@"No voice send method found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No voice send method found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Send failed: %@", e.reason]];
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Send failed: %@"), e.reason]];
}
}
@@ -121,10 +122,10 @@ static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewControll
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Send anyway" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Send anyway") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
sciSendAudioFile(url, weakVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Open GitHub" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Open GitHub") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[[UIApplication sharedApplication]
openURL:[NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram/issues"]
options:@{} completionHandler:nil];
@@ -135,19 +136,38 @@ static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewControll
[presenter presentViewController:alert animated:YES completion:nil];
}
static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
// FFmpeg path: any format → AAC M4A, with optional trim
static void sciFFmpegConvertAndSend(NSURL *url, UIViewController *threadVC, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
// Allowlisted formats skip AVFoundation entirely; trim is ignored since
// AVFoundation can't read their timelines anyway.
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_ffaudio_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
[SCIUtils showToastForDuration:1.5 title:isVideo ? @"Extracting audio..." : @"Converting..."];
NSMutableString *cmd = [NSMutableString stringWithFormat:@"-y -i \"%@\"", url.path];
if (hasTrim) {
double ss = CMTimeGetSeconds(trimRange.start);
double dur = CMTimeGetSeconds(trimRange.duration);
[cmd appendFormat:@" -ss %.3f -t %.3f", ss, dur];
}
[cmd appendFormat:@" -vn -c:a aac -b:a 128k -ar 44100 -ac 1 \"%@\"", out];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
} else {
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
}
});
}];
}
// AVFoundation fallback for iOS-native formats
static void sciAVFoundationConvertAndSend(NSURL *url, UIViewController *threadVC, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
dispatch_async(dispatch_get_global_queue(0, 0), ^{
AVAsset *asset = [AVAsset assetWithURL:url];
@@ -192,9 +212,36 @@ static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVide
});
}
// Extensions IG accepts as voice messages without conversion. Append after testing.
// m4a/aac — native iOS recording format
// ogg/opus — what web/desktop IG sends
static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
// Passthrough formats IG accepts directly (no conversion needed, trim ignored)
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && !hasTrim && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
[SCIUtils showToastForDuration:1.5 title:isVideo ? SCILocalized(@"Extracting audio...") : SCILocalized(@"Converting...")];
// FFmpeg handles any format + video→audio extraction
if ([SCIFFmpeg isAvailable]) {
sciFFmpegConvertAndSend(url, threadVC, trimRange);
return;
}
// Passthrough without trim when no FFmpeg
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
// AVFoundation fallback
sciAVFoundationConvertAndSend(url, threadVC, trimRange);
}
// Formats IG accepts as-is (no conversion needed)
static NSSet<NSString *> *sciPassthroughAudioExts(void) {
static NSSet *set;
static dispatch_once_t once;
@@ -261,7 +308,7 @@ static const CGFloat kTrackMargin = 24.0;
sendBtn.frame = CGRectMake(kTrackMargin, bottomY - 56, w - kTrackMargin * 2, 50);
sendBtn.backgroundColor = [UIColor systemBlueColor];
sendBtn.layer.cornerRadius = 14;
[sendBtn setTitle:@"Send Audio" forState:UIControlStateNormal];
[sendBtn setTitle:SCILocalized(@"Send Audio") forState:UIControlStateNormal];
[sendBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
sendBtn.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
[sendBtn addTarget:self action:@selector(sendTapped) forControlEvents:UIControlEventTouchUpInside];
@@ -364,7 +411,7 @@ static const CGFloat kTrackMargin = 24.0;
self.durationLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.3];
self.durationLabel.font = [UIFont systemFontOfSize:12];
self.durationLabel.textAlignment = NSTextAlignmentCenter;
self.durationLabel.text = [NSString stringWithFormat:@"Total: %@", [self formatTime:self.totalDuration]];
self.durationLabel.text = [NSString stringWithFormat:SCILocalized(@"Total: %@"), [self formatTime:self.totalDuration]];
[self.view addSubview:self.durationLabel];
// ── cancel X button (top-left) ──
@@ -532,7 +579,7 @@ static const CGFloat kTrackMargin = 24.0;
[self stopPlayback];
double dur = self.endTime - self.startTime;
if (dur < 0.5) {
[SCIUtils showErrorHUDWithDescription:@"Selection too short (min 0.5s)"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Selection too short (min 0.5s)")];
return;
}
@@ -564,28 +611,30 @@ static void sciShowTrimVC(NSURL *url, BOOL isVideo, UIViewController *threadVC)
static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciAudioThreadVC = threadVC;
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Upload Audio"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Upload Audio")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Audio/Video from Files" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Audio/Video from Files") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
NSArray *types = [SCIFFmpeg isAvailable]
? @[@"public.audio", @"public.audiovisual-content"]
: @[@"public.audio", @"public.mpeg-4-audio", @"public.mp3", @"com.microsoft.waveform-audio",
@"public.aiff-audio", @"com.apple.m4a-audio",
@"public.movie", @"public.mpeg-4", @"com.apple.quicktime-movie"];
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
initWithDocumentTypes:@[@"public.audio", @"public.mpeg-4-audio", @"public.mp3", @"com.microsoft.waveform-audio",
@"public.aiff-audio", @"com.apple.m4a-audio",
@"public.movie", @"public.mpeg-4", @"com.apple.quicktime-movie"]
inMode:UIDocumentPickerModeImport];
initWithDocumentTypes:types inMode:UIDocumentPickerModeImport];
#pragma clang diagnostic pop
picker.delegate = (id<UIDocumentPickerDelegate>)vc;
[vc presentViewController:picker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Video from Library" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Video from Library") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
UIImagePickerController *imgPicker = [[UIImagePickerController alloc] init];
@@ -597,7 +646,7 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
[vc presentViewController:imgPicker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[threadVC presentViewController:alert animated:YES completion:nil];
}
@@ -654,23 +703,52 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciDMMenuPending = YES;
}
// file picker delegate — show trim UI
// Convert unsupported formats to M4A before showing trim UI
static void sciPrepareAndShowTrim(NSURL *url, UIViewController *threadVC) {
AVAsset *asset = [AVAsset assetWithURL:url];
double dur = CMTimeGetSeconds(asset.duration);
BOOL avCanRead = dur > 0 && !isnan(dur);
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
if (avCanRead) {
sciShowTrimVC(url, isVideo, threadVC);
return;
}
// AVFoundation can't read it — pre-convert with FFmpeg
if ([SCIFFmpeg isAvailable]) {
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Converting...")];
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_pre_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
NSString *cmd = [NSString stringWithFormat:@"-y -i \"%@\" -vn -c:a aac -b:a 128k -ar 44100 \"%@\"", url.path, out];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
sciShowTrimVC([NSURL fileURLWithPath:out], NO, threadVC);
} else {
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
}
});
}];
return;
}
// No FFmpeg, can't read — unsupported
sciShowUnsupportedAlert(url, @"Format not supported without FFmpegKit", threadVC);
}
// File picker delegate
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
NSURL *url = urls.firstObject;
if (!url) return;
// detect if it's a video file
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url {
if (!url) return;
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {}
@@ -680,7 +758,7 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
[picker dismissViewControllerAnimated:YES completion:nil];
NSURL *videoURL = info[UIImagePickerControllerMediaURL];
if (!videoURL) {
[SCIUtils showErrorHUDWithDescription:@"Could not get video URL"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not get video URL")];
return;
}
// UIImagePickerController with allowsEditing already trimmed the video for us
+103
View File
@@ -0,0 +1,103 @@
// Send files in DMs — adds a "Send File" option to the plus menu.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
static BOOL sciFileMenuPending = NO;
static __weak UIViewController *sciFileThreadVC = nil;
@interface _SCIFilePickerDelegate : NSObject <UIDocumentPickerDelegate>
@property (nonatomic, weak) UIViewController *threadVC;
@end
static _SCIFilePickerDelegate *sciFilePickerDelegate = nil;
@implementation _SCIFilePickerDelegate
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
NSURL *url = urls.firstObject;
if (!url || !self.threadVC) return;
id msgSenderFC = nil;
@try { msgSenderFC = [self.threadVC valueForKey:@"messageSenderFeatureController"]; } @catch (__unused id e) {}
if (!msgSenderFC) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Message sender not found")]; return; }
id sender = nil;
@try { sender = [msgSenderFC valueForKey:@"messageSender"]; } @catch (__unused id e) {}
if (!sender) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Send service not found")]; return; }
SEL sendSel = NSSelectorFromString(@"sendFileWithURL:threadKey:attribution:replyMessagePk:quotedPublishedMessage:messageSentSpeedLogger:messageSentSpeedMarker:localSendSpeedLogger:localSendSpeedMarker:");
if (![sender respondsToSelector:sendSel]) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"File sending not supported")]; return; }
id threadKey = nil;
@try { threadKey = [self.threadVC valueForKey:@"threadKey"]; } @catch (__unused id e) {}
if (!threadKey) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No thread key")]; return; }
typedef void (*SendFn)(id, SEL, id, id, id, id, id, id, id, id, id);
((SendFn)objc_msgSend)(sender, sendSel, url, threadKey, nil, nil, nil, nil, nil, nil, nil);
}
@end
static void sciShowFilePicker(UIViewController *threadVC) {
sciFilePickerDelegate = [_SCIFilePickerDelegate new];
sciFilePickerDelegate.threadVC = threadVC;
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
initWithDocumentTypes:@[@"public.data"] inMode:UIDocumentPickerModeImport];
picker.delegate = sciFilePickerDelegate;
picker.allowsMultipleSelection = NO;
[threadVC presentViewController:picker animated:YES completion:nil];
}
// MARK: - Plus menu injection
%hook IGDSMenu
- (id)initWithMenuItems:(NSArray *)items edr:(BOOL)edr headerLabelText:(id)header {
if (![SCIUtils getBoolPref:@"send_file"] || !sciFileMenuPending) return %orig;
sciFileMenuPending = NO;
for (id item in items) {
if ([item respondsToSelector:@selector(title)]) {
id title = [item valueForKey:@"title"];
if ([title isKindOfClass:[NSString class]] && [title isEqualToString:@"Send File"]) return %orig;
}
}
Class itemClass = NSClassFromString(@"IGDSMenuItem");
if (!itemClass) return %orig;
UIImage *img = [[UIImage systemImageNamed:@"doc"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
void (^handler)(void) = ^{
if (sciFileThreadVC) sciShowFilePicker(sciFileThreadVC);
};
SEL initSel = @selector(initWithTitle:image:handler:);
if (![itemClass instancesRespondToSelector:initSel]) return %orig;
typedef id (*InitFn)(id, SEL, id, id, id);
id fileItem = ((InitFn)objc_msgSend)([itemClass alloc], initSel, @"Send File", img, handler);
if (!fileItem) return %orig;
NSMutableArray *newItems = [NSMutableArray arrayWithObject:fileItem];
[newItems addObjectsFromArray:items];
return %orig(newItems, edr, header);
}
%end
// MARK: - Thread VC hook
%hook IGDirectThreadViewController
- (void)composerOverflowButtonMenuWillPrepareExpandWithPlusButton:(id)plusButton {
%orig;
if (![SCIUtils getBoolPref:@"send_file"]) return;
sciFileThreadVC = self;
sciFileMenuPending = YES;
}
%end
@@ -118,7 +118,7 @@ extern "C" NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *items) {
if (!menuItemCls) return items;
BOOL on = sciIGAudioEnabled();
NSString *title = on ? @"Mute story audio" : @"Unmute story audio";
NSString *title = on ? SCILocalized(@"Mute story audio") : SCILocalized(@"Unmute story audio");
void (^handler)(void) = ^{ sciToggleStoryAudio(); };
id newItem = nil;
@@ -0,0 +1,523 @@
// View story mentions — list mentioned users for the current story item.
// Reachable via eye long-press menu and the 3-dot story menu.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../Networking/SCIInstagramAPI.h"
#import "StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
extern __weak UIViewController *sciActiveStoryViewerVC;
// Forward decl — defined below.
static id sciFieldCacheValue(id obj, NSString *key);
static NSString *sciUserPK(id userObj) {
if (!userObj) return nil;
id pk = sciFieldCacheValue(userObj, @"strong_id__");
if (!pk) pk = sciFieldCacheValue(userObj, @"pk");
if (!pk) {
@try {
Ivar pkIvar = class_getInstanceVariable([userObj class], "_pk");
if (pkIvar) pk = object_getIvar(userObj, pkIvar);
} @catch (__unused id e) {}
}
return pk ? [NSString stringWithFormat:@"%@", pk] : nil;
}
static void sciStyleFollowBtn(UIButton *btn, BOOL following) {
[btn setTitle:following ? SCILocalized(@"Following") : SCILocalized(@"Follow") forState:UIControlStateNormal];
btn.backgroundColor = following ? [UIColor tertiarySystemFillColor] : [UIColor systemBlueColor];
[btn setTitleColor:following ? [UIColor labelColor] : [UIColor whiteColor] forState:UIControlStateNormal];
}
// ============ Mention extraction ============
static NSArray *sciCurrentStoryMentions(UIView *anchor) {
UIViewController *storyVC = nil;
if (anchor) storyVC = sciFindVC(anchor, @"IGStoryViewerViewController");
if (!storyVC) storyVC = sciActiveStoryViewerVC;
if (!storyVC) return nil;
UIResponder *start = anchor ?: (UIResponder *)storyVC.view;
id item = sciGetCurrentStoryItem(start);
IGMedia *media = nil;
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) {
media = (IGMedia *)item;
} else {
media = sciExtractMediaFromItem(item);
}
if (!media) {
@try {
id sc = sciFindSectionController(storyVC);
if (sc) {
SEL csi = NSSelectorFromString(@"currentStoryItem");
if ([sc respondsToSelector:csi])
media = ((id(*)(id,SEL))objc_msgSend)(sc, csi);
}
} @catch (__unused id e) {}
}
if (!media) {
@try {
id vm = sciCall(storyVC, @selector(currentViewModel));
id storyItem = sciCall1(storyVC, @selector(currentStoryItemForViewModel:), vm);
if ([storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) {
media = (IGMedia *)storyItem;
} else {
media = sciExtractMediaFromItem(storyItem);
}
} @catch (__unused id e) {}
}
if (!media) return nil;
SEL sel = NSSelectorFromString(@"reelMentions");
if (![media respondsToSelector:sel]) return nil;
return ((id(*)(id,SEL))objc_msgSend)(media, sel);
}
// IGUser stores fields in a Pando-backed dictionary. KVC goes through a
// resolver that returns NSNull for many keys, so we read the dict directly.
static id sciFieldCacheValue(id obj, NSString *key) {
if (!obj || !key) return nil;
static Ivar fcIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGAPIStorableObject");
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
});
if (!fcIvar) return nil;
NSDictionary *fc = object_getIvar(obj, fcIvar);
if (!fc) return nil;
id val = fc[key];
if (!val || [val isKindOfClass:[NSNull class]]) return nil;
return val;
}
static NSDictionary *sciMentionUserInfo(id mention) {
if (!mention) return nil;
NSMutableDictionary *info = [NSMutableDictionary dictionary];
@try {
id user = [mention valueForKey:@"user"];
if (!user) return nil;
info[@"userObj"] = user;
NSString *username = sciFieldCacheValue(user, @"username");
if (username.length) info[@"username"] = username;
NSString *fullName = sciFieldCacheValue(user, @"full_name");
if (fullName.length) info[@"fullName"] = fullName;
NSString *picStr = sciFieldCacheValue(user, @"profile_pic_url");
if (picStr.length) {
NSURL *picURL = [NSURL URLWithString:picStr];
if (picURL) info[@"picURL"] = picURL;
}
} @catch (__unused id e) {}
return info.count > 1 ? [info copy] : nil;
}
// ============ Bottom sheet VC ============
#define kAvatarSize 52.0
#define kRowHeight 72.0
@interface SCIStoryMentionsVC : UIViewController <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, strong) NSArray<NSDictionary *> *userInfos;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) NSString *currentUsername;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSDictionary *> *friendshipStatuses;
@end
@implementation SCIStoryMentionsVC
- (void)viewDidLoad {
[super viewDidLoad];
@try {
id window = [[UIApplication sharedApplication] keyWindow];
if ([window respondsToSelector:@selector(userSession)])
self.currentUsername = ((IGUserSession *)[window valueForKey:@"userSession"]).user.username;
} @catch (__unused id e) {}
UIColor *bg = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) {
return tc.userInterfaceStyle == UIUserInterfaceStyleDark
? [UIColor colorWithRed:0.09 green:0.09 blue:0.09 alpha:1]
: [UIColor colorWithRed:0.98 green:0.98 blue:0.98 alpha:1];
}];
self.view.backgroundColor = bg;
UILabel *titleLabel = [[UILabel alloc] init];
titleLabel.text = SCILocalized(@"Mentions");
titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
titleLabel.textColor = [UIColor labelColor];
titleLabel.textAlignment = NSTextAlignmentCenter;
titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
UIButton *closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
UIImage *closeImg = [UIImage systemImageNamed:@"xmark"
withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:15
weight:UIImageSymbolWeightSemibold]];
[closeBtn setImage:closeImg forState:UIControlStateNormal];
closeBtn.tintColor = [UIColor secondaryLabelColor];
closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
[closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
UIView *sep = [[UIView alloc] init];
sep.backgroundColor = [UIColor separatorColor];
sep.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView.backgroundColor = bg;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleSingleLine;
self.tableView.separatorColor = [UIColor separatorColor];
self.tableView.separatorInset = UIEdgeInsetsMake(0, 16 + kAvatarSize + 14, 0, 0);
self.tableView.rowHeight = kRowHeight;
[self.view addSubview:titleLabel];
[self.view addSubview:closeBtn];
[self.view addSubview:sep];
[self.view addSubview:self.tableView];
[NSLayoutConstraint activateConstraints:@[
[titleLabel.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:22],
[titleLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[closeBtn.centerYAnchor constraintEqualToAnchor:titleLabel.centerYAnchor],
[closeBtn.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16],
[closeBtn.widthAnchor constraintEqualToConstant:30],
[closeBtn.heightAnchor constraintEqualToConstant:30],
[sep.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:14],
[sep.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[sep.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[sep.heightAnchor constraintEqualToConstant:1.0 / [UIScreen mainScreen].scale],
[self.tableView.topAnchor constraintEqualToAnchor:sep.bottomAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
]];
// Bulk-fetch friendship statuses for all mentions in one round trip.
self.friendshipStatuses = [NSMutableDictionary dictionary];
NSMutableArray *pks = [NSMutableArray array];
for (NSDictionary *info in self.userInfos) {
NSString *pk = sciUserPK(info[@"userObj"]);
if (pk.length) [pks addObject:pk];
}
if (pks.count) {
__weak typeof(self) weakSelf = self;
[SCIInstagramAPI fetchFriendshipStatusesForPKs:pks completion:^(NSDictionary *statuses, NSError *error) {
if (!statuses.count) return;
[weakSelf.friendshipStatuses addEntriesFromDictionary:statuses];
[weakSelf.tableView reloadData];
}];
}
if (self.userInfos.count == 0) {
UIImageView *emptyIcon = [[UIImageView alloc] initWithImage:
[UIImage systemImageNamed:@"at"
withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:36
weight:UIImageSymbolWeightLight]]];
emptyIcon.tintColor = [UIColor tertiaryLabelColor];
emptyIcon.translatesAutoresizingMaskIntoConstraints = NO;
UILabel *emptyLabel = [[UILabel alloc] init];
emptyLabel.text = SCILocalized(@"No mentions in this story");
emptyLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
emptyLabel.textColor = [UIColor secondaryLabelColor];
emptyLabel.textAlignment = NSTextAlignmentCenter;
emptyLabel.translatesAutoresizingMaskIntoConstraints = NO;
UIStackView *empty = [[UIStackView alloc] initWithArrangedSubviews:@[emptyIcon, emptyLabel]];
empty.axis = UILayoutConstraintAxisVertical;
empty.spacing = 12;
empty.alignment = UIStackViewAlignmentCenter;
empty.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:empty];
[NSLayoutConstraint activateConstraints:@[
[empty.centerXAnchor constraintEqualToAnchor:self.tableView.centerXAnchor],
[empty.centerYAnchor constraintEqualToAnchor:self.tableView.centerYAnchor],
]];
}
}
- (void)closeTapped {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
// Resume story playback when mentions sheet dismisses
if (sciActiveStoryViewerVC) {
SEL sel = NSSelectorFromString(@"tryResumePlayback");
if ([sciActiveStoryViewerVC respondsToSelector:sel]) {
((void(*)(id,SEL))objc_msgSend)(sciActiveStoryViewerVC, sel);
}
}
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.userInfos.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *rid = @"mention";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:rid];
UIImageView *avatar;
UILabel *nameLabel, *subLabel;
UIButton *followBtn;
UIActivityIndicatorView *spinner;
static const NSInteger kAvTag = 101, kNmTag = 102, kSbTag = 103, kFlTag = 104, kSpTag = 105;
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
cell.backgroundColor = [UIColor clearColor];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
avatar = [[UIImageView alloc] init];
avatar.tag = kAvTag;
avatar.layer.cornerRadius = kAvatarSize / 2.0;
avatar.clipsToBounds = YES;
avatar.contentMode = UIViewContentModeScaleAspectFill;
avatar.backgroundColor = [UIColor secondarySystemBackgroundColor];
avatar.translatesAutoresizingMaskIntoConstraints = NO;
nameLabel = [[UILabel alloc] init];
nameLabel.tag = kNmTag;
nameLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
nameLabel.textColor = [UIColor labelColor];
nameLabel.translatesAutoresizingMaskIntoConstraints = NO;
subLabel = [[UILabel alloc] init];
subLabel.tag = kSbTag;
subLabel.font = [UIFont systemFontOfSize:14];
subLabel.textColor = [UIColor secondaryLabelColor];
subLabel.translatesAutoresizingMaskIntoConstraints = NO;
followBtn = [UIButton buttonWithType:UIButtonTypeSystem];
followBtn.tag = kFlTag;
followBtn.titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
followBtn.layer.cornerRadius = 8;
followBtn.clipsToBounds = YES;
followBtn.translatesAutoresizingMaskIntoConstraints = NO;
spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
spinner.tag = kSpTag;
spinner.hidesWhenStopped = YES;
spinner.translatesAutoresizingMaskIntoConstraints = NO;
UIStackView *text = [[UIStackView alloc] initWithArrangedSubviews:@[nameLabel, subLabel]];
text.axis = UILayoutConstraintAxisVertical;
text.spacing = 2;
text.translatesAutoresizingMaskIntoConstraints = NO;
[cell.contentView addSubview:avatar];
[cell.contentView addSubview:text];
[cell.contentView addSubview:followBtn];
[followBtn addSubview:spinner];
[NSLayoutConstraint activateConstraints:@[
[avatar.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16],
[avatar.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[avatar.widthAnchor constraintEqualToConstant:kAvatarSize],
[avatar.heightAnchor constraintEqualToConstant:kAvatarSize],
[text.leadingAnchor constraintEqualToAnchor:avatar.trailingAnchor constant:14],
[text.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[text.trailingAnchor constraintLessThanOrEqualToAnchor:followBtn.leadingAnchor constant:-10],
[followBtn.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-16],
[followBtn.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[followBtn.widthAnchor constraintGreaterThanOrEqualToConstant:90],
[followBtn.heightAnchor constraintEqualToConstant:32],
[spinner.centerXAnchor constraintEqualToAnchor:followBtn.centerXAnchor],
[spinner.centerYAnchor constraintEqualToAnchor:followBtn.centerYAnchor],
]];
} else {
avatar = [cell.contentView viewWithTag:kAvTag];
nameLabel = [cell.contentView viewWithTag:kNmTag];
subLabel = [cell.contentView viewWithTag:kSbTag];
followBtn = [cell.contentView viewWithTag:kFlTag];
spinner = [followBtn viewWithTag:kSpTag];
}
NSDictionary *info = self.userInfos[indexPath.row];
NSString *username = info[@"username"] ?: @"Unknown";
NSString *fullName = info[@"fullName"];
NSURL *picURL = info[@"picURL"];
nameLabel.text = username;
subLabel.text = fullName ?: @"";
subLabel.hidden = !fullName.length;
avatar.image = [UIImage systemImageNamed:@"person.circle.fill"];
avatar.tintColor = [UIColor tertiaryLabelColor];
if (picURL) {
NSURL *url = [picURL copy];
NSInteger row = indexPath.row;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:url];
if (!data) return;
UIImage *img = [UIImage imageWithData:data];
if (!img) return;
dispatch_async(dispatch_get_main_queue(), ^{
UITableViewCell *c = [tableView cellForRowAtIndexPath:
[NSIndexPath indexPathForRow:row inSection:0]];
if (!c) return;
UIImageView *av = [c.contentView viewWithTag:kAvTag];
if (av) { av.image = img; av.tintColor = nil; }
});
});
}
[followBtn removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
[spinner stopAnimating];
spinner.color = [UIColor whiteColor];
BOOL isMe = self.currentUsername && [username isEqualToString:self.currentUsername];
if (isMe) {
followBtn.hidden = YES;
} else {
followBtn.hidden = NO;
id userObj = info[@"userObj"];
BOOL following = NO;
NSString *pk = sciUserPK(userObj);
NSDictionary *status = pk ? self.friendshipStatuses[pk] : nil;
if ([status isKindOfClass:[NSDictionary class]]) {
following = [status[@"following"] boolValue];
}
sciStyleFollowBtn(followBtn, following);
objc_setAssociatedObject(followBtn, "userObj", userObj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[followBtn addTarget:self action:@selector(followTapped:) forControlEvents:UIControlEventTouchUpInside];
}
return cell;
}
- (void)followTapped:(UIButton *)sender {
id userObj = objc_getAssociatedObject(sender, "userObj");
if (!userObj) return;
NSString *pk = sciUserPK(userObj);
if (!pk.length) return;
BOOL currentlyFollowing = [[sender titleForState:UIControlStateNormal] isEqualToString:@"Following"];
void (^doIt)(void) = ^{
UIActivityIndicatorView *spinner = [sender viewWithTag:105];
NSString *savedTitle = [sender titleForState:UIControlStateNormal];
[sender setTitle:@"" forState:UIControlStateNormal];
sender.userInteractionEnabled = NO;
[spinner startAnimating];
__weak typeof(self) weakSelf = self;
SCIAPICompletion done = ^(NSDictionary *response, NSError *error) {
[spinner stopAnimating];
sender.userInteractionEnabled = YES;
BOOL ok = (response && [response[@"status"] isEqualToString:@"ok"]);
if (ok) {
sciStyleFollowBtn(sender, !currentlyFollowing);
NSMutableDictionary *s = [weakSelf.friendshipStatuses[pk] mutableCopy] ?: [NSMutableDictionary dictionary];
s[@"following"] = @(!currentlyFollowing);
weakSelf.friendshipStatuses[pk] = [s copy];
} else {
[sender setTitle:savedTitle forState:UIControlStateNormal];
}
};
if (currentlyFollowing) [SCIInstagramAPI unfollowUserPK:pk completion:done];
else [SCIInstagramAPI followUserPK:pk completion:done];
};
if (!currentlyFollowing && [SCIUtils getBoolPref:@"follow_confirm"]) {
[SCIUtils showConfirmation:doIt];
} else if (currentlyFollowing && [SCIUtils getBoolPref:@"unfollow_confirm"]) {
[SCIUtils showConfirmation:doIt];
} else {
doIt();
}
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *info = self.userInfos[indexPath.row];
NSString *username = info[@"username"];
if (!username) return;
[self dismissViewControllerAnimated:YES completion:^{
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]];
if ([[UIApplication sharedApplication] canOpenURL:url])
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}];
}
@end
// ============ Entry points ============
void sciShowStoryMentions(UIViewController *presenter, UIView *anchor) {
if (![SCIUtils getBoolPref:@"view_story_mentions"]) return;
NSArray *mentions = sciCurrentStoryMentions(anchor);
NSMutableArray *infos = [NSMutableArray array];
for (id mention in mentions) {
NSDictionary *info = sciMentionUserInfo(mention);
if (info) [infos addObject:info];
}
SCIStoryMentionsVC *vc = [[SCIStoryMentionsVC alloc] init];
vc.userInfos = [infos copy];
vc.modalPresentationStyle = UIModalPresentationPageSheet;
if (@available(iOS 15.0, *)) {
UISheetPresentationController *sheet = vc.sheetPresentationController;
sheet.detents = @[UISheetPresentationControllerDetent.mediumDetent,
UISheetPresentationControllerDetent.largeDetent];
@try { [sheet setValue:@YES forKey:@"prefersGrabberIndicator"]; } @catch (__unused id e) {}
sheet.prefersScrollingExpandsWhenScrolledToEdge = YES;
}
[presenter presentViewController:vc animated:YES completion:nil];
}
NSArray *sciMaybeAppendStoryMentionsMenuItem(NSArray *items) {
if (!sciActiveStoryViewerVC) return items;
if (![SCIUtils getBoolPref:@"view_story_mentions"]) return items;
BOOL looksLikeStoryHeader = NO;
for (id it in items) {
@try {
NSString *t = [NSString stringWithFormat:@"%@", [it valueForKey:@"title"] ?: @""];
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
[t isEqualToString:@"Hide"]) { looksLikeStoryHeader = YES; break; }
} @catch (__unused id e) {}
}
if (!looksLikeStoryHeader) return items;
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
void (^handler)(void) = ^{
UIViewController *vc = weakVC;
if (!vc) return;
sciShowStoryMentions(vc, vc.view);
};
id newItem = nil;
@try {
typedef id (*Init)(id, SEL, id, id, id);
newItem = ((Init)objc_msgSend)([menuItemCls alloc],
@selector(initWithTitle:image:handler:), @"View mentions", nil, handler);
} @catch (__unused id e) {}
if (!newItem) return items;
NSMutableArray *newItems = [items mutableCopy];
[newItems addObject:newItem];
return [newItems copy];
}
+34
View File
@@ -293,6 +293,9 @@
- (void)handleLongPress:(UILongPressGestureRecognizer *)gr; // new
@end
@interface IGHomeFeedHeaderView : UIView
@end
@interface IGHomeFeedHeaderViewController
- (void)headerDidLongPressLogo:(id)arg1;
@end
@@ -434,6 +437,9 @@
@interface IGUFIInteractionCountsView : UIView
@end
@interface IGUFIButtonBarView : UIView
@end
@interface IGFeedItemUFICell : UIView
- (void)UFIButtonBarDidTapOnRepost:(id)arg1;
@end
@@ -482,6 +488,9 @@
@property (readonly, nonatomic) long long destination;
@end
@interface IGCommentThreadConfiguration : NSObject
@end
@interface IGDSMenuItem : NSObject
@end
@@ -520,6 +529,31 @@
@property (readonly, nonatomic) IGCreationActionBarButton *button;
@end
// Call buttons in DM thread header. Coordinator owns _audioCallButton / _videoCallButton
// (both IGDirectCallButton) and forwards taps to _didTapAudioButton: / _didTapVideoButton:.
// Discovered by dumping the thread VC view hierarchy for IGDirectCallButton.
@interface IGDirectThreadCallButtonsCoordinator : NSObject @end
@interface IGDirectCallButton : UIView @end
// IG's UINavigationBar subclass — hosts the iOS 26 liquid-glass platter layout.
@interface IGNavigationBar : UINavigationBar @end
// Story tray list adapter — drives data source updates for the home feed tray.
@interface IGListAdapter : NSObject
- (void)performUpdatesAnimated:(BOOL)animated completion:(void (^)(BOOL))completion;
@end
// Reels/feed video cell — used for long-press zoom gesture attachment.
@interface IGFeedItemPageVideoCell : UICollectionViewCell @end
// Profile page view controller — `user` is the IGUser being displayed.
@interface IGProfileViewController : UIViewController
@property (nonatomic, strong) id user;
@end
// Notes thought-bubble view on profiles — the note's touch target.
@interface IGDirectNotesThoughtBubbleView : UIView @end
/////////////////////////////////////////////////////////////////////////////
@@ -0,0 +1,905 @@
/*
* RyukGram — Localizable.strings (English source of truth)
* -------------------------------------------------------------------------
*
* Every user-facing string in RyukGram goes through the macro
* SCILocalized(@"English text here")
* in the Objective-C source. The argument is BOTH the lookup key and the
* English fallback, so if a translation is missing the user still sees
* clean English — nothing ever breaks.
*
*
* HOW TO ADD A NEW LANGUAGE
* -------------------------------------------------------------------------
*
* 1. Copy this file into a new folder named after the language code:
* src/Localization/Resources/<code>.lproj/Localizable.strings
* e.g. ar.lproj (Arabic)
* es.lproj (Spanish)
* fr.lproj (French)
* 2. Translate the RIGHT-hand side of every `"key" = "value";` line.
* Do NOT touch the left-hand side — that is the lookup key and must
* stay identical to the English version, otherwise the app will never
* find your translation.
* 3. Keep every format specifier (%@, %lu, %d, %lld, %1$@, …) exactly
* as-is, in the same order. If you need to reorder them, switch to
* positional specifiers (%1$@ %2$lu).
* 4. Keep embedded quotes escaped with a backslash: \" — and newlines
* as \n.
* 5. Open a pull request at https://github.com/faroukbmiled/RyukGram/pulls
* so we can ship the language in the next release.
*
*
* HOW TO ADD A NEW STRING IN CODE
* -------------------------------------------------------------------------
*
* Just wrap the English text with SCILocalized(...) in the .m / .x / .xm
* file — the helper resolves to the English text automatically when no
* translation exists. Then add the same English text as BOTH the key and
* the value inside the matching section below, e.g.
*
* "Download all items" = "Download all items";
*
* Translators copy that line into their own .lproj and translate only the
* right-hand side.
*
*
* FILE FORMAT NOTES
* -------------------------------------------------------------------------
*
* - UTF-8, LF line endings.
* - Slash-star block comments and double-slash line comments both work.
* - DO NOT nest one slash-star block comment inside another — the
* parser will close the outer block at the first inner close marker
* and every lookup in the file will silently fail.
* - Keys and values are both quoted; every line ends with a semicolon.
*/
//////////////////////////////////////////////////////////////////////////////
// CHROME — TOP BAR, LANGUAGE PICKER, FIRST-RUN //
// Shown on the root Settings screen: title, search bar, the globe language //
// menu, and the one-time welcome alert. These use dotted keys (settings.*) //
// and are hand-authored rather than extracted from English source. //
//////////////////////////////////////////////////////////////////////////////
"settings.firstrun.message" = "In the future: Hold down on the three lines at the top right of your profile page, to re-open RyukGram settings.";
"settings.firstrun.ok" = "I understand!";
"settings.firstrun.title" = "RyukGram Settings Info";
"settings.language.system" = "System default";
"settings.language.title" = "Language";
"settings.language.english_only" = "RyukGram currently ships with English only. Other languages are wired up and waiting for translations — help translate into your language by following the short guide in the README.";
"settings.language.ok" = "OK";
"settings.language.help_translate" = "Help translate";
"settings.results.many" = "%lu results";
"settings.results.none" = "No results";
"settings.results.one" = "%lu result";
"settings.search.placeholder" = "Search settings";
"settings.title" = "RyukGram Settings";
//////////////////////////////////////////////////////////////////////////////
// GENERAL //
// Settings → General tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a copy option to the comment long-press menu" = "Adds a copy option to the comment long-press menu";
"Adds a download option for GIF comments" = "Adds a download option for GIF comments";
"Browser" = "Browser";
"Comments" = "Comments";
"Copy comment text" = "Copy comment text";
"Copy description" = "Copy description";
"Copy description text fields by long-pressing on them" = "Copy description text fields by long-pressing on them";
"Date format" = "Date format";
"Disable app haptics" = "Disable app haptics";
"Disables haptics/vibrations within the app" = "Disables haptics/vibrations within the app";
"Do not save recent searches" = "Do not save recent searches";
"Download GIF comments" = "Download GIF comments";
"Embed domain" = "Embed domain";
"Embed domain: %@" = "Embed domain: %@";
"Enable liquid glass buttons" = "Enable liquid glass buttons";
"Enable liquid glass surfaces" = "Enable liquid glass surfaces";
"Enable teen app icons" = "Enable teen app icons";
"Enables experimental liquid glass buttons" = "Enables experimental liquid glass buttons";
"Enables liquid glass tab bar, floating navigation, and other UI elements" = "Enables liquid glass tab bar, floating navigation, and other UI elements";
"Experimental features" = "Experimental features";
"Focus/distractions" = "Focus/distractions";
"General" = "General";
"Hide Meta AI" = "Hide Meta AI";
"Hide ads" = "Hide ads";
"Hide explore posts grid" = "Hide explore posts grid";
"Hide friends map" = "Hide friends map";
"Hide metrics" = "Hide metrics";
"Hide notes tray" = "Hide notes tray";
"Hide trending searches" = "Hide trending searches";
"Hides all suggested users for you to follow, outside your feed" = "Hides all suggested users for you to follow, outside your feed";
"Hides like/comment/share counts on posts and reels" = "Hides like/comment/share counts on posts and reels";
"Hides the friends map icon in the notes tray" = "Hides the friends map icon in the notes tray";
"Hides the grid of suggested posts on the explore/search tab" = "Hides the grid of suggested posts on the explore/search tab";
"Hides the meta ai buttons/functionality within the app" = "Hides the meta ai buttons/functionality within the app";
"Hides the notes tray in the DM inbox" = "Hides the notes tray in the DM inbox";
"Hides the suggested broadcast channels in direct messages" = "Hides the suggested broadcast channels in direct messages";
"Hides the trending searches under the explore search bar" = "Hides the trending searches under the explore search bar";
"Hold down on the Instagram logo to change the app icon" = "Hold down on the Instagram logo to change the app icon";
"Long press on the eyedropper tool in stories to customize the text color more precisely" = "Long press on the eyedropper tool in stories to customize the text color more precisely";
"No suggested chats" = "No suggested chats";
"No suggested users" = "No suggested users";
"Notes" = "Notes";
"Open links in external browser" = "Open links in external browser";
"Opens links in Safari instead of Instagram's in-app browser" = "Opens links in Safari instead of Instagram's in-app browser";
"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs";
"Removes all ads from the Instagram app" = "Removes all ads from the Instagram app";
"Removes igsh, utm_source, and other tracking parameters from shared links" = "Removes igsh, utm_source, and other tracking parameters from shared links";
"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker.";
"Replace domain in shared links" = "Replace domain in shared links";
"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc.";
"Search bars will no longer save your recent searches" = "Search bars will no longer save your recent searches";
"Sharing" = "Sharing";
"Strip tracking from links" = "Strip tracking from links";
"Strip tracking params" = "Strip tracking params";
"These features rely on hidden Instagram flags and may not work on all accounts or versions.\nExperimental flags research by @euoradan (Radan)." = "These features rely on hidden Instagram flags and may not work on all accounts or versions.\nExperimental flags research by @euoradan (Radan).";
"Use detailed color picker" = "Use detailed color picker";
//////////////////////////////////////////////////////////////////////////////
// DATE FORMAT //
// Settings → Date format tab //
//////////////////////////////////////////////////////////////////////////////
"Alternate" = "Alternate";
"Always ask" = "Always ask";
"Balanced" = "Balanced";
"Block all" = "Block all";
"Block selected" = "Block selected";
"Button" = "Button";
"Classic" = "Classic";
"Date format — %@" = "Date format — %@";
"Default" = "Default";
"Disabled" = "Disabled";
"Download and share" = "Download and share";
"Download to Photos" = "Download to Photos";
"Enabled" = "Enabled";
"Expand" = "Expand";
"Explore" = "Explore";
"Fast" = "Fast";
"Feed" = "Feed";
"High" = "High";
"Inbox" = "Inbox";
"Low" = "Low";
"Max" = "Max";
"Medium" = "Medium";
"Mute/Unmute" = "Mute/Unmute";
"Open menu" = "Open menu";
"Pause/Play" = "Pause/Play";
"Profile" = "Profile";
"Quality" = "Quality";
"Reels" = "Reels";
"Requires restart" = "Requires restart";
"Save to Photos" = "Save to Photos";
"Share sheet" = "Share sheet";
"Standard" = "Standard";
"Toggle" = "Toggle";
//////////////////////////////////////////////////////////////////////////////
// FEED //
// Settings → Feed tab //
//////////////////////////////////////////////////////////////////////////////
"Action button" = "Action button";
"Adds 'View profile picture' and 'View cover' to story tray long-press menus" = "Adds 'View profile picture' and 'View cover' to story tray long-press menus";
"Adds a RyukGram action button under each feed post with download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below." = "Adds a RyukGram action button under each feed post with download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below.";
"Controls when and how the feed refreshes. Background refresh occurs when returning to the app after ~10 minutes. Home button refresh occurs when tapping the Home tab while already on it." = "Controls when and how the feed refreshes. Background refresh occurs when returning to the app after ~10 minutes. Home button refresh occurs when tapping the Home tab while already on it.";
"Default tap action" = "Default tap action";
"Disable background refresh" = "Disable background refresh";
"Disable home button refresh" = "Disable home button refresh";
"Disable home button scroll" = "Disable home button scroll";
"Disable video autoplay" = "Disable video autoplay";
"Hide" = "Hide";
"Hide entire feed" = "Hide entire feed";
"Hide repost button" = "Hide repost button";
"Hide stories tray" = "Hide stories tray";
"Hide suggested stories" = "Hide suggested stories";
"Hides suggested accounts" = "Hides suggested accounts";
"Hides suggested reels" = "Hides suggested reels";
"Hides suggested threads posts" = "Hides suggested threads posts";
"Hides the repost button on feed posts" = "Hides the repost button on feed posts";
"Hides the story tray at the top" = "Hides the story tray at the top";
"Inserts a button row below like/comment/share on each post" = "Inserts a button row below like/comment/share on each post";
"Long press on media to expand in full-screen viewer" = "Long press on media to expand in full-screen viewer";
"Media" = "Media";
"Media zoom" = "Media zoom";
"No suggested for you" = "No suggested for you";
"No suggested posts" = "No suggested posts";
"No suggested reels" = "No suggested reels";
"No suggested threads" = "No suggested threads";
"Prevents feed from reloading when returning from background" = "Prevents feed from reloading when returning from background";
"Prevents videos from playing automatically" = "Prevents videos from playing automatically";
"Refresh" = "Refresh";
"Removes all content from your home feed" = "Removes all content from your home feed";
"Removes suggested accounts from the stories tray" = "Removes suggested accounts from the stories tray";
"Removes suggested posts" = "Removes suggested posts";
"Scroll to top without refreshing when tapping Home" = "Scroll to top without refreshing when tapping Home";
"Show action button" = "Show action button";
"Stories tray" = "Stories tray";
"Tapping Home does nothing when already on feed" = "Tapping Home does nothing when already on feed";
"Tray long-press actions" = "Tray long-press actions";
"What happens on a single tap. Long-press always opens the full menu" = "What happens on a single tap. Long-press always opens the full menu";
//////////////////////////////////////////////////////////////////////////////
// REELS //
// Settings → Reels tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a RyukGram action button above the reel sidebar with view-cover/download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below." = "Adds a RyukGram action button above the reel sidebar with view-cover/download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below.";
"Always show progress scrubber" = "Always show progress scrubber";
"Change what happens when you tap on a reel" = "Change what happens when you tap on a reel";
"Confirm reel refresh" = "Confirm reel refresh";
"Disable auto-unmuting reels" = "Disable auto-unmuting reels";
"Disable scrolling reels" = "Disable scrolling reels";
"Disable tab button refresh" = "Disable tab button refresh";
"Doom scrolling limit" = "Doom scrolling limit";
"Forces the progress bar to appear on every reel" = "Forces the progress bar to appear on every reel";
"Hide reels header" = "Hide reels header";
"Hides the repost button on the reels sidebar" = "Hides the repost button on the reels sidebar";
"Hides the top navigation bar when watching reels" = "Hides the top navigation bar when watching reels";
"Hiding" = "Hiding";
"Limits" = "Limits";
"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "Limits the amount of reels available to scroll at any given time, and prevents refreshing";
"Only loads %@ %@" = "Only loads %@ %@";
"Places a button above the like/comment/share column on each reel" = "Places a button above the like/comment/share column on each reel";
"Prevent doom scrolling" = "Prevent doom scrolling";
"Prevents reels from being scrolled to the next video" = "Prevents reels from being scrolled to the next video";
"Prevents reels from unmuting when the volume/silent button is pressed" = "Prevents reels from unmuting when the volume/silent button is pressed";
"Shows an alert when you trigger a reels refresh" = "Shows an alert when you trigger a reels refresh";
"Shows buttons to reveal and auto-fill the password on locked reels" = "Shows buttons to reveal and auto-fill the password on locked reels";
"Tap Controls" = "Tap Controls";
"Tapping the Reels tab while on reels does nothing" = "Tapping the Reels tab while on reels does nothing";
"Unlock password-locked reels" = "Unlock password-locked reels";
//////////////////////////////////////////////////////////////////////////////
// PROFILE //
// Settings → Profile tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a button next to the burger menu on profiles to copy username, name or bio" = "Adds a button next to the burger menu on profiles to copy username, name or bio";
"Adds a view option to the highlight long-press menu to open the cover in full-screen" = "Adds a view option to the highlight long-press menu to open the cover in full-screen";
"Copy note on long press" = "Copy note on long press";
"Follow indicator" = "Follow indicator";
"Long press a profile picture to open it in full-screen with zoom, share, and save" = "Long press a profile picture to open it in full-screen with zoom, share, and save";
"Long press the note bubble on a profile to copy the text" = "Long press the note bubble on a profile to copy the text";
"Long press to download directly (ignored when zoom is on)" = "Long press to download directly (ignored when zoom is on)";
"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "Long-press gestures on profile elements — kept separate from the per-feature action buttons.";
"Profile copy button" = "Profile copy button";
"Save profile picture" = "Save profile picture";
"Shows whether the profile user follows you" = "Shows whether the profile user follows you";
"View highlight cover" = "View highlight cover";
"Zoom profile photo" = "Zoom profile photo";
//////////////////////////////////////////////////////////////////////////////
// SAVING & DOWNLOADS //
// Settings → Saving tab //
//////////////////////////////////////////////////////////////////////////////
"Confirm before download" = "Confirm before download";
"Deprecated. The RyukGram action button (configured per feature in Feed/Reels/Stories) is the new way to download media. Enable this master toggle only if you prefer the old multi-finger long-press directly on the media." = "Deprecated. The RyukGram action button (configured per feature in Feed/Reels/Stories) is the new way to download media. Enable this master toggle only if you prefer the old multi-finger long-press directly on the media.";
"Downloads" = "Downloads";
"Downloads with %@ %@" = "Downloads with %@ %@";
"Enable long-press gesture" = "Enable long-press gesture";
"Finger count for long-press" = "Finger count for long-press";
"Legacy long-press gesture" = "Legacy long-press gesture";
"Long-press hold time" = "Long-press hold time";
"Master toggle for the deprecated gesture workflow (off by default)" = "Master toggle for the deprecated gesture workflow (off by default)";
"Press finger(s) for %@ %@" = "Press finger(s) for %@ %@";
"Route saves into a dedicated album in Photos instead of the camera roll root" = "Route saves into a dedicated album in Photos instead of the camera roll root";
"Save action" = "Save action";
"Save to RyukGram album" = "Save to RyukGram album";
"Saving" = "Saving";
"Show a confirmation dialog before starting a download" = "Show a confirmation dialog before starting a download";
"What happens after the gesture downloads" = "What happens after the gesture downloads";
"When \"Save to RyukGram album\" is on, downloads and share-sheet \"Save to Photos\" picks are routed into a dedicated \"RyukGram\" album in your Photos library." = "When \"Save to RyukGram album\" is on, downloads and share-sheet \"Save to Photos\" picks are routed into a dedicated \"RyukGram\" album in your Photos library.";
//////////////////////////////////////////////////////////////////////////////
// STORIES //
// Settings → Stories tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a RyukGram action button next to the eye button on stories with download/share/copy/expand/repost/view-mentions entries. Tap opens the menu by default; change the tap behavior below." = "Adds a RyukGram action button next to the eye button on stories with download/share/copy/expand/repost/view-mentions entries. Tap opens the menu by default; change the tap behavior below.";
"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu" = "Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu";
"Advance on story like" = "Advance on story like";
"Advance on story reply" = "Advance on story reply";
"Advance when marking as seen" = "Advance when marking as seen";
"Audio" = "Audio";
"Block all: all stories blocked — listed users are exceptions.\nBlock selected: only listed users are blocked — everything else is normal.\nBoth lists are saved independently." = "Block all: all stories blocked — listed users are exceptions.\nBlock selected: only listed users are blocked — everything else is normal.\nBoth lists are saved independently.";
"Blocking mode" = "Blocking mode";
"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" = "Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)";
"Disable instants creation" = "Disable instants creation";
"Disable story seen receipt" = "Disable story seen receipt";
"Enable story user list" = "Enable story user list";
"Hides the functionality to create/send instants" = "Hides the functionality to create/send instants";
"Hides the notification for others when you view their story" = "Hides the notification for others when you view their story";
"Inserts a button next to the seen/eye button on story overlays" = "Inserts a button next to the seen/eye button on story overlays";
"Keep stories visually unseen" = "Keep stories visually unseen";
"Liking a story automatically advances to the next one after a short delay" = "Liking a story automatically advances to the next one after a short delay";
"Manage list" = "Manage list";
"Manage list (%lu)" = "Manage list (%lu)";
"Manual seen button mode" = "Manual seen button mode";
"Mark seen on story like" = "Mark seen on story like";
"Mark seen on story reply" = "Mark seen on story reply";
"Marks a story as seen the moment you tap the heart, even with seen blocking on" = "Marks a story as seen the moment you tap the heart, even with seen blocking on";
"Marks a story as seen when you send a reply or emoji reaction, even with seen blocking on" = "Marks a story as seen when you send a reply or emoji reaction, even with seen blocking on";
"Master toggle. When off, the list is ignored" = "Master toggle. When off, the list is ignored";
"Other" = "Other";
"Playback" = "Playback";
"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" = "Prevents stories from visually marking as seen in the tray (keeps colorful ring)";
"Quick list button in stories" = "Quick list button in stories";
"Search, sort, swipe to remove" = "Search, sort, swipe to remove";
"Seen receipts" = "Seen receipts";
"Sending a reply or emoji reaction automatically advances to the next story" = "Sending a reply or emoji reaction automatically advances to the next story";
"Show mentioned users in eye button and story menu" = "Show mentioned users in eye button and story menu";
"Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only" = "Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only";
"Stop story auto-advance" = "Stop story auto-advance";
"Stories" = "Stories";
"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "Stories won't auto-skip to the next one when the timer ends. Tap to advance manually";
"Story audio toggle" = "Story audio toggle";
"Story user list" = "Story user list";
"Tapping the eye button to mark a story as seen advances to the next story automatically" = "Tapping the eye button to mark a story as seen advances to the next story automatically";
"View story mentions" = "View story mentions";
"Which stories get seen-receipt blocking" = "Which stories get seen-receipt blocking";
//////////////////////////////////////////////////////////////////////////////
// MESSAGES — READ RECEIPTS //
// Settings → Read receipts tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a button to DM threads to mark messages as seen" = "Adds a button to DM threads to mark messages as seen";
"Auto mark seen on interact" = "Auto mark seen on interact";
"Auto mark seen on typing" = "Auto mark seen on typing";
"Control when messages are marked as seen" = "Control when messages are marked as seen";
"How the seen button behaves" = "How the seen button behaves";
"Manually mark messages as seen" = "Manually mark messages as seen";
"Marks messages as seen when you send any message" = "Marks messages as seen when you send any message";
"Marks messages as seen when you start typing" = "Marks messages as seen when you start typing";
"Read receipt mode" = "Read receipt mode";
"Read receipts" = "Read receipts";
//////////////////////////////////////////////////////////////////////////////
// MESSAGES — KEEP DELETED //
// Settings → Keep deleted messages tab //
//////////////////////////////////////////////////////////////////////////////
"Activity" = "Activity";
"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" = "Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio";
"Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram" = "Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram";
"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages" = "Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages";
"Adds copy text, download GIF/audio to the note long-press menu" = "Adds copy text, download GIF/audio to the note long-press menu";
"Block all: all chats blocked — listed chats are exceptions.\nBlock selected: only listed chats are blocked — everything else is normal.\nBoth lists are saved independently. Long-press a chat in the inbox to add or remove." = "Block all: all chats blocked — listed chats are exceptions.\nBlock selected: only listed chats are blocked — everything else is normal.\nBoth lists are saved independently. Long-press a chat in the inbox to add or remove.";
"Block keep-deleted for excluded chats" = "Block keep-deleted for excluded chats";
"Block keep-deleted for unlisted chats" = "Block keep-deleted for unlisted chats";
"Chat list" = "Chat list";
"Confirmation dialog before clearing preserved messages" = "Confirmation dialog before clearing preserved messages";
"Copies note text directly on long press without opening the menu" = "Copies note text directly on long press without opening the menu";
"Copy text on hold" = "Copy text on hold";
"Custom emojis and background/text colors" = "Custom emojis and background/text colors";
"Custom note themes" = "Custom note themes";
"Disable disappearing mode swipe" = "Disable disappearing mode swipe";
"Disable screenshot detection" = "Disable screenshot detection";
"Disable typing status" = "Disable typing status";
"Disable view-once limitations" = "Disable view-once limitations";
"Download voice messages" = "Download voice messages";
"Enable chat list" = "Enable chat list";
"Enable note theming" = "Enable note theming";
"Enables the notes theme picker" = "Enables the notes theme picker";
"Files" = "Files";
"Full last active date" = "Full last active date";
"Hide reels blend button" = "Hide reels blend button";
"Hide video call button" = "Hide video call button";
"Hide voice call button" = "Hide voice call button";
"Hides the blend button in DMs" = "Hides the blend button in DMs";
"Hides typing indicator from others" = "Hides typing indicator from others";
"Indicate unsent messages" = "Indicate unsent messages";
"Keep deleted messages" = "Keep deleted messages";
"Makes view-once messages behave like normal visual messages (loopable/pauseable)" = "Makes view-once messages behave like normal visual messages (loopable/pauseable)";
"Note actions" = "Note actions";
"Preserve messages that others unsend" = "Preserve messages that others unsend";
"Preserves messages that others unsend" = "Preserves messages that others unsend";
"Prevents accidental swipe-up activation of disappearing mode" = "Prevents accidental swipe-up activation of disappearing mode";
"Quick list button in chats" = "Quick list button in chats";
"Removes the audio call button from DM thread header" = "Removes the audio call button from DM thread header";
"Removes the screenshot-prevention features for visual messages in DMs" = "Removes the screenshot-prevention features for visual messages in DMs";
"Removes the video call button from DM thread header" = "Removes the video call button from DM thread header";
"Replay visual messages without expiring. Toggle in the eye button menu, or as a standalone button when the eye button is disabled" = "Replay visual messages without expiring. Toggle in the eye button menu, or as a standalone button when the eye button is disabled";
"Search, sort, swipe to remove or toggle keep-deleted" = "Search, sort, swipe to remove or toggle keep-deleted";
"Send audio as file" = "Send audio as file";
"Send files (experimental)" = "Send files (experimental)";
"Show full date instead of \"Active 2h ago\"" = "Show full date instead of \"Active 2h ago\"";
"Shows a button in DM threads to add/remove chats from the list. Long-press for more options" = "Shows a button in DM threads to add/remove chats from the list. Long-press for more options";
"Shows a notification pill when a message is unsent" = "Shows a notification pill when a message is unsent";
"Shows an \"Unsent\" label on preserved messages" = "Shows an \"Unsent\" label on preserved messages";
"Unlimited replay of visual messages" = "Unlimited replay of visual messages";
"Unsent message notification" = "Unsent message notification";
"Visual messages" = "Visual messages";
"Voice messages" = "Voice messages";
"Warn before clearing on refresh" = "Warn before clearing on refresh";
"Which chats get read-receipt blocking" = "Which chats get read-receipt blocking";
"⚠️ Pull-to-refresh in the DMs tab clears all preserved messages. Enable the warning below to get a confirmation dialog." = "⚠️ Pull-to-refresh in the DMs tab clears all preserved messages. Enable the warning below to get a confirmation dialog.";
//////////////////////////////////////////////////////////////////////////////
// MESSAGES //
// Settings → Messages tab //
//////////////////////////////////////////////////////////////////////////////
"Messages" = "Messages";
"Threads" = "Threads";
//////////////////////////////////////////////////////////////////////////////
// NAVIGATION //
// Settings → Navigation tab //
//////////////////////////////////////////////////////////////////////////////
"Hide create tab" = "Hide create tab";
"Hide explore tab" = "Hide explore tab";
"Hide feed tab" = "Hide feed tab";
"Hide messages tab" = "Hide messages tab";
"Hide reels tab" = "Hide reels tab";
"Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab." = "Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab.";
"Hides the create tab on the bottom navigation bar" = "Hides the create tab on the bottom navigation bar";
"Hides the direct messages tab on the bottom navigation bar" = "Hides the direct messages tab on the bottom navigation bar";
"Hides the explore/search tab on the bottom navigation bar" = "Hides the explore/search tab on the bottom navigation bar";
"Hides the feed/home tab on the bottom navigation bar" = "Hides the feed/home tab on the bottom navigation bar";
"Hides the reels tab on the bottom navigation bar" = "Hides the reels tab on the bottom navigation bar";
"Hiding tabs" = "Hiding tabs";
"Icon order" = "Icon order";
"Launch tab" = "Launch tab";
"Lets you swipe to switch between navigation bar tabs" = "Lets you swipe to switch between navigation bar tabs";
"Messages only" = "Messages only";
"Messages-only mode" = "Messages-only mode";
"Navigation" = "Navigation";
"Swipe between tabs" = "Swipe between tabs";
"Tab the app opens to. Ignored when Messages-only is on" = "Tab the app opens to. Ignored when Messages-only is on";
"The order of the icons on the bottom navigation bar" = "The order of the icons on the bottom navigation bar";
"Turn IG into a DM-only client" = "Turn IG into a DM-only client";
//////////////////////////////////////////////////////////////////////////////
// CONFIRM ACTIONS //
// Settings → Confirm actions tab //
//////////////////////////////////////////////////////////////////////////////
"Confirm actions" = "Confirm actions";
"Confirm call" = "Confirm call";
"Confirm changing theme" = "Confirm changing theme";
"Confirm follow" = "Confirm follow";
"Confirm follow requests" = "Confirm follow requests";
"Confirm like: Posts/Stories" = "Confirm like: Posts/Stories";
"Confirm like: Reels" = "Confirm like: Reels";
"Confirm posting comment" = "Confirm posting comment";
"Confirm repost" = "Confirm repost";
"Confirm shh mode" = "Confirm shh mode";
"Confirm sticker interaction" = "Confirm sticker interaction";
"Confirm unfollow" = "Confirm unfollow";
"Confirm voice messages" = "Confirm voice messages";
"Shows an alert to confirm before sending a voice message" = "Shows an alert to confirm before sending a voice message";
"Shows an alert to confirm before toggling disappearing messages" = "Shows an alert to confirm before toggling disappearing messages";
"Shows an alert when you accept/decline a follow request" = "Shows an alert when you accept/decline a follow request";
"Shows an alert when you change a chat theme to confirm" = "Shows an alert when you change a chat theme to confirm";
"Shows an alert when you click a sticker on someone's story to confirm the action" = "Shows an alert when you click a sticker on someone's story to confirm the action";
"Shows an alert when you click the audio/video call button to confirm before calling" = "Shows an alert when you click the audio/video call button to confirm before calling";
"Shows an alert when you click the follow button to confirm the follow" = "Shows an alert when you click the follow button to confirm the follow";
"Shows an alert when you click the like button on posts or stories to confirm the like" = "Shows an alert when you click the like button on posts or stories to confirm the like";
"Shows an alert when you click the like button on reels to confirm the like" = "Shows an alert when you click the like button on reels to confirm the like";
"Shows an alert when you click the post comment button to confirm" = "Shows an alert when you click the post comment button to confirm";
"Shows an alert when you click the repost button to confirm before resposting" = "Shows an alert when you click the repost button to confirm before resposting";
"Shows an alert when you click the unfollow button to confirm" = "Shows an alert when you click the unfollow button to confirm";
//////////////////////////////////////////////////////////////////////////////
// BACKUP & RESTORE //
// Settings → Backup & Restore tab //
//////////////////////////////////////////////////////////////////////////////
"Backup & Restore" = "Backup & Restore";
"Export settings" = "Export settings";
"Export your RyukGram settings to a JSON file and import them later. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes." = "Export your RyukGram settings to a JSON file and import them later. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.";
"Import settings" = "Import settings";
"Load settings from a JSON file" = "Load settings from a JSON file";
"Reset to defaults" = "Reset to defaults";
"Revert every RyukGram preference" = "Revert every RyukGram preference";
"Save settings as a JSON file" = "Save settings as a JSON file";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL //
// Settings → Experimental tab //
//////////////////////////////////////////////////////////////////////////////
"Experimental" = "Experimental";
"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!";
"Warning" = "Warning";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED //
// Settings → Advanced tab //
//////////////////////////////////////////////////////////////////////////////
"Advanced" = "Advanced";
"Automatically opens settings when the app launches" = "Automatically opens settings when the app launches";
"Disable safe mode" = "Disable safe mode";
"Enable tweak settings quick-access" = "Enable tweak settings quick-access";
"Hold on the home tab to open RyukGram settings" = "Hold on the home tab to open RyukGram settings";
"Instagram" = "Instagram";
"Pause playback when opening settings" = "Pause playback when opening settings";
"Pauses any playing video/audio when settings opens" = "Pauses any playing video/audio when settings opens";
"Prevents Instagram from resetting settings after crashes (at your own risk)" = "Prevents Instagram from resetting settings after crashes (at your own risk)";
"Reset onboarding state" = "Reset onboarding state";
"Settings" = "Settings";
"Show tweak settings on app launch" = "Show tweak settings on app launch";
//////////////////////////////////////////////////////////////////////////////
// DEBUG //
// Settings → Debug tab //
//////////////////////////////////////////////////////////////////////////////
"Button Cell" = "Button Cell";
"Change the value on the right" = "Change the value on the right";
"Debug" = "Debug";
"Enable FLEX gesture" = "Enable FLEX gesture";
"Hold 5 fingers on the screen to open FLEX" = "Hold 5 fingers on the screen to open FLEX";
"I have %@%@" = "I have %@%@";
"Link Cell" = "Link Cell";
"Menu Cell" = "Menu Cell";
"Open FLEX on app focus" = "Open FLEX on app focus";
"Open FLEX on app launch" = "Open FLEX on app launch";
"Opens FLEX when the app is focused" = "Opens FLEX when the app is focused";
"Opens FLEX when the app launches" = "Opens FLEX when the app launches";
"Static Cell" = "Static Cell";
"Stepper cell" = "Stepper cell";
"Switch Cell" = "Switch Cell";
"Switch Cell (Restart)" = "Switch Cell (Restart)";
"Tap the switch" = "Tap the switch";
"Using icon" = "Using icon";
"Using image" = "Using image";
"_ Example" = "_ Example";
//////////////////////////////////////////////////////////////////////////////
// DOWNLOADS & MEDIA ACTIONS //
// Action button menus, download/share/copy toasts, quality picker pills. //
//////////////////////////////////////////////////////////////////////////////
"%@ settings" = "%@ settings";
"Cancelled" = "Cancelled";
"Copied %lu URLs" = "Copied %lu URLs";
"Copied caption" = "Copied caption";
"Copied download URL" = "Copied download URL";
"Copy all URLs" = "Copy all URLs";
"Copy caption" = "Copy caption";
"Copy download URL" = "Copy download URL";
"Could not extract any URLs" = "Could not extract any URLs";
"Could not extract media URL" = "Could not extract media URL";
"Could not extract photo URL" = "Could not extract photo URL";
"Could not extract video URL" = "Could not extract video URL";
"Done" = "Done";
"Download all (%lu)" = "Download all (%lu)";
"Download all stories and share?" = "Download all stories and share?";
"Download all to Photos" = "Download all to Photos";
"Download and share all" = "Download and share all";
"Download and share?" = "Download and share?";
"Download failed" = "Download failed";
"Downloaded %lu items" = "Downloaded %lu items";
"Downloading %@..." = "Downloading %@...";
"Downloading..." = "Downloading...";
"Failed to save" = "Failed to save";
"HD download complete" = "HD download complete";
"Mute audio" = "Mute audio";
"No URLs" = "No URLs";
"No URLs found" = "No URLs found";
"No caption on this post" = "No caption on this post";
"No carousel children" = "No carousel children";
"No cover image" = "No cover image";
"No files downloaded" = "No files downloaded";
"No media" = "No media";
"No media URL" = "No media URL";
"No media to expand" = "No media to expand";
"No media to show" = "No media to show";
"No video URL" = "No video URL";
"Not a carousel" = "Not a carousel";
"Nothing to save" = "Nothing to save";
"Nothing to share" = "Nothing to share";
"Opening creator..." = "Opening creator...";
"Photo library access denied" = "Photo library access denied";
"Photos access denied" = "Photos access denied";
"Preparing repost..." = "Preparing repost...";
"Repost" = "Repost";
"Repost unavailable" = "Repost unavailable";
"Save all stories to Photos?" = "Save all stories to Photos?";
"Save failed" = "Save failed";
"Save to Photos?" = "Save to Photos?";
"Saved %lu items" = "Saved %lu items";
"Saved to Photos" = "Saved to Photos";
"Saved to RyukGram" = "Saved to RyukGram";
"Tap to cancel" = "Tap to cancel";
"Unmute audio" = "Unmute audio";
"View cover" = "View cover";
"View mentions" = "View mentions";
//////////////////////////////////////////////////////////////////////////////
// STORIES & MESSAGES (FEATURES) //
// Buttons, menu entries, toasts and alerts shown while watching stories or //
// inside DM threads. //
//////////////////////////////////////////////////////////////////////////////
"A message was unsent" = "A message was unsent";
"Add" = "Add";
"Add to block list" = "Add to block list";
"Add to block list?" = "Add to block list?";
"Added to block list" = "Added to block list";
"Audio not loaded yet. Play the message first and try again." = "Audio not loaded yet. Play the message first and try again.";
"Audio sent" = "Audio sent";
"Audio/Video from Files" = "Audio/Video from Files";
"Blocked" = "Blocked";
"Cancel" = "Cancel";
"Clear preserved messages?" = "Clear preserved messages?";
"Converting..." = "Converting...";
"Copy text" = "Copy text";
"Could not find media" = "Could not find media";
"Could not find story media" = "Could not find story media";
"Could not get audio data. Try again after refreshing the chat." = "Could not get audio data. Try again after refreshing the chat.";
"Could not get video URL" = "Could not get video URL";
"Disable read receipts" = "Disable read receipts";
"Done!" = "Done!";
"Download audio" = "Download audio";
"Downloading audio..." = "Downloading audio...";
"Enable read receipts" = "Enable read receipts";
"Error: %@" = "Error: %@";
"Exclude chat" = "Exclude chat";
"Exclude story seen" = "Exclude story seen";
"Excluded" = "Excluded";
"Extracting audio..." = "Extracting audio...";
"Failed to encode GIF" = "Failed to encode GIF";
"File sending not supported" = "File sending not supported";
"Follow" = "Follow";
"Following" = "Following";
"Mark messages as seen" = "Mark messages as seen";
"Mark seen" = "Mark seen";
"Marked as seen" = "Marked as seen";
"Marked as viewed" = "Marked as viewed";
"Marked messages as seen" = "Marked messages as seen";
"Mentions" = "Mentions";
"Message sender not found" = "Message sender not found";
"Messages settings" = "Messages settings";
"Mute story audio" = "Mute story audio";
"No audio URL found. Try again after refreshing the chat." = "No audio URL found. Try again after refreshing the chat.";
"No mentions in this story" = "No mentions in this story";
"No thread key" = "No thread key";
"No voice send method found" = "No voice send method found";
"Note not found" = "Note not found";
"Note text copied" = "Note text copied";
"Open GitHub" = "Open GitHub";
"Read receipts disabled" = "Read receipts disabled";
"Read receipts enabled" = "Read receipts enabled";
"Read receipts will be blocked for this chat." = "Read receipts will be blocked for this chat.";
"Read receipts will no longer be blocked for this chat." = "Read receipts will no longer be blocked for this chat.";
"Remove" = "Remove";
"Remove from block list" = "Remove from block list";
"Remove from block list?" = "Remove from block list?";
"Removed" = "Removed";
"Save GIF" = "Save GIF";
"Selection too short (min 0.5s)" = "Selection too short (min 0.5s)";
"Send Audio" = "Send Audio";
"Send anyway" = "Send anyway";
"Send failed: %@" = "Send failed: %@";
"Send service not found" = "Send service not found";
"Share" = "Share";
"Story read receipts disabled" = "Story read receipts disabled";
"Story read receipts enabled" = "Story read receipts enabled";
"Story seen receipts will be blocked for @%@." = "Story seen receipts will be blocked for @%@.";
"This chat will resume normal read-receipt behavior." = "This chat will resume normal read-receipt behavior.";
"Total: %@" = "Total: %@";
"Un-exclude" = "Un-exclude";
"Un-exclude chat" = "Un-exclude chat";
"Un-exclude chat?" = "Un-exclude chat?";
"Un-exclude story seen" = "Un-exclude story seen";
"Un-exclude story seen?" = "Un-exclude story seen?";
"Un-excluded" = "Un-excluded";
"Unblock" = "Unblock";
"Unblocked" = "Unblocked";
"Unlimited replay enabled" = "Unlimited replay enabled";
"Unmute story audio" = "Unmute story audio";
"Unsent" = "Unsent";
"Upload Audio" = "Upload Audio";
"VC not found" = "VC not found";
"Video from Library" = "Video from Library";
"Visual messages will expire" = "Visual messages will expire";
"Visual messages: expiring" = "Visual messages: expiring";
"Visual messages: unlimited replay" = "Visual messages: unlimited replay";
"Will sync when leaving stories" = "Will sync when leaving stories";
//////////////////////////////////////////////////////////////////////////////
// GENERAL FEATURES //
// Strings inside per-feature overlays: fake location, color picker, notes //
// customization, profile copy, etc. //
//////////////////////////////////////////////////////////////////////////////
"Add location" = "Add location";
"Add preset" = "Add preset";
"Change location" = "Change location";
"Click the Apply button after this to see the emoji" = "Click the Apply button after this to see the emoji";
"Copied text to clipboard" = "Copied text to clipboard";
"Copy" = "Copy";
"Copy all" = "Copy all";
"Copy bio" = "Copy bio";
"Copy from profile" = "Copy from profile";
"Copy name" = "Copy name";
"Could not find cover image" = "Could not find cover image";
"Current: %@" = "Current: %@";
"Disable" = "Disable";
"Download GIF" = "Download GIF";
"Enable" = "Enable";
"Enter Emoji Text" = "Enter Emoji Text";
"Fake location" = "Fake location";
"Name" = "Name";
"Nothing to copy" = "Nothing to copy";
"Save" = "Save";
"Save preset" = "Save preset";
"Saved locations" = "Saved locations";
"Select color" = "Select color";
"Set location" = "Set location";
"Settings…" = "Settings…";
"Type emoji..." = "Type emoji...";
"direct-inbox-tab" = "direct-inbox-tab";
"mainfeed-tab" = "mainfeed-tab";
//////////////////////////////////////////////////////////////////////////////
// SETTINGS VIEWS & DIALOGS //
// Excluded-lists managers, backup/restore flows, in-picker labels. //
//////////////////////////////////////////////////////////////////////////////
"Add custom domain" = "Add custom domain";
"Add preset…" = "Add preset…";
"All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect." = "All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect.";
"Apply" = "Apply";
"Apply imported settings?" = "Apply imported settings?";
"Apply to" = "Apply to";
"Chats" = "Chats";
"Could not read file." = "Could not read file.";
"Could not write temporary file." = "Could not write temporary file.";
"Current location" = "Current location";
"Custom" = "Custom";
"Date Format" = "Date Format";
"Delete" = "Delete";
"Done editing" = "Done editing";
"Edit values" = "Edit values";
"Enable fake location" = "Enable fake location";
"Every RyukGram preference will revert to its built-in default. This can't be undone." = "Every RyukGram preference will revert to its built-in default. This can't be undone.";
"Excluded chats" = "Excluded chats";
"Excluded users" = "Excluded users";
"File is not a valid RyukGram settings export." = "File is not a valid RyukGram settings export.";
"Follow default" = "Follow default";
"Force OFF (allow unsends)" = "Force OFF (allow unsends)";
"Force ON (preserve unsends)" = "Force ON (preserve unsends)";
"Form view" = "Form view";
"Format" = "Format";
"Import failed" = "Import failed";
"Import preview" = "Import preview";
"Included chats" = "Included chats";
"Included users" = "Included users";
"KD: ON" = "KD: ON";
"KD: default" = "KD: default";
"Keep-deleted" = "Keep-deleted";
"Keep-deleted override" = "Keep-deleted override";
"Off" = "Off";
"On" = "On";
"Presets" = "Presets";
"Raw JSON view" = "Raw JSON view";
"Remove Selected" = "Remove Selected";
"Remove from list" = "Remove from list";
"Reset" = "Reset";
"Reset all settings?" = "Reset all settings?";
"Saved presets are reusable. Tap a preset to make it the active location." = "Saved presets are reusable. Tap a preset to make it the active location.";
"Search address or place" = "Search address or place";
"Search by name or username" = "Search by name or username";
"Search by username or name" = "Search by username or name";
"Search settings" = "Search settings";
"Select" = "Select";
"Select location on map" = "Select location on map";
"Set current location" = "Set current location";
"Set keep-deleted override" = "Set keep-deleted override";
"Settings exported" = "Settings exported";
"Settings imported" = "Settings imported";
"Show seconds" = "Show seconds";
"Sort by" = "Sort by";
"Story users" = "Story users";
"Toggle each NSDate formatter IG uses. Different surfaces (feed, comments, stories, DMs) go through different methods — enable the ones you want the custom format applied to." = "Toggle each NSDate formatter IG uses. Different surfaces (feed, comments, stories, DMs) go through different methods — enable the ones you want the custom format applied to.";
"Use this location" = "Use this location";
"When on, all CoreLocation requests inside Instagram return the location below." = "When on, all CoreLocation requests inside Instagram return the location below.";
"When on, all CoreLocation requests inside Instagram return the location below. Toggle the map button to show or hide the quick toggle on the Friends Map view." = "When on, all CoreLocation requests inside Instagram return the location below. Toggle the map button to show or hide the quick toggle on the Friends Map view.";
"Show map button" = "Show map button";
//////////////////////////////////////////////////////////////////////////////
// REELS (FEATURES) //
// Strings from Reels. //
//////////////////////////////////////////////////////////////////////////////
"Copied!" = "Copied!";
"No password found" = "No password found";
"No text field found" = "No text field found";
"Password" = "Password";
"Refresh Reels?" = "Refresh Reels?";
//////////////////////////////////////////////////////////////////////////////
// PROFILE (FEATURES) //
// Strings from Profile. //
//////////////////////////////////////////////////////////////////////////////
"Doesn't follow you" = "Doesn't follow you";
"Follows you" = "Follows you";
"Note copied" = "Note copied";
//////////////////////////////////////////////////////////////////////////////
// CONFIRM DIALOGS (IN-FEATURE) //
// Strings from Confirm dialogs. //
//////////////////////////////////////////////////////////////////////////////
"Unfollow?" = "Unfollow?";
//////////////////////////////////////////////////////////////////////////////
// MISC //
// Anything that didn't fit a named section. Usually short labels. //
//////////////////////////////////////////////////////////////////////////////
"720p • progressive • fastest" = "720p • progressive • fastest";
"Are you sure?" = "Are you sure?";
"Copy audio URL" = "Copy audio URL";
"Copy quality info" = "Copy quality info";
"Copy video URL" = "Copy video URL";
"Could not access reel media" = "Could not access reel media";
"Could not access reel photo" = "Could not access reel photo";
"Could not extract photo url from post" = "Could not extract photo url from post";
"Could not extract photo url from reel" = "Could not extract photo url from reel";
"Could not extract photo url from story" = "Could not extract photo url from story";
"Could not extract video url from post" = "Could not extract video url from post";
"Could not extract video url from reel" = "Could not extract video url from reel";
"Could not extract video url from story" = "Could not extract video url from story";
"Download Quality" = "Download Quality";
"FFmpegKit Debug" = "FFmpegKit Debug";
"Later" = "Later";
"No!" = "No!";
"Restart" = "Restart";
"Restart required" = "Restart required";
"Yes" = "Yes";
"You must restart the app to apply this change" = "You must restart the app to apply this change";
//////////////////////////////////////////////////////////////////////////////
// ABOUT / CREDITS //
// Strings from the About / Credits footer of Settings. //
//////////////////////////////////////////////////////////////////////////////
"%@ — view source, report issues, see releases" = "%@ — view source, report issues, see releases";
"Credits" = "Credits";
"Developer" = "Developer";
"Donate to SoCuul" = "Donate to SoCuul";
"Original SCInsta developer" = "Original SCInsta developer";
"Ryuk" = "Ryuk";
"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul";
"RyukGram on GitHub" = "RyukGram on GitHub";
"SoCuul" = "SoCuul";
"Support the original developer" = "Support the original developer";
"View Repo" = "View Repo";
"View the source code on GitHub" = "View the source code on GitHub";
//////////////////////////////////////////////////////////////////////////////
// HD DOWNLOADS //
// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). //
//////////////////////////////////////////////////////////////////////////////
"Download video at the highest available quality" = "Download video at the highest available quality";
"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit.";
"Encoding speed" = "Encoding speed";
"Enhanced downloads" = "Enhanced downloads";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable.";
"Faster = lower quality" = "Faster = lower quality";
"Photo quality" = "Photo quality";
"Use highest resolution available" = "Use highest resolution available";
"Video quality" = "Video quality";
"Which quality to download" = "Which quality to download";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL / DEBUG //
// Placeholder rows only shown in the experimental settings sandbox. //
//////////////////////////////////////////////////////////////////////////////
"Navigation Cell" = "Navigation Cell";
+38
View File
@@ -0,0 +1,38 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
#ifdef __cplusplus
extern "C" {
#endif
// Localization pref key — value is BCP-47 code ("en", "ar", "es") or "system".
extern NSString *const SCILanguagePrefKey;
// Resource bundle (RyukGram.bundle) shipped next to the dylib.
// Returns nil only on broken installs; callers fall back to the key itself.
NSBundle * _Nullable SCILocalizationBundle(void);
// Fresh lookup each call — cheap enough (NSBundle caches strings files internally).
// `fallback` is returned when the key is missing. Pass the English source text.
NSString *SCILocalizedString(NSString *key, NSString * _Nullable fallback);
// Languages we actually ship. `system` means "follow iOS locale".
// Ordered for the picker UI; first entry is always "system".
NSArray<NSDictionary<NSString *, NSString *> *> *SCIAvailableLanguages(void);
// Currently-active language code ("en", "ar", …) after resolving "system".
NSString *SCIResolvedLanguageCode(void);
// Invalidate cached bundles/strings after a language switch.
void SCILocalizationReset(void);
#ifdef __cplusplus
}
#endif
NS_ASSUME_NONNULL_END
// Convenience macro — key doubles as English fallback so missing translations
// degrade gracefully to the source text.
#define SCILocalized(key) SCILocalizedString((key), (key))
+99
View File
@@ -0,0 +1,99 @@
#import "SCILocalization.h"
#import <dlfcn.h>
NSString *const SCILanguagePrefKey = @"sci_language";
static NSBundle *gResourceBundle = nil;
static NSBundle *gLanguageBundle = nil;
static NSString *gLanguageBundleCode = nil;
static dispatch_once_t gResourceOnce;
static NSBundle *resolveResourceBundle(void) {
// 1) Sideload: cyan copies RyukGram.bundle into the app's resource root.
NSString *path = [[NSBundle mainBundle] pathForResource:@"RyukGram" ofType:@"bundle"];
// 2) Jailbreak: .deb drops the bundle into Library/Application Support.
if (!path) {
NSArray *fallbacks = @[
@"/var/jb/Library/Application Support/RyukGram.bundle",
@"/Library/Application Support/RyukGram.bundle",
];
NSFileManager *fm = [NSFileManager defaultManager];
for (NSString *p in fallbacks) {
if ([fm fileExistsAtPath:p]) { path = p; break; }
}
}
// 3) Last resort: sibling of the loaded dylib (dev / Feather with loose files).
if (!path) {
Dl_info info;
if (dladdr((const void *)&resolveResourceBundle, &info) && info.dli_fname) {
NSString *dylibPath = [NSString stringWithUTF8String:info.dli_fname];
NSString *candidate = [[dylibPath stringByDeletingLastPathComponent]
stringByAppendingPathComponent:@"RyukGram.bundle"];
if ([[NSFileManager defaultManager] fileExistsAtPath:candidate]) path = candidate;
}
}
return path ? [NSBundle bundleWithPath:path] : nil;
}
NSBundle *SCILocalizationBundle(void) {
dispatch_once(&gResourceOnce, ^{ gResourceBundle = resolveResourceBundle(); });
return gResourceBundle;
}
static NSString *preferredLanguageCode(NSBundle *resource) {
NSString *pref = [[NSUserDefaults standardUserDefaults] stringForKey:SCILanguagePrefKey];
if (pref.length && ![pref isEqualToString:@"system"]) return pref;
// Match iOS locale against the languages actually shipped in the bundle.
NSArray<NSString *> *shipped = [resource localizations];
NSArray<NSString *> *matches = [NSBundle preferredLocalizationsFromArray:shipped
forPreferences:[NSLocale preferredLanguages]];
return matches.firstObject ?: @"en";
}
NSString *SCIResolvedLanguageCode(void) {
NSBundle *b = SCILocalizationBundle();
return b ? preferredLanguageCode(b) : @"en";
}
static NSBundle *activeLanguageBundle(void) {
NSBundle *resource = SCILocalizationBundle();
if (!resource) return nil;
NSString *code = preferredLanguageCode(resource);
if (gLanguageBundle && [code isEqualToString:gLanguageBundleCode]) return gLanguageBundle;
NSString *lprojPath = [resource pathForResource:code ofType:@"lproj"];
if (!lprojPath) lprojPath = [resource pathForResource:@"en" ofType:@"lproj"];
gLanguageBundle = lprojPath ? [NSBundle bundleWithPath:lprojPath] : resource;
gLanguageBundleCode = [code copy];
return gLanguageBundle;
}
NSString *SCILocalizedString(NSString *key, NSString *fallback) {
if (key.length == 0) return fallback ?: @"";
NSBundle *lang = activeLanguageBundle();
if (!lang) return fallback ?: key;
// NSBundle returns the key itself when missing (when `value` is nil) —
// that's our signal to fall back to the English source text.
NSString *value = [lang localizedStringForKey:key value:@"\x01SCI_MISSING\x01" table:nil];
if ([value isEqualToString:@"\x01SCI_MISSING\x01"]) return fallback ?: key;
return value;
}
NSArray<NSDictionary<NSString *, NSString *> *> *SCIAvailableLanguages(void) {
// `code` is what we persist; `native` is shown in the picker (endonyms read best).
return @[
@{ @"code": @"system", @"native": @"System", @"english": @"System default" },
@{ @"code": @"en", @"native": @"English", @"english": @"English" },
];
}
void SCILocalizationReset(void) {
gLanguageBundle = nil;
gLanguageBundleCode = nil;
}
+35
View File
@@ -0,0 +1,35 @@
// Reusable wrapper for Instagram private API calls. Reads the Bearer token
// for the active account from IG's keychain group and uses it to talk to
// the legacy /api/v1/ endpoints. Account switches are picked up automatically.
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef void(^SCIAPICompletion)(NSDictionary * _Nullable response, NSError * _Nullable error);
typedef void(^SCIAPIStatusesCompletion)(NSDictionary * _Nullable statuses, NSError * _Nullable error);
@interface SCIInstagramAPI : NSObject
// ============ Generic ============
// `path` is the part after /api/v1/, e.g. "friendships/create/123/".
// `body` is form-encoded if non-nil. `completion` runs on the main queue.
+ (void)sendRequestWithMethod:(NSString *)method
path:(NSString *)path
body:(nullable NSDictionary *)body
completion:(nullable SCIAPICompletion)completion;
// ============ Friendships ============
+ (void)followUserPK:(NSString *)pk completion:(nullable SCIAPICompletion)completion;
+ (void)unfollowUserPK:(NSString *)pk completion:(nullable SCIAPICompletion)completion;
// Bulk-fetch friendship statuses for a set of user PKs in one round trip.
// Statuses dict maps pk → {following, outgoing_request, is_private, ...}.
+ (void)fetchFriendshipStatusesForPKs:(NSArray<NSString *> *)pks
completion:(nullable SCIAPIStatusesCompletion)completion;
@end
NS_ASSUME_NONNULL_END
+190
View File
@@ -0,0 +1,190 @@
// Reusable IG private API helper. See SCIInstagramAPI.h.
#import "SCIInstagramAPI.h"
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <objc/message.h>
#import <sys/sysctl.h>
#define SCI_API_BASE @"https://i.instagram.com/api/v1/"
#define SCI_APP_ID @"124024574287414" // public IG iOS app id constant
// User-Agent in IG's exact format, generated from the device + IG bundle.
static NSString *sciUserAgent(void) {
static NSString *ua = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSString *version = [NSBundle mainBundle].infoDictionary[@"CFBundleShortVersionString"] ?: @"424.0.0";
char machine[64] = {0};
size_t size = sizeof(machine);
sysctlbyname("hw.machine", machine, &size, NULL, 0);
NSString *device = machine[0] ? [NSString stringWithUTF8String:machine] : @"iPhone15,2";
NSString *iosVersion = [[UIDevice currentDevice].systemVersion stringByReplacingOccurrencesOfString:@"." withString:@"_"];
NSString *locale = [NSLocale currentLocale].localeIdentifier ?: @"en_US";
NSString *lang = [[NSLocale preferredLanguages] firstObject] ?: @"en";
UIScreen *screen = [UIScreen mainScreen];
ua = [NSString stringWithFormat:@"Instagram %@ (%@; iOS %@; %@; %@; scale=%.2f; %.0fx%.0f; 0)",
version, device, iosVersion, locale, lang,
screen.scale, screen.nativeBounds.size.width, screen.nativeBounds.size.height];
});
return ua;
}
// ============ IG runtime accessors ============
// Active IGUserSession. Walks every window across all connected scenes
// since key window can be nil in some states.
static id sciCurrentUserSession(void) {
@try {
UIApplication *app = [UIApplication sharedApplication];
NSMutableArray *windows = [NSMutableArray array];
if (app.keyWindow) [windows addObject:app.keyWindow];
for (UIWindow *w in app.windows) if (w) [windows addObject:w];
for (UIScene *scene in app.connectedScenes) {
if ([scene isKindOfClass:[UIWindowScene class]]) {
for (UIWindow *w in ((UIWindowScene *)scene).windows) if (w) [windows addObject:w];
}
}
for (id w in windows) {
if ([w respondsToSelector:@selector(userSession)]) {
id s = [w valueForKey:@"userSession"];
if (s) return s;
}
}
} @catch (__unused id e) {}
return nil;
}
// PK of the currently active account. Changes on quick-switch.
static NSString *sciCurrentUserPK(void) {
@try {
id session = sciCurrentUserSession();
id user = session ? [session valueForKey:@"user"] : nil;
if (!user) return nil;
Ivar pkIvar = class_getInstanceVariable([user class], "_pk");
if (pkIvar) {
id pk = object_getIvar(user, pkIvar);
if (pk) return [NSString stringWithFormat:@"%@", pk];
}
} @catch (__unused id e) {}
return nil;
}
// Bearer token for the active account, read fresh from
// -[IGUserSession authHeaderManager] -> -[IGUserAuthHeaderManager authHeader].
static NSString *sciAuthHeader(void) {
@try {
id session = sciCurrentUserSession();
if (!session || ![session respondsToSelector:@selector(authHeaderManager)]) return nil;
id manager = ((id(*)(id, SEL))objc_msgSend)(session, @selector(authHeaderManager));
if (!manager || ![manager respondsToSelector:@selector(authHeader)]) return nil;
id header = ((id(*)(id, SEL))objc_msgSend)(manager, @selector(authHeader));
if ([header isKindOfClass:[NSString class]] && [(NSString *)header length]) return header;
} @catch (__unused id e) {}
return nil;
}
// ============ Request building ============
static NSString *sciFormEncode(NSDictionary *params) {
if (!params.count) return @"";
NSMutableArray *parts = [NSMutableArray array];
NSCharacterSet *allowed = [NSCharacterSet URLQueryAllowedCharacterSet];
for (NSString *key in params) {
NSString *val = [NSString stringWithFormat:@"%@", params[key]];
NSString *ek = [key stringByAddingPercentEncodingWithAllowedCharacters:allowed];
NSString *ev = [val stringByAddingPercentEncodingWithAllowedCharacters:allowed];
[parts addObject:[NSString stringWithFormat:@"%@=%@", ek, ev]];
}
return [parts componentsJoinedByString:@"&"];
}
static NSMutableURLRequest *sciBuildRequest(NSString *method, NSURL *url, NSDictionary *body) {
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url];
req.HTTPMethod = method ?: @"GET";
[req setValue:sciUserAgent() forHTTPHeaderField:@"User-Agent"];
[req setValue:SCI_APP_ID forHTTPHeaderField:@"X-IG-App-ID"];
[req setValue:@"WIFI" forHTTPHeaderField:@"X-IG-Connection-Type"];
[req setValue:@"en-US" forHTTPHeaderField:@"Accept-Language"];
NSString *auth = sciAuthHeader();
if (auth) [req setValue:auth forHTTPHeaderField:@"Authorization"];
for (NSHTTPCookie *c in [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:url]) {
if ([c.name isEqualToString:@"csrftoken"]) {
[req setValue:c.value forHTTPHeaderField:@"X-CSRFToken"];
break;
}
}
if (body) {
req.HTTPBody = [sciFormEncode(body) dataUsingEncoding:NSUTF8StringEncoding];
[req setValue:@"application/x-www-form-urlencoded; charset=UTF-8"
forHTTPHeaderField:@"Content-Type"];
}
return req;
}
static void sciPerformRequest(NSMutableURLRequest *req, SCIAPICompletion completion) {
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:req
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
NSDictionary *resp = nil;
if (data.length) {
@try {
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if ([parsed isKindOfClass:[NSDictionary class]]) resp = parsed;
} @catch (__unused id e) {}
}
if (completion) {
dispatch_async(dispatch_get_main_queue(), ^{ completion(resp, error); });
}
}];
[task resume];
}
@implementation SCIInstagramAPI
// ============ Generic ============
+ (void)sendRequestWithMethod:(NSString *)method
path:(NSString *)path
body:(NSDictionary *)body
completion:(SCIAPICompletion)completion {
NSString *clean = [path hasPrefix:@"/"] ? [path substringFromIndex:1] : path;
NSURL *url = [NSURL URLWithString:[SCI_API_BASE stringByAppendingString:clean]];
sciPerformRequest(sciBuildRequest(method, url, body), completion);
}
// ============ Friendships ============
+ (void)followUserPK:(NSString *)pk completion:(SCIAPICompletion)completion {
if (!pk.length) { if (completion) completion(nil, nil); return; }
[self sendRequestWithMethod:@"POST"
path:[NSString stringWithFormat:@"friendships/create/%@/", pk]
body:@{@"user_id": pk, @"radio_type": @"wifi-none"}
completion:completion];
}
+ (void)unfollowUserPK:(NSString *)pk completion:(SCIAPICompletion)completion {
if (!pk.length) { if (completion) completion(nil, nil); return; }
[self sendRequestWithMethod:@"POST"
path:[NSString stringWithFormat:@"friendships/destroy/%@/", pk]
body:@{@"user_id": pk, @"radio_type": @"wifi-none"}
completion:completion];
}
+ (void)fetchFriendshipStatusesForPKs:(NSArray<NSString *> *)pks
completion:(SCIAPIStatusesCompletion)completion {
if (!pks.count) { if (completion) completion(nil, nil); return; }
[self sendRequestWithMethod:@"POST"
path:@"friendships/show_many/"
body:@{@"user_ids": [pks componentsJoinedByString:@","]}
completion:^(NSDictionary *response, NSError *error) {
NSDictionary *statuses = nil;
id s = response[@"friendship_statuses"];
if ([s isKindOfClass:[NSDictionary class]]) statuses = s;
if (completion) completion(statuses, error);
}];
}
@end
+33
View File
@@ -0,0 +1,33 @@
// SCIDashParser — parses DASH MPD manifests from IGMedia for HD streams.
#import <Foundation/Foundation.h>
@interface SCIDashRepresentation : NSObject
@property (nonatomic, strong) NSURL *url;
@property (nonatomic, assign) NSInteger bandwidth;
@property (nonatomic, assign) NSInteger width;
@property (nonatomic, assign) NSInteger height;
@property (nonatomic, copy) NSString *contentType; // "video" or "audio"
@property (nonatomic, copy) NSString *qualityLabel; // "1080p", "720p", etc.
@property (nonatomic, assign) float frameRate; // 0 if unknown
@property (nonatomic, copy) NSString *codecs; // e.g. "avc1.4d401f" or "mp4a.40.2"
@end
typedef NS_ENUM(NSInteger, SCIVideoQuality) {
SCIVideoQualityLowest,
SCIVideoQualityMedium,
SCIVideoQualityHighest,
SCIVideoQualityAsk
};
@interface SCIDashParser : NSObject
+ (NSArray<SCIDashRepresentation *> *)parseManifest:(NSString *)xmlString;
+ (SCIDashRepresentation *)bestVideoFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (SCIDashRepresentation *)bestAudioFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (NSArray<SCIDashRepresentation *> *)videoRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (SCIDashRepresentation *)representationForQuality:(SCIVideoQuality)quality
fromRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (NSString *)dashManifestForMedia:(id)media;
@end
+217
View File
@@ -0,0 +1,217 @@
#import "SCIDashParser.h"
#import <objc/runtime.h>
#import <objc/message.h>
@implementation SCIDashRepresentation
@end
static id sciDashFieldCache(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;
id fc = nil;
@try { fc = object_getIvar(obj, fcIvar); } @catch (__unused id e) { return nil; }
if (![fc isKindOfClass:[NSDictionary class]]) return nil;
id val = ((NSDictionary *)fc)[key];
if (!val || [val isKindOfClass:[NSNull class]]) return nil;
return val;
}
@implementation SCIDashParser
+ (NSString *)dashManifestForMedia:(id)media {
if (!media) return nil;
NSArray *keys = @[@"video_dash_manifest", @"dash_manifest",
@"video_dash_manifest_url", @"dash_manifest_url"];
for (NSString *key in keys) {
id val = sciDashFieldCache(media, key);
if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10)
return val;
}
id video = nil;
SEL videoSel = @selector(video);
if ([media respondsToSelector:videoSel]) {
video = ((id(*)(id, SEL))objc_msgSend)(media, videoSel);
if (video && ![(id)video isKindOfClass:[NSObject class]]) video = nil;
}
if (video) {
for (NSString *key in keys) {
id val = sciDashFieldCache(video, key);
if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10)
return val;
}
}
return nil;
}
+ (NSArray<SCIDashRepresentation *> *)parseManifest:(NSString *)xmlString {
if (!xmlString.length) return @[];
NSMutableArray<SCIDashRepresentation *> *results = [NSMutableArray array];
NSError *err = nil;
// AdaptationSet blocks (handles both contentType= and mimeType= patterns)
NSRegularExpression *adaptRE = [NSRegularExpression
regularExpressionWithPattern:@"(<AdaptationSet[^>]*>)(.*?)</AdaptationSet>"
options:NSRegularExpressionDotMatchesLineSeparators error:&err];
if (err) return @[];
NSRegularExpression *ctRE = [NSRegularExpression
regularExpressionWithPattern:@"contentType=\"(video|audio)\"" options:NSRegularExpressionCaseInsensitive error:nil];
NSRegularExpression *mtRE = [NSRegularExpression
regularExpressionWithPattern:@"mimeType=\"(video|audio)/[^\"]*\"" options:NSRegularExpressionCaseInsensitive error:nil];
NSRegularExpression *repRE = [NSRegularExpression
regularExpressionWithPattern:@"<Representation[^>]*>"
options:0 error:nil];
NSRegularExpression *baseURLRE = [NSRegularExpression
regularExpressionWithPattern:@"<BaseURL>(.*?)</BaseURL>"
options:0 error:nil];
NSRegularExpression *bwRE = [NSRegularExpression
regularExpressionWithPattern:@"bandwidth=\"(\\d+)\"" options:0 error:nil];
NSRegularExpression *widthRE = [NSRegularExpression
regularExpressionWithPattern:@"(?:^|\\s)width=\"(\\d+)\"" options:0 error:nil];
NSRegularExpression *heightRE = [NSRegularExpression
regularExpressionWithPattern:@"(?:^|\\s)height=\"(\\d+)\"" options:0 error:nil];
NSRegularExpression *labelRE = [NSRegularExpression
regularExpressionWithPattern:@"FBQualityLabel=\"([^\"]+)\"" options:0 error:nil];
NSRegularExpression *fpsRE = [NSRegularExpression
regularExpressionWithPattern:@"frameRate=\"([0-9./]+)\"" options:0 error:nil];
NSRegularExpression *codecsRE = [NSRegularExpression
regularExpressionWithPattern:@"codecs=\"([^\"]+)\"" options:0 error:nil];
[adaptRE enumerateMatchesInString:xmlString options:0
range:NSMakeRange(0, xmlString.length)
usingBlock:^(NSTextCheckingResult *adaptMatch, __unused NSMatchingFlags flags, __unused BOOL *stop) {
NSString *adaptTag = [xmlString substringWithRange:[adaptMatch rangeAtIndex:1]];
NSString *adaptBody = [xmlString substringWithRange:[adaptMatch rangeAtIndex:2]];
NSString *contentType = nil;
NSTextCheckingResult *ctMatch = [ctRE firstMatchInString:adaptTag options:0
range:NSMakeRange(0, adaptTag.length)];
if (ctMatch) {
contentType = [[adaptTag substringWithRange:[ctMatch rangeAtIndex:1]] lowercaseString];
} else {
NSTextCheckingResult *mtMatch = [mtRE firstMatchInString:adaptTag options:0
range:NSMakeRange(0, adaptTag.length)];
if (mtMatch) {
contentType = [[adaptTag substringWithRange:[mtMatch rangeAtIndex:1]] lowercaseString];
}
}
if (!contentType) return;
NSArray<NSTextCheckingResult *> *repMatches =
[repRE matchesInString:adaptBody options:0 range:NSMakeRange(0, adaptBody.length)];
NSArray<NSTextCheckingResult *> *urlMatches =
[baseURLRE matchesInString:adaptBody options:0 range:NSMakeRange(0, adaptBody.length)];
for (NSUInteger i = 0; i < repMatches.count && i < urlMatches.count; i++) {
NSString *repTag = [adaptBody substringWithRange:repMatches[i].range];
NSString *baseURL = [adaptBody substringWithRange:[urlMatches[i] rangeAtIndex:1]];
if (!baseURL.length) continue;
baseURL = [baseURL stringByReplacingOccurrencesOfString:@"&amp;" withString:@"&"];
SCIDashRepresentation *rep = [SCIDashRepresentation new];
rep.url = [NSURL URLWithString:baseURL];
rep.contentType = contentType;
NSTextCheckingResult *bwMatch = [bwRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (bwMatch) rep.bandwidth = [[repTag substringWithRange:[bwMatch rangeAtIndex:1]] integerValue];
NSTextCheckingResult *wMatch = [widthRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (wMatch) rep.width = [[repTag substringWithRange:[wMatch rangeAtIndex:1]] integerValue];
NSTextCheckingResult *hMatch = [heightRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (hMatch) rep.height = [[repTag substringWithRange:[hMatch rangeAtIndex:1]] integerValue];
NSTextCheckingResult *fpsMatch = [fpsRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (fpsMatch) {
NSString *raw = [repTag substringWithRange:[fpsMatch rangeAtIndex:1]];
NSArray *parts = [raw componentsSeparatedByString:@"/"];
if (parts.count == 2) {
float num = [parts[0] floatValue], den = [parts[1] floatValue];
if (den > 0) rep.frameRate = num / den;
} else {
rep.frameRate = [raw floatValue];
}
}
NSTextCheckingResult *codecsMatch = [codecsRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (codecsMatch) rep.codecs = [repTag substringWithRange:[codecsMatch rangeAtIndex:1]];
// Quality label from shorter dimension (1080x1920 → "1080p")
if (rep.width > 0 && rep.height > 0) {
NSInteger shortSide = MIN(rep.width, rep.height);
rep.qualityLabel = [NSString stringWithFormat:@"%ldp", (long)shortSide];
} else if (rep.height > 0) {
rep.qualityLabel = [NSString stringWithFormat:@"%ldp", (long)rep.height];
} else {
NSTextCheckingResult *lMatch = [labelRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (lMatch) rep.qualityLabel = [repTag substringWithRange:[lMatch rangeAtIndex:1]];
}
if (rep.url) [results addObject:rep];
}
}];
return [results copy];
}
+ (SCIDashRepresentation *)bestVideoFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
return [[self videoRepresentations:reps] firstObject];
}
+ (SCIDashRepresentation *)bestAudioFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
SCIDashRepresentation *best = nil;
for (SCIDashRepresentation *r in reps) {
if (![r.contentType isEqualToString:@"audio"]) continue;
if (!best || r.bandwidth > best.bandwidth) best = r;
}
return best;
}
+ (NSArray<SCIDashRepresentation *> *)videoRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
NSMutableArray *videos = [NSMutableArray array];
for (SCIDashRepresentation *r in reps) {
if ([r.contentType isEqualToString:@"video"]) [videos addObject:r];
}
return [videos sortedArrayUsingComparator:^NSComparisonResult(SCIDashRepresentation *a, SCIDashRepresentation *b) {
return [@(b.bandwidth) compare:@(a.bandwidth)]; // descending
}];
}
+ (SCIDashRepresentation *)representationForQuality:(SCIVideoQuality)quality
fromRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
NSArray *sorted = [self videoRepresentations:reps];
if (!sorted.count) return nil;
switch (quality) {
case SCIVideoQualityHighest: return sorted.firstObject;
case SCIVideoQualityLowest: return sorted.lastObject;
case SCIVideoQualityMedium: return sorted[sorted.count / 2];
case SCIVideoQualityAsk: return sorted.firstObject; // caller handles the picker
}
return sorted.firstObject;
}
@end
+40
View File
@@ -0,0 +1,40 @@
// SCIFFmpeg — runtime FFmpegKit wrapper (loads dynamically via dlopen).
#import <Foundation/Foundation.h>
@interface SCIFFmpeg : NSObject
+ (BOOL)isAvailable;
// Cancel any in-flight downloads and running FFmpeg sessions.
+ (void)cancelAll;
+ (BOOL)isCancelled;
+ (void)executeCommand:(NSString *)command
completion:(void(^)(BOOL success, NSString *output))completion;
+ (void)probeCommand:(NSString *)command
completion:(void(^)(BOOL success, NSString *output))completion;
+ (void)muxVideoURL:(NSURL *)videoURL
audioURL:(NSURL *)audioURL
preset:(NSString *)preset
progress:(void(^)(float progress, NSString *stage))progressBlock
completion:(void(^)(NSURL *outputURL, NSError *error))completion;
// Same as above but publishes a per-session cancel block via cancelOut (called once,
// synchronously or on main, before the mux starts). Tapping the pill's ticket cancel
// invokes this — cancels only THIS mux, not other in-flight downloads.
+ (void)muxVideoURL:(NSURL *)videoURL
audioURL:(NSURL *)audioURL
preset:(NSString *)preset
progress:(void(^)(float progress, NSString *stage))progressBlock
completion:(void(^)(NSURL *outputURL, NSError *error))completion
cancelOut:(void(^)(void (^cancelBlock)(void)))cancelOut;
+ (void)convertAudioAtPath:(NSString *)inputPath
toFormat:(NSString *)format
bitrate:(NSString *)bitrate
completion:(void(^)(NSURL *outputURL, NSError *error))completion;
@end
+597
View File
@@ -0,0 +1,597 @@
#import "SCIFFmpeg.h"
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <objc/message.h>
#import <dlfcn.h>
#import <libkern/OSAtomic.h>
static Class FFmpegKitClass = nil;
static Class FFmpegSessionClass = nil;
static Class ReturnCodeClass = nil;
static BOOL sciFFmpegLoaded = NO;
static BOOL sciFFmpegChecked = NO;
// Cancellation state. All access to sciActiveURLSessions goes through sciCancelQueue.
static volatile int32_t sciCancelRequested = 0;
static NSHashTable<NSURLSession *> *sciActiveURLSessions = nil;
static dispatch_queue_t sciCancelQueue(void) {
static dispatch_queue_t q;
static dispatch_once_t once;
dispatch_once(&once, ^{
q = dispatch_queue_create("com.ryuk.scinsta.ffmpeg.cancel", DISPATCH_QUEUE_SERIAL);
sciActiveURLSessions = [NSHashTable weakObjectsHashTable];
});
return q;
}
static void sciRegisterSession(NSURLSession *session) {
if (!session) return;
dispatch_queue_t q = sciCancelQueue();
dispatch_sync(q, ^{ [sciActiveURLSessions addObject:session]; });
}
static void sciUnregisterSession(NSURLSession *session) {
if (!session) return;
dispatch_queue_t q = sciCancelQueue();
dispatch_sync(q, ^{ [sciActiveURLSessions removeObject:session]; });
}
static NSArray<NSURLSession *> *sciActiveSessionsSnapshot(void) {
__block NSArray *out = @[];
dispatch_queue_t q = sciCancelQueue();
dispatch_sync(q, ^{ out = [sciActiveURLSessions allObjects] ?: @[]; });
return out;
}
// Resolve the directory our dylib lives in (works for any injection method)
static NSString *sciDylibDir(void) {
Dl_info info;
if (dladdr((void *)sciDylibDir, &info) && info.dli_fname) {
NSString *path = [[NSString stringWithUTF8String:info.dli_fname] stringByDeletingLastPathComponent];
return path;
}
return nil;
}
static void sciLoadFFmpegKit(void) {
if (sciFFmpegChecked) return;
sciFFmpegChecked = YES;
NSMutableArray *paths = [NSMutableArray arrayWithArray:@[
// Sideload (Feather): .bundle copied to app root
[[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"RyukGram.bundle/ffmpegkit.framework/ffmpegkit"],
// Sideload (cyan): injected into Frameworks/
[[[NSBundle mainBundle] privateFrameworksPath] stringByAppendingPathComponent:@"ffmpegkit.framework/ffmpegkit"],
// Jailbreak rootless
@"/var/jb/Library/Application Support/RyukGram.bundle/ffmpegkit.framework/ffmpegkit",
@"/var/jb/Library/MobileSubstrate/DynamicLibraries/ffmpegkit.framework/ffmpegkit",
// Jailbreak rootful
@"/Library/Application Support/RyukGram.bundle/ffmpegkit.framework/ffmpegkit",
@"/Library/MobileSubstrate/DynamicLibraries/ffmpegkit.framework/ffmpegkit",
]];
// Relative to our own dylib
NSString *dylibDir = sciDylibDir();
if (dylibDir) {
[paths insertObject:[dylibDir stringByAppendingPathComponent:@"ffmpegkit.framework/ffmpegkit"] atIndex:0];
}
NSFileManager *fm = [NSFileManager defaultManager];
void *handle = NULL;
NSMutableArray *dlErrors = [NSMutableArray array];
for (NSString *fwPath in paths) {
if (![fm fileExistsAtPath:fwPath]) continue;
// Preload deps (renamed _sci dir, original binary name)
NSString *fwDir = [[fwPath stringByDeletingLastPathComponent] stringByDeletingLastPathComponent];
NSArray *deps = @[@"libavutil", @"libswresample", @"libswscale",
@"libavcodec", @"libavformat", @"libavfilter", @"libavdevice"];
for (NSString *dep in deps) {
// Try _sci first (sideload), then original (jailbreak)
NSString *sciPath = [NSString stringWithFormat:@"%@/%@_sci.framework/%@", fwDir, dep, dep];
NSString *origPath = [NSString stringWithFormat:@"%@/%@.framework/%@", fwDir, dep, dep];
if ([fm fileExistsAtPath:sciPath]) dlopen(sciPath.UTF8String, RTLD_NOW | RTLD_GLOBAL);
else if ([fm fileExistsAtPath:origPath]) dlopen(origPath.UTF8String, RTLD_NOW | RTLD_GLOBAL);
}
handle = dlopen(fwPath.UTF8String, RTLD_NOW | RTLD_GLOBAL);
if (handle) {
NSLog(@"[SCInsta] FFmpegKit loaded from %@", fwPath);
break;
}
const char *err = dlerror();
[dlErrors addObject:[NSString stringWithFormat:@"%@\n%s", [fwPath lastPathComponent], err ?: "unknown"]];
}
if (!handle) {
NSLog(@"[SCInsta] FFmpegKit not available");
for (NSString *e in dlErrors) NSLog(@"[SCInsta] dlopen: %@", e);
dispatch_async(dispatch_get_main_queue(), ^{
NSMutableString *msg = [NSMutableString stringWithString:@"dlopen errors:\n"];
for (NSString *e in dlErrors) [msg appendFormat:@"%@\n\n", e];
[msg appendString:@"\nTried paths:\n"];
NSFileManager *fm2 = [NSFileManager defaultManager];
for (NSString *p in paths) {
BOOL exists = [fm2 fileExistsAtPath:p];
[msg appendFormat:@"%@ %@\n", exists ? @"" : @"", [p lastPathComponent]];
if (!exists) {
NSString *parent = [p stringByDeletingLastPathComponent];
NSString *grandparent = [parent stringByDeletingLastPathComponent];
[msg appendFormat:@" dir: %@ %@\n dir: %@ %@\n",
[fm2 fileExistsAtPath:parent] ? @"" : @"", [parent lastPathComponent],
[fm2 fileExistsAtPath:grandparent] ? @"" : @"", [grandparent lastPathComponent]];
}
}
NSString *bundlePath = [[NSBundle mainBundle] bundlePath];
NSArray *rootContents = [fm2 contentsOfDirectoryAtPath:bundlePath error:nil];
[msg appendString:@"\nApp bundle root:\n"];
for (NSString *item in rootContents)
if ([item containsString:@"RyukGram"] || [item containsString:@"ffmpeg"] || [item containsString:@".bundle"])
[msg appendFormat:@" %@\n", item];
NSString *fwPath = [[NSBundle mainBundle] privateFrameworksPath];
NSArray *fwContents = [fm2 contentsOfDirectoryAtPath:fwPath error:nil];
[msg appendString:@"\nFrameworks/:\n"];
for (NSString *item in fwContents)
if ([item containsString:@"ffmpeg"] || [item containsString:@"libav"] || [item containsString:@"libsw"] || [item containsString:@"RyukGram"])
[msg appendFormat:@" %@\n", item];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"FFmpegKit Debug")
message:msg preferredStyle:UIAlertControllerStyleAlert];
NSString *copyMsg = [msg copy];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[UIPasteboard generalPasteboard].string = copyMsg;
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
UIViewController *root = [UIApplication sharedApplication].keyWindow.rootViewController;
while (root.presentedViewController) root = root.presentedViewController;
[root presentViewController:alert animated:YES completion:nil];
});
});
return;
}
FFmpegKitClass = NSClassFromString(@"FFmpegKit");
FFmpegSessionClass = NSClassFromString(@"FFmpegSession");
ReturnCodeClass = NSClassFromString(@"ReturnCode");
if (FFmpegKitClass) {
sciFFmpegLoaded = YES;
NSLog(@"[SCInsta] FFmpegKit ready");
} else {
NSLog(@"[SCInsta] FFmpegKit classes not found after dlopen");
dlclose(handle);
}
}
@implementation SCIFFmpeg
+ (BOOL)isAvailable {
sciLoadFFmpegKit();
return sciFFmpegLoaded;
}
+ (BOOL)isCancelled {
return sciCancelRequested == 1;
}
+ (void)cancelAll {
OSAtomicCompareAndSwap32(0, 1, &sciCancelRequested);
for (NSURLSession *s in sciActiveSessionsSnapshot()) {
@try { [s invalidateAndCancel]; } @catch (__unused id e) {}
}
// Class-level cancel stops any running FFmpeg session.
if (FFmpegKitClass) {
SEL cancelSel = NSSelectorFromString(@"cancel");
if ([FFmpegKitClass respondsToSelector:cancelSel]) {
@try { ((void(*)(id, SEL))objc_msgSend)(FFmpegKitClass, cancelSel); }
@catch (__unused id e) {}
}
}
// Grace period so the next download can proceed.
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
OSAtomicCompareAndSwap32(1, 0, &sciCancelRequested);
});
}
+ (void)executeCommand:(NSString *)command
completion:(void(^)(BOOL success, NSString *output))completion {
if (![self isAvailable]) {
if (completion) completion(NO, @"FFmpegKit not available");
return;
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@try {
SEL executeSel = NSSelectorFromString(@"execute:");
if (![FFmpegKitClass respondsToSelector:executeSel]) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, @"FFmpegKit execute: not found");
});
return;
}
id session = ((id(*)(id, SEL, id))objc_msgSend)(FFmpegKitClass, executeSel, command);
if (!session) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, @"FFmpegKit session nil");
});
return;
}
id returnCode = nil;
SEL rcSel = NSSelectorFromString(@"getReturnCode");
if ([session respondsToSelector:rcSel]) {
returnCode = ((id(*)(id, SEL))objc_msgSend)(session, rcSel);
}
BOOL success = NO;
if (ReturnCodeClass && returnCode) {
SEL isSuccessSel = NSSelectorFromString(@"isSuccess:");
if ([ReturnCodeClass respondsToSelector:isSuccessSel]) {
success = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, returnCode);
}
}
NSString *output = nil;
SEL outputSel = NSSelectorFromString(@"getOutput");
if ([session respondsToSelector:outputSel]) {
output = ((id(*)(id, SEL))objc_msgSend)(session, outputSel);
}
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(success, output);
});
} @catch (NSException *e) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, [NSString stringWithFormat:@"Exception: %@", e.reason]);
});
}
});
}
+ (void)probeCommand:(NSString *)command
completion:(void(^)(BOOL success, NSString *output))completion {
if (![self isAvailable]) {
if (completion) completion(NO, @"FFmpegKit not available");
return;
}
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
@try {
Class probeClass = NSClassFromString(@"FFprobeKit");
SEL executeSel = NSSelectorFromString(@"execute:");
if (!probeClass || ![probeClass respondsToSelector:executeSel]) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, @"FFprobeKit not found");
});
return;
}
id session = ((id(*)(id, SEL, id))objc_msgSend)(probeClass, executeSel, command);
NSString *output = nil;
SEL outputSel = NSSelectorFromString(@"getOutput");
if (session && [session respondsToSelector:outputSel]) {
output = ((id(*)(id, SEL))objc_msgSend)(session, outputSel);
}
id returnCode = nil;
SEL rcSel = NSSelectorFromString(@"getReturnCode");
if (session && [session respondsToSelector:rcSel]) {
returnCode = ((id(*)(id, SEL))objc_msgSend)(session, rcSel);
}
BOOL success = NO;
if (ReturnCodeClass && returnCode) {
SEL isSuccessSel = NSSelectorFromString(@"isSuccess:");
if ([ReturnCodeClass respondsToSelector:isSuccessSel])
success = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, returnCode);
}
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(success, output);
});
} @catch (NSException *e) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, e.reason);
});
}
});
}
+ (void)convertAudioAtPath:(NSString *)inputPath
toFormat:(NSString *)format
bitrate:(NSString *)bitrate
completion:(void(^)(NSURL *outputURL, NSError *error))completion {
if (![self isAvailable]) {
if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:1
userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit not available"}]);
return;
}
NSString *outputPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_audio_%@.%@", [[NSUUID UUID] UUIDString], format]];
NSString *codecFlag;
if ([format isEqualToString:@"mp3"]) {
codecFlag = [NSString stringWithFormat:@"-c:a libmp3lame -b:a %@", bitrate ?: @"192k"];
} else {
codecFlag = [NSString stringWithFormat:@"-c:a aac -b:a %@", bitrate ?: @"192k"];
}
NSString *cmd = [NSString stringWithFormat:
@"-y -hide_banner -loglevel error -i '%@' -vn -map a %@ '%@'",
inputPath, codecFlag, outputPath];
[self executeCommand:cmd completion:^(BOOL success, NSString *output) {
if (success && [[NSFileManager defaultManager] fileExistsAtPath:outputPath]) {
if (completion) completion([NSURL fileURLWithPath:outputPath], nil);
} else {
if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:4
userInfo:@{NSLocalizedDescriptionKey: output ?: @"Audio conversion failed"}]);
}
}];
}
+ (void)muxVideoURL:(NSURL *)videoURL
audioURL:(NSURL *)audioURL
preset:(NSString *)preset
progress:(void(^)(float progress, NSString *stage))progressBlock
completion:(void(^)(NSURL *outputURL, NSError *error))completion {
[self muxVideoURL:videoURL audioURL:audioURL preset:preset
progress:progressBlock completion:completion cancelOut:nil];
}
+ (void)muxVideoURL:(NSURL *)videoURL
audioURL:(NSURL *)audioURL
preset:(NSString *)preset
progress:(void(^)(float progress, NSString *stage))progressBlock
completion:(void(^)(NSURL *outputURL, NSError *error))completion
cancelOut:(void(^)(void (^cancelBlock)(void)))cancelOut {
if (![self isAvailable]) {
if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:1
userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit not available"}]);
return;
}
__block BOOL completionCalled = NO;
void (^finish)(NSURL *, NSError *) = ^(NSURL *url, NSError *err) {
if (completionCalled) return;
completionCalled = YES;
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(url, err);
});
};
// Per-call cancellation — scoped to this mux only.
__block volatile int32_t thisCancelled = 0;
__block NSURLSession *bgSessionRef = nil;
__block long ffmpegSidRef = 0;
BOOL (^isCancelledLocal)(void) = ^BOOL{ return thisCancelled == 1; };
void (^cancelSelf)(void) = ^{
OSAtomicCompareAndSwap32(0, 1, &thisCancelled);
NSURLSession *s = bgSessionRef;
if (s) { @try { [s invalidateAndCancel]; } @catch (__unused id e) {} }
long sid = ffmpegSidRef;
if (sid && FFmpegKitClass) {
SEL cancelSel = NSSelectorFromString(@"cancel:");
if ([FFmpegKitClass respondsToSelector:cancelSel]) {
@try { ((void(*)(id, SEL, long))objc_msgSend)(FFmpegKitClass, cancelSel, sid); }
@catch (__unused id e) {}
}
}
};
if (cancelOut) cancelOut(cancelSelf);
void (^report)(float, NSString *) = ^(float p, NSString *s) {
if (!progressBlock || isCancelledLocal()) return;
dispatch_async(dispatch_get_main_queue(), ^{ progressBlock(p, s); });
};
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *tmpDir = NSTemporaryDirectory();
NSString *videoPath = [tmpDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_video_%@.mp4", [[NSUUID UUID] UUIDString]]];
NSString *audioPath = [tmpDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_audio_%@.m4a", [[NSUUID UUID] UUIDString]]];
NSString *outputPath = [tmpDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_muxed_%@.mp4", [[NSUUID UUID] UUIDString]]];
NSError *(^cancelledError)(void) = ^NSError *{
return [NSError errorWithDomain:@"SCIFFmpeg" code:NSUserCancelledError
userInfo:@{NSLocalizedDescriptionKey: @"Cancelled"}];
};
void (^cleanupTmp)(void) = ^{
NSFileManager *fm = [NSFileManager defaultManager];
[fm removeItemAtPath:videoPath error:nil];
[fm removeItemAtPath:audioPath error:nil];
[fm removeItemAtPath:outputPath error:nil];
};
report(0.0, @"Downloading video...");
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
__block NSMutableData *videoAccum = [NSMutableData data];
__block NSError *videoErr = nil;
NSURLSession *bgSession = [NSURLSession sessionWithConfiguration:
[NSURLSessionConfiguration ephemeralSessionConfiguration]];
bgSessionRef = bgSession;
sciRegisterSession(bgSession);
NSURLSessionDownloadTask *videoTask = [bgSession downloadTaskWithURL:videoURL
completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
videoErr = err;
if (loc) videoAccum = [[NSMutableData alloc] initWithContentsOfURL:loc];
dispatch_semaphore_signal(sem);
}];
[videoTask resume];
while (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) {
if (isCancelledLocal()) {
[videoTask cancel];
break;
}
int64_t received = videoTask.countOfBytesReceived;
int64_t expected = videoTask.countOfBytesExpectedToReceive;
if (expected > 0) {
float frac = (float)received / (float)expected;
report(frac * 0.8f, @"Downloading video...");
}
}
if (isCancelledLocal()) {
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
cleanupTmp();
finish(nil, cancelledError());
return;
}
if (!videoAccum.length) {
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
cleanupTmp();
NSString *desc = videoErr ? videoErr.localizedDescription : @"Empty response";
finish(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:2
userInfo:@{NSLocalizedDescriptionKey:
[NSString stringWithFormat:@"Failed to download video: %@", desc]}]);
return;
}
[videoAccum writeToFile:videoPath atomically:YES];
report(0.8f, @"Downloading audio...");
BOOL hasAudio = (audioURL != nil);
if (hasAudio) {
__block NSMutableData *audioAccum = nil;
__block NSURLSessionDownloadTask *audioTask = nil;
audioTask = [bgSession downloadTaskWithURL:audioURL
completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
if (loc) audioAccum = [[NSMutableData alloc] initWithContentsOfURL:loc];
dispatch_semaphore_signal(sem);
}];
[audioTask resume];
while (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) {
if (isCancelledLocal()) { [audioTask cancel]; break; }
}
if (isCancelledLocal()) {
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
cleanupTmp();
finish(nil, cancelledError());
return;
}
if (audioAccum.length) {
[audioAccum writeToFile:audioPath atomically:YES];
} else {
hasAudio = NO;
}
}
sciUnregisterSession(bgSession);
[bgSession invalidateAndCancel];
report(0.9f, @"Encoding...");
// Encoding speed → videotoolbox bitrate
NSString *encFlags;
if ([preset isEqualToString:@"max"]) {
encFlags = @"-b:v 50M -profile:v high -level 5.1 -coder cabac";
} else if ([preset isEqualToString:@"fast"]) {
encFlags = @"-b:v 20M";
} else if ([preset isEqualToString:@"veryfast"]) {
encFlags = @"-b:v 12M";
} else {
encFlags = @"-b:v 8M -realtime 1";
}
NSString *cmd;
if (hasAudio) {
cmd = [NSString stringWithFormat:
@"-y -hide_banner "
@"-analyzeduration 1M -probesize 1M -fflags +genpts "
@"-i '%@' -i '%@' "
@"-map 0:v:0 -map 1:a:0 "
@"-c:a copy -c:v h264_videotoolbox %@ -allow_sw 1 "
@"-movflags +faststart -shortest '%@'",
videoPath, audioPath, encFlags, outputPath];
} else {
cmd = [NSString stringWithFormat:
@"-y -hide_banner "
@"-analyzeduration 1M -probesize 1M -fflags +genpts "
@"-i '%@' "
@"-c:v h264_videotoolbox %@ -allow_sw 1 "
@"-movflags +faststart '%@'",
videoPath, encFlags, outputPath];
}
// executeAsync returns the session synchronously so we can capture its id
// for per-session cancel.
__block BOOL ffSuccess = NO;
__block NSString *ffOutput = nil;
dispatch_semaphore_t ffSem = dispatch_semaphore_create(0);
id (^ffCallback)(id) = ^id(id session) {
SEL rcSel = NSSelectorFromString(@"getReturnCode");
if ([session respondsToSelector:rcSel]) {
id rc = ((id(*)(id, SEL))objc_msgSend)(session, rcSel);
if (ReturnCodeClass && rc) {
SEL isSuccessSel = NSSelectorFromString(@"isSuccess:");
if ([ReturnCodeClass respondsToSelector:isSuccessSel])
ffSuccess = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, rc);
}
}
SEL outSel = NSSelectorFromString(@"getOutput");
if ([session respondsToSelector:outSel])
ffOutput = ((id(*)(id, SEL))objc_msgSend)(session, outSel);
dispatch_semaphore_signal(ffSem);
return nil;
};
SEL asyncSel = NSSelectorFromString(@"executeAsync:withCompleteCallback:");
if ([FFmpegKitClass respondsToSelector:asyncSel]) {
id session = ((id(*)(id, SEL, id, id))objc_msgSend)(FFmpegKitClass, asyncSel, cmd, ffCallback);
SEL sidSel = NSSelectorFromString(@"getSessionId");
if (session && [session respondsToSelector:sidSel]) {
ffmpegSidRef = ((long(*)(id, SEL))objc_msgSend)(session, sidSel);
}
dispatch_semaphore_wait(ffSem, DISPATCH_TIME_FOREVER);
} else {
// Fallback: synchronous execute (coarse cancel only).
[SCIFFmpeg executeCommand:cmd completion:^(BOOL ok, NSString *out) {
ffSuccess = ok; ffOutput = out; dispatch_semaphore_signal(ffSem);
}];
dispatch_semaphore_wait(ffSem, DISPATCH_TIME_FOREVER);
}
NSFileManager *fm = [NSFileManager defaultManager];
[fm removeItemAtPath:videoPath error:nil];
[fm removeItemAtPath:audioPath error:nil];
if (isCancelledLocal()) {
[fm removeItemAtPath:outputPath error:nil];
finish(nil, cancelledError());
return;
}
if (ffSuccess && [fm fileExistsAtPath:outputPath]) {
finish([NSURL fileURLWithPath:outputPath], nil);
} else {
[fm removeItemAtPath:outputPath error:nil];
finish(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:3
userInfo:@{NSLocalizedDescriptionKey: ffOutput ?: @"FFmpeg mux failed"}]);
}
});
}
@end
+5
View File
@@ -0,0 +1,5 @@
// Precompiled prefix for RyukGram — imported into every TU so SCILocalized
// is callable from every feature file without per-file imports.
#ifdef __OBJC__
#import "Localization/SCILocalization.h"
#endif
+15
View File
@@ -0,0 +1,15 @@
// SCIQualityPicker — quality selection bottom sheet for HD downloads.
#import <UIKit/UIKit.h>
#import "SCIDashParser.h"
@interface SCIQualityPicker : NSObject
/// Show quality picker or auto-pick based on prefs. Returns NO if
/// enhanced downloads are off or no DASH manifest found (calls fallback).
+ (BOOL)pickQualityForMedia:(id)media
fromView:(UIView *)sourceView
picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked
fallback:(void(^)(void))fallback;
@end
+475
View File
@@ -0,0 +1,475 @@
#import "SCIQualityPicker.h"
#import "SCIFFmpeg.h"
#import "Utils.h"
#import "InstagramHeaders.h"
#import <AVFoundation/AVFoundation.h>
#import <AVKit/AVKit.h>
#import <objc/message.h>
// MARK: - Row cell
@interface _SCIQualityCell : UITableViewCell
@property (nonatomic, strong) UIButton *playButton;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *subtitleLabel;
@property (nonatomic, strong) UIButton *menuButton;
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
@end
@implementation _SCIQualityCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (!self) return nil;
self.selectionStyle = UITableViewCellSelectionStyleDefault;
self.backgroundColor = [UIColor secondarySystemGroupedBackgroundColor];
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
_playButton = [UIButton buttonWithType:UIButtonTypeSystem];
[_playButton setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:cfg] forState:UIControlStateNormal];
_playButton.tintColor = [UIColor labelColor];
_playButton.translatesAutoresizingMaskIntoConstraints = NO;
[self.contentView addSubview:_playButton];
_spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
_spinner.translatesAutoresizingMaskIntoConstraints = NO;
_spinner.hidesWhenStopped = YES;
[self.contentView addSubview:_spinner];
_titleLabel = [UILabel new];
_titleLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightSemibold];
_titleLabel.textColor = [UIColor labelColor];
_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self.contentView addSubview:_titleLabel];
_subtitleLabel = [UILabel new];
_subtitleLabel.font = [UIFont systemFontOfSize:11];
_subtitleLabel.textColor = [UIColor secondaryLabelColor];
_subtitleLabel.numberOfLines = 1;
_subtitleLabel.adjustsFontSizeToFitWidth = YES;
_subtitleLabel.minimumScaleFactor = 0.85;
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self.contentView addSubview:_subtitleLabel];
UIImageSymbolConfiguration *menuCfg = [UIImageSymbolConfiguration configurationWithPointSize:17 weight:UIFontWeightMedium];
_menuButton = [UIButton buttonWithType:UIButtonTypeSystem];
[_menuButton setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:menuCfg] forState:UIControlStateNormal];
_menuButton.tintColor = [UIColor secondaryLabelColor];
_menuButton.translatesAutoresizingMaskIntoConstraints = NO;
_menuButton.showsMenuAsPrimaryAction = YES;
[self.contentView addSubview:_menuButton];
[NSLayoutConstraint activateConstraints:@[
[_playButton.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor constant:14],
[_playButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_playButton.widthAnchor constraintEqualToConstant:32],
[_playButton.heightAnchor constraintEqualToConstant:32],
[_spinner.centerXAnchor constraintEqualToAnchor:_playButton.centerXAnchor],
[_spinner.centerYAnchor constraintEqualToAnchor:_playButton.centerYAnchor],
[_titleLabel.leadingAnchor constraintEqualToAnchor:_playButton.trailingAnchor constant:12],
[_titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10],
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_titleLabel.leadingAnchor],
[_subtitleLabel.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:2],
[_subtitleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_menuButton.leadingAnchor constant:-8],
[_menuButton.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor constant:-10],
[_menuButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_menuButton.widthAnchor constraintEqualToConstant:32],
[_menuButton.heightAnchor constraintEqualToConstant:32],
]];
return self;
}
- (void)setLoading:(BOOL)loading {
if (loading) {
self.playButton.hidden = YES;
[self.spinner startAnimating];
} else {
[self.spinner stopAnimating];
self.playButton.hidden = NO;
}
}
@end
// MARK: - Sheet VC
@interface _SCIQualitySheetVC : UIViewController <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UIButton *closeButton;
@property (nonatomic, strong) NSArray<SCIDashRepresentation *> *videoReps;
@property (nonatomic, strong) SCIDashRepresentation *audioRep;
@property (nonatomic, strong) NSURL *standardURL; // progressive 720p
@property (nonatomic, copy) void (^onPickStandard)(void);
@property (nonatomic, copy) void (^onPickHD)(SCIDashRepresentation *video, SCIDashRepresentation *audio);
@end
@implementation _SCIQualitySheetVC
- (void)viewDidLoad {
[super viewDidLoad];
// Match the expanded-sheet grey so the initial state doesn't look glass-transparent.
UIColor *sheetGrey = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) {
return tc.userInterfaceStyle == UIUserInterfaceStyleDark
? [UIColor colorWithRed:0.11 green:0.11 blue:0.12 alpha:1.0]
: [UIColor colorWithRed:0.95 green:0.95 blue:0.97 alpha:1.0];
}];
self.view.backgroundColor = sheetGrey;
self.view.opaque = YES;
UIView *solidCard = [UIView new];
solidCard.backgroundColor = sheetGrey;
solidCard.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:solidCard];
[self.view sendSubviewToBack:solidCard];
[NSLayoutConstraint activateConstraints:@[
[solidCard.topAnchor constraintEqualToAnchor:self.view.topAnchor],
[solidCard.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
[solidCard.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[solidCard.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
]];
self.titleLabel = [UILabel new];
self.titleLabel.text = SCILocalized(@"Download Quality");
self.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
self.titleLabel.textColor = [UIColor labelColor];
self.titleLabel.textAlignment = NSTextAlignmentCenter;
self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.titleLabel];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.backgroundColor = [UIColor clearColor];
self.tableView.rowHeight = 56;
self.tableView.sectionHeaderTopPadding = 8;
[self.tableView registerClass:[_SCIQualityCell class] forCellReuseIdentifier:@"q"];
[self.view addSubview:self.tableView];
[NSLayoutConstraint activateConstraints:@[
[self.titleLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[self.titleLabel.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:14],
[self.tableView.topAnchor constraintEqualToAnchor:self.titleLabel.bottomAnchor constant:8],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
]];
}
- (void)dismiss { [self dismissViewControllerAnimated:YES completion:nil]; }
// MARK: - Table
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; }
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return section == 0 ? 1 : (NSInteger)self.videoReps.count;
}
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
return section == 0 ? @"Standard" : @"HD";
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
_SCIQualityCell *cell = [tv dequeueReusableCellWithIdentifier:@"q" forIndexPath:ip];
[cell setLoading:NO];
if (ip.section == 0) {
cell.titleLabel.text = SCILocalized(@"Standard");
cell.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
cell.subtitleLabel.text = SCILocalized(@"720p • progressive • fastest");
cell.playButton.hidden = (self.standardURL == nil);
cell.menuButton.hidden = (self.standardURL == nil);
cell.accessoryType = UITableViewCellAccessoryNone;
cell.playButton.tag = -1;
[cell.playButton removeTarget:self action:NULL forControlEvents:UIControlEventTouchUpInside];
[cell.playButton addTarget:self action:@selector(playStandardPreview:) forControlEvents:UIControlEventTouchUpInside];
cell.menuButton.menu = [self menuForStandard];
} else {
SCIDashRepresentation *rep = self.videoReps[ip.row];
cell.accessoryType = UITableViewCellAccessoryNone;
cell.playButton.hidden = NO;
cell.menuButton.hidden = NO;
NSString *label = rep.qualityLabel ?: @"";
if (rep.height > 0) {
NSInteger shortSide = MIN(rep.width, rep.height);
if (shortSide > 0) label = [NSString stringWithFormat:@"%ldp", (long)shortSide];
}
NSString *bw = rep.bandwidth > 1000000
? [NSString stringWithFormat:@"%.1f Mbps", rep.bandwidth / 1000000.0]
: [NSString stringWithFormat:@"%ld Kbps", (long)(rep.bandwidth / 1000)];
cell.titleLabel.text = [NSString stringWithFormat:@"%@ • %@", label, bw];
cell.titleLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightSemibold];
NSMutableArray *parts = [NSMutableArray array];
if (rep.width > 0 && rep.height > 0)
[parts addObject:[NSString stringWithFormat:@"%ld×%ld", (long)rep.width, (long)rep.height]];
if (rep.frameRate > 0)
[parts addObject:[NSString stringWithFormat:@"%.0ffps", rep.frameRate]];
if (rep.codecs.length) {
NSString *codec = [[rep.codecs componentsSeparatedByString:@"."] firstObject] ?: rep.codecs;
[parts addObject:codec];
}
cell.subtitleLabel.text = [parts componentsJoinedByString:@""];
cell.playButton.tag = ip.row;
[cell.playButton removeTarget:self action:NULL forControlEvents:UIControlEventTouchUpInside];
[cell.playButton addTarget:self action:@selector(playPreview:) forControlEvents:UIControlEventTouchUpInside];
cell.menuButton.menu = [self menuForRow:ip.row videoRep:rep];
}
return cell;
}
- (UIMenu *)menuForStandard {
NSURL *url = self.standardURL;
if (!url) return nil;
UIAction *copy = [UIAction actionWithTitle:SCILocalized(@"Copy video URL")
image:[UIImage systemImageNamed:@"video.fill"]
identifier:nil
handler:^(__unused UIAction *a) {
[UIPasteboard generalPasteboard].string = url.absoluteString;
}];
return [UIMenu menuWithTitle:@"" children:@[copy]];
}
- (void)playStandardPreview:(UIButton *)sender {
NSURL *url = self.standardURL;
if (!url) return;
AVPlayerViewController *playerVC = [AVPlayerViewController new];
playerVC.player = [AVPlayer playerWithURL:url];
playerVC.modalPresentationStyle = UIModalPresentationOverFullScreen;
[self presentViewController:playerVC animated:YES completion:^{ [playerVC.player play]; }];
}
- (UIMenu *)menuForRow:(NSInteger)row videoRep:(SCIDashRepresentation *)videoRep {
NSURL *vURL = videoRep.url;
NSURL *aURL = self.audioRep.url;
UIAction *copyV = [UIAction actionWithTitle:SCILocalized(@"Copy video URL")
image:[UIImage systemImageNamed:@"video.fill"]
identifier:nil
handler:^(__unused UIAction *a) {
if (vURL) [UIPasteboard generalPasteboard].string = vURL.absoluteString;
}];
NSMutableArray *items = [NSMutableArray arrayWithObject:copyV];
if (aURL) {
UIAction *copyA = [UIAction actionWithTitle:SCILocalized(@"Copy audio URL")
image:[UIImage systemImageNamed:@"waveform"]
identifier:nil
handler:^(__unused UIAction *a) {
[UIPasteboard generalPasteboard].string = aURL.absoluteString;
}];
[items addObject:copyA];
}
UIAction *copyMPD = [UIAction actionWithTitle:SCILocalized(@"Copy quality info")
image:[UIImage systemImageNamed:@"info.circle"]
identifier:nil
handler:^(__unused UIAction *a) {
NSString *info = [NSString stringWithFormat:@"%ldp — %ld×%ld — %.1f Mbps",
(long)MIN(videoRep.width, videoRep.height),
(long)videoRep.width, (long)videoRep.height,
videoRep.bandwidth / 1000000.0];
[UIPasteboard generalPasteboard].string = info;
}];
[items addObject:copyMPD];
return [UIMenu menuWithTitle:@"" children:items];
}
// MARK: - Selection
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip {
[tv deselectRowAtIndexPath:ip animated:YES];
[self dismissViewControllerAnimated:YES completion:^{
if (ip.section == 0) {
if (self.onPickStandard) self.onPickStandard();
} else {
SCIDashRepresentation *rep = self.videoReps[ip.row];
if (self.onPickHD) self.onPickHD(rep, self.audioRep);
}
}];
}
// MARK: - Preview
- (void)playPreview:(UIButton *)sender {
NSInteger idx = sender.tag;
if (idx < 0 || idx >= (NSInteger)self.videoReps.count) return;
_SCIQualityCell *cell = (_SCIQualityCell *)[self.tableView cellForRowAtIndexPath:
[NSIndexPath indexPathForRow:idx inSection:1]];
[cell setLoading:YES];
SCIDashRepresentation *videoRep = self.videoReps[idx];
NSURL *videoURL = videoRep.url;
NSURL *audioURL = self.audioRep.url;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *tmp = NSTemporaryDirectory();
NSString *vPath = [tmp stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_preview_v_%@.mp4", [[NSUUID UUID] UUIDString]]];
NSString *aPath = [tmp stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_preview_a_%@.m4a", [[NSUUID UUID] UUIDString]]];
NSString *oPath = [tmp stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_preview_%@.mp4", [[NSUUID UUID] UUIDString]]];
NSData *vData = [NSURLConnection sendSynchronousRequest:
[NSURLRequest requestWithURL:videoURL] returningResponse:nil error:nil];
if (!vData.length) {
dispatch_async(dispatch_get_main_queue(), ^{ [self restorePlayButton:idx]; });
return;
}
[vData writeToFile:vPath atomically:YES];
NSString *cmd;
if (audioURL) {
NSData *aData = [NSURLConnection sendSynchronousRequest:
[NSURLRequest requestWithURL:audioURL] returningResponse:nil error:nil];
if (aData.length) {
[aData writeToFile:aPath atomically:YES];
cmd = [NSString stringWithFormat:
@"-y -hide_banner "
@"-analyzeduration 1M -probesize 1M -fflags +genpts "
@"-i '%@' -i '%@' -map 0:v:0 -map 1:a:0 "
@"-c:a copy -c:v h264_videotoolbox -b:v 8M -realtime 1 -allow_sw 1 "
@"-movflags +faststart -shortest '%@'",
vPath, aPath, oPath];
} else {
cmd = [NSString stringWithFormat:
@"-y -hide_banner "
@"-analyzeduration 1M -probesize 1M -fflags +genpts "
@"-i '%@' -c:v h264_videotoolbox -b:v 8M -realtime 1 -allow_sw 1 "
@"-movflags +faststart '%@'",
vPath, oPath];
}
} else {
cmd = [NSString stringWithFormat:
@"-y -hide_banner "
@"-analyzeduration 1M -probesize 1M -fflags +genpts "
@"-i '%@' -c:v h264_videotoolbox -b:v 8M -realtime 1 -allow_sw 1 "
@"-movflags +faststart '%@'",
vPath, oPath];
}
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
[[NSFileManager defaultManager] removeItemAtPath:vPath error:nil];
[[NSFileManager defaultManager] removeItemAtPath:aPath error:nil];
if (success && [[NSFileManager defaultManager] fileExistsAtPath:oPath]) {
AVPlayerViewController *playerVC = [AVPlayerViewController new];
playerVC.player = [AVPlayer playerWithURL:[NSURL fileURLWithPath:oPath]];
playerVC.modalPresentationStyle = UIModalPresentationOverFullScreen;
[self presentViewController:playerVC animated:YES completion:^{
[playerVC.player play];
}];
}
[self restorePlayButton:idx];
}];
});
}
- (void)restorePlayButton:(NSInteger)idx {
dispatch_async(dispatch_get_main_queue(), ^{
_SCIQualityCell *cell = (_SCIQualityCell *)[self.tableView cellForRowAtIndexPath:
[NSIndexPath indexPathForRow:idx inSection:1]];
[cell setLoading:NO];
});
}
@end
// MARK: - Public API
@implementation SCIQualityPicker
+ (BOOL)pickQualityForMedia:(id)media
fromView:(UIView *)sourceView
picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked
fallback:(void(^)(void))fallback {
if (!media) { if (fallback) fallback(); return NO; }
BOOL prefOn = [SCIUtils getBoolPref:@"enhance_download_quality"];
BOOL ffmpegOK = [SCIFFmpeg isAvailable];
if (!prefOn || !ffmpegOK) { if (fallback) fallback(); return NO; }
BOOL isVideo = ([SCIUtils getVideoUrlForMedia:(IGMedia *)media] != nil);
if (!isVideo) { if (fallback) fallback(); return NO; }
NSString *manifest = [SCIDashParser dashManifestForMedia:media];
if (!manifest.length) { if (fallback) fallback(); return NO; }
NSArray<SCIDashRepresentation *> *allReps = [SCIDashParser parseManifest:manifest];
NSArray<SCIDashRepresentation *> *videoReps = [SCIDashParser videoRepresentations:allReps];
SCIDashRepresentation *audioRep = [SCIDashParser bestAudioFromRepresentations:allReps];
if (!videoReps.count) { if (fallback) fallback(); return NO; }
NSString *qualityPref = [SCIUtils getStringPref:@"default_video_quality"];
if (!qualityPref.length) qualityPref = @"always_ask";
if ([qualityPref isEqualToString:@"always_ask"]) {
NSURL *standardURL = [SCIUtils getVideoUrlForMedia:(IGMedia *)media];
[self showSheetWithVideoReps:videoReps
audioRep:audioRep
standardURL:standardURL
picked:picked
fallback:fallback];
} else {
SCIVideoQuality q = SCIVideoQualityHighest;
if ([qualityPref isEqualToString:@"medium"]) q = SCIVideoQualityMedium;
else if ([qualityPref isEqualToString:@"low"]) q = SCIVideoQualityLowest;
SCIDashRepresentation *videoRep = [SCIDashParser representationForQuality:q fromRepresentations:allReps];
if (picked) picked(videoRep, audioRep);
}
return YES;
}
+ (void)showSheetWithVideoReps:(NSArray<SCIDashRepresentation *> *)videoReps
audioRep:(SCIDashRepresentation *)audioRep
standardURL:(NSURL *)standardURL
picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked
fallback:(void(^)(void))fallback {
dispatch_async(dispatch_get_main_queue(), ^{
_SCIQualitySheetVC *vc = [_SCIQualitySheetVC new];
vc.videoReps = videoReps;
vc.audioRep = audioRep;
vc.standardURL = standardURL;
vc.onPickStandard = fallback;
vc.onPickHD = picked;
vc.modalPresentationStyle = UIModalPresentationPageSheet;
if (@available(iOS 15.0, *)) {
UISheetPresentationController *sheetPC = vc.sheetPresentationController;
sheetPC.detents = @[
UISheetPresentationControllerDetent.mediumDetent,
UISheetPresentationControllerDetent.largeDetent,
];
SEL grabberSel = NSSelectorFromString(@"setPrefersGrabberIndicator:");
if ([sheetPC respondsToSelector:grabberSel]) {
((void(*)(id,SEL,BOOL))objc_msgSend)(sheetPC, grabberSel, YES);
}
sheetPC.prefersScrollingExpandsWhenScrolledToEdge = YES;
}
[topMostController() presentViewController:vc animated:YES completion:nil];
});
}
@end
+8
View File
@@ -0,0 +1,8 @@
#import <UIKit/UIKit.h>
@interface SCIDateFormatPickerVC : UIViewController <UITableViewDataSource, UITableViewDelegate>
/// Returns the formatted example string for the currently selected format.
+ (NSString *)currentFormatExample;
@end
+171
View File
@@ -0,0 +1,171 @@
#import "SCIDateFormatPickerVC.h"
#import "../Utils.h"
#import "../Features/General/SCIDateFormatEntries.h"
static NSString *const kFmtKey = @"feed_date_format";
static NSString *const kSecKey = @"feed_date_show_seconds";
// [key, pattern, pattern_with_seconds]
static NSArray<NSArray *> *sciDateFormatOptions(void) {
static NSArray *opts = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
opts = @[
@[@"default", @"", @""],
@[@"short", @"MMM d", @"MMM d"],
@[@"medium", @"MMM d, yyyy", @"MMM d, yyyy"],
@[@"full", @"MMM d, yyyy 'at' h:mm a", @"MMM d, yyyy 'at' h:mm:ss a"],
@[@"time_12", @"MMM d 'at' h:mm a", @"MMM d 'at' h:mm:ss a"],
@[@"time_24", @"MMM d 'at' HH:mm", @"MMM d 'at' HH:mm:ss"],
@[@"dd_mmm", @"dd-MMM-yyyy 'at' h:mm a", @"dd-MMM-yyyy 'at' h:mm:ss a"],
@[@"day_slash", @"dd/MM/yyyy h:mm a", @"dd/MM/yyyy h:mm:ss a"],
@[@"month_slash", @"MM/dd/yyyy h:mm a", @"MM/dd/yyyy h:mm:ss a"],
@[@"euro", @"dd.MM.yyyy HH:mm", @"dd.MM.yyyy HH:mm:ss"],
@[@"iso", @"yyyy-MM-dd", @"yyyy-MM-dd"],
@[@"iso_time", @"yyyy-MM-dd HH:mm", @"yyyy-MM-dd HH:mm:ss"],
];
});
return opts;
}
// [pref_key, label]
static NSArray<NSArray<NSString *> *> *sciSurfaceEntries(void) {
static NSArray *entries = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSMutableArray *m = [NSMutableArray array];
NSMutableSet *seen = [NSMutableSet set];
#define SCI_EMIT(NAME, SEL_, LABEL, ARITY, PREF) \
if (strlen(LABEL) && ![seen containsObject:@PREF]) { \
[seen addObject:@PREF]; \
[m addObject:@[@PREF, @LABEL]]; \
}
SCI_DATE_FORMAT_ENTRIES(SCI_EMIT)
#undef SCI_EMIT
entries = [m copy];
});
return entries;
}
static NSDate *sciRefDate(void) {
static NSDate *ref = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{ ref = [NSDate dateWithTimeIntervalSince1970:1736348730]; });
return ref;
}
static NSString *sciExampleForKey(NSString *key) {
if (!key.length || [key isEqualToString:@"default"]) return @"Default";
BOOL sec = [[NSUserDefaults standardUserDefaults] boolForKey:kSecKey];
for (NSArray *opt in sciDateFormatOptions()) {
if ([opt[0] isEqualToString:key]) {
NSString *pattern = sec ? opt[2] : opt[1];
if (!pattern.length) return SCILocalized(@"Default");
NSDateFormatter *df = [NSDateFormatter new];
df.dateFormat = pattern;
return [df stringFromDate:sciRefDate()];
}
}
return SCILocalized(@"Default");
}
@implementation SCIDateFormatPickerVC {
UITableView *_tableView;
}
+ (NSString *)currentFormatExample {
return sciExampleForKey([SCIUtils getStringPref:kFmtKey]);
}
- (void)viewDidLoad {
[super viewDidLoad];
self.title = SCILocalized(@"Date Format");
_tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped];
_tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
_tableView.dataSource = self;
_tableView.delegate = self;
[self.view addSubview:_tableView];
}
// Sections: 0 = format options, 1 = show seconds, 2 = surface toggles
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 3; }
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)s {
if (s == 0) return (NSInteger)sciDateFormatOptions().count;
if (s == 1) return 1;
return (NSInteger)sciSurfaceEntries().count;
}
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)s {
if (s == 0) return SCILocalized(@"Format");
if (s == 2) return SCILocalized(@"Apply to");
return @"";
}
- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)s {
if (s == 2) return SCILocalized(@"Toggle each NSDate formatter IG uses. Different surfaces (feed, comments, stories, DMs) go through different methods — enable the ones you want the custom format applied to.");
return nil;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
if (ip.section == 1) {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"sec"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"sec"];
cell.textLabel.text = SCILocalized(@"Show seconds");
UISwitch *sw = [UISwitch new];
sw.on = [[NSUserDefaults standardUserDefaults] boolForKey:kSecKey];
[sw addTarget:self action:@selector(secondsToggled:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
return cell;
}
if (ip.section == 2) {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"surf"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"surf"];
NSArray *entry = sciSurfaceEntries()[ip.row];
cell.textLabel.text = entry[1];
cell.textLabel.numberOfLines = 0;
cell.textLabel.font = [UIFont systemFontOfSize:15];
UISwitch *sw = [UISwitch new];
sw.on = [[NSUserDefaults standardUserDefaults] boolForKey:entry[0]];
sw.tag = ip.row;
[sw addTarget:self action:@selector(surfaceToggled:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
return cell;
}
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"df"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"df"];
NSString *key = sciDateFormatOptions()[ip.row][0];
cell.textLabel.text = sciExampleForKey(key);
cell.textLabel.font = [UIFont systemFontOfSize:16];
NSString *current = [SCIUtils getStringPref:kFmtKey];
if (!current.length) current = @"default";
cell.accessoryType = [current isEqualToString:key]
? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone;
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip {
[tv deselectRowAtIndexPath:ip animated:YES];
if (ip.section != 0) return;
[[NSUserDefaults standardUserDefaults] setObject:sciDateFormatOptions()[ip.row][0] forKey:kFmtKey];
[tv reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationNone];
}
- (void)secondsToggled:(UISwitch *)sw {
[[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:kSecKey];
[_tableView reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationNone];
}
- (void)surfaceToggled:(UISwitch *)sw {
NSArray *entry = sciSurfaceEntries()[sw.tag];
[[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:entry[0]];
}
@end
+6 -6
View File
@@ -16,7 +16,7 @@ static NSArray *sciPresetDomains(void) {
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Embed domain";
self.title = SCILocalized(@"Embed domain");
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
@@ -44,7 +44,7 @@ static NSArray *sciPresetDomains(void) {
}
- (void)addCustom {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Add custom domain"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Add custom domain")
message:nil
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) {
@@ -53,7 +53,7 @@ static NSArray *sciPresetDomains(void) {
tf.autocorrectionType = UITextAutocorrectionTypeNo;
tf.keyboardType = UIKeyboardTypeURL;
}];
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
NSString *domain = alert.textFields.firstObject.text;
domain = [domain stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
domain = [domain stringByReplacingOccurrencesOfString:@"https://" withString:@""];
@@ -66,7 +66,7 @@ static NSArray *sciPresetDomains(void) {
[[NSUserDefaults standardUserDefaults] setObject:domain forKey:@"embed_link_domain"];
[self reload];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[self presentViewController:alert animated:YES completion:nil];
}
@@ -75,7 +75,7 @@ static NSArray *sciPresetDomains(void) {
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; }
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
return section == 0 ? @"Presets" : @"Custom";
return section == 0 ? SCILocalized(@"Presets") : SCILocalized(@"Custom");
}
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
@@ -108,7 +108,7 @@ static NSArray *sciPresetDomains(void) {
if (indexPath.section == 0) return nil;
NSString *domain = self.customDomains[indexPath.row];
UIContextualAction *del = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive title:@"Delete"
contextualActionWithStyle:UIContextualActionStyleDestructive title:SCILocalized(@"Delete")
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
NSMutableArray *all = [self.customDomains mutableCopy];
[all removeObject:domain];
+19 -19
View File
@@ -16,12 +16,12 @@
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Chats";
self.title = SCILocalized(@"Chats");
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.searchBar = [[UISearchBar alloc] init];
self.searchBar.delegate = self;
self.searchBar.placeholder = @"Search by name or username";
self.searchBar.placeholder = SCILocalized(@"Search by name or username");
[self.searchBar sizeToFit];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
@@ -51,7 +51,7 @@
initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"]
style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)];
self.editBtn = [[UIBarButtonItem alloc]
initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
initWithTitle:SCILocalized(@"Select") style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn];
[self reload];
@@ -60,7 +60,7 @@
- (void)toggleEdit {
BOOL entering = !self.tableView.isEditing;
[self.tableView setEditing:entering animated:YES];
self.editBtn.title = entering ? @"Done" : @"Select";
self.editBtn.title = entering ? SCILocalized(@"Done") : SCILocalized(@"Select");
self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
self.batchToolbar.hidden = !entering;
if (entering) [self updateToolbar];
@@ -68,9 +68,9 @@
- (void)updateToolbar {
UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:SCILocalized(@"Remove") style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
del.tintColor = [UIColor systemRedColor];
UIBarButtonItem *kd = [[UIBarButtonItem alloc] initWithTitle:@"Keep-deleted" style:UIBarButtonItemStylePlain target:self action:@selector(batchKeepDeleted)];
UIBarButtonItem *kd = [[UIBarButtonItem alloc] initWithTitle:SCILocalized(@"Keep-deleted") style:UIBarButtonItemStylePlain target:self action:@selector(batchKeepDeleted)];
self.batchToolbar.items = @[del, flex, kd];
}
@@ -88,7 +88,7 @@
- (void)batchKeepDeleted {
NSArray<NSIndexPath *> *sel = self.tableView.indexPathsForSelectedRows;
if (!sel.count) return;
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Set keep-deleted override" message:nil preferredStyle:UIAlertControllerStyleActionSheet];
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Set keep-deleted override") message:nil preferredStyle:UIAlertControllerStyleActionSheet];
void (^apply)(SCIKeepDeletedOverride) = ^(SCIKeepDeletedOverride mode) {
for (NSIndexPath *ip in sel) {
NSDictionary *e = self.filtered[ip.row];
@@ -97,22 +97,22 @@
[self toggleEdit];
[self reload];
};
[sheet addAction:[UIAlertAction actionWithTitle:@"Follow default" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Follow default") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
apply(SCIKeepDeletedOverrideDefault);
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Force ON (preserve unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Force ON (preserve unsends)") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
apply(SCIKeepDeletedOverrideIncluded);
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Force OFF (allow unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Force OFF (allow unsends)") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
apply(SCIKeepDeletedOverrideExcluded);
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.barButtonItem = self.batchToolbar.items.lastObject;
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)toggleSort {
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by"
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *titles = @[@"Recently added", @"Name (AZ)"];
@@ -126,7 +126,7 @@
if (i == self.sortMode) [a setValue:@YES forKey:@"checked"];
[sheet addAction:a];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.barButtonItem = self.sortBtn;
[self presentViewController:sheet animated:YES completion:nil];
}
@@ -155,7 +155,7 @@
}
self.filtered = all;
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *label = bs ? @"Included chats" : @"Excluded chats";
NSString *label = bs ? SCILocalized(@"Included chats") : SCILocalized(@"Excluded chats");
self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count];
[self.tableView reloadData];
}
@@ -220,7 +220,7 @@
NSString *tid = e[@"threadId"];
UIContextualAction *del = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive
title:@"Remove"
title:SCILocalized(@"Remove")
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
[SCIExcludedThreads removeThreadId:tid];
[self reload];
@@ -246,7 +246,7 @@
if (v == mode) a.state = UIMenuElementStateOn;
return a;
};
UIMenu *kdMenu = [UIMenu menuWithTitle:@"Keep-deleted override"
UIMenu *kdMenu = [UIMenu menuWithTitle:SCILocalized(@"Keep-deleted override")
image:[UIImage systemImageNamed:@"trash.slash"]
identifier:nil
options:0
@@ -255,7 +255,7 @@
kdAction(@"Force ON (preserve unsends)", SCIKeepDeletedOverrideIncluded),
kdAction(@"Force OFF (allow unsends)", SCIKeepDeletedOverrideExcluded),
]];
UIAction *remove = [UIAction actionWithTitle:@"Remove from list"
UIAction *remove = [UIAction actionWithTitle:SCILocalized(@"Remove from list")
image:[UIImage systemImageNamed:@"trash"]
identifier:nil
handler:^(__kindof UIAction *_) {
@@ -273,8 +273,8 @@
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
SCIKeepDeletedOverride next = (mode + 1) % 3;
NSString *title = (next == SCIKeepDeletedOverrideExcluded) ? @"KD: OFF"
: (next == SCIKeepDeletedOverrideIncluded) ? @"KD: ON"
: @"KD: default";
: (next == SCIKeepDeletedOverrideIncluded) ? SCILocalized(@"KD: ON")
: SCILocalized(@"KD: default");
UIContextualAction *toggle = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleNormal
title:title
@@ -16,12 +16,12 @@
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Story users";
self.title = SCILocalized(@"Story users");
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.searchBar = [[UISearchBar alloc] init];
self.searchBar.delegate = self;
self.searchBar.placeholder = @"Search by username or name";
self.searchBar.placeholder = SCILocalized(@"Search by username or name");
[self.searchBar sizeToFit];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
@@ -51,7 +51,7 @@
initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"]
style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)];
self.editBtn = [[UIBarButtonItem alloc]
initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
initWithTitle:SCILocalized(@"Select") style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn];
[self reload];
@@ -60,12 +60,12 @@
- (void)toggleEdit {
BOOL entering = !self.tableView.isEditing;
[self.tableView setEditing:entering animated:YES];
self.editBtn.title = entering ? @"Done" : @"Select";
self.editBtn.title = entering ? SCILocalized(@"Done") : SCILocalized(@"Select");
self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
self.batchToolbar.hidden = !entering;
if (entering) {
UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove Selected" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:SCILocalized(@"Remove Selected") style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
del.tintColor = [UIColor systemRedColor];
self.batchToolbar.items = @[flex, del, flex];
}
@@ -83,7 +83,7 @@
}
- (void)toggleSort {
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by"
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *titles = @[@"Recently added", @"Username (AZ)"];
@@ -97,7 +97,7 @@
if (i == self.sortMode) [a setValue:@YES forKey:@"checked"];
[sheet addAction:a];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.barButtonItem = self.sortBtn;
[self presentViewController:sheet animated:YES completion:nil];
}
@@ -123,7 +123,7 @@
}
self.filtered = all;
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
NSString *label = bs ? @"Included users" : @"Excluded users";
NSString *label = bs ? SCILocalized(@"Included users") : SCILocalized(@"Excluded users");
self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count];
[self.tableView reloadData];
}
@@ -169,7 +169,7 @@
NSString *pk = e[@"pk"];
UIContextualAction *del = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive
title:@"Remove"
title:SCILocalized(@"Remove")
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
[SCIExcludedStoryUsers removePK:pk];
[self reload];
+12
View File
@@ -0,0 +1,12 @@
// Map picker — long-press to drop a draggable pin, search suggestions via MKLocalSearchCompleter.
#import <UIKit/UIKit.h>
#import <CoreLocation/CoreLocation.h>
@interface SCIFakeLocationPickerVC : UIViewController
@property (nonatomic, copy) void (^onPick)(double lat, double lon, NSString *name);
@property (nonatomic, assign) CLLocationCoordinate2D initialCoord;
@property (nonatomic, copy) NSString *titleText;
@end
+388
View File
@@ -0,0 +1,388 @@
#import "SCIFakeLocationPickerVC.h"
#import <MapKit/MapKit.h>
#import "../Localization/SCILocalization.h"
#pragma mark - Search results
@interface SCIFakeLocationSearchResultsVC : UITableViewController <MKLocalSearchCompleterDelegate>
@property (nonatomic, strong) MKLocalSearchCompleter *completer;
@property (nonatomic, copy) NSArray<MKLocalSearchCompletion *> *results;
@property (nonatomic, copy) void (^onSelect)(MKLocalSearchCompletion *completion);
@property (nonatomic, assign) MKCoordinateRegion region;
@end
@implementation SCIFakeLocationSearchResultsVC
- (instancetype)init {
self = [super initWithStyle:UITableViewStylePlain];
if (self) {
self.completer = [MKLocalSearchCompleter new];
self.completer.delegate = self;
self.results = @[];
}
return self;
}
- (void)setRegion:(MKCoordinateRegion)region {
_region = region;
if (CLLocationCoordinate2DIsValid(region.center)) self.completer.region = region;
}
- (void)setQuery:(NSString *)q {
if (!q.length) { self.results = @[]; [self.tableView reloadData]; return; }
self.completer.queryFragment = q;
}
- (void)completerDidUpdateResults:(MKLocalSearchCompleter *)c {
self.results = c.results;
[self.tableView reloadData];
}
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)s { return self.results.count; }
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"r"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"r"];
MKLocalSearchCompletion *r = self.results[ip.row];
cell.textLabel.text = r.title;
cell.detailTextLabel.text = r.subtitle;
cell.imageView.image = [UIImage systemImageNamed:@"mappin.circle"];
cell.imageView.tintColor = [UIColor systemRedColor];
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip {
[tv deselectRowAtIndexPath:ip animated:YES];
if (self.onSelect) self.onSelect(self.results[ip.row]);
}
@end
#pragma mark - Picker
@interface SCIFakeLocationPickerVC () <MKMapViewDelegate, UISearchResultsUpdating, UISearchControllerDelegate, CLLocationManagerDelegate>
@property (nonatomic, strong) MKMapView *mapView;
@property (nonatomic, strong) MKPointAnnotation *pin;
@property (nonatomic, strong) UISearchController *searchController;
@property (nonatomic, strong) SCIFakeLocationSearchResultsVC *resultsVC;
@property (nonatomic, strong) UIButton *locateButton;
@property (nonatomic, strong) UIVisualEffectView *cardView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *subtitleLabel;
@property (nonatomic, strong) UIButton *useButton;
@property (nonatomic, copy) NSString *resolvedName;
@property (nonatomic, strong) CLLocationManager *locationManager;
@property (nonatomic, assign) BOOL didRequestAuth;
@end
@implementation SCIFakeLocationPickerVC
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.title = self.titleText.length ? self.titleText : SCILocalized(@"Pick location");
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(cancel)];
self.locationManager = [CLLocationManager new];
self.locationManager.delegate = self;
[self setupMap];
[self setupSearch];
[self setupLocateButton];
[self setupCard];
CLLocationCoordinate2D coord = CLLocationCoordinate2DIsValid(self.initialCoord)
? self.initialCoord : CLLocationCoordinate2DMake(48.8584, 2.2945);
[self.mapView setRegion:MKCoordinateRegionMakeWithDistance(coord, 1500, 1500) animated:NO];
self.resultsVC.region = self.mapView.region;
if (CLLocationCoordinate2DIsValid(self.initialCoord)) {
[self dropPinAt:self.initialCoord name:nil reverseGeocode:YES];
}
}
#pragma mark - Setup
- (void)setupMap {
self.mapView = [[MKMapView alloc] initWithFrame:self.view.bounds];
self.mapView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.mapView.delegate = self;
self.mapView.showsUserLocation = YES;
self.mapView.showsCompass = YES;
[self.view addSubview:self.mapView];
UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc]
initWithTarget:self action:@selector(onLongPress:)];
lp.minimumPressDuration = 0.35;
[self.mapView addGestureRecognizer:lp];
}
- (void)setupSearch {
self.resultsVC = [SCIFakeLocationSearchResultsVC new];
__weak typeof(self) weakSelf = self;
self.resultsVC.onSelect = ^(MKLocalSearchCompletion *r) { [weakSelf performSearchForCompletion:r]; };
UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:self.resultsVC];
sc.searchResultsUpdater = self;
sc.delegate = self;
sc.obscuresBackgroundDuringPresentation = YES;
sc.searchBar.placeholder = SCILocalized(@"Search address or place");
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.definesPresentationContext = YES;
self.searchController = sc;
}
- (void)setupLocateButton {
self.locateButton = [UIButton buttonWithType:UIButtonTypeSystem];
self.locateButton.backgroundColor = [UIColor secondarySystemBackgroundColor];
self.locateButton.tintColor = [UIColor systemBlueColor];
[self.locateButton setImage:[UIImage systemImageNamed:@"location"] forState:UIControlStateNormal];
self.locateButton.layer.cornerRadius = 8;
self.locateButton.translatesAutoresizingMaskIntoConstraints = NO;
[self.locateButton addTarget:self action:@selector(onLocateTap) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:self.locateButton];
[NSLayoutConstraint activateConstraints:@[
[self.locateButton.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor constant:-12],
[self.locateButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-140],
[self.locateButton.widthAnchor constraintEqualToConstant:40],
[self.locateButton.heightAnchor constraintEqualToConstant:40],
]];
}
- (void)onLocateTap {
CLAuthorizationStatus status = self.locationManager.authorizationStatus;
if (status == kCLAuthorizationStatusNotDetermined) {
self.didRequestAuth = YES;
[self.locationManager requestWhenInUseAuthorization];
return;
}
if (status == kCLAuthorizationStatusDenied || status == kCLAuthorizationStatusRestricted) {
[self showLocationDeniedAlert];
return;
}
if (!CLLocationManager.locationServicesEnabled) {
[self showServicesDisabledAlert];
return;
}
self.mapView.showsUserLocation = YES;
self.mapView.userTrackingMode = MKUserTrackingModeFollow;
CLLocation *loc = self.mapView.userLocation.location ?: self.locationManager.location;
if (loc) {
[self.mapView setRegion:MKCoordinateRegionMakeWithDistance(loc.coordinate, 800, 800) animated:YES];
}
}
- (void)showLocationDeniedAlert {
UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Location access denied")
message:SCILocalized(@"Enable Location Services for Instagram in Settings to use your current location.")
preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Open Settings") style:UIAlertActionStyleDefault handler:^(UIAlertAction *x) {
[[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil];
}]];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleCancel handler:nil]];
[self presentViewController:a animated:YES completion:nil];
}
- (void)showServicesDisabledAlert {
UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Location Services off")
message:SCILocalized(@"Turn Location Services on in Settings → Privacy to use your current location.")
preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleCancel handler:nil]];
[self presentViewController:a animated:YES completion:nil];
}
- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager {
CLAuthorizationStatus s = manager.authorizationStatus;
if (!self.didRequestAuth) return;
self.didRequestAuth = NO;
if (s == kCLAuthorizationStatusAuthorizedWhenInUse || s == kCLAuthorizationStatusAuthorizedAlways) {
[self onLocateTap];
} else if (s == kCLAuthorizationStatusDenied || s == kCLAuthorizationStatusRestricted) {
[self showLocationDeniedAlert];
}
}
- (void)setupCard {
UIBlurEffect *blur = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemThickMaterial];
self.cardView = [[UIVisualEffectView alloc] initWithEffect:blur];
self.cardView.layer.cornerRadius = 16;
self.cardView.layer.cornerCurve = kCACornerCurveContinuous;
self.cardView.clipsToBounds = YES;
self.cardView.translatesAutoresizingMaskIntoConstraints = NO;
self.cardView.hidden = YES;
[self.view addSubview:self.cardView];
self.titleLabel = [UILabel new];
self.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
self.titleLabel.numberOfLines = 1;
self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
self.subtitleLabel = [UILabel new];
self.subtitleLabel.font = [UIFont monospacedDigitSystemFontOfSize:13 weight:UIFontWeightRegular];
self.subtitleLabel.textColor = [UIColor secondaryLabelColor];
self.subtitleLabel.numberOfLines = 1;
self.subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
self.useButton = [UIButton buttonWithType:UIButtonTypeSystem];
[self.useButton setTitle:SCILocalized(@"Use this location") forState:UIControlStateNormal];
self.useButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
self.useButton.backgroundColor = [UIColor systemBlueColor];
[self.useButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
self.useButton.layer.cornerRadius = 12;
self.useButton.translatesAutoresizingMaskIntoConstraints = NO;
[self.useButton addTarget:self action:@selector(commit) forControlEvents:UIControlEventTouchUpInside];
UIView *content = self.cardView.contentView;
[content addSubview:self.titleLabel];
[content addSubview:self.subtitleLabel];
[content addSubview:self.useButton];
[NSLayoutConstraint activateConstraints:@[
[self.cardView.leadingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor constant:12],
[self.cardView.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor constant:-12],
[self.cardView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-12],
[self.titleLabel.topAnchor constraintEqualToAnchor:content.topAnchor constant:14],
[self.titleLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:16],
[self.titleLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-16],
[self.subtitleLabel.topAnchor constraintEqualToAnchor:self.titleLabel.bottomAnchor constant:2],
[self.subtitleLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:16],
[self.subtitleLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-16],
[self.useButton.topAnchor constraintEqualToAnchor:self.subtitleLabel.bottomAnchor constant:12],
[self.useButton.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:12],
[self.useButton.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-12],
[self.useButton.bottomAnchor constraintEqualToAnchor:content.bottomAnchor constant:-12],
[self.useButton.heightAnchor constraintEqualToConstant:46],
]];
}
#pragma mark - Pin
- (void)onLongPress:(UILongPressGestureRecognizer *)g {
if (g.state != UIGestureRecognizerStateBegan) return;
CGPoint p = [g locationInView:self.mapView];
CLLocationCoordinate2D c = [self.mapView convertPoint:p toCoordinateFromView:self.mapView];
[self dropPinAt:c name:nil reverseGeocode:YES];
}
- (void)dropPinAt:(CLLocationCoordinate2D)coord name:(NSString *)name reverseGeocode:(BOOL)resolve {
if (self.pin) [self.mapView removeAnnotation:self.pin];
self.pin = [MKPointAnnotation new];
self.pin.coordinate = coord;
self.pin.title = name;
[self.mapView addAnnotation:self.pin];
[self.mapView selectAnnotation:self.pin animated:YES];
self.resolvedName = name;
[self updateCard];
if (resolve && !name.length) {
CLLocation *loc = [[CLLocation alloc] initWithLatitude:coord.latitude longitude:coord.longitude];
CLGeocoder *g = [CLGeocoder new];
[g reverseGeocodeLocation:loc completionHandler:^(NSArray<CLPlacemark *> *pm, NSError *err) {
if (err || !pm.count) return;
CLPlacemark *p = pm.firstObject;
NSString *resolved = p.name ?: p.locality ?: p.country;
if (!resolved.length) return;
if (!self.pin ||
fabs(self.pin.coordinate.latitude - coord.latitude) > 0.0001 ||
fabs(self.pin.coordinate.longitude - coord.longitude) > 0.0001) return;
self.resolvedName = resolved;
self.pin.title = resolved;
[self updateCard];
}];
}
}
- (void)updateCard {
if (!self.pin) { self.cardView.hidden = YES; return; }
self.cardView.hidden = NO;
CLLocationCoordinate2D c = self.pin.coordinate;
self.titleLabel.text = self.resolvedName.length ? self.resolvedName : SCILocalized(@"Dropped pin");
self.subtitleLabel.text = [NSString stringWithFormat:@"%.5f, %.5f", c.latitude, c.longitude];
}
#pragma mark - Search
- (void)updateSearchResultsForSearchController:(UISearchController *)sc {
self.resultsVC.region = self.mapView.region;
[self.resultsVC setQuery:sc.searchBar.text];
}
- (void)performSearchForCompletion:(MKLocalSearchCompletion *)completion {
MKLocalSearchRequest *req = [[MKLocalSearchRequest alloc] initWithCompletion:completion];
MKLocalSearch *search = [[MKLocalSearch alloc] initWithRequest:req];
[search startWithCompletionHandler:^(MKLocalSearchResponse *resp, NSError *err) {
if (err || !resp.mapItems.count) return;
MKMapItem *item = resp.mapItems.firstObject;
CLLocationCoordinate2D c = item.placemark.coordinate;
NSString *name = item.name ?: completion.title;
dispatch_async(dispatch_get_main_queue(), ^{
[self.searchController setActive:NO];
[self.mapView setRegion:MKCoordinateRegionMakeWithDistance(c, 1500, 1500) animated:YES];
[self dropPinAt:c name:name reverseGeocode:NO];
});
}];
}
#pragma mark - Map delegate (draggable pin)
- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id<MKAnnotation>)annotation {
if ([annotation isKindOfClass:[MKUserLocation class]]) return nil;
static NSString *kID = @"scipin";
MKMarkerAnnotationView *v = (MKMarkerAnnotationView *)[mapView dequeueReusableAnnotationViewWithIdentifier:kID];
if (!v) {
v = [[MKMarkerAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:kID];
} else {
v.annotation = annotation;
}
v.draggable = YES;
v.canShowCallout = YES;
v.markerTintColor = [UIColor systemRedColor];
v.animatesWhenAdded = YES;
return v;
}
- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view
didChangeDragState:(MKAnnotationViewDragState)newState fromOldState:(MKAnnotationViewDragState)oldState {
if (newState == MKAnnotationViewDragStateEnding) {
view.dragState = MKAnnotationViewDragStateNone;
CLLocationCoordinate2D c = view.annotation.coordinate;
self.resolvedName = nil;
[self updateCard];
CLLocation *loc = [[CLLocation alloc] initWithLatitude:c.latitude longitude:c.longitude];
[[CLGeocoder new] reverseGeocodeLocation:loc completionHandler:^(NSArray<CLPlacemark *> *pm, NSError *err) {
if (err || !pm.count || !self.pin) return;
if (fabs(self.pin.coordinate.latitude - c.latitude) > 0.0001 ||
fabs(self.pin.coordinate.longitude - c.longitude) > 0.0001) return;
CLPlacemark *p = pm.firstObject;
NSString *name = p.name ?: p.locality ?: p.country;
if (!name.length) return;
self.resolvedName = name;
self.pin.title = name;
[self updateCard];
}];
}
}
#pragma mark - Actions
- (void)cancel { [self dismissViewControllerAnimated:YES completion:nil]; }
- (void)commit {
if (!self.pin) return;
CLLocationCoordinate2D c = self.pin.coordinate;
NSString *name = self.resolvedName.length ? self.resolvedName
: [NSString stringWithFormat:@"%.4f, %.4f", c.latitude, c.longitude];
void (^cb)(double, double, NSString *) = self.onPick;
[self dismissViewControllerAnimated:YES completion:^{
if (cb) cb(c.latitude, c.longitude, name);
}];
}
@end
+4
View File
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIFakeLocationSettingsVC : UIViewController <UITableViewDataSource, UITableViewDelegate>
@end
+234
View File
@@ -0,0 +1,234 @@
#import "SCIFakeLocationSettingsVC.h"
#import "SCIFakeLocationPickerVC.h"
#import "../Utils.h"
static NSString *const kEnabled = @"fake_location_enabled";
static NSString *const kShowBtn = @"show_fake_location_map_button";
static NSString *const kLat = @"fake_location_lat";
static NSString *const kLon = @"fake_location_lon";
static NSString *const kName = @"fake_location_name";
static NSString *const kPresets = @"fake_location_presets";
@interface SCIFakeLocationSettingsVC ()
@property (nonatomic, strong) UITableView *tableView;
@end
@implementation SCIFakeLocationSettingsVC
- (void)viewDidLoad {
[super viewDidLoad];
self.title = SCILocalized(@"Fake location");
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped];
self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.tableView.dataSource = self;
self.tableView.delegate = self;
[self.view addSubview:self.tableView];
}
// MARK: - Storage helpers
- (NSArray<NSDictionary *> *)presets {
NSArray *raw = [[NSUserDefaults standardUserDefaults] objectForKey:kPresets];
return [raw isKindOfClass:[NSArray class]] ? raw : @[];
}
- (void)setPresets:(NSArray<NSDictionary *> *)presets {
[[NSUserDefaults standardUserDefaults] setObject:presets forKey:kPresets];
}
- (void)applyCoord:(double)lat lon:(double)lon name:(NSString *)name {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
[d setObject:@(lat) forKey:kLat];
[d setObject:@(lon) forKey:kLon];
[d setObject:(name ?: @"") forKey:kName];
[self.tableView reloadData];
}
// Sections: 0 toggle • 1 current + select • 2 presets + add
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 3; }
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)s {
if (s == 0) return 2;
if (s == 1) return 2;
return self.presets.count + 1;
}
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)s {
if (s == 1) return SCILocalized(@"Current location");
if (s == 2) return SCILocalized(@"Saved locations");
return nil;
}
- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)s {
if (s == 0) return SCILocalized(@"When on, all CoreLocation requests inside Instagram return the location below. Toggle the map button to show or hide the quick toggle on the Friends Map view.");
if (s == 2) return SCILocalized(@"Saved presets are reusable. Tap a preset to make it the active location.");
return nil;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
if (ip.section == 0) {
if (ip.row == 0) {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"sw"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"sw"];
cell.textLabel.text = SCILocalized(@"Enable fake location");
UISwitch *sw = [UISwitch new];
sw.on = [SCIUtils getBoolPref:kEnabled];
[sw addTarget:self action:@selector(masterToggled:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
return cell;
}
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"swShow"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"swShow"];
cell.textLabel.text = SCILocalized(@"Show map button");
UISwitch *sw = [UISwitch new];
sw.on = [SCIUtils getBoolPref:kShowBtn];
[sw addTarget:self action:@selector(showBtnToggled:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
return cell;
}
if (ip.section == 1) {
if (ip.row == 0) {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"cur"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"cur"];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
double lat = [[d objectForKey:kLat] doubleValue];
double lon = [[d objectForKey:kLon] doubleValue];
NSString *name = [d objectForKey:kName] ?: @"";
cell.textLabel.text = name.length ? name : @"(unset)";
cell.detailTextLabel.text = [NSString stringWithFormat:@"%.5f, %.5f", lat, lon];
cell.detailTextLabel.font = [UIFont monospacedDigitSystemFontOfSize:12 weight:UIFontWeightRegular];
cell.imageView.image = [UIImage systemImageNamed:@"location.fill"];
cell.imageView.tintColor = [UIColor systemGreenColor];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
cell.accessoryType = UITableViewCellAccessoryNone;
return cell;
}
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"sel"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"sel"];
cell.textLabel.text = SCILocalized(@"Select location on map");
cell.textLabel.textColor = [UIColor systemBlueColor];
cell.imageView.image = [UIImage systemImageNamed:@"map"];
cell.imageView.tintColor = [UIColor systemBlueColor];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
return cell;
}
// Presets
NSArray<NSDictionary *> *presets = self.presets;
if (ip.row < (NSInteger)presets.count) {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"p"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"p"];
NSDictionary *p = presets[ip.row];
cell.textLabel.text = p[@"name"] ?: @"Preset";
cell.detailTextLabel.text = [NSString stringWithFormat:@"%.5f, %.5f",
[p[@"lat"] doubleValue], [p[@"lon"] doubleValue]];
cell.detailTextLabel.font = [UIFont monospacedDigitSystemFontOfSize:12 weight:UIFontWeightRegular];
cell.imageView.image = [UIImage systemImageNamed:@"mappin.circle.fill"];
cell.imageView.tintColor = [UIColor systemRedColor];
cell.accessoryType = UITableViewCellAccessoryNone;
return cell;
}
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"add"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"add"];
cell.textLabel.text = SCILocalized(@"Add preset…");
cell.textLabel.textColor = [UIColor systemBlueColor];
cell.imageView.image = [UIImage systemImageNamed:@"plus.circle.fill"];
cell.imageView.tintColor = [UIColor systemBlueColor];
return cell;
}
- (BOOL)tableView:(UITableView *)tv canEditRowAtIndexPath:(NSIndexPath *)ip {
return ip.section == 2 && ip.row < (NSInteger)self.presets.count;
}
- (void)tableView:(UITableView *)tv commitEditingStyle:(UITableViewCellEditingStyle)style forRowAtIndexPath:(NSIndexPath *)ip {
if (style != UITableViewCellEditingStyleDelete) return;
NSMutableArray *presets = [self.presets mutableCopy];
[presets removeObjectAtIndex:ip.row];
[self setPresets:presets];
[tv deleteRowsAtIndexPaths:@[ip] withRowAnimation:UITableViewRowAnimationAutomatic];
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip {
[tv deselectRowAtIndexPath:ip animated:YES];
if (ip.section == 1 && ip.row == 1) {
[self openPickerForCurrent];
} else if (ip.section == 2) {
NSArray<NSDictionary *> *presets = self.presets;
if (ip.row < (NSInteger)presets.count) {
NSDictionary *p = presets[ip.row];
[self applyCoord:[p[@"lat"] doubleValue] lon:[p[@"lon"] doubleValue] name:p[@"name"]];
} else {
[self openPickerForNewPreset];
}
}
}
// MARK: - Actions
- (void)masterToggled:(UISwitch *)sw {
[[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:kEnabled];
}
- (void)showBtnToggled:(UISwitch *)sw {
[[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:kShowBtn];
[[NSNotificationCenter defaultCenter] postNotificationName:@"SCIFakeLocationMapBtnPrefChanged" object:nil];
}
- (void)openPickerForCurrent {
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:kLat] doubleValue],
[[d objectForKey:kLon] doubleValue]);
vc.titleText = SCILocalized(@"Set current location");
__weak typeof(self) weakSelf = self;
vc.onPick = ^(double lat, double lon, NSString *name) {
[weakSelf applyCoord:lat lon:lon name:name];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
[self presentViewController:nav animated:YES completion:nil];
}
- (void)openPickerForNewPreset {
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:kLat] doubleValue],
[[d objectForKey:kLon] doubleValue]);
vc.titleText = SCILocalized(@"Add preset");
__weak typeof(self) weakSelf = self;
vc.onPick = ^(double lat, double lon, NSString *name) {
// Confirm name via simple alert
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Save preset")
message:nil
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) {
tf.placeholder = SCILocalized(@"Name");
tf.text = name;
tf.autocapitalizationType = UITextAutocapitalizationTypeSentences;
}];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
NSString *finalName = alert.textFields.firstObject.text.length ? alert.textFields.firstObject.text : name;
NSDictionary *preset = @{@"name": finalName ?: @"", @"lat": @(lat), @"lon": @(lon)};
NSMutableArray *presets = [weakSelf.presets mutableCopy];
[presets addObject:preset];
[weakSelf setPresets:presets];
[weakSelf.tableView reloadData];
}]];
[weakSelf presentViewController:alert animated:YES completion:nil];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
[self presentViewController:nav animated:YES completion:nil];
}
@end
+10
View File
@@ -0,0 +1,10 @@
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
// Gives the settings search bar an opaque pill when liquid glass is off.
@interface SCISearchBarStyler : NSObject
+ (void)styleSearchBar:(UISearchBar *)searchBar;
@end
NS_ASSUME_NONNULL_END
+45
View File
@@ -0,0 +1,45 @@
#import "SCISearchBarStyler.h"
#import "../Utils.h"
@implementation SCISearchBarStyler
+ (UIColor *)fieldColor {
return [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) {
if (tc.userInterfaceStyle == UIUserInterfaceStyleDark) {
return [UIColor colorWithRed:58/255.0 green:58/255.0 blue:60/255.0 alpha:1.0];
}
return [UIColor colorWithRed:190/255.0 green:190/255.0 blue:195/255.0 alpha:1.0];
}];
}
+ (void)styleSearchBar:(UISearchBar *)sb {
// Liquid glass already gives the field a proper backdrop.
if ([SCIUtils getBoolPref:@"liquid_glass_buttons"]) return;
UITextField *tf = sb.searchTextField;
if (!tf) return;
UIColor *fill = [self fieldColor];
// Hide UIKit's wide rectangular bg; we paint the text field itself
// so the rounded pill shape survives and UIKit keeps owning layout.
for (UIView *v in sb.subviews) {
for (UIView *c in v.subviews) {
if ([NSStringFromClass(c.class) isEqualToString:@"UISearchBarBackground"]) c.hidden = YES;
}
}
for (UIView *v in tf.subviews) {
NSString *n = NSStringFromClass(v.class);
if ([n containsString:@"Background"] || [n containsString:@"Backdrop"]) v.hidden = YES;
}
tf.borderStyle = UITextBorderStyleNone;
tf.backgroundColor = fill;
tf.layer.backgroundColor = [fill resolvedColorWithTraitCollection:sb.traitCollection].CGColor;
tf.layer.cornerCurve = kCACornerCurveContinuous;
tf.layer.cornerRadius = 18;
tf.layer.masksToBounds = YES;
tf.opaque = YES;
}
@end
+1
View File
@@ -30,6 +30,7 @@ typedef NS_ENUM(NSInteger, SCITableCell) {
@property (nonatomic, strong) NSURL *imageUrl;
@property (nonatomic) BOOL requiresRestart;
@property (nonatomic) BOOL disabled;
@property (nonatomic) double min;
@property (nonatomic) double max;
+1
View File
@@ -6,6 +6,7 @@ NS_ASSUME_NONNULL_BEGIN
+ (void)presentExport;
+ (void)presentImport;
+ (void)presentReset;
@end
+67 -19
View File
@@ -6,6 +6,7 @@
#import <CoreImage/CoreImage.h>
#import <objc/runtime.h>
#import "../../modules/JGProgressHUD/JGProgressHUD.h"
#import "SCISearchBarStyler.h"
// Settings backup/restore: export/import prefs as JSON file
// or photo. Import resets known prefs to defaults then applies imported ones.
@@ -43,7 +44,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
+ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw;
@end
@interface SCIBackupPreviewVC : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating>
@interface SCIBackupPreviewVC : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating, UISearchControllerDelegate>
@property (nonatomic, strong) NSMutableDictionary *mutableSettings;
@property (nonatomic, copy) NSString *primaryActionTitle;
@property (nonatomic, copy) void (^primaryAction)(SCIBackupPreviewVC *vc);
@@ -100,16 +101,42 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil];
sc.searchResultsUpdater = self;
sc.delegate = self;
sc.obscuresBackgroundDuringPresentation = NO;
sc.searchBar.placeholder = @"Search settings";
sc.searchBar.placeholder = SCILocalized(@"Search settings");
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
if (![SCIUtils getBoolPref:@"liquid_glass_buttons"]) {
self.definesPresentationContext = YES;
}
self.searchController = sc;
self.allGroups = [SCISettingsBackup buildPreviewGroupsForSettings:self.mutableSettings];
self.visibleGroups = self.allGroups;
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self sciStyleSearchBar];
}
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
if (![SCIUtils getBoolPref:@"liquid_glass_buttons"] && self.searchController.isActive) {
self.searchController.active = NO;
}
}
- (void)sciStyleSearchBar { [SCISearchBarStyler styleSearchBar:self.searchController.searchBar]; }
- (void)willPresentSearchController:(UISearchController *)searchController { [self sciStyleSearchBar]; }
- (void)didPresentSearchController:(UISearchController *)searchController {
[self sciStyleSearchBar];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self sciStyleSearchBar];
});
}
#pragma mark Search
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
@@ -194,14 +221,14 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
- (UIMenu *)buildMoreMenu {
__weak typeof(self) weakSelf = self;
UIAction *editAction = [UIAction actionWithTitle:(self.editMode ? @"Done editing" : @"Edit values")
UIAction *editAction = [UIAction actionWithTitle:(self.editMode ? SCILocalized(@"Done editing") : SCILocalized(@"Edit values"))
image:[UIImage systemImageNamed:(self.editMode ? @"checkmark" : @"pencil")]
identifier:nil
handler:^(__kindof UIAction *_) {
[weakSelf toggleEditMode];
}];
if (self.jsonMode) editAction.attributes = UIMenuElementAttributesDisabled;
UIAction *jsonAction = [UIAction actionWithTitle:(self.jsonMode ? @"Form view" : @"Raw JSON view")
UIAction *jsonAction = [UIAction actionWithTitle:(self.jsonMode ? SCILocalized(@"Form view") : SCILocalized(@"Raw JSON view"))
image:[UIImage systemImageNamed:(self.jsonMode ? @"list.bullet" : @"curlybraces")]
identifier:nil
handler:^(__kindof UIAction *_) {
@@ -273,7 +300,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
withRowAnimation:UITableViewRowAnimationFade];
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
sheet.popoverPresentationController.sourceView = cell;
sheet.popoverPresentationController.sourceRect = cell.bounds;
@@ -372,7 +399,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
if (self.expectingExportPick) {
self.expectingExportPick = NO;
[SCISettingsBackup showSuccessHUD:@"Settings exported"];
[SCISettingsBackup showSuccessHUD:SCILocalized(@"Settings exported")];
return;
}
NSURL *url = urls.firstObject;
@@ -381,7 +408,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
NSData *data = [NSData dataWithContentsOfURL:url];
if (access) [url stopAccessingSecurityScopedResource];
if (!data) {
[SCISettingsBackup showError:@"Could not read file."];
[SCISettingsBackup showError:SCILocalized(@"Could not read file.")];
return;
}
[SCISettingsBackup presentApplyConfirmationForData:data];
@@ -413,7 +440,11 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
+ (NSSet<NSString *> *)allPrefKeys {
NSMutableSet *keys = [NSMutableSet set];
// Settings UI (recursive — picks up every cell + menu)
[self collectKeysFromSections:[SCITweakSettings sections] into:keys];
// Every default registered by Tweak.x — covers prefs without a UI cell
[keys addObjectsFromArray:[[SCIUtils sciRegisteredDefaults] allKeys]];
// Manually-tracked storage (lists/dicts not exposed via registerDefaults)
[keys addObjectsFromArray:[self extraDataKeys]];
return keys;
}
@@ -574,7 +605,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
r.kind = SCIBackupPreviewRowKindSwitch;
id raw = values[s.defaultsKey];
BOOL on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO;
r.value = on ? @"On" : @"Off";
r.value = on ? SCILocalized(@"On") : SCILocalized(@"Off");
} else if (s.type == SCITableCellStepper) {
r.kind = SCIBackupPreviewRowKindReadOnly;
id raw = values[s.defaultsKey];
@@ -623,7 +654,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
if ([raw isKindOfClass:[NSNumber class]]) {
NSNumber *n = raw;
const char *t = n.objCType;
if (t && strcmp(t, "c") == 0) return n.boolValue ? @"On" : @"Off";
if (t && strcmp(t, "c") == 0) return n.boolValue ? SCILocalized(@"On") : SCILocalized(@"Off");
return n.stringValue;
}
if ([raw isKindOfClass:[NSString class]]) return raw;
@@ -705,7 +736,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
}
+ (void)showError:(NSString *)message {
UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Import failed"
UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Import failed")
message:message
preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
@@ -718,7 +749,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
NSDictionary *snap = [self snapshotCurrentSettings];
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = @"Export settings";
vc.title = SCILocalized(@"Export settings");
vc.mutableSettings = [snap mutableCopy];
vc.primaryActionTitle = @"Save";
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
@@ -727,7 +758,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname];
NSError *err = nil;
[data writeToURL:tmp options:NSDataWritingAtomic error:&err];
if (err) { [self showError:@"Could not write temporary file."]; return; }
if (err) { [self showError:SCILocalized(@"Could not write temporary file.")]; return; }
UIDocumentPickerViewController *p =
[[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]];
SCIBackupHelper *helper = [SCIBackupHelper shared];
@@ -747,6 +778,23 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
[self pickFromFiles];
}
+ (void)presentReset {
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:SCILocalized(@"Reset all settings?")
message:SCILocalized(@"Every RyukGram preference will revert to its built-in default. This can't be undone.")
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Reset")
style:UIAlertActionStyleDestructive
handler:^(__unused UIAlertAction *a) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
for (NSString *key in [self allPrefKeys]) [d removeObjectForKey:key];
[d synchronize];
[SCIUtils showRestartConfirmation];
}]];
[topMostController() presentViewController:alert animated:YES completion:nil];
}
+ (void)pickFromFiles {
UIDocumentPickerViewController *p =
[[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"public.json", @"public.text", @"public.data"]
@@ -759,24 +807,24 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
+ (void)presentApplyConfirmationForData:(NSData *)data {
NSDictionary *settings = [self parseSettingsFromData:data];
if (!settings) {
[self showError:@"File is not a valid RyukGram settings export."];
[self showError:SCILocalized(@"File is not a valid RyukGram settings export.")];
return;
}
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = @"Import preview";
vc.title = SCILocalized(@"Import preview");
vc.mutableSettings = [settings mutableCopy];
vc.primaryActionTitle = @"Apply";
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
UIAlertController *confirm =
[UIAlertController alertControllerWithTitle:@"Apply imported settings?"
message:@"All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect."
[UIAlertController alertControllerWithTitle:SCILocalized(@"Apply imported settings?")
message:SCILocalized(@"All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect.")
preferredStyle:UIAlertControllerStyleAlert];
[confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[confirm addAction:[UIAlertAction actionWithTitle:@"Apply" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[confirm addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[confirm addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Apply") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCISettingsBackup applySettings:previewVC.mutableSettings];
[previewVC dismissViewControllerAnimated:YES completion:^{
[SCISettingsBackup showSuccessHUD:@"Settings imported"];
[SCISettingsBackup showSuccessHUD:SCILocalized(@"Settings imported")];
[SCIUtils showRestartConfirmation];
}];
}]];
+115 -13
View File
@@ -1,8 +1,9 @@
#import "SCISettingsViewController.h"
#import "SCISearchBarStyler.h"
static char rowStaticRef[] = "row";
@interface SCISettingsViewController () <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating>
@interface SCISettingsViewController () <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating, UISearchControllerDelegate>
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, copy) NSArray *sections;
@@ -73,18 +74,90 @@ static char rowStaticRef[] = "row";
if (self.isRoot) {
UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil];
sc.searchResultsUpdater = self;
sc.delegate = self;
sc.obscuresBackgroundDuringPresentation = NO;
sc.searchBar.placeholder = @"Search settings";
sc.searchBar.placeholder = SCILocalized(@"settings.search.placeholder");
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
if (![SCIUtils getBoolPref:@"liquid_glass_buttons"]) {
self.definesPresentationContext = YES;
}
self.searchController = sc;
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemClose
target:self action:@selector(sciDismissSettings)];
// Compact globe button — English is the only shipped language for now,
// so the tap shows an info alert instead of a picker. Re-enable the
// menu below once additional translations land.
UIImage *globe = [UIImage systemImageNamed:@"globe"];
UIBarButtonItem *langItem = [[UIBarButtonItem alloc] initWithImage:globe
style:UIBarButtonItemStylePlain
target:self
action:@selector(sciShowLanguageInfo)];
self.navigationItem.rightBarButtonItem = langItem;
}
}
- (void)sciShowLanguageInfo {
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:SCILocalized(@"settings.language.title")
message:SCILocalized(@"settings.language.english_only")
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"settings.language.ok") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"settings.language.help_translate") style:UIAlertActionStyleDefault
handler:^(__unused UIAlertAction *a) {
NSURL *url = [NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram#translating-ryukgram"];
if (url) [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}]];
[self presentViewController:alert animated:YES completion:nil];
}
- (UIMenu *)sciBuildLanguageMenu {
NSString *current = [[NSUserDefaults standardUserDefaults] stringForKey:SCILanguagePrefKey] ?: @"system";
NSMutableArray<UIAction *> *actions = [NSMutableArray array];
for (NSDictionary<NSString *, NSString *> *lang in SCIAvailableLanguages()) {
NSString *code = lang[@"code"];
NSString *title = [code isEqualToString:@"system"]
? SCILocalized(@"settings.language.system")
: lang[@"native"];
UIAction *action = [UIAction actionWithTitle:title
image:nil
identifier:nil
handler:^(UIAction * _Nonnull a) {
NSString *prev = [[NSUserDefaults standardUserDefaults] stringForKey:SCILanguagePrefKey] ?: @"system";
if ([prev isEqualToString:code]) return;
[[NSUserDefaults standardUserDefaults] setObject:code forKey:SCILanguagePrefKey];
SCILocalizationReset();
[self sciApplyLanguageChange];
// Most IG-side hooks cache their labels at load time, so a full
// restart is the only way to flip every menu/button cleanly.
[SCIUtils showRestartConfirmation];
}];
action.state = [code isEqualToString:current] ? UIMenuElementStateOn : UIMenuElementStateOff;
[actions addObject:action];
}
return [UIMenu menuWithTitle:SCILocalized(@"settings.language.title") children:actions];
}
- (void)sciApplyLanguageChange {
// Root title + search placeholder reflect the new language immediately.
self.title = SCILocalized(@"settings.title");
self.searchController.searchBar.placeholder = SCILocalized(@"settings.search.placeholder");
if (self.navigationItem.rightBarButtonItem.menu) {
self.navigationItem.rightBarButtonItem.menu = [self sciBuildLanguageMenu];
}
[self.tableView reloadData];
// Features watching for runtime label refreshes (IG menu items, overlay
// buttons, toasts) can subscribe to this to re-read their strings.
[[NSNotificationCenter defaultCenter] postNotificationName:@"SCILanguageDidChange" object:nil];
}
- (void)sciDismissSettings {
[self dismissViewControllerAnimated:YES completion:nil];
}
@@ -92,6 +165,17 @@ static char rowStaticRef[] = "row";
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self.tableView reloadData];
[self sciStyleSearchBar];
}
- (void)sciStyleSearchBar { [SCISearchBarStyler styleSearchBar:self.searchController.searchBar]; }
- (void)willPresentSearchController:(UISearchController *)searchController { [self sciStyleSearchBar]; }
- (void)didPresentSearchController:(UISearchController *)searchController {
[self sciStyleSearchBar];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[self sciStyleSearchBar];
});
}
#pragma mark - Search
@@ -163,13 +247,17 @@ static char rowStaticRef[] = "row";
- (void)viewWillDisappear:(BOOL)animated {
[super viewWillDisappear:animated];
// Without this the search bar strands itself as a floating bar on return.
if (![SCIUtils getBoolPref:@"liquid_glass_buttons"] && self.searchController.isActive) {
self.searchController.active = NO;
}
if (![[[NSUserDefaults standardUserDefaults] objectForKey:@"SCInstaFirstRun"] isEqualToString:SCIVersionString]) {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"RyukGram Settings Info"
message:@"In the future: Hold down on the three lines at the top right of your profile page, to re-open RyukGram settings."
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"settings.firstrun.title")
message:SCILocalized(@"settings.firstrun.message")
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"I understand!"
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"settings.firstrun.ok")
style:UIAlertActionStyleDefault
handler:nil]];
@@ -235,15 +323,19 @@ static char rowStaticRef[] = "row";
case SCITableCellSwitch: {
UISwitch *toggle = [UISwitch new];
toggle.on = [[NSUserDefaults standardUserDefaults] boolForKey:row.defaultsKey];
toggle.on = row.disabled ? NO : [[NSUserDefaults standardUserDefaults] boolForKey:row.defaultsKey];
toggle.onTintColor = [SCIUtils SCIColor_Primary];
toggle.enabled = !row.disabled;
objc_setAssociatedObject(toggle, rowStaticRef, row, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[toggle addTarget:self action:@selector(switchChanged:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = toggle;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
if (row.disabled) {
cell.contentView.alpha = 0.4;
}
break;
}
@@ -288,9 +380,13 @@ static char rowStaticRef[] = "row";
menuButton.configuration = config;
[menuButton sizeToFit];
cell.accessoryView = menuButton;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
if (row.disabled) {
menuButton.enabled = NO;
cell.contentView.alpha = 0.4;
}
break;
}
@@ -313,7 +409,9 @@ static char rowStaticRef[] = "row";
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
if ([self isSearching]) {
NSUInteger n = self.searchResults.count;
return n ? [NSString stringWithFormat:@"%lu result%@", (unsigned long)n, n == 1 ? @"" : @"s"] : @"No results";
if (n == 0) return SCILocalized(@"settings.results.none");
NSString *fmt = n == 1 ? SCILocalized(@"settings.results.one") : SCILocalized(@"settings.results.many");
return [NSString stringWithFormat:fmt, (unsigned long)n];
}
return self.sections[section][@"header"];
}
@@ -367,6 +465,10 @@ static char rowStaticRef[] = "row";
if (row.requiresRestart) {
[SCIUtils showRestartConfirmation];
}
if ([row.defaultsKey isEqualToString:@"hide_suggested_stories"]) {
[[NSNotificationCenter defaultCenter] postNotificationName:@"SCISuggestedStoriesReload" object:nil];
}
}
- (void)stepperChanged:(UIStepper *)sender {
File diff suppressed because it is too large Load Diff
+111 -18
View File
@@ -2,6 +2,7 @@
#import "InstagramHeaders.h"
#import "Tweak.h"
#import "Utils.h"
#include "../modules/fishhook/fishhook.h"
///////////////////////////////////////////////////////////
@@ -13,7 +14,7 @@
///////////////////////////////////////////////////////////
// * Tweak version *
NSString *SCIVersionString = @"v1.1.5.1";
NSString *SCIVersionString = @"v1.2.0";
// Variables that work across features
BOOL dmVisualMsgsViewedButtonEnabled = false;
@@ -30,12 +31,52 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"remove_screenshot_alert": @(YES),
@"call_confirm": @(YES),
@"keep_deleted_message": @(NO),
@"dw_feed_posts": @(YES),
@"dw_reels": @(YES),
@"dw_story": @(YES),
@"hide_suggested_stories": @(NO),
@"story_tray_actions": @(NO),
@"zoom_profile_photo": @(NO),
@"follow_indicator": @(NO),
@"profile_note_copy": @(NO),
@"disable_disappearing_mode_swipe": @(NO),
@"hide_voice_call_button": @(NO),
@"hide_video_call_button": @(NO),
@"fake_location_enabled": @(NO),
@"show_fake_location_map_button": @(NO),
@"fake_location_lat": @(48.8584),
@"fake_location_lon": @(2.2945),
@"fake_location_name": @"Eiffel Tower",
@"fake_location_presets": @[],
@"messages_only": @(NO),
@"launch_tab": @"default",
@"save_profile": @(YES),
@"dw_method": @"button",
@"dw_confirm": @(YES),
// Per-context action buttons (new in 1.1.6)
@"feed_media_zoom": @(NO),
@"disable_bg_refresh": @(NO),
@"disable_home_refresh": @(NO),
@"disable_home_scroll": @(NO),
@"disable_reels_tab_refresh": @(NO),
@"dm_full_last_active": @(NO),
@"send_file": @(NO),
@"note_actions": @(NO),
@"note_copy_on_hold": @(NO),
@"feed_date_format": @"default",
// Per-surface date format toggles (see SCIDateFormatEntries.h)
@"date_fmt_mixed": @(YES),
@"date_fmt_notes_comments_stories": @(NO),
@"date_fmt_dms": @(NO),
@"feed_action_button": @(YES),
@"feed_action_default": @"menu",
@"reels_action_button": @(YES),
@"reels_action_default": @"menu",
@"stories_action_button": @(YES),
@"stories_action_default": @"menu",
// Legacy long-press gesture (off by default — kept for users who prefer it)
@"dw_legacy_gesture": @(NO),
@"dw_confirm": @(NO),
@"enhance_download_quality": @(YES),
@"default_video_quality": @"always_ask",
@"default_photo_quality": @"high",
@"ffmpeg_encoding_speed": @"ultrafast",
@"unfollow_confirm": @(NO),
@"dw_save_action": @"share",
@"dw_finger_count": @(3),
@"dw_finger_duration": @(0.5),
@@ -56,6 +97,8 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"seen_auto_on_interact": @(NO),
@"seen_auto_on_typing": @(NO),
@"seen_on_story_like": @(NO),
@"seen_on_story_reply": @(NO),
@"advance_on_story_reply": @(NO),
@"advance_on_mark_seen": @(NO),
@"advance_on_story_like": @(NO),
@"indicate_unsent_messages": @(NO),
@@ -70,6 +113,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"story_excluded_show_unexclude_eye": @(YES),
@"story_seen_mode": @"button",
@"story_audio_toggle": @(NO),
@"view_story_mentions": @(YES),
@"settings_pause_playback": @(YES),
@"embed_links": @(NO),
@"embed_link_domain": @"kkinstagram.com",
@@ -79,9 +123,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"strip_browser_tracking": @(NO),
@"hide_feed_repost": @(NO),
@"copy_comment": @(YES),
@"download_gif_comment": @(YES)
@"download_gif_comment": @(YES),
@"sci_language": @"system"
};
[[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults];
[SCIUtils setSciRegisteredDefaults:sciDefaults];
// Override instagram defaults
if ([SCIUtils getBoolPref:@"liquid_glass_buttons"]) {
@@ -608,31 +654,27 @@ shouldPersistLastBugReportId:(id)arg6
for (id obj in originalObjs) {
BOOL shouldHide = NO;
// Meta AI
if (
[[obj valueForKey:@"title"] isEqualToString:@"AI images"]
|| [[obj valueForKey:@"title"] isEqualToString:@"Meta AI"]
) {
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
NSLog(@"[SCInsta] Hiding meta ai from IGDS menu");
NSString *itemTitle = nil;
@try { itemTitle = [obj valueForKey:@"title"]; } @catch (__unused id e) {}
// Meta AI
if ([itemTitle isEqualToString:@"AI images"] || [itemTitle isEqualToString:@"Meta AI"]) {
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
shouldHide = YES;
}
}
// Populate new objs array
if (!shouldHide) {
[filteredObjs addObject:obj];
}
}
extern NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *);
extern NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *);
extern NSArray *sciMaybeAppendStoryMentionsMenuItem(NSArray *);
NSArray *finalObjs = sciMaybeAppendStoryExcludeMenuItem([filteredObjs copy]);
finalObjs = sciMaybeAppendStoryAudioMenuItem(finalObjs);
finalObjs = sciMaybeAppendStoryMentionsMenuItem(finalObjs);
return %orig(finalObjs, edr, headerLabelText);
}
%end
@@ -798,7 +840,42 @@ static BOOL new_expHelper_isHomeFeed(id self, SEL _cmd) {
return orig_expHelper_isHomeFeed(self, _cmd);
}
// Liquid glass tab bar — C function hooks via fishhook
// Credits: @euoradan (Radan) for discovering these flags
static BOOL (*orig_IGFloatingTabBarEnabled)(void) = NULL;
static BOOL (*orig_IGTabBarDynamicSizingEnabled)(void) = NULL;
static BOOL (*orig_IGTabBarEnhancedDynamicSizingEnabled)(void) = NULL;
static BOOL (*orig_IGTabBarHomecomingWithFloatingTabEnabled)(void) = NULL;
static BOOL (*orig_IGTabBarViewPointFixEnabled)(void) = NULL;
static NSInteger (*orig_IGTabBarStyleForLauncherSet)(NSInteger) = NULL;
static BOOL hook_IGFloatingTabBarEnabled(void) {
if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES;
return orig_IGFloatingTabBarEnabled ? orig_IGFloatingTabBarEnabled() : NO;
}
static BOOL hook_IGTabBarDynamicSizingEnabled(void) {
if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES;
return orig_IGTabBarDynamicSizingEnabled ? orig_IGTabBarDynamicSizingEnabled() : NO;
}
static BOOL hook_IGTabBarEnhancedDynamicSizingEnabled(void) {
if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES;
return orig_IGTabBarEnhancedDynamicSizingEnabled ? orig_IGTabBarEnhancedDynamicSizingEnabled() : NO;
}
static BOOL hook_IGTabBarHomecomingWithFloatingTabEnabled(void) {
if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES;
return orig_IGTabBarHomecomingWithFloatingTabEnabled ? orig_IGTabBarHomecomingWithFloatingTabEnabled() : NO;
}
static BOOL hook_IGTabBarViewPointFixEnabled(void) {
if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES;
return orig_IGTabBarViewPointFixEnabled ? orig_IGTabBarViewPointFixEnabled() : NO;
}
static NSInteger hook_IGTabBarStyleForLauncherSet(NSInteger set) {
if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return 1;
return orig_IGTabBarStyleForLauncherSet ? orig_IGTabBarStyleForLauncherSet(set) : set;
}
%ctor {
// ObjC hooks for liquid glass buttons
Class swizzleToggle = objc_getClass("IGLiquidGlassSwizzle.IGLiquidGlassSwizzleToggle");
if (swizzleToggle) {
MSHookMessageEx(swizzleToggle, @selector(isEnabled),
@@ -812,4 +889,20 @@ static BOOL new_expHelper_isHomeFeed(id self, SEL _cmd) {
MSHookMessageEx(expHelper, @selector(isHomeFeedHeaderEnabled),
(IMP)new_expHelper_isHomeFeed, (IMP *)&orig_expHelper_isHomeFeed);
}
// C function hooks for liquid glass tab bar / surfaces (fishhook)
if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) {
int result = rebind_symbols((struct rebinding[]){
{"IGFloatingTabBarEnabled", (void *)hook_IGFloatingTabBarEnabled, (void **)&orig_IGFloatingTabBarEnabled},
{"IGTabBarDynamicSizingEnabled", (void *)hook_IGTabBarDynamicSizingEnabled, (void **)&orig_IGTabBarDynamicSizingEnabled},
{"IGTabBarEnhancedDynamicSizingEnabled", (void *)hook_IGTabBarEnhancedDynamicSizingEnabled, (void **)&orig_IGTabBarEnhancedDynamicSizingEnabled},
{"IGTabBarHomecomingWithFloatingTabEnabled", (void *)hook_IGTabBarHomecomingWithFloatingTabEnabled, (void **)&orig_IGTabBarHomecomingWithFloatingTabEnabled},
{"IGTabBarViewPointFixEnabled", (void *)hook_IGTabBarViewPointFixEnabled, (void **)&orig_IGTabBarViewPointFixEnabled},
{"IGTabBarStyleForLauncherSet", (void *)hook_IGTabBarStyleForLauncherSet, (void **)&orig_IGTabBarStyleForLauncherSet},
}, 6);
NSLog(@"[SCInsta] Liquid glass fishhook result=%d floating=%p dynamic=%p enhanced=%p homecoming=%p viewpoint=%p style=%p",
result, orig_IGFloatingTabBarEnabled, orig_IGTabBarDynamicSizingEnabled,
orig_IGTabBarEnhancedDynamicSizingEnabled, orig_IGTabBarHomecomingWithFloatingTabEnabled,
orig_IGTabBarViewPointFixEnabled, orig_IGTabBarStyleForLauncherSet);
}
}
+6
View File
@@ -8,6 +8,7 @@
#import "InstagramHeaders.h"
#import "QuickLook.h"
#import "Localization/SCILocalization.h"
#import "Settings/SCISettingsViewController.h"
@@ -25,6 +26,11 @@
+ (double)getDoublePref:(NSString *)key;
+ (NSString *)getStringPref:(NSString *)key;
// Registered SCInsta defaults (set once at app launch by Tweak.x). Used by
// the settings backup so any new pref is included automatically.
+ (NSDictionary<NSString *, id> *)sciRegisteredDefaults;
+ (void)setSciRegisteredDefaults:(NSDictionary<NSString *, id> *)defaults;
+ (_Bool)liquidGlassEnabledBool:(_Bool)fallback;
+ (void)cleanCache;
+16 -9
View File
@@ -20,6 +20,13 @@
return [[NSUserDefaults standardUserDefaults] stringForKey:key];
}
static NSDictionary *sciRegisteredDefaultsRef = nil;
+ (NSDictionary<NSString *, id> *)sciRegisteredDefaults { return sciRegisteredDefaultsRef ?: @{}; }
+ (void)setSciRegisteredDefaults:(NSDictionary<NSString *, id> *)defaults {
sciRegisteredDefaultsRef = [defaults copy];
}
+ (_Bool)liquidGlassEnabledBool:(_Bool)fallback {
BOOL setting = [SCIUtils getBoolPref:@"liquid_glass_surfaces"];
return setting ? true : fallback;
@@ -271,22 +278,22 @@
// Alerts
+ (BOOL)showConfirmation:(void(^)(void))okHandler title:(NSString *)title {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:@"Are you sure?" preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Yes" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:SCILocalized(@"Are you sure?") preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Yes") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
okHandler();
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"No!" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"No!") style:UIAlertActionStyleCancel handler:nil]];
[topMostController() presentViewController:alert animated:YES completion:nil];
return nil;
};
+ (BOOL)showConfirmation:(void(^)(void))okHandler cancelHandler:(void(^)(void))cancelHandler title:(NSString *)title {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:@"Are you sure?" preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Yes" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:SCILocalized(@"Are you sure?") preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Yes") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
okHandler();
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"No!" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"No!") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {
if (cancelHandler != nil) {
cancelHandler();
}
@@ -303,11 +310,11 @@
return [self showConfirmation:okHandler cancelHandler:cancelHandler title:nil];
}
+ (void)showRestartConfirmation {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"Restart required" message:@"You must restart the app to apply this change" preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Restart" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
UIAlertController* alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Restart required") message:SCILocalized(@"You must restart the app to apply this change") preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Restart") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) {
exit(0);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Later" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Later") style:UIAlertActionStyleCancel handler:nil]];
[topMostController() presentViewController:alert animated:YES completion:nil];
};