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