[release] RyukGram v1.2.2

- Profile Analyzer (beta) — follower/following scans with mutuals, non-followbacks, new/lost trackers, and profile change history; searchable lists with batch follow/unfollow
- Theme settings — force dark mode, Full OLED, OLED chat theme, and keyboard theme picker
- Confirm story like
- Confirm story emoji reaction
- Swipe down to dismiss media viewer
- Manually add users to story/chat exclusion lists by username
- Keep stories visually seen locally
- Auto-scroll reels mode
- Quality picker: audio-only and raw photo download rows
- Clear cache button with optional auto-clear interval
- Spanish, Russian, Korean, Arabic, and Chinese (Traditional) translations
- About page with version, credits, and links
- Release notes popup on first launch of a new version
- Anonymous live viewing
- Toggle live comments
- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle
- Hide RyukGram UI on screenshots, screen recordings, and mirroring
- Open link from clipboard — long-press the search tab
- Messages-only mode: optional "Hide tab bar" sub-toggle
- Fake profile stats — verified badge and follower/following/post counts on your own profile
- Language switcher + import/export localization from Debug
- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting
- Force legacy Quiz sticker back into the story composer tray
- Advanced experimental features menu — toggle hidden IG experiments (QuickSnap, Homecoming, Prism, Direct Notes reply types) with apply-on-restart batching and a crash-loop auto-reset
- Shortcut to Advanced experimental features from the General experimental features section

- Push notifications render with rich previews on sideload again
- IG 426 compatibility across story audio toggle, like confirmation, seen-on-like, live comments, notes audio download
- Call confirm split into separate voice-call and video-call toggles
- Messages-only mode: tab swiping disabled
- Settings quick-access broken in non-English languages
- Story seen-receipt block restored on IG v426
- Block selected mode no longer marks listed stories as seen
- Hide explore posts grid works again on recent IG versions
- Hide suggested stories no longer breaks profile highlights
- Hide trending searches now also hides the category chip bar
- Story eye long-press menu opens next to the button
- Disable video autoplay: tap-to-play now works on videos inside carousels
- Disable vanish mode swipe fixed on IG 426

- "Confirm shh mode" renamed to "Confirm vanish mode" across all languages
- Confirm sticker interaction split into separate story and highlight toggles
- Shared link embed presets: added eeinstagram.com and vxinstagram.com
- Downloaded media filenames follow `@username_context_timestamp`
- Reels pause mode: optional tap-to-mute on photo reels
- Backup & Restore — scope picker with live preview for Settings / Excluded lists / Analyzer data
- Profile Analyzer: filter by Not verified
- Settings header: tap to open a sheet with GitHub and Telegram channel links

- Thanks to Furamako for the Spanish translation
- Thanks to [ZomkaDEV](https://github.com/ZomkaDEV) for the Russian translation
- Thanks to [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) for the Korean translation
- Thanks to [@bruuhim](https://github.com/bruuhim) for the Arabic translation
- Thanks to [@jaydenjcpy](https://github.com/jaydenjcpy) for the Chinese (Traditional) translation
- Thanks to [@darthplagueiswise](https://github.com/darthplagueiswise) (Radan) for the experimental flag feature set
- Thanks to [@asdfzxcvbn](https://github.com/asdfzxcvbn) for [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject) and [ipapatch](https://github.com/asdfzxcvbn/ipapatch)

- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh clears them (warning available in settings)
- "Delete for you" detection uses a ~2s window after the local action — a real unsend landing in that window may be missed (rare)
- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures
This commit is contained in:
faroukbmiled
2026-04-24 02:50:30 +01:00
parent 3fd1d8e138
commit 2977873932
125 changed files with 14437 additions and 3368 deletions
+5
View File
@@ -118,11 +118,16 @@ const void *kSCIDismissKey = &kSCIDismissKey;
id media = provider(sender);
if (media == (id)kCFNull) return;
SCIActionContext tapCtx = (SCIActionContext)ctxNum.integerValue;
NSString *tapCtxLabel = [SCIMediaActions contextLabelForContext:tapCtx];
if ([tap isEqualToString:@"expand"]) {
[SCIMediaActions expandMedia:media fromView:sender caption:nil];
} else if ([tap isEqualToString:@"download_share"]) {
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
[SCIMediaActions downloadAndShareMedia:media];
} else if ([tap isEqualToString:@"download_photos"]) {
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
[SCIMediaActions downloadAndSaveMedia:media];
} else if ([tap isEqualToString:@"copy_link"]) {
[SCIMediaActions copyURLForMedia:media];
+23
View File
@@ -2,6 +2,7 @@
#import <UIKit/UIKit.h>
#import "../InstagramHeaders.h"
#import "../Downloader/Download.h"
#import "SCIActionMenu.h"
NS_ASSUME_NONNULL_BEGIN
@@ -16,6 +17,18 @@ typedef NS_ENUM(NSInteger, SCIActionContext) {
@interface SCIMediaActions : NSObject
// MARK: - Filename naming
// `@username_context_yyyyMMdd_HHmmss` (sanitized). UUID fallback on failure.
+ (NSString *)filenameStemForMedia:(nullable id)media contextLabel:(NSString *)ctxLabel;
// "feed" / "reels" / "stories".
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx;
// Stem read by the download + mux write sites to name output files.
+ (nullable NSString *)currentFilenameStem;
+ (void)setCurrentFilenameStem:(nullable NSString *)stem;
// MARK: - Media extraction
/// Return the post's caption string. Tries selectors first, falls back to
@@ -28,6 +41,16 @@ typedef NS_ENUM(NSInteger, SCIActionContext) {
/// Ordered children of a carousel IGMedia. Empty array for non-carousels.
+ (NSArray *)carouselChildrenForMedia:(id)media;
/// YES if the media has an audio track (`has_audio` fieldCache == 1).
+ (BOOL)mediaHasAudio:(id)media;
/// Download the raw photo URL, skipping any video route.
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action;
/// Extract the audio-only track from the DASH manifest via FFmpeg. Photos
/// library can't hold audio, so both actions end at the share sheet.
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action;
/// 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;
+198 -2
View File
@@ -21,6 +21,57 @@ static SCIDownloadDelegate *sciActiveDownloadDelegate = nil;
extern void sciToggleStoryAudio(void);
extern BOOL sciIsStoryAudioEnabled(void);
// MARK: - Filename naming
static NSString *sciCurrentFilenameStem = nil;
static NSString *sciSanitizeFilenameComponent(NSString *s) {
if (!s.length) return @"";
NSMutableCharacterSet *bad = [NSMutableCharacterSet alphanumericCharacterSet];
[bad addCharactersInString:@"._-"];
NSCharacterSet *drop = bad.invertedSet;
NSArray *parts = [s componentsSeparatedByCharactersInSet:drop];
NSString *out = [parts componentsJoinedByString:@""];
if (out.length > 30) out = [out substringToIndex:30];
return out;
}
// IGAPIStorableObject's backing dict.
static NSDictionary *sciMediaFieldCache(id obj) {
if (!obj) 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 v = object_getIvar(obj, fcIvar);
return [v isKindOfClass:[NSDictionary class]] ? v : nil;
}
static NSString *sciUsernameForMedia(id media) {
if (!media) return nil;
@try {
id user = nil;
@try { user = [media valueForKey:@"user"]; } @catch (__unused id e) {}
if (!user) {
NSDictionary *fc = sciMediaFieldCache(media);
user = fc[@"user"];
}
if (!user) return nil;
NSString *u = nil;
@try { u = [user valueForKey:@"username"]; } @catch (__unused id e) {}
if (![u isKindOfClass:[NSString class]] || !u.length) {
NSDictionary *ufc = sciMediaFieldCache(user);
id v = ufc[@"username"];
if ([v isKindOfClass:[NSString class]]) u = v;
else if ([user isKindOfClass:[NSDictionary class]]) u = ((NSDictionary *)user)[@"username"];
}
return [u isKindOfClass:[NSString class]] ? u : nil;
} @catch (__unused id e) { return nil; }
}
// Match keys used in the settings-entry title map for openSettingsForContext:
static NSString *sciSettingsTitleForContext(SCIActionContext ctx) {
switch (ctx) {
@@ -74,6 +125,38 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
@implementation SCIMediaActions
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx {
switch (ctx) {
case SCIActionContextFeed: return @"feed";
case SCIActionContextReels: return @"reels";
case SCIActionContextStories: return @"stories";
}
return @"media";
}
+ (NSString *)filenameStemForMedia:(id)media contextLabel:(NSString *)ctxLabel {
@try {
NSString *user = sciSanitizeFilenameComponent(sciUsernameForMedia(media));
NSString *userPart = user.length ? [@"@" stringByAppendingString:user] : @"media";
NSString *ctxPart = sciSanitizeFilenameComponent(ctxLabel);
if (!ctxPart.length) ctxPart = @"media";
static NSDateFormatter *fmt = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
fmt = [NSDateFormatter new];
fmt.dateFormat = @"yyyyMMdd_HHmmss";
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
});
NSString *ts = [fmt stringFromDate:[NSDate date]];
return [NSString stringWithFormat:@"%@_%@_%@", userPart, ctxPart, ts];
} @catch (__unused id e) {
return [[NSUUID UUID] UUIDString];
}
}
+ (NSString *)currentFilenameStem { return sciCurrentFilenameStem; }
+ (void)setCurrentFilenameStem:(NSString *)stem { sciCurrentFilenameStem = [stem copy]; }
// MARK: - Media extraction
+ (NSString *)captionForMedia:(id)media {
@@ -209,6 +292,96 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
return @[];
}
+ (BOOL)mediaHasAudio:(id)media {
if (!media) return NO;
// fieldCache on media (old IG path).
id v = sciFieldCache(media, @"has_audio");
if ([v respondsToSelector:@selector(boolValue)] && [v boolValue]) return YES;
// IGVideo.isAudioDetected — positive signal only; NO often means "IG
// hasn't decoded the manifest yet" for stories, not actually silent.
@try {
id video = nil;
if ([media respondsToSelector:@selector(video)])
video = ((id(*)(id, SEL))objc_msgSend)(media, @selector(video));
if (video && [video respondsToSelector:@selector(isAudioDetected)]) {
if (((BOOL(*)(id, SEL))objc_msgSend)(video, @selector(isAudioDetected))) return YES;
}
} @catch (__unused id e) {}
// Stories often carry audio but don't surface it in fieldCache. If any
// of these music/audio hints are present, treat as audio-bearing.
for (NSString *key in @[@"music_metadata", @"story_music_stickers",
@"is_story_image_with_music", @"story_sound_on",
@"spotify_stickers", @"story_music_lyric_stickers"]) {
id val = sciFieldCache(media, key);
if (val && ![val isKindOfClass:[NSNull class]]) {
if ([val respondsToSelector:@selector(boolValue)] && [val boolValue]) return YES;
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return YES;
if ([val isKindOfClass:[NSDictionary class]] && [(NSDictionary *)val count]) return YES;
}
}
// Last resort: if a DASH manifest exists, assume audio is present.
return [SCIDashParser dashManifestForMedia:media].length > 0;
}
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action {
NSURL *url = [self hdPhotoURLForMedia:media];
if (!url) url = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
if (!url) url = [self fieldCachePhotoURLForMedia:media];
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo URL")]; return; }
NSString *ext = [[url lastPathComponent] pathExtension];
if (!ext.length) ext = @"jpg";
sciActiveDownloadDelegate = sciMakeDownloader(action, NO);
[sciActiveDownloadDelegate downloadFileWithURL:url fileExtension:ext hudLabel:nil];
}
// Photos library can't hold audio — save action falls back to share sheet.
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action {
NSString *manifest = [SCIDashParser dashManifestForMedia:media];
if (!manifest.length) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio stream available")];
return;
}
NSArray *reps = [SCIDashParser parseManifest:manifest];
SCIDashRepresentation *audio = [SCIDashParser bestAudioFromRepresentations:reps];
if (!audio.url) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio track found")];
return;
}
if (![SCIFFmpeg isAvailable]) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"FFmpeg not available")];
return;
}
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
NSString *ticket = [pill beginTicketWithTitle:SCILocalized(@"Downloading audio...")
onCancel:^{ [SCIFFmpeg cancelAll]; }];
NSString *audioStem = [self currentFilenameStem] ?: [[NSUUID UUID] UUIDString];
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.m4a", audioStem]];
NSString *cmd = [NSString stringWithFormat:@"-i \"%@\" -vn -c:a copy -y \"%@\"",
audio.url.absoluteString, outPath];
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
dispatch_async(dispatch_get_main_queue(), ^{
if (!success) {
[pill finishTicket:ticket errorMessage:SCILocalized(@"Audio extract failed")];
return;
}
[pill finishTicket:ticket successMessage:SCILocalized(@"Audio ready")];
NSURL *fileURL = [NSURL fileURLWithPath:outPath];
switch (action) {
case quickLook: [SCIUtils showQuickLookVC:@[fileURL]]; break;
case share:
case saveToPhotos:
default: [SCIUtils showShareVC:fileURL]; break;
}
});
}];
}
+ (NSURL *)bestURLForMedia:(id)media {
if (!media) return nil;
@@ -328,6 +501,7 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
// Try enhanced HD path via reusable quality picker
BOOL handled = [SCIQualityPicker pickQualityForMedia:media
fromView:sourceView
action:action
picked:^(SCIDashRepresentation *video, SCIDashRepresentation *audio) {
[self downloadDASHVideo:video audio:audio action:action];
}
@@ -621,13 +795,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
NSMutableArray<NSURL *> *files = [NSMutableArray array];
NSLock *lock = [NSLock new];
__block NSUInteger completed = 0;
NSString *bulkStem = [self currentFilenameStem];
NSUInteger __idx = 0;
for (NSURL *url in urls) {
if (cancelled) break;
dispatch_group_enter(group);
NSString *ext = [[url lastPathComponent] pathExtension];
NSString *name = bulkStem
? [NSString stringWithFormat:@"%@_%lu", bulkStem, (unsigned long)(++__idx)]
: [[NSUUID UUID] UUIDString];
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString],
[NSString stringWithFormat:@"%@.%@", name,
ext.length ? ext : @"jpg"]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
@@ -763,6 +942,12 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
fromView:(UIView *)sourceView {
NSMutableArray<SCIAction *> *out = [NSMutableArray array];
NSString *ctxLabel = [self contextLabelForContext:ctx];
// Stamp the filename stem before a download fires.
void (^stampStemForMedia)(id) = ^(id m) {
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:m contextLabel:ctxLabel]];
};
// Resolve parent media for carousel detection + bulk actions.
id parentMedia = media;
if (media && ![self isCarouselMedia:media]) {
@@ -946,9 +1131,11 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
[SCIMediaActions copyAllURLsForMedia:bulkMedia];
}],
[SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{
stampStemForMedia(bulkMedia);
[SCIMediaActions downloadAllAndShareMedia:bulkMedia];
}],
[SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{
stampStemForMedia(bulkMedia);
[SCIMediaActions downloadAllAndSaveMedia:bulkMedia];
}],
];
@@ -1068,6 +1255,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
if (u) [urls addObject:u];
}
if (!urls.count) return;
stampStemForMedia(capturedMedias.firstObject);
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Download all stories and share?") done:^(NSArray<NSURL *> *files) {
if (!files.count) return;
UIViewController *top = topMostController();
@@ -1083,6 +1271,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
if (u) [urls addObject:u];
}
if (!urls.count) return;
stampStemForMedia(capturedMedias.firstObject);
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Save all stories to Photos?") done:^(NSArray<NSURL *> *files) {
[SCIMediaActions bulkSaveFiles:files];
}];
@@ -1105,11 +1294,13 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download and share")
icon:@"square.and.arrow.up"
handler:^{
stampStemForMedia(media);
[SCIMediaActions downloadAndShareMedia:media];
}]];
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download to Photos")
icon:@"square.and.arrow.down"
handler:^{
stampStemForMedia(media);
[SCIMediaActions downloadAndSaveMedia:media];
}]];
@@ -1138,13 +1329,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
NSMutableArray<NSURL *> *files = [NSMutableArray array];
NSLock *lock = [NSLock new];
__block NSUInteger completed = 0;
NSString *bulkStem2 = [self currentFilenameStem];
NSUInteger __idx2 = 0;
for (NSURL *url in urls) {
if (cancelled) break;
dispatch_group_enter(group);
NSString *ext = [[url lastPathComponent] pathExtension];
NSString *name = bulkStem2
? [NSString stringWithFormat:@"%@_%lu", bulkStem2, (unsigned long)(++__idx2)]
: [[NSUUID UUID] UUIDString];
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString],
[NSString stringWithFormat:@"%@.%@", name,
ext.length ? ext : @"jpg"]];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
+5 -9
View File
@@ -1,5 +1,6 @@
#import "SCIMediaViewer.h"
#import "../Utils.h"
#import "../SCIImageCache.h"
#import <AVFoundation/AVFoundation.h>
#import <AVKit/AVKit.h>
@@ -57,15 +58,10 @@
[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;
});
});
[SCIImageCache loadImageFromURL:self.photoURL completion:^(UIImage *img) {
[self.spinner stopAnimating];
if (img) self.imageView.image = img;
}];
// Double-tap to zoom
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

+4 -5
View File
@@ -1,4 +1,5 @@
#import "Manager.h"
#import "../ActionButton/SCIMediaActions.h"
@implementation SCIDownloadManager
@@ -31,8 +32,6 @@
// URLSession methods
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
NSLog(@"Task wrote %lld bytes of %lld bytes", bytesWritten, totalBytesExpectedToWrite);
float progress = (float)totalBytesWritten / (float)totalBytesExpectedToWrite;
[self.delegate downloadDidProgress:progress];
@@ -46,8 +45,7 @@
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
NSLog(@"Task completed with error: %@", error);
if (error) NSLog(@"[SCInsta] Download error: %@", error);
[self.delegate downloadDidFinishWithError:error];
}
@@ -56,7 +54,8 @@
NSFileManager *fileManager = [NSFileManager defaultManager];
NSString *cacheDirectoryPath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", NSUUID.UUID.UUIDString, self.fileExtension]];
NSString *stem = [SCIMediaActions currentFilenameStem] ?: NSUUID.UUID.UUIDString;
NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", stem, self.fileExtension]];
NSLog(@"[SCInsta] Download Handler: Moving file from: %@ to: %@", oldPath.absoluteString, newPath.absoluteString);
@@ -4,6 +4,7 @@
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../SCIChrome.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import <objc/runtime.h>
@@ -212,16 +213,13 @@ static IGMedia *sciFeedMediaFromButton(UIView *button) {
if (![SCIUtils getBoolPref:@"feed_action_button"]) return;
UIButton *btn = (UIButton *)[self viewWithTag:kFeedActionBtnTag];
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kFeedActionBtnTag];
if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil;
if (!btn) {
btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:21 diameter:36];
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;
btn.iconTint = [UIColor labelColor];
btn.bubbleColor = [UIColor clearColor];
[self addSubview:btn];
// Position: right side, left of bookmark. Shifted up 4pt to
+16 -10
View File
@@ -3,6 +3,7 @@
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../SCIChrome.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import <objc/runtime.h>
@@ -119,17 +120,14 @@ static id sciReelsMediaProvider(UIView *sourceView) {
if (![SCIUtils getBoolPref:@"reels_action_button"]) return;
if (!self.superview) return;
UIButton *btn = (UIButton *)[self viewWithTag:kReelActionBtnTag];
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kReelActionBtnTag];
if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil;
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.
// Bake the drop shadow into the image so no CALayer shadow is needed.
CGFloat pad = 8;
CGSize sz = CGSizeMake(base.size.width + pad * 2, base.size.height + pad * 2);
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz];
@@ -144,11 +142,20 @@ static id sciReelsMediaProvider(UIView *sourceView) {
CGContextRestoreGState(c);
}];
[btn setImage:icon forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn = [[SCIChromeButton alloc] initWithSymbol:@"" pointSize:0 diameter:40];
btn.tag = kReelActionBtnTag;
btn.bubbleColor = [UIColor clearColor];
btn.iconView.image = icon;
// Capsule configuration gives us the native dark platter animation
// when the menu opens/closes — behaviour parity with IG's own chrome.
UIButtonConfiguration *cfg = [UIButtonConfiguration plainButtonConfiguration];
cfg.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
cfg.background.backgroundColor = [UIColor clearColor];
cfg.contentInsets = NSDirectionalEdgeInsetsZero;
btn.configuration = cfg;
self.clipsToBounds = NO;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
@@ -159,7 +166,6 @@ static id sciReelsMediaProvider(UIView *sourceView) {
]];
}
// Reconfigure with fresh media provider.
[SCIActionButton configureButton:btn
context:SCIActionContextReels
prefKey:@"reels_action_default"
+21 -9
View File
@@ -1,22 +1,34 @@
#import "../../Utils.h"
%hook IGDirectThreadCallButtonsCoordinator
// Voice Call
- (void)_didTapAudioButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"call_confirm"]) {
NSLog(@"[SCInsta] Call confirm triggered");
// 426+ dropped the sender arg
- (void)_didTapAudioButton {
if ([SCIUtils getBoolPref:@"voice_call_confirm"]) {
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
- (void)_didTapVideoButton {
if ([SCIUtils getBoolPref:@"video_call_confirm"]) {
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
// Pre-426 signatures
- (void)_didTapAudioButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"voice_call_confirm"]) {
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
// Video Call
- (void)_didTapVideoButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"call_confirm"]) {
NSLog(@"[SCInsta] Call confirm triggered");
if ([SCIUtils getBoolPref:@"video_call_confirm"]) {
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
+62 -3
View File
@@ -1,4 +1,54 @@
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// Reels like tap goes through a Swift class method on
// IGSundialViewerLikeButtonActionHandler since IG 426.
typedef void (*SciHandleTapFn)(Class, SEL, id, id, BOOL);
typedef void (*SciHandleTapCompFn)(Class, SEL, id, id, BOOL, id);
static SciHandleTapFn orig_sciHandleTap = NULL;
static SciHandleTapCompFn orig_sciHandleTapComp = NULL;
static void new_sciHandleTap(Class cls, SEL _cmd, id ctx, id btn, BOOL anim) {
if (![SCIUtils getBoolPref:@"like_confirm_reels"]) {
orig_sciHandleTap(cls, _cmd, ctx, btn, anim);
return;
}
__strong id sCtx = ctx;
__strong id sBtn = btn;
[SCIUtils showConfirmation:^{
@try { orig_sciHandleTap(cls, _cmd, sCtx, sBtn, anim); }
@catch (__unused id e) {}
}];
}
// Copy the completion block — it's a stack block and won't survive the alert.
static void new_sciHandleTapComp(Class cls, SEL _cmd, id ctx, id btn, BOOL anim, id comp) {
if (![SCIUtils getBoolPref:@"like_confirm_reels"]) {
orig_sciHandleTapComp(cls, _cmd, ctx, btn, anim, comp);
return;
}
__strong id sCtx = ctx;
__strong id sBtn = btn;
id sComp = comp ? [comp copy] : nil;
[SCIUtils showConfirmation:^{
@try { orig_sciHandleTapComp(cls, _cmd, sCtx, sBtn, anim, sComp); }
@catch (__unused id e) {}
}];
}
__attribute__((constructor)) static void _sciHookReelsLikeHandler(void) {
Class c = NSClassFromString(@"_TtC30IGSundialOverlayActionHandlers38IGSundialViewerLikeButtonActionHandler");
if (!c) return;
Class meta = object_getClass(c);
SEL s1 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:");
SEL s2 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:completion:");
if (class_getClassMethod(c, s1))
MSHookMessageEx(meta, s1, (IMP)new_sciHandleTap, (IMP *)&orig_sciHandleTap);
if (class_getClassMethod(c, s2))
MSHookMessageEx(meta, s2, (IMP)new_sciHandleTapComp, (IMP *)&orig_sciHandleTapComp);
}
#define CONFIRMPOSTLIKE(orig) \
if ([SCIUtils getBoolPref:@"like_confirm"]) \
@@ -15,11 +65,17 @@
- (void)_onLikeButtonPressed:(id)arg1 {
CONFIRMPOSTLIKE(%orig);
}
- (void)_onLikeButtonPressed {
CONFIRMPOSTLIKE(%orig);
}
%end
%hook IGFeedPhotoView
- (void)_onDoubleTap:(id)arg1 {
CONFIRMPOSTLIKE(%orig);
}
- (void)_onDoubleTap {
CONFIRMPOSTLIKE(%orig);
}
%end
%hook IGVideoPlayerOverlayContainerView
- (void)_handleDoubleTapGesture:(id)arg1 {
@@ -32,9 +88,6 @@
- (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 {
CONFIRMREELSLIKE(%orig);
}
- (void)controlsOverlayControllerDidLongPressLikeButton:(id)arg1 gestureRecognizer:(id)arg2 {
CONFIRMREELSLIKE(%orig);
}
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
CONFIRMREELSLIKE(%orig);
}
@@ -46,6 +99,9 @@
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
CONFIRMREELSLIKE(%orig);
}
- (void)swift_photoCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 {
CONFIRMREELSLIKE(%orig);
}
%end
%hook IGSundialViewerCarouselCell
- (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 {
@@ -54,6 +110,9 @@
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
CONFIRMREELSLIKE(%orig);
}
- (void)carouselCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 {
CONFIRMREELSLIKE(%orig);
}
%end
// Liking comments
+15 -4
View File
@@ -1,13 +1,24 @@
#import "../../Utils.h"
#import <objc/runtime.h>
// Split by _analyticsModule: "highlight" substring → highlights toggle, else stories toggle.
static BOOL sciTapIsHighlight(id target) {
Ivar iv = class_getInstanceVariable(object_getClass(target), "_analyticsModule");
if (!iv) return NO;
id v = nil;
@try { v = object_getIvar(target, iv); } @catch (__unused id e) { return NO; }
if (![v isKindOfClass:[NSString class]]) return NO;
return [((NSString *)v).lowercaseString containsString:@"highlight"];
}
%hook IGStoryViewerTapTarget
- (void)_didTap:(id)arg1 forEvent:(id)arg2 {
if ([SCIUtils getBoolPref:@"sticker_interact_confirm"]) {
NSLog(@"[SCInsta] Confirm sticker interact triggered");
NSString *key = sciTapIsHighlight(self) ? @"sticker_interact_confirm_highlights" : @"sticker_interact_confirm";
if ([SCIUtils getBoolPref:key]) {
[SCIUtils showConfirmation:^(void) { %orig; }];
} else {
return %orig;
}
}
%end
%end
+135
View File
@@ -0,0 +1,135 @@
// Hooks installed iff sci_exp_flags_enabled.
// Override: MetaLocalExperiment group{,Peek}Name — substring-match _experimentName, return "test"/nil.
// View-only: IGMobileConfigContextManager get{Bool,Int64,Double,String}[:withDefault:] — record, no override.
#import "../../Utils.h"
#import "SCIExpFlags.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// MetaLocalExperiment
static NSString *experimentNameOf(id obj) {
if (!obj) return nil;
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
if (!iv) return nil;
@try {
id v = object_getIvar(obj, iv);
if ([v isKindOfClass:[NSString class]]) return v;
} @catch (__unused id e) {}
return nil;
}
static id overrideGroupFor(NSString *expName, id origGroup) {
if (!expName.length) return origGroup;
NSString *lower = expName.lowercaseString;
for (NSString *key in [SCIExpFlags allOverriddenNames]) {
if (![lower containsString:key.lowercaseString]) continue;
SCIExpFlagOverride o = [SCIExpFlags overrideForName:key];
if (o == SCIExpFlagOverrideTrue) return @"test";
if (o == SCIExpFlagOverrideFalse) return nil;
}
return origGroup;
}
static id (*orig_groupName)(id, SEL);
static id new_groupName(id self, SEL _cmd) {
id orig = orig_groupName ? orig_groupName(self, _cmd) : nil;
NSString *name = experimentNameOf(self);
[SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil];
return overrideGroupFor(name, orig);
}
static id (*orig_peekGroupName)(id, SEL);
static id new_peekGroupName(id self, SEL _cmd) {
id orig = orig_peekGroupName ? orig_peekGroupName(self, _cmd) : nil;
NSString *name = experimentNameOf(self);
[SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil];
return overrideGroupFor(name, orig);
}
// IGMobileConfigContextManager — view-only.
// param arg is {uint64} struct, ABI-identical to unsigned long long on arm64.
static BOOL (*orig_mcBool)(id, SEL, unsigned long long);
static BOOL new_mcBool(id self, SEL _cmd, unsigned long long pid) {
BOOL v = orig_mcBool(self, _cmd, pid);
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:v ? @"YES" : @"NO"];
return v;
}
static BOOL (*orig_mcBool_def)(id, SEL, unsigned long long, BOOL);
static BOOL new_mcBool_def(id self, SEL _cmd, unsigned long long pid, BOOL def) {
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:def ? @"YES" : @"NO"];
return orig_mcBool_def(self, _cmd, pid, def);
}
static long long (*orig_mcInt)(id, SEL, unsigned long long);
static long long new_mcInt(id self, SEL _cmd, unsigned long long pid) {
long long v = orig_mcInt(self, _cmd, pid);
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", v]];
return v;
}
static long long (*orig_mcInt_def)(id, SEL, unsigned long long, long long);
static long long new_mcInt_def(id self, SEL _cmd, unsigned long long pid, long long def) {
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", def]];
return orig_mcInt_def(self, _cmd, pid, def);
}
static double (*orig_mcDouble)(id, SEL, unsigned long long);
static double new_mcDouble(id self, SEL _cmd, unsigned long long pid) {
double v = orig_mcDouble(self, _cmd, pid);
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", v]];
return v;
}
static double (*orig_mcDouble_def)(id, SEL, unsigned long long, double);
static double new_mcDouble_def(id self, SEL _cmd, unsigned long long pid, double def) {
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", def]];
return orig_mcDouble_def(self, _cmd, pid, def);
}
static id (*orig_mcString)(id, SEL, unsigned long long);
static id new_mcString(id self, SEL _cmd, unsigned long long pid) {
id v = orig_mcString(self, _cmd, pid);
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[v description] ?: @""];
return v;
}
static id (*orig_mcString_def)(id, SEL, unsigned long long, id);
static id new_mcString_def(id self, SEL _cmd, unsigned long long pid, id def) {
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[def description] ?: @""];
return orig_mcString_def(self, _cmd, pid, def);
}
// install
static void install(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
if (!cls) return;
SEL s = NSSelectorFromString(selName);
if (!class_getInstanceMethod(cls, s)) return;
MSHookMessageEx(cls, s, newImp, origOut);
}
%ctor {
if (![SCIUtils getBoolPref:@"sci_exp_flags_enabled"]) return;
if ([SCIExpFlags checkAndHandleCrashLoop]) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[SCIUtils showToastForDuration:4.0 title:@"Exp flags reset after repeated crashes"];
});
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{ [SCIExpFlags markLaunchStable]; });
// Family inherits Meta — one install covers both
Class meta = NSClassFromString(@"MetaLocalExperiment");
install(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName);
install(meta, @"peekGroupName", (IMP)new_peekGroupName, (IMP *)&orig_peekGroupName);
Class mc = NSClassFromString(@"IGMobileConfigContextManager");
install(mc, @"getBool:", (IMP)new_mcBool, (IMP *)&orig_mcBool);
install(mc, @"getBool:withDefault:", (IMP)new_mcBool_def, (IMP *)&orig_mcBool_def);
install(mc, @"getInt64:", (IMP)new_mcInt, (IMP *)&orig_mcInt);
install(mc, @"getInt64:withDefault:", (IMP)new_mcInt_def, (IMP *)&orig_mcInt_def);
install(mc, @"getDouble:", (IMP)new_mcDouble, (IMP *)&orig_mcDouble);
install(mc, @"getDouble:withDefault:", (IMP)new_mcDouble_def, (IMP *)&orig_mcDouble_def);
install(mc, @"getString:", (IMP)new_mcString, (IMP *)&orig_mcString);
install(mc, @"getString:withDefault:", (IMP)new_mcString_def, (IMP *)&orig_mcString_def);
}
+56
View File
@@ -0,0 +1,56 @@
// Exp flag override store + observation logs.
// Override works only for MetaLocalExperiment (name-substring match on _experimentName).
// MC reads + scanned names are view-only — no reliable name→ID mapping.
#import <Foundation/Foundation.h>
typedef NS_ENUM(NSInteger, SCIExpFlagOverride) {
SCIExpFlagOverrideOff = 0,
SCIExpFlagOverrideTrue = 1,
SCIExpFlagOverrideFalse = 2,
};
typedef NS_ENUM(NSInteger, SCIExpMCType) {
SCIExpMCTypeBool,
SCIExpMCTypeInt,
SCIExpMCTypeDouble,
SCIExpMCTypeString,
};
@interface SCIExpObservation : NSObject
@property (nonatomic, copy) NSString *experimentName;
@property (nonatomic, copy) NSString *lastGroup;
@property (nonatomic, assign) NSUInteger hitCount;
@end
@interface SCIExpMCObservation : NSObject
@property (nonatomic, assign) unsigned long long paramID;
@property (nonatomic, assign) SCIExpMCType type;
@property (nonatomic, copy) NSString *lastDefault;
@property (nonatomic, assign) NSUInteger hitCount;
@end
@interface SCIExpFlags : NSObject
// overrides (persisted)
+ (SCIExpFlagOverride)overrideForName:(NSString *)name;
+ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name;
+ (NSArray<NSString *> *)allOverriddenNames;
+ (void)resetAllOverrides;
// meta observations (live)
+ (void)recordExperimentName:(NSString *)name group:(NSString *)group;
+ (NSArray<SCIExpObservation *> *)allObservations;
// MC id observations (live, view-only)
+ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def;
+ (NSArray<SCIExpMCObservation *> *)allMCObservations;
// binary-scanned names (bg, cb on main)
+ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray<NSString *> *names))completion;
// crash-loop guard — 3 bad launches wipe overrides
+ (BOOL)checkAndHandleCrashLoop;
+ (void)markLaunchStable;
@end
+187
View File
@@ -0,0 +1,187 @@
#import "SCIExpFlags.h"
#import <sys/mman.h>
#import <sys/stat.h>
#import <fcntl.h>
static NSString *const kOverridesKey = @"sci_exp_overrides_by_name";
static NSString *const kCrashCounterKey = @"sci_exp_flags_unstable_launches";
static const NSInteger kCrashThreshold = 3;
@implementation SCIExpObservation
@end
@implementation SCIExpMCObservation
@end
@implementation SCIExpFlags
// overrides
+ (NSMutableDictionary *)loadOverrides {
NSDictionary *d = [[NSUserDefaults standardUserDefaults] dictionaryForKey:kOverridesKey];
return d ? [d mutableCopy] : [NSMutableDictionary dictionary];
}
+ (void)saveOverrides:(NSDictionary *)d {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
if (d.count == 0) [ud removeObjectForKey:kOverridesKey];
else [ud setObject:d forKey:kOverridesKey];
}
+ (SCIExpFlagOverride)overrideForName:(NSString *)name {
if (!name.length) return SCIExpFlagOverrideOff;
NSNumber *n = [self loadOverrides][name];
return n ? (SCIExpFlagOverride)n.integerValue : SCIExpFlagOverrideOff;
}
+ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name {
if (!name.length) return;
NSMutableDictionary *d = [self loadOverrides];
if (o == SCIExpFlagOverrideOff) [d removeObjectForKey:name];
else d[name] = @(o);
[self saveOverrides:d];
}
+ (NSArray<NSString *> *)allOverriddenNames { return [[self loadOverrides] allKeys]; }
+ (void)resetAllOverrides { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kOverridesKey]; }
// meta observations
static NSMutableDictionary<NSString *, SCIExpObservation *> *gMetaObs = nil;
static dispatch_queue_t metaQueue(void) {
static dispatch_queue_t q;
static dispatch_once_t once;
dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.meta", DISPATCH_QUEUE_CONCURRENT); });
return q;
}
+ (void)recordExperimentName:(NSString *)name group:(NSString *)group {
if (!name.length) return;
dispatch_barrier_async(metaQueue(), ^{
if (!gMetaObs) gMetaObs = [NSMutableDictionary dictionary];
SCIExpObservation *o = gMetaObs[name];
if (!o) { o = [SCIExpObservation new]; o.experimentName = name; gMetaObs[name] = o; }
o.lastGroup = group;
o.hitCount++;
});
}
+ (NSArray<SCIExpObservation *> *)allObservations {
__block NSArray *snap = @[];
dispatch_sync(metaQueue(), ^{ snap = gMetaObs ? [gMetaObs.allValues copy] : @[]; });
return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpObservation *a, SCIExpObservation *b) {
return [a.experimentName caseInsensitiveCompare:b.experimentName];
}];
}
// MC observations (view-only)
static NSMutableDictionary<NSNumber *, SCIExpMCObservation *> *gMCObs = nil;
static dispatch_queue_t mcQueue(void) {
static dispatch_queue_t q;
static dispatch_once_t once;
dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.mc", DISPATCH_QUEUE_CONCURRENT); });
return q;
}
+ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def {
dispatch_barrier_async(mcQueue(), ^{
if (!gMCObs) gMCObs = [NSMutableDictionary dictionary];
NSNumber *k = @(pid);
SCIExpMCObservation *o = gMCObs[k];
if (!o) { o = [SCIExpMCObservation new]; o.paramID = pid; o.type = t; gMCObs[k] = o; }
o.lastDefault = def ?: @"";
o.hitCount++;
});
}
+ (NSArray<SCIExpMCObservation *> *)allMCObservations {
__block NSArray *snap = @[];
dispatch_sync(mcQueue(), ^{ snap = gMCObs ? [gMCObs.allValues copy] : @[]; });
// hot flags first
return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpMCObservation *a, SCIExpMCObservation *b) {
if (a.hitCount != b.hitCount) return a.hitCount > b.hitCount ? NSOrderedAscending : NSOrderedDescending;
if (a.paramID < b.paramID) return NSOrderedAscending;
if (a.paramID > b.paramID) return NSOrderedDescending;
return NSOrderedSame;
}];
}
// crash-loop guard
+ (BOOL)checkAndHandleCrashLoop {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSInteger c = [ud integerForKey:kCrashCounterKey] + 1;
if (c >= kCrashThreshold && [self loadOverrides].count > 0) {
[self resetAllOverrides];
[ud removeObjectForKey:kCrashCounterKey];
return YES;
}
[ud setInteger:c forKey:kCrashCounterKey];
return NO;
}
+ (void)markLaunchStable { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCrashCounterKey]; }
// binary scan — mmap executable, grep for flag-prefix strings, dedupe/sort
+ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray<NSString *> *))completion {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
NSArray *names = [self scanExecutable];
dispatch_async(dispatch_get_main_queue(), ^{ completion(names ?: @[]); });
});
}
+ (NSArray<NSString *> *)scanExecutable {
NSString *path = [[NSBundle mainBundle] executablePath];
if (!path) return @[];
int fd = open(path.UTF8String, O_RDONLY);
if (fd < 0) return @[];
struct stat st;
if (fstat(fd, &st) != 0 || st.st_size <= 0) { close(fd); return @[]; }
size_t size = (size_t)st.st_size;
const char *base = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
close(fd);
if (base == MAP_FAILED) return @[];
// Meta flag/analytics name prefixes
static const char *prefixes[] = {
"ig_ios_", "ig_android_", "ig_direct_", "ig_feed_", "ig_reels_",
"ig_stories_", "ig_explore_", "ig_camera_", "ig_growth_", "ig_privacy_",
"fbios_", "fb_ios_"
};
const size_t pc = sizeof(prefixes) / sizeof(prefixes[0]);
NSMutableSet *seen = [NSMutableSet set];
for (size_t i = 0; i < size; i++) {
char c = base[i];
if (c != 'i' && c != 'f') continue;
if (i > 0) {
char prev = base[i - 1];
if ((prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '_' || prev == '.') continue;
}
size_t matched = 0;
const char *rem = base + i;
size_t left = size - i;
for (size_t p = 0; p < pc; p++) {
size_t L = strlen(prefixes[p]);
if (left >= L && memcmp(rem, prefixes[p], L) == 0) { matched = L; break; }
}
if (!matched) continue;
size_t j = i + matched;
while (j < size) {
char ch = base[j];
if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.')) break;
j++;
}
size_t nl = j - i;
if (nl >= 16 && nl <= 160) {
NSString *s = [[NSString alloc] initWithBytes:(base + i) length:nl encoding:NSASCIIStringEncoding];
if (s) [seen addObject:s];
}
i = j;
}
munmap((void *)base, size);
return [[seen allObjects] sortedArrayUsingSelector:@selector(compare:)];
}
@end
@@ -0,0 +1,60 @@
// Direct Notes experimental reply types + friend map. Gates: igt_directnotes_*.
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
#include "../../../modules/fishhook/fishhook.h"
static inline BOOL prefFriendMap(void) { return [SCIUtils getBoolPref:@"igt_directnotes_friendmap"]; }
static inline BOOL prefAudio(void) { return [SCIUtils getBoolPref:@"igt_directnotes_audio_reply"]; }
static inline BOOL prefAvatar(void) { return [SCIUtils getBoolPref:@"igt_directnotes_avatar_reply"]; }
static inline BOOL prefGifs(void) { return [SCIUtils getBoolPref:@"igt_directnotes_gifs_reply"]; }
static inline BOOL prefPhoto(void) { return [SCIUtils getBoolPref:@"igt_directnotes_photo_reply"]; }
static BOOL rep_friendmap(void) { return prefFriendMap(); }
static BOOL rep_audio(void) { return prefAudio(); }
static BOOL rep_avatar(void) { return prefAvatar(); }
static BOOL rep_gifs(void) { return prefGifs(); }
static BOOL rep_photo(void) { return prefPhoto(); }
static inline BOOL containsAny(NSString *s, NSArray<NSString *> *needles) {
if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO;
NSString *lower = s.lowercaseString;
for (NSString *n in needles) if ([lower containsString:n]) return YES;
return NO;
}
static BOOL matchesDirectNotes(NSString *name) {
if (prefFriendMap() && containsAny(name, @[@"friendmap", @"friends_map",
@"ig_ios_friendmap_", @"friendmapenabled"])) return YES;
if (prefAudio() && containsAny(name, @[@"audio"])) return YES;
if (prefAvatar() && containsAny(name, @[@"avatar"])) return YES;
if (prefGifs() && containsAny(name, @[@"gifs", @"sticker"])) return YES;
if (prefPhoto() && containsAny(name, @[@"photo"])) return YES;
return NO;
}
static BOOL (*orig_isIn)(id, SEL, id) = NULL;
static BOOL new_isIn(id self, SEL _cmd, id name) {
if (matchesDirectNotes(name)) return YES;
return orig_isIn ? orig_isIn(self, _cmd, name) : NO;
}
%ctor {
if (!(prefFriendMap() || prefAudio() || prefAvatar() || prefGifs() || prefPhoto())) return;
struct rebinding binds[] = {
{"IGDirectNotesFriendMapEnabled", (void *)rep_friendmap, NULL},
{"IGDirectNotesEnableAudioNoteReplyType", (void *)rep_audio, NULL},
{"IGDirectNotesEnableAvatarReplyTypes", (void *)rep_avatar, NULL},
{"IGDirectNotesEnableGifsStickersReplyTypes", (void *)rep_gifs, NULL},
{"IGDirectNotesEnablePhotoNoteReplyType", (void *)rep_photo, NULL},
};
rebind_symbols(binds, sizeof(binds) / sizeof(binds[0]));
Class helper = NSClassFromString(@"IGDirectNotesExperimentHelper");
SEL sel = NSSelectorFromString(@"isInExperiment:");
if (helper && class_getInstanceMethod(helper, sel)) {
MSHookMessageEx(helper, sel, (IMP)new_isIn, (IMP *)&orig_isIn);
}
}
@@ -0,0 +1,97 @@
// Experiment-name substring override. Gates: igt_quicksnap, igt_directnotes_friendmap, igt_prism.
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
static inline BOOL containsAny(NSString *s, NSArray<NSString *> *needles) {
if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO;
NSString *lower = s.lowercaseString;
for (NSString *n in needles) if ([lower containsString:n]) return YES;
return NO;
}
static BOOL matchQuickSnap(NSString *name) {
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return NO;
return containsAny(name, @[@"quicksnap", @"quick_snap", @"instants", @"xma_quicksnap",
@"_ig_ios_quicksnap_", @"_ig_ios_quick_snap_", @"_ig_ios_instants_"]);
}
static BOOL matchFriendMap(NSString *name) {
if (![SCIUtils getBoolPref:@"igt_directnotes_friendmap"]) return NO;
return containsAny(name, @[@"friendmap", @"friends_map", @"direct_notes",
@"ig_direct_notes_ios", @"_ig_ios_friendmap_", @"_ig_ios_friends_map_"]);
}
static BOOL matchPrism(NSString *name) {
if (![SCIUtils getBoolPref:@"igt_prism"]) return NO;
return containsAny(name, @[@"prism"]);
}
static inline BOOL shouldForceOn(NSString *name) {
return matchQuickSnap(name) || matchFriendMap(name) || matchPrism(name);
}
static NSString *expNameOf(id obj) {
if (!obj) return nil;
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName");
if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
if (!iv) return nil;
@try {
id v = object_getIvar(obj, iv);
if ([v isKindOfClass:[NSString class]]) return v;
} @catch (__unused id e) {}
return nil;
}
static BOOL (*orig_meta_isIn)(id, SEL) = NULL;
static BOOL new_meta_isIn(id self, SEL _cmd) {
if (shouldForceOn(expNameOf(self))) return YES;
return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO;
}
static BOOL (*orig_family_isIn)(id, SEL) = NULL;
static BOOL new_family_isIn(id self, SEL _cmd) {
if (shouldForceOn(expNameOf(self))) return YES;
return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO;
}
static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL;
static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) {
if (shouldForceOn(name)) return YES;
return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO;
}
static id (*orig_groupName)(id, SEL) = NULL;
static id new_groupName(id self, SEL _cmd) {
if (shouldForceOn(expNameOf(self))) return @"test";
return orig_groupName ? orig_groupName(self, _cmd) : nil;
}
static id (*orig_peekGroup)(id, SEL) = NULL;
static id new_peekGroup(id self, SEL _cmd) {
if (shouldForceOn(expNameOf(self))) return @"test";
return orig_peekGroup ? orig_peekGroup(self, _cmd) : nil;
}
static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
if (!cls) return;
SEL s = NSSelectorFromString(selName);
if (!class_getInstanceMethod(cls, s)) return;
MSHookMessageEx(cls, s, newImp, origOut);
}
%ctor {
if (!([SCIUtils getBoolPref:@"igt_quicksnap"] ||
[SCIUtils getBoolPref:@"igt_directnotes_friendmap"] ||
[SCIUtils getBoolPref:@"igt_prism"])) return;
Class meta = NSClassFromString(@"MetaLocalExperiment");
hook(meta, @"isInExperiment", (IMP)new_meta_isIn, (IMP *)&orig_meta_isIn);
hook(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName);
hook(meta, @"peekGroupName", (IMP)new_peekGroup, (IMP *)&orig_peekGroup);
hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment",
(IMP)new_family_isIn, (IMP *)&orig_family_isIn);
hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:",
(IMP)new_lid_enabled, (IMP *)&orig_lid_enabled);
}
@@ -0,0 +1,63 @@
// Force-enable Homecoming nav experiment. Gate: igt_homecoming.
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
static NSString *expNameOf(id obj) {
if (!obj) return nil;
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName");
if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
if (!iv) return nil;
@try {
id v = object_getIvar(obj, iv);
if ([v isKindOfClass:[NSString class]]) return v;
} @catch (__unused id e) {}
return nil;
}
static inline BOOL matchesHomecoming(NSString *s) {
return [s isKindOfClass:[NSString class]] && s.length &&
[s.lowercaseString containsString:@"homecoming"];
}
static BOOL (*orig_meta_isIn)(id, SEL) = NULL;
static BOOL new_meta_isIn(id self, SEL _cmd) {
if (matchesHomecoming(expNameOf(self))) return YES;
return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO;
}
static BOOL (*orig_family_isIn)(id, SEL) = NULL;
static BOOL new_family_isIn(id self, SEL _cmd) {
if (matchesHomecoming(expNameOf(self))) return YES;
return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO;
}
static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL;
static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) {
if (matchesHomecoming(name)) return YES;
return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO;
}
static BOOL (*orig_nav_isHC)(id, SEL) = NULL;
static BOOL new_nav_isHC(id self, SEL _cmd) { return YES; }
static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
if (!cls) return;
SEL s = NSSelectorFromString(selName);
if (!class_getInstanceMethod(cls, s)) return;
MSHookMessageEx(cls, s, newImp, origOut);
}
%ctor {
if (![SCIUtils getBoolPref:@"igt_homecoming"]) return;
hook(NSClassFromString(@"MetaLocalExperiment"), @"isInExperiment",
(IMP)new_meta_isIn, (IMP *)&orig_meta_isIn);
hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment",
(IMP)new_family_isIn, (IMP *)&orig_family_isIn);
hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:",
(IMP)new_lid_enabled, (IMP *)&orig_lid_enabled);
hook(NSClassFromString(@"_TtC18IGNavConfiguration18IGNavConfiguration"),
@"isHomecomingEnabled", (IMP)new_nav_isHC, (IMP *)&orig_nav_isHC);
}
@@ -0,0 +1,108 @@
// Force-enable QuickSnap (Instants) surfaces. Gate: igt_quicksnap.
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
#define QS_BOOL1_RETURN_YES(fnName) \
static BOOL (*orig_##fnName)(id, SEL, id) = NULL; \
static BOOL new_##fnName(id self, SEL _cmd, id arg) { return YES; }
QS_BOOL1_RETURN_YES(qs_enabled)
QS_BOOL1_RETURN_YES(qs_enabled_feed)
QS_BOOL1_RETURN_YES(qs_enabled_inbox)
QS_BOOL1_RETURN_YES(qs_enabled_stories)
QS_BOOL1_RETURN_YES(qs_enabled_peek)
QS_BOOL1_RETURN_YES(qs_enabled_tray)
QS_BOOL1_RETURN_YES(qs_enabled_tray_peek)
QS_BOOL1_RETURN_YES(qs_enabled_tray_pog)
QS_BOOL1_RETURN_YES(qs_enabled_empty_pog)
QS_BOOL1_RETURN_YES(qs_isqp)
static BOOL (*orig_qs_corner)(id, SEL) = NULL;
static BOOL new_qs_corner(id self, SEL _cmd) { return YES; }
static BOOL (*orig_qs_dialog)(id, SEL) = NULL;
static BOOL new_qs_dialog(id self, SEL _cmd) { return YES; }
static BOOL (*orig_peek)(id, SEL) = NULL;
static BOOL new_peek(id self, SEL _cmd) { return YES; }
static BOOL (*orig_recap)(id, SEL) = NULL;
static BOOL new_recap(id self, SEL _cmd) { return YES; }
static BOOL (*orig__recap)(id, SEL) = NULL;
static BOOL new__recap(id self, SEL _cmd) { return YES; }
static BOOL (*orig_recap_media)(id, SEL) = NULL;
static BOOL new_recap_media(id self, SEL _cmd) { return YES; }
static BOOL (*orig_recap_video)(id, SEL) = NULL;
static BOOL new_recap_video(id self, SEL _cmd) { return YES; }
static BOOL (*orig_hidden)(id, SEL) = NULL;
static BOOL new_hidden(id self, SEL _cmd) { return NO; }
static BOOL (*orig__hidden)(id, SEL) = NULL;
static BOOL new__hidden(id self, SEL _cmd) { return NO; }
static void hookClassMethod(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
Class cls = NSClassFromString(cn);
if (!cls) return;
Class meta = object_getClass(cls);
if (!meta) return;
SEL sel = NSSelectorFromString(sn);
if (!class_getInstanceMethod(meta, sel)) return;
MSHookMessageEx(meta, sel, impl, orig);
}
static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
Class cls = NSClassFromString(cn);
if (!cls) return;
SEL sel = NSSelectorFromString(sn);
if (!class_getInstanceMethod(cls, sel)) return;
MSHookMessageEx(cls, sel, impl, orig);
}
static void hookZeroArgAcross(NSArray<NSString *> *classes, NSString *sn, IMP impl, IMP *orig) {
SEL sel = NSSelectorFromString(sn);
for (NSString *cn in classes) {
Class cls = NSClassFromString(cn);
if (!cls || !class_getInstanceMethod(cls, sel)) continue;
MSHookMessageEx(cls, sel, impl, orig);
}
}
%ctor {
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return;
NSString *helper = @"_TtC26IGQuickSnapExperimentation32IGQuickSnapExperimentationHelper";
hookClassMethod(helper, @"isQuicksnapEnabled:", (IMP)new_qs_enabled, (IMP *)&orig_qs_enabled);
hookClassMethod(helper, @"isQuicksnapEnabledInFeed:", (IMP)new_qs_enabled_feed, (IMP *)&orig_qs_enabled_feed);
hookClassMethod(helper, @"isQuicksnapEnabledInInbox:", (IMP)new_qs_enabled_inbox, (IMP *)&orig_qs_enabled_inbox);
hookClassMethod(helper, @"isQuicksnapEnabledInStories:", (IMP)new_qs_enabled_stories, (IMP *)&orig_qs_enabled_stories);
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTray:", (IMP)new_qs_enabled_tray, (IMP *)&orig_qs_enabled_tray);
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPeek:", (IMP)new_qs_enabled_tray_peek, (IMP *)&orig_qs_enabled_tray_peek);
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPog:", (IMP)new_qs_enabled_tray_pog, (IMP *)&orig_qs_enabled_tray_pog);
hookClassMethod(helper, @"isQuicksnapNotesTrayEmptyPogEnabled:", (IMP)new_qs_enabled_empty_pog, (IMP *)&orig_qs_enabled_empty_pog);
hookClassMethod(helper, @"isQuicksnapEnabledAsPeek:", (IMP)new_qs_enabled_peek, (IMP *)&orig_qs_enabled_peek);
NSString *tray = @"_TtC21IGNotesTrayController21IGNotesTrayController";
hookInstance(tray, @"_isEligibleForQuicksnapCornerStackTransitionDialog", (IMP)new_qs_corner, (IMP *)&orig_qs_corner);
hookInstance(tray, @"_isEligibleForQuicksnapDialog", (IMP)new_qs_dialog, (IMP *)&orig_qs_dialog);
hookInstance(tray, @"isQPEnabled:", (IMP)new_qs_isqp, (IMP *)&orig_qs_isqp);
hookInstance(@"IGDirectNotesTrayRowSectionController", @"isQPEnabled:", (IMP)new_qs_isqp, NULL);
hookInstance(@"_TtC24IGDirectNotesTrayUISwift37IGDirectNotesTrayRowSectionController",
@"isQPEnabled:", (IMP)new_qs_isqp, NULL);
NSArray *instantsClasses = @[
@"IGInstantGestureRecognizer",
@"IGAPIQuickSnapData",
@"XDTQuickSnapData",
@"IGAPIQuicksnapRecapMediaInfo",
@"XDTQuicksnapRecapMediaInfo",
];
hookZeroArgAcross(instantsClasses, @"isEligibleForPeek", (IMP)new_peek, (IMP *)&orig_peek);
hookZeroArgAcross(instantsClasses, @"isQuicksnapRecap", (IMP)new_recap, (IMP *)&orig_recap);
hookZeroArgAcross(instantsClasses, @"_isQuicksnapRecap", (IMP)new__recap, (IMP *)&orig__recap);
hookZeroArgAcross(instantsClasses, @"hasQuicksnapRecapMedia",(IMP)new_recap_media, (IMP *)&orig_recap_media);
hookZeroArgAcross(instantsClasses, @"isInstantsRecapVideo", (IMP)new_recap_video, (IMP *)&orig_recap_video);
NSString *svc = @"_TtC18IGQuickSnapService18IGQuickSnapService";
hookInstance(svc, @"isHiddenByServer", (IMP)new_hidden, (IMP *)&orig_hidden);
hookInstance(svc, @"_isHiddenByServer", (IMP)new__hidden, (IMP *)&orig__hidden);
}
@@ -0,0 +1,43 @@
// MobileConfig override for any ig_boolForKey: naming QuickSnap. Gate: igt_quicksnap.
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
static inline BOOL keyMatchesQuickSnap(id key) {
if (![key isKindOfClass:[NSString class]]) return NO;
NSString *s = ((NSString *)key).lowercaseString;
return [s containsString:@"quicksnap"] ||
[s containsString:@"quick_snap"] ||
[s containsString:@"instants"] ||
[s containsString:@"xma_quicksnap"];
}
static BOOL (*orig_bool_key)(id, SEL, id) = NULL;
static BOOL new_bool_key(id self, SEL _cmd, id key) {
if (keyMatchesQuickSnap(key)) return YES;
return orig_bool_key ? orig_bool_key(self, _cmd, key) : NO;
}
static BOOL (*orig_bool_key_def)(id, SEL, id, BOOL) = NULL;
static BOOL new_bool_key_def(id self, SEL _cmd, id key, BOOL def) {
if (keyMatchesQuickSnap(key)) return YES;
return orig_bool_key_def ? orig_bool_key_def(self, _cmd, key, def) : def;
}
static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
Class cls = NSClassFromString(cn);
if (!cls) return;
SEL sel = NSSelectorFromString(sn);
if (!class_getInstanceMethod(cls, sel)) return;
MSHookMessageEx(cls, sel, impl, orig);
}
%ctor {
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return;
hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key);
hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def);
hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key);
hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def);
}
@@ -0,0 +1,12 @@
// Crash-loop guard + pref registry for igt_* experimental flags.
#import <Foundation/Foundation.h>
@interface SCIExperimentalGuard : NSObject
+ (NSArray<NSString *> *)allPrefKeys;
+ (BOOL)anyEnabled;
+ (void)resetAll;
+ (BOOL)didResetThisLaunch;
@end
@@ -0,0 +1,68 @@
#import "SCIExperimentalGuard.h"
#import "../../Utils.h"
static NSString *const kCounterKey = @"sci_exp_unstable_launches";
static NSInteger const kThreshold = 3;
static BOOL gDidReset = NO;
@implementation SCIExperimentalGuard
+ (NSArray<NSString *> *)allPrefKeys {
static NSArray *keys;
static dispatch_once_t once;
dispatch_once(&once, ^{
keys = @[
@"igt_homecoming",
@"igt_quicksnap",
@"igt_prism",
@"igt_directnotes_friendmap",
@"igt_directnotes_audio_reply",
@"igt_directnotes_avatar_reply",
@"igt_directnotes_gifs_reply",
@"igt_directnotes_photo_reply",
];
});
return keys;
}
+ (BOOL)anyEnabled {
for (NSString *k in [self allPrefKeys]) {
if ([SCIUtils getBoolPref:k]) return YES;
}
return NO;
}
+ (void)resetAll {
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
for (NSString *k in [self allPrefKeys]) [ud setBool:NO forKey:k];
}
+ (BOOL)didResetThisLaunch { return gDidReset; }
+ (void)load {
if (![self anyEnabled]) return;
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
NSInteger c = [ud integerForKey:kCounterKey] + 1;
if (c >= kThreshold) {
[self resetAll];
[ud removeObjectForKey:kCounterKey];
gDidReset = YES;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[SCIUtils showToastForDuration:5.0
title:SCILocalized(@"Experimental flags reset")
subtitle:SCILocalized(@"Disabled after repeated crashes.")];
});
return;
}
[ud setInteger:c forKey:kCounterKey];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[[NSUserDefaults standardUserDefaults] removeObjectForKey:kCounterKey];
});
}
@end
+48 -19
View File
@@ -1,32 +1,61 @@
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// IGFeedPlayback.IGFeedPlaybackStrategy gained new init parameters in IG 423+.
// Both the 2-arg and 3-arg variants are hooked to force shouldDisableAutoplay=YES.
// Hooked via MSHookMessageEx in %ctor since the class has a Swift-mangled name.
// IGFeedPlayback.IGFeedPlaybackStrategy has a Swift-mangled name. Both init
// variants force shouldDisableAutoplay=YES when the pref is on.
static id (*orig_initStrategy2)(id, SEL, BOOL, BOOL);
static id new_initStrategy2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
static id (*orig_feedInit2)(id, SEL, BOOL, BOOL);
static id new_feedInit2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
return orig_initStrategy2(self, _cmd, shouldDisable, shouldClearStale);
return orig_feedInit2(self, _cmd, shouldDisable, shouldClearStale);
}
static id (*orig_initStrategy3)(id, SEL, BOOL, BOOL, BOOL);
static id new_initStrategy3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) {
static id (*orig_feedInit3)(id, SEL, BOOL, BOOL, BOOL);
static id new_feedInit3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) {
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
return orig_initStrategy3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
return orig_feedInit3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
}
// Carousel tap-to-play. The modern feed video cell receives single-taps via
// this delegate callback, but the Swift implementation skips resume when the
// cell sits inside a carousel. Force retryStartPlayback after orig.
static void (*orig_cellDidSingleTap)(id, SEL, id, id);
static void new_cellDidSingleTap(id self, SEL _cmd, id overlay, id gr) {
orig_cellDidSingleTap(self, _cmd, overlay, gr);
if (![SCIUtils getBoolPref:@"disable_feed_autoplay"]) return;
UIView *sv = [(UIView *)self superview];
if (!sv || !strstr(class_getName([sv class]), "Carousel")) return;
if ([self respondsToSelector:@selector(retryStartPlayback)])
((void(*)(id, SEL))objc_msgSend)(self, @selector(retryStartPlayback));
}
static void sciHookFeedStrategy(void) {
Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy");
if (!cls) return;
SEL s2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:);
if ([cls instancesRespondToSelector:s2])
MSHookMessageEx(cls, s2, (IMP)new_feedInit2, (IMP *)&orig_feedInit2);
SEL s3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:);
if ([cls instancesRespondToSelector:s3])
MSHookMessageEx(cls, s3, (IMP)new_feedInit3, (IMP *)&orig_feedInit3);
}
static void sciHookVideoCell(void) {
static BOOL hooked = NO;
if (hooked) return;
Class cls = objc_getClass("IGModernFeedVideoCell.IGModernFeedVideoCell");
if (!cls) return;
SEL s = @selector(videoPlayerOverlayControllerDidSingleTap:gestureRecognizer:);
if (![cls instancesRespondToSelector:s]) return;
MSHookMessageEx(cls, s, (IMP)new_cellDidSingleTap, (IMP *)&orig_cellDidSingleTap);
hooked = YES;
}
%ctor {
Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy");
if (!cls) return;
SEL sel2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:);
if ([cls instancesRespondToSelector:sel2])
MSHookMessageEx(cls, sel2, (IMP)new_initStrategy2, (IMP *)&orig_initStrategy2);
SEL sel3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:);
if ([cls instancesRespondToSelector:sel3])
MSHookMessageEx(cls, sel3, (IMP)new_initStrategy3, (IMP *)&orig_initStrategy3);
sciHookFeedStrategy();
sciHookVideoCell();
// Swift cell class can load after dylib init; retry on main runloop.
dispatch_async(dispatch_get_main_queue(), ^{ sciHookVideoCell(); });
}
+48 -29
View File
@@ -1,13 +1,15 @@
// Quick fake-location toggle injected into IG's Friends Map (DMs > Maps).
#import "../../Utils.h"
#import "../../SCIChrome.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 const NSInteger kSciMapBtnTag = 0x5C1F4B;
static const NSInteger kSciMapHitBtnTag = 0x5C1F4C;
static UIViewController *sciTopMost(void) {
UIWindow *win = nil;
@@ -179,6 +181,8 @@ static UIMenu *sciBuildMapMenu(void) {
static void sciRemoveMapButton(UIView *mapView) {
UIView *btn = [mapView viewWithTag:kSciMapBtnTag];
if (btn) [btn removeFromSuperview];
UIView *hit = [mapView viewWithTag:kSciMapHitBtnTag];
if (hit) [hit removeFromSuperview];
}
static void sciAddMapButton(UIView *mapView) {
@@ -186,40 +190,55 @@ static void sciAddMapButton(UIView *mapView) {
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];
// Visible chrome — static, never absorbed into the menu platter animation.
BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"];
SCIChromeButton *chrome = [[SCIChromeButton alloc] initWithSymbol:on ? @"location.fill" : @"location.slash"
pointSize:18
diameter:48];
chrome.tag = kSciMapBtnTag;
chrome.bubbleColor = [UIColor secondarySystemBackgroundColor];
chrome.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor];
chrome.layer.shadowColor = [UIColor blackColor].CGColor;
chrome.layer.shadowOpacity = 0.18;
chrome.layer.shadowRadius = 5;
chrome.layer.shadowOffset = CGSizeMake(0, 2);
chrome.userInteractionEnabled = NO;
[mapView addSubview:chrome];
[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],
[chrome.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16],
[chrome.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78],
[chrome.widthAnchor constraintEqualToConstant:48],
[chrome.heightAnchor constraintEqualToConstant:48],
]];
// Invisible hit target owns the menu; visible chrome below stays put
// when UIKit absorbs the hit into the menu platter on dismiss.
UIButton *hit = [UIButton buttonWithType:UIButtonTypeCustom];
hit.tag = kSciMapHitBtnTag;
hit.backgroundColor = [UIColor clearColor];
hit.translatesAutoresizingMaskIntoConstraints = NO;
hit.showsMenuAsPrimaryAction = YES;
hit.menu = sciBuildMapMenu();
[hit addAction:[UIAction actionWithHandler:^(__unused UIAction *a) {
hit.menu = sciBuildMapMenu();
}] forControlEvents:UIControlEventMenuActionTriggered];
[mapView addSubview:hit];
[NSLayoutConstraint activateConstraints:@[
[hit.leadingAnchor constraintEqualToAnchor:chrome.leadingAnchor],
[hit.trailingAnchor constraintEqualToAnchor:chrome.trailingAnchor],
[hit.topAnchor constraintEqualToAnchor:chrome.topAnchor],
[hit.bottomAnchor constraintEqualToAnchor:chrome.bottomAnchor],
]];
}
static void sciRefreshMapButton(UIView *mapView) {
UIButton *btn = (UIButton *)[mapView viewWithTag:kSciMapBtnTag];
if (!btn) return;
SCIChromeButton *btn = (SCIChromeButton *)[mapView viewWithTag:kSciMapBtnTag];
if (![btn isKindOfClass:[SCIChromeButton class]]) 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();
btn.symbolName = on ? @"location.fill" : @"location.slash";
btn.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor];
// Don't touch btn.menu here — reassigning mid-dismiss flickers the button.
// UIControlEventMenuActionTriggered rebuilds on next open.
}
static void (*orig_mapLayout)(UIView *, SEL);
+158 -17
View File
@@ -1,31 +1,172 @@
// Explore tab hide toggles.
// hide_explore_grid → posts grid + shimmer loader
// hide_trending_searches → category chip bar + algo button on the right
//
// Grid revealing rules: tapping a chip or focusing the search bar counts as
// engagement and unhides the grid until the user leaves the Explore tab.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/runtime.h>
%hook IGExploreGridViewController
- (void)viewDidLoad {
if ([SCIUtils getBoolPref:@"hide_explore_grid"]) {
NSLog(@"[SCInsta] Hiding explore grid");
static BOOL sciHideGrid(void) { return [SCIUtils getBoolPref:@"hide_explore_grid"]; }
static BOOL sciHideSearch(void) { return [SCIUtils getBoolPref:@"hide_trending_searches"]; }
[[self view] removeFromSuperview];
static __weak UIViewController *gActiveExploreVC = nil;
static BOOL gSearchFocused = NO;
static BOOL gUserEngaged = NO;
return;
// MARK: - Hide helpers
// Alpha + userInteraction instead of .hidden keeps IG's data fetch and the
// shimmer animation alive, so toggling the pref back on shows fresh content
// instantly without a restart.
static void sciSetViewVisuallyHidden(UIView *v, BOOL hidden) {
if (!v) return;
v.alpha = hidden ? 0.0 : 1.0;
v.userInteractionEnabled = !hidden;
}
static void sciSetIvarViewHidden(id host, const char *name, BOOL hidden) {
Ivar iv = class_getInstanceVariable([host class], name);
if (!iv) return;
@try {
UIView *v = object_getIvar(host, iv);
if ([v isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(v, hidden);
} @catch (__unused id e) {}
}
static void sciApplyExploreHide(id vc) {
// Chips stay visible while search is focused (they act as filters then).
BOOL hideChips = sciHideSearch() && !gSearchFocused;
sciSetIvarViewHidden(vc, "_nidoChipBar", hideChips);
// Force re-layout so pref flips reflect on re-entry.
Ivar stvIvar = class_getInstanceVariable([vc class], "_searchTitleView");
if (stvIvar) {
@try {
UIView *tv = object_getIvar(vc, stvIvar);
if ([tv isKindOfClass:[UIView class]]) {
[tv setNeedsLayout];
[tv layoutIfNeeded];
}
} @catch (__unused id e) {}
}
return %orig;
// Grid reveals on chip tap or search focus.
BOOL hideGrid = sciHideGrid() && !gUserEngaged && !gSearchFocused;
sciSetIvarViewHidden(vc, "_shimmeringGridView", hideGrid);
Ivar gvcIvar = class_getInstanceVariable([vc class], "_gridViewController");
if (!gvcIvar) return;
@try {
UIViewController *grid = object_getIvar(vc, gvcIvar);
if (![grid isKindOfClass:[UIViewController class]] || !grid.isViewLoaded) return;
sciSetViewVisuallyHidden(grid.view, hideGrid);
Ivar cvIvar = class_getInstanceVariable([grid class], "_collectionView");
if (cvIvar) {
UIView *cv = object_getIvar(grid, cvIvar);
if ([cv isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(cv, hideGrid);
}
} @catch (__unused id e) {}
}
// Algo button vs Cancel: both are IGTapButton siblings of the search bar.
// Cancel has a UIButtonLabel (the "Cancel" text); the algo button is square
// with just an icon child.
static BOOL sciIsAlgoButton(UIView *btn) {
if (btn.bounds.size.width != btn.bounds.size.height) return NO;
for (UIView *sub in btn.subviews) {
if ([sub isKindOfClass:[UILabel class]]) return NO;
}
return YES;
}
// MARK: - VC hooks
%group HideExploreGroup
%hook IGExploreViewController
- (void)viewDidLayoutSubviews {
%orig;
gActiveExploreVC = self;
sciApplyExploreHide(self);
}
- (void)viewWillAppear:(BOOL)animated {
%orig;
gActiveExploreVC = self;
sciApplyExploreHide(self);
}
- (void)viewDidDisappear:(BOOL)animated {
%orig;
gUserEngaged = NO;
gSearchFocused = NO;
}
- (void)exploreChipBarView:(id)bar didSelectChipAtIndex:(NSInteger)idx {
%orig;
gUserEngaged = YES;
sciApplyExploreHide(self);
[self.view setNeedsLayout];
}
%end
%hook IGExploreViewController
- (void)viewDidLoad {
%hook IGAnimatablePlaceholderTextField
- (BOOL)becomeFirstResponder {
BOOL r = %orig;
gSearchFocused = YES;
if (gActiveExploreVC) {
sciApplyExploreHide(gActiveExploreVC);
[gActiveExploreVC.view setNeedsLayout];
}
return r;
}
- (BOOL)resignFirstResponder {
BOOL r = %orig;
gSearchFocused = NO;
if (gActiveExploreVC) {
sciApplyExploreHide(gActiveExploreVC);
[gActiveExploreVC.view setNeedsLayout];
}
return r;
}
%end
// Hook the search title view's own layout — catches every relayout at the
// source, so hiding the algo button + stretching the bar has no lagged frame.
%hook IGExploreSearchTitleView
- (void)layoutSubviews {
%orig;
BOOL hide = sciHideSearch();
Class tapBtnCls = NSClassFromString(@"IGTapButton");
Class dotCls = NSClassFromString(@"IGDSDotView");
Class searchCls = NSClassFromString(@"IGSearchBar");
if ([SCIUtils getBoolPref:@"hide_explore_grid"]) {
NSLog(@"[SCInsta] Hiding explore grid");
IGShimmeringGridView *shimmeringGridView = MSHookIvar<IGShimmeringGridView *>(self, "_shimmeringGridView");
if (shimmeringGridView != nil) {
[shimmeringGridView removeFromSuperview];
UIView *searchBar = nil;
for (UIView *sub in self.subviews) {
if (searchCls && [sub isKindOfClass:searchCls]) {
searchBar = sub;
} else if (tapBtnCls && [sub isKindOfClass:tapBtnCls] && sciIsAlgoButton(sub)) {
sub.hidden = hide;
} else if (dotCls && [sub isKindOfClass:dotCls]) {
sub.hidden = hide;
}
}
if (searchBar && hide) {
CGFloat target = self.bounds.size.width;
if (searchBar.frame.size.width != target) {
CGRect f = searchBar.frame;
f.size.width = target;
searchBar.frame = f;
}
}
}
%end
%end
%end // HideExploreGroup
%ctor {
if ([SCIUtils getBoolPref:@"hide_explore_grid"] ||
[SCIUtils getBoolPref:@"hide_trending_searches"]) {
%init(HideExploreGroup);
}
}
+26 -18
View File
@@ -1,5 +1,7 @@
// Hide suggested stories from the tray. Only filters when suggested items
// are present — skips clean inputs to avoid IGListKit diff cascade.
// Hide suggested stories from the feed tray. The adapter hook is shared
// with profile highlights, so we key off diffIdentifier: only suggested
// items use a 32-char hex UUID (real users use numeric PKs, highlights use
// "highlight:<pk>"). Default-keep on anything ambiguous.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
@@ -7,15 +9,27 @@
#import <objc/message.h>
#import <substrate.h>
static BOOL sciIsSuggestedTrayItem(id obj) {
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO;
static BOOL sciIsHexUUIDString(NSString *s) {
if (s.length != 32) return NO;
static NSCharacterSet *nonHex;
static dispatch_once_t once;
dispatch_once(&once, ^{
nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdef"] invertedSet];
});
return [s rangeOfCharacterFromSet:nonHex].location == NSNotFound;
}
static BOOL sciIsSuggestedTrayItem(id obj) {
@try {
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO;
if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) return NO;
NSString *diffId = nil;
@try { diffId = [[obj performSelector:@selector(diffIdentifier)] description]; } @catch (...) {}
if (!sciIsHexUUIDString(diffId)) return NO;
id owner = [obj valueForKey:@"reelOwner"];
if (!owner) return NO;
Ivar userIvar = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!userIvar) return NO;
id igUser = object_getIvar(owner, userIvar);
@@ -25,14 +39,11 @@ static BOOL sciIsSuggestedTrayItem(id obj) {
for (Class c = [igUser class]; c && !fcIvar; c = class_getSuperclass(c))
fcIvar = class_getInstanceVariable(c, "_fieldCache");
if (!fcIvar) return NO;
id fc = object_getIvar(igUser, fcIvar);
if (![fc isKindOfClass:[NSDictionary class]]) return NO;
if ([(NSDictionary *)fc count] == 0) return YES;
id fs = [(NSDictionary *)fc objectForKey:@"friendship_status"];
if (!fs) return NO;
return ![[fs valueForKey:@"following"] boolValue];
} @catch (__unused NSException *e) {
return NO;
@@ -42,15 +53,13 @@ static BOOL sciIsSuggestedTrayItem(id obj) {
static NSArray *(*orig_objectsForListAdapter)(id, SEL, id);
static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
NSArray *objects = orig_objectsForListAdapter(self, _cmd, adapter);
if (![SCIUtils getBoolPref:@"hide_suggested_stories"]) return objects;
// Pass through unchanged when input has no suggestions (avoids cascade).
BOOL hasSuggested = NO;
BOOL anySuggested = NO;
for (id obj in objects) {
if (sciIsSuggestedTrayItem(obj)) { hasSuggested = YES; break; }
if (sciIsSuggestedTrayItem(obj)) { anySuggested = YES; break; }
}
if (!hasSuggested) return objects;
if (!anySuggested) return objects;
NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count];
for (id obj in objects) {
@@ -60,10 +69,9 @@ static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
}
%ctor {
Class dsCls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
if (!dsCls) return;
Class cls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
if (!cls) return;
SEL sel = NSSelectorFromString(@"objectsForListAdapter:");
if (class_getInstanceMethod(dsCls, sel))
MSHookMessageEx(dsCls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
if (!class_getInstanceMethod(cls, sel)) return;
MSHookMessageEx(cls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
}
+17 -8
View File
@@ -1,16 +1,25 @@
// Hide the trending-searches pill bar under the explore search bar.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
%group HideTrendingSearchesGroup
%hook IGDSSegmentedPillBarView
- (void)didMoveToWindow {
- (void)didMoveToSuperview {
%orig;
if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return;
self.hidden = YES;
}
- (void)layoutSubviews {
%orig;
if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return;
self.hidden = YES;
}
%end
%end
if ([[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) {
if ([SCIUtils getBoolPref:@"hide_trending_searches"]) {
NSLog(@"[SCInsta] Hiding trending searches");
[self removeFromSuperview];
}
%ctor {
if ([SCIUtils getBoolPref:@"hide_trending_searches"]) {
%init(HideTrendingSearchesGroup);
}
}
%end
+59
View File
@@ -2,10 +2,14 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../SCIChrome.h"
#import <objc/runtime.h>
#import <objc/message.h>
static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
static BOOL sciMsgOnlyHideTabBar(void) {
return sciMsgOnly() && [SCIUtils getBoolPref:@"messages_only_hide_tabbar"];
}
%hook IGTabBarController
@@ -30,6 +34,21 @@ static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
}
}
- (void)viewDidLayoutSubviews {
%orig;
if (!sciMsgOnlyHideTabBar()) return;
Ivar tbIv = class_getInstanceVariable([self class], "_tabBar");
UIView *tabBar = tbIv ? object_getIvar(self, tbIv) : nil;
if (tabBar) {
tabBar.hidden = YES;
tabBar.alpha = 0.0;
}
UIViewController *selected = [self valueForKey:@"selectedViewController"];
if (selected.isViewLoaded) {
selected.view.frame = self.view.bounds;
}
}
// 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 {
@@ -63,3 +82,43 @@ static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
}
%end
// Floating settings button — long-press on tab bar is gone when it's hidden.
static const void *kSCIMsgOnlyBtnKey = &kSCIMsgOnlyBtnKey;
static void sciMsgOnlyInjectSettingsButton(UIViewController *vc) {
if (!sciMsgOnlyHideTabBar() || !vc || !vc.isViewLoaded) return;
if (objc_getAssociatedObject(vc, kSCIMsgOnlyBtnKey)) return;
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"gearshape"
pointSize:18
diameter:36];
btn.iconTint = [UIColor labelColor];
btn.bubbleColor = [UIColor clearColor];
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:vc action:@selector(sciMsgOnlyOpenSettings)
forControlEvents:UIControlEventTouchUpInside];
[vc.view addSubview:btn];
UILayoutGuide *sa = vc.view.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[btn.leadingAnchor constraintEqualToAnchor:sa.leadingAnchor constant:12],
[btn.topAnchor constraintEqualToAnchor:sa.topAnchor constant:6],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36],
]];
objc_setAssociatedObject(vc, kSCIMsgOnlyBtnKey, btn, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
%hook IGDirectInboxViewController
- (void)viewWillAppear:(BOOL)animated {
%orig;
sciMsgOnlyInjectSettingsButton((UIViewController *)self);
}
%new - (void)sciMsgOnlyOpenSettings {
UIViewController *vc = (UIViewController *)self;
[SCIUtils showSettingsVC:vc.view.window];
}
%end
+118
View File
@@ -0,0 +1,118 @@
// Long-press the Explore/search tab to open an IG link from the clipboard.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/runtime.h>
static const void *kPasteGestureKey = &kPasteGestureKey;
// Parse the clipboard string into a URL IG will recognize. Accepts bare
// hostnames, canonical IG hosts, and fix-embed mirrors (any host with
// "instagram" in it — ddinstagram, eeinstagram, vxinstagram, etc.) which
// get rewritten to www.instagram.com.
static NSURL *sciNormalizeIGURL(NSString *raw) {
if (!raw.length) return nil;
raw = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
if (![raw containsString:@"://"]) raw = [@"https://" stringByAppendingString:raw];
NSURL *url = [NSURL URLWithString:raw];
NSString *scheme = url.scheme.lowercaseString;
if ([scheme isEqualToString:@"instagram"]) return url;
if (![scheme isEqualToString:@"http"] && ![scheme isEqualToString:@"https"]) return nil;
NSString *host = url.host.lowercaseString;
if (!host.length) return nil;
if ([host isEqualToString:@"instagram.com"]
|| [host hasSuffix:@".instagram.com"]
|| [host isEqualToString:@"instagr.am"]
|| [host isEqualToString:@"ig.me"]) {
return url;
}
if ([host containsString:@"instagram"]) {
NSURLComponents *comps = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
comps.scheme = @"https";
comps.host = @"www.instagram.com";
return comps.URL;
}
return nil;
}
@interface SCIPasteLinkHandler : NSObject <UIGestureRecognizerDelegate>
+ (instancetype)shared;
- (void)longPressed:(UILongPressGestureRecognizer *)g;
@end
@implementation SCIPasteLinkHandler
+ (instancetype)shared {
static SCIPasteLinkHandler *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [SCIPasteLinkHandler new]; });
return s;
}
// Gate the gesture on the pref. When off, IG's default long-press falls through.
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)g {
return [SCIUtils getBoolPref:@"paste_link_from_search"];
}
- (void)longPressed:(UILongPressGestureRecognizer *)g {
if (g.state != UIGestureRecognizerStateBegan) return;
NSURL *url = sciNormalizeIGURL([[UIPasteboard generalPasteboard] string]);
if (!url) {
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Clipboard is not an Instagram URL")];
return;
}
// https URLs route through universal-link handling, not openURL:options:.
UIApplication *app = [UIApplication sharedApplication];
id<UIApplicationDelegate> delegate = app.delegate;
if ([url.scheme.lowercaseString isEqualToString:@"instagram"]) {
if ([delegate respondsToSelector:@selector(application:openURL:options:)]) {
[delegate application:app openURL:url options:@{}];
}
return;
}
NSUserActivity *activity = [[NSUserActivity alloc] initWithActivityType:NSUserActivityTypeBrowsingWeb];
activity.webpageURL = url;
SEL contSel = @selector(application:continueUserActivity:restorationHandler:);
if ([delegate respondsToSelector:contSel]) {
BOOL handled = [delegate application:app
continueUserActivity:activity
restorationHandler:^(NSArray<id<UIUserActivityRestoring>> *_Nullable _) {}];
if (handled) return;
}
if ([delegate respondsToSelector:@selector(application:openURL:options:)]) {
[delegate application:app openURL:url options:@{}];
}
}
@end
static void sciAttachPasteGesture(UIButton *btn) {
if (!btn || objc_getAssociatedObject(btn, kPasteGestureKey)) return;
SCIPasteLinkHandler *handler = [SCIPasteLinkHandler shared];
UILongPressGestureRecognizer *g = [[UILongPressGestureRecognizer alloc]
initWithTarget:handler action:@selector(longPressed:)];
g.minimumPressDuration = 0.5;
g.delegate = handler;
// Cancel the tap so IG's tab-tap doesn't fire after and clobber our nav.
g.cancelsTouchesInView = YES;
[btn addGestureRecognizer:g];
objc_setAssociatedObject(btn, kPasteGestureKey, g, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
%hook IGTabBarController
- (void)viewDidLayoutSubviews {
%orig;
Ivar iv = class_getInstanceVariable([self class], "_exploreButton");
if (!iv) return;
id btn = object_getIvar(self, iv);
if ([btn isKindOfClass:[UIButton class]]) sciAttachPasteGesture(btn);
}
%end
+7 -10
View File
@@ -1,5 +1,6 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../SCIChrome.h"
#import "../../../modules/JGProgressHUD/JGProgressHUD.h"
#import <objc/runtime.h>
#import <substrate.h>
@@ -103,9 +104,6 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
NSString *fullName = [sci_valueForAnyKey(user, @[@"fullName", @"fullname", @"name"]) description];
NSString *biography = [sci_valueForAnyKey(user, @[@"biography", @"bio", @"profileBiography"]) description];
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
username, fullName, (unsigned long)biography.length);
UIAlertController *menu = [UIAlertController alertControllerWithTitle:SCILocalized(@"Copy from profile")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
@@ -154,15 +152,14 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
@end
static UIView *sci_buildCopyButton(void) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"doc.on.doc"
pointSize:16
diameter:24];
btn.accessibilityIdentifier = @"sci-profile-copy-button";
btn.accessibilityLabel = @"Copy profile info";
UIImageSymbolConfiguration *cfg =
[UIImageSymbolConfiguration configurationWithPointSize:16
weight:UIImageSymbolWeightRegular];
UIImage *icon = [[UIImage systemImageNamed:@"doc.on.doc"] imageByApplyingSymbolConfiguration:cfg];
[btn setImage:icon forState:UIControlStateNormal];
btn.tintColor = [UIColor labelColor];
btn.iconTint = [UIColor labelColor];
btn.bubbleColor = [UIColor clearColor];
btn.translatesAutoresizingMaskIntoConstraints = YES;
btn.frame = CGRectMake(0, 0, 24, 44);
[btn addTarget:[SCIProfileCopyTarget shared]
action:@selector(handleTap:)
+34
View File
@@ -0,0 +1,34 @@
// Compute and clear Instagram's local caches (Library/Caches, Application
// Support, tmp, NSURLCache).
#import <Foundation/Foundation.h>
// Posted on main after a non-transient scan completes. Object is NSNumber.
extern NSString *const SCICacheSizeDidUpdateNotification;
@interface SCICacheManager : NSObject
// Scan + update cachedSize + persist. Completion on main.
+ (void)getCacheSizeWithCompletion:(void(^)(uint64_t bytes))completion;
// Scan without touching cachedSize / persistence / notification.
+ (void)getCacheSizeTransientWithCompletion:(void(^)(uint64_t bytes))completion;
// Last computed value; lazy-loads from NSUserDefaults on first call.
+ (uint64_t)cachedSize;
+ (void)refreshSizeInBackground;
// No-op when `cache_auto_check_size` is off.
+ (void)refreshSizeInBackgroundIfEnabled;
// Completion reports bytes reclaimed, on main.
+ (void)clearCacheWithCompletion:(void(^)(uint64_t bytesCleared))completion;
// Fires a silent clear if the configured interval has elapsed. Called from
// applicationDidEnterBackground.
+ (void)runAutoClearIfDue;
+ (NSString *)formattedSize:(uint64_t)bytes;
@end
+202
View File
@@ -0,0 +1,202 @@
#import "SCICacheManager.h"
#import <stdatomic.h>
#import <fts.h>
#import <sys/stat.h>
#import <dirent.h>
#import <removefile.h>
static NSString *const kAutoClearModeKey = @"cache_auto_clear_mode";
static NSString *const kLastAutoClearKey = @"cache_last_auto_clear_ts";
static NSString *const kLastKnownSizeKey = @"cache_last_known_size";
NSString *const SCICacheSizeDidUpdateNotification = @"SCICacheSizeDidUpdateNotification";
static _Atomic uint64_t gCachedSize = 0;
static dispatch_once_t gLoadPersistedOnce;
static void sciLoadPersistedSizeOnce(void) {
dispatch_once(&gLoadPersistedOnce, ^{
uint64_t stored = (uint64_t)[[NSUserDefaults standardUserDefaults] doubleForKey:kLastKnownSizeKey];
atomic_store(&gCachedSize, stored);
});
}
static NSArray<NSString *> *sciCacheDirs(void) {
NSMutableArray *dirs = [NSMutableArray array];
NSArray *caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
if (caches.firstObject) [dirs addObject:caches.firstObject];
NSArray *appSupport = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES);
if (appSupport.firstObject) [dirs addObject:appSupport.firstObject];
NSString *tmp = NSTemporaryDirectory();
if (tmp.length) [dirs addObject:tmp];
return dirs;
}
// Top-level entry names under any cache root that belong to RyukGram user
// data (analyzer snapshots, header cache, future persistent state) and must
// survive a cache wipe.
static BOOL sciIsProtectedEntryName(const char *name) {
return strcmp(name, "RyukGram") == 0;
}
// POSIX fts — avoids the NSDirectoryEnumerator per-entry alloc overhead.
static uint64_t sciDirectorySize(NSString *path) {
const char *root = [path fileSystemRepresentation];
if (!root) return 0;
char * const paths[] = { (char *)root, NULL };
FTS *fts = fts_open(paths, FTS_PHYSICAL | FTS_NOCHDIR | FTS_XDEV, NULL);
if (!fts) return 0;
uint64_t total = 0;
FTSENT *ent;
while ((ent = fts_read(fts))) {
// Don't descend into RyukGram user-data subtrees.
if (ent->fts_info == FTS_D && ent->fts_level == 1 &&
sciIsProtectedEntryName(ent->fts_name)) {
fts_set(fts, ent, FTS_SKIP);
continue;
}
if (ent->fts_info == FTS_F && ent->fts_statp) {
total += (uint64_t)ent->fts_statp->st_size;
}
}
fts_close(fts);
return total;
}
// Recursive delete of directory contents — the top-level dir itself is
// preserved so IG's file handles stay valid, and RyukGram subtrees are
// skipped so our analyzer snapshots + header cache survive.
static void sciDeleteDirectoryContents(NSString *path) {
const char *root = [path fileSystemRepresentation];
if (!root) return;
DIR *dp = opendir(root);
if (!dp) return;
struct dirent *de;
while ((de = readdir(dp))) {
if (de->d_name[0] == '.' && (de->d_name[1] == 0 ||
(de->d_name[1] == '.' && de->d_name[2] == 0))) continue;
if (sciIsProtectedEntryName(de->d_name)) continue;
char full[PATH_MAX];
snprintf(full, sizeof(full), "%s/%s", root, de->d_name);
removefile(full, NULL, REMOVEFILE_RECURSIVE);
}
closedir(dp);
}
@implementation SCICacheManager
// Transient mode reports the size to the caller but skips persisting it
// and firing the update notification — used by the "Show cache size" off
// tap path to scan on demand without leaking state.
+ (void)_scanWithQos:(qos_class_t)qos
transient:(BOOL)transient
completion:(void(^)(uint64_t))completion {
dispatch_queue_t q = dispatch_get_global_queue(qos, 0);
dispatch_async(q, ^{
NSArray<NSString *> *dirs = sciCacheDirs();
__block _Atomic uint64_t running = 0;
dispatch_group_t group = dispatch_group_create();
for (NSString *d in dirs) {
dispatch_group_async(group, q, ^{
atomic_fetch_add(&running, sciDirectorySize(d));
});
}
dispatch_group_async(group, q, ^{
atomic_fetch_add(&running, (uint64_t)[[NSURLCache sharedURLCache] currentDiskUsage]);
});
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
uint64_t total = atomic_load(&running);
if (!transient) {
atomic_store(&gCachedSize, total);
[[NSUserDefaults standardUserDefaults] setDouble:(double)total forKey:kLastKnownSizeKey];
}
dispatch_async(dispatch_get_main_queue(), ^{
if (!transient) {
[[NSNotificationCenter defaultCenter] postNotificationName:SCICacheSizeDidUpdateNotification
object:@(total)];
}
if (completion) completion(total);
});
});
}
+ (void)getCacheSizeWithCompletion:(void(^)(uint64_t))completion {
[self _scanWithQos:QOS_CLASS_USER_INITIATED transient:NO completion:completion];
}
+ (void)getCacheSizeTransientWithCompletion:(void(^)(uint64_t))completion {
[self _scanWithQos:QOS_CLASS_USER_INITIATED transient:YES completion:completion];
}
+ (uint64_t)cachedSize {
sciLoadPersistedSizeOnce();
return atomic_load(&gCachedSize);
}
+ (void)refreshSizeInBackground {
[self _scanWithQos:QOS_CLASS_BACKGROUND transient:NO completion:nil];
}
+ (void)refreshSizeInBackgroundIfEnabled {
if (![[NSUserDefaults standardUserDefaults] boolForKey:@"cache_auto_check_size"]) return;
[self refreshSizeInBackground];
}
+ (void)clearCacheWithCompletion:(void(^)(uint64_t))completion {
dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0);
dispatch_async(q, ^{
// Snapshot the known size; only re-scan if we never measured.
uint64_t reclaimed = atomic_load(&gCachedSize);
if (reclaimed == 0) {
for (NSString *d in sciCacheDirs()) reclaimed += sciDirectorySize(d);
reclaimed += (uint64_t)[[NSURLCache sharedURLCache] currentDiskUsage];
}
NSArray<NSString *> *dirs = sciCacheDirs();
dispatch_group_t group = dispatch_group_create();
for (NSString *d in dirs) {
dispatch_group_async(group, q, ^{ sciDeleteDirectoryContents(d); });
}
dispatch_group_async(group, q, ^{
[[NSURLCache sharedURLCache] removeAllCachedResponses];
});
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
atomic_store(&gCachedSize, 0);
[[NSUserDefaults standardUserDefaults] setDouble:0 forKey:kLastKnownSizeKey];
[[NSUserDefaults standardUserDefaults] setDouble:[NSDate date].timeIntervalSince1970
forKey:kLastAutoClearKey];
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:SCICacheSizeDidUpdateNotification
object:@(0)];
if (completion) completion(reclaimed);
});
});
}
+ (void)runAutoClearIfDue {
NSString *mode = [[NSUserDefaults standardUserDefaults] stringForKey:kAutoClearModeKey];
if (!mode.length || [mode isEqualToString:@"off"]) { [self refreshSizeInBackgroundIfEnabled]; return; }
NSTimeInterval interval = 0;
if ([mode isEqualToString:@"daily"]) interval = 24 * 60 * 60;
else if ([mode isEqualToString:@"weekly"]) interval = 7 * 24 * 60 * 60;
else if ([mode isEqualToString:@"monthly"]) interval = 30 * 24 * 60 * 60;
else { [self refreshSizeInBackgroundIfEnabled]; return; }
NSTimeInterval last = [[NSUserDefaults standardUserDefaults] doubleForKey:kLastAutoClearKey];
NSTimeInterval now = [NSDate date].timeIntervalSince1970;
if (last > 0 && (now - last) < interval) { [self refreshSizeInBackgroundIfEnabled]; return; }
[self clearCacheWithCompletion:^(uint64_t bytes) {
NSLog(@"[SCInsta] auto-clear cache mode=%@ reclaimed=%@", mode, [self formattedSize:bytes]);
}];
}
+ (NSString *)formattedSize:(uint64_t)bytes {
return [NSByteCountFormatter stringFromByteCount:(long long)bytes
countStyle:NSByteCountFormatterCountStyleFile];
}
@end
+16
View File
@@ -0,0 +1,16 @@
// SCIChangelog — fetches RyukGram release notes from GitHub and presents
// them in a scrollable popup. Shows automatically on launch when the tweak
// version changes; also available from the About page.
#import <UIKit/UIKit.h>
@interface SCIChangelog : NSObject
/// Present the latest release notes when this is a version the user hasn't
/// seen yet. No-op otherwise. Safe to call on every launch.
+ (void)presentIfNewFromWindow:(UIWindow *)window;
/// Present a browser of every release (tap a row → see its notes).
+ (void)presentAllFromViewController:(UIViewController *)host;
@end
+374
View File
@@ -0,0 +1,374 @@
#import "SCIChangelog.h"
#import "../../Utils.h"
#import "../../Tweak.h"
static NSString *const kRepo = @"faroukbmiled/RyukGram";
// Stores the SCIVersionString of the last tweak build whose popup was shown.
// When the tweak updates, this mismatches and triggers a fresh check.
static NSString *const kLastSeenVersionKey = @"sci_changelog_last_seen_version";
// Debug pref: when YES, the popup fires every launch regardless of version.
static NSString *const kForceShowKey = @"sci_changelog_force_show";
// MARK: - Cache
static NSString *sciChangelogCacheDir(void) {
static NSString *dir = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSString *base = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
dir = [base stringByAppendingPathComponent:@"RyukGramChangelog"];
[[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
});
return dir;
}
static NSString *sciCachedReleasePath(NSString *tag) {
NSString *safe = [tag stringByReplacingOccurrencesOfString:@"/" withString:@"_"];
return [sciChangelogCacheDir() stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.json", safe]];
}
static NSDictionary *sciLoadCachedRelease(NSString *tag) {
NSData *data = [NSData dataWithContentsOfFile:sciCachedReleasePath(tag)];
if (!data) return nil;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
return [obj isKindOfClass:[NSDictionary class]] ? obj : nil;
}
static void sciSaveCachedRelease(NSString *tag, NSDictionary *json) {
NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil];
if (data) [data writeToFile:sciCachedReleasePath(tag) atomically:YES];
}
// MARK: - Network
static void sciFetchJSON(NSString *url, void (^completion)(NSDictionary *)) {
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
[req setValue:@"application/vnd.github+json" forHTTPHeaderField:@"Accept"];
[[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
if (![json isKindOfClass:[NSDictionary class]] || !json[@"tag_name"]) {
dispatch_async(dispatch_get_main_queue(), ^{ completion(nil); });
return;
}
dispatch_async(dispatch_get_main_queue(), ^{ completion(json); });
}] resume];
}
// Fetch a specific tag, falling back to /releases/latest on 404 so the popup
// works in the window between a local version bump and the release being
// published on GitHub.
static void sciFetchRelease(NSString *tag, void (^completion)(NSDictionary *)) {
NSString *tagURL = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases/tags/%@", kRepo, tag];
sciFetchJSON(tagURL, ^(NSDictionary *json) {
if (json) {
sciSaveCachedRelease(json[@"tag_name"], json);
completion(json);
return;
}
NSString *latestURL = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases/latest", kRepo];
sciFetchJSON(latestURL, ^(NSDictionary *latest) {
if (latest) sciSaveCachedRelease(latest[@"tag_name"], latest);
completion(latest);
});
});
}
static void sciFetchReleaseList(void (^completion)(NSArray<NSDictionary *> *)) {
NSString *url = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases?per_page=50", kRepo];
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
[req setValue:@"application/vnd.github+json" forHTTPHeaderField:@"Accept"];
[[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
NSArray *arr = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
dispatch_async(dispatch_get_main_queue(), ^{
completion([arr isKindOfClass:[NSArray class]] ? arr : nil);
});
}] resume];
}
// MARK: - Markdown renderer
static NSAttributedString *sciRenderMarkdown(NSString *md) {
NSMutableAttributedString *out = [[NSMutableAttributedString alloc] init];
if (!md.length) return out;
UIFont *body = [UIFont systemFontOfSize:15];
UIFont *h2 = [UIFont systemFontOfSize:20 weight:UIFontWeightBold];
UIFont *h3 = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
UIColor *fg = [UIColor labelColor];
UIColor *muted = [UIColor secondaryLabelColor];
NSMutableParagraphStyle *bodyPS = [NSMutableParagraphStyle new];
bodyPS.lineSpacing = 2;
bodyPS.paragraphSpacing = 3;
NSMutableParagraphStyle *headingPS = [NSMutableParagraphStyle new];
headingPS.lineSpacing = 2;
headingPS.paragraphSpacing = 4;
headingPS.paragraphSpacingBefore = 10;
NSArray<NSString *> *lines = [md componentsSeparatedByString:@"\n"];
BOOL firstEmitted = NO;
for (NSString *raw in lines) {
// Skip blank lines — paragraph spacing already handles breathing room.
if (raw.length == 0) continue;
NSString *line = raw;
NSMutableDictionary *attrs = [@{
NSFontAttributeName: body,
NSForegroundColorAttributeName: fg,
NSParagraphStyleAttributeName: bodyPS,
} mutableCopy];
NSString *prefix = nil;
if ([line hasPrefix:@"## "]) {
attrs[NSFontAttributeName] = h2;
attrs[NSParagraphStyleAttributeName] = firstEmitted ? headingPS : bodyPS;
line = [line substringFromIndex:3];
} else if ([line hasPrefix:@"### "]) {
attrs[NSFontAttributeName] = h3;
attrs[NSParagraphStyleAttributeName] = firstEmitted ? headingPS : bodyPS;
line = [line substringFromIndex:4];
} else if ([line hasPrefix:@"- "] || [line hasPrefix:@"* "]) {
prefix = @"";
line = [line substringFromIndex:2];
} else if ([line hasPrefix:@"> "]) {
attrs[NSForegroundColorAttributeName] = muted;
line = [line substringFromIndex:2];
}
if (firstEmitted) {
[out appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n" attributes:attrs]];
}
if (prefix) {
[out appendAttributedString:[[NSAttributedString alloc] initWithString:prefix attributes:attrs]];
}
NSMutableAttributedString *seg = [[NSMutableAttributedString alloc] initWithString:line attributes:attrs];
// Inline **bold**
NSRegularExpression *boldRx = [NSRegularExpression regularExpressionWithPattern:@"\\*\\*(.+?)\\*\\*" options:0 error:nil];
NSArray *boldMatches = [boldRx matchesInString:seg.string options:0 range:NSMakeRange(0, seg.string.length)];
for (NSTextCheckingResult *m in [boldMatches reverseObjectEnumerator]) {
NSString *inner = [seg.string substringWithRange:[m rangeAtIndex:1]];
UIFont *baseFont = attrs[NSFontAttributeName];
UIFont *boldFont = [UIFont systemFontOfSize:baseFont.pointSize weight:UIFontWeightBold];
NSMutableDictionary *boldAttrs = [attrs mutableCopy];
boldAttrs[NSFontAttributeName] = boldFont;
NSAttributedString *replacement = [[NSAttributedString alloc] initWithString:inner attributes:boldAttrs];
[seg replaceCharactersInRange:m.range withAttributedString:replacement];
}
// Inline [text](url) links
NSRegularExpression *linkRx = [NSRegularExpression regularExpressionWithPattern:@"\\[([^\\]]+)\\]\\(([^)]+)\\)" options:0 error:nil];
NSArray *linkMatches = [linkRx matchesInString:seg.string options:0 range:NSMakeRange(0, seg.string.length)];
for (NSTextCheckingResult *m in [linkMatches reverseObjectEnumerator]) {
NSString *text = [seg.string substringWithRange:[m rangeAtIndex:1]];
NSString *url = [seg.string substringWithRange:[m rangeAtIndex:2]];
NSMutableDictionary *linkAttrs = [attrs mutableCopy];
linkAttrs[NSLinkAttributeName] = url;
NSAttributedString *replacement = [[NSAttributedString alloc] initWithString:text attributes:linkAttrs];
[seg replaceCharactersInRange:m.range withAttributedString:replacement];
}
[out appendAttributedString:seg];
firstEmitted = YES;
}
return out;
}
// MARK: - Detail view controller (renders one release)
@interface _SCIChangelogDetailVC : UIViewController
@property (nonatomic, copy) NSDictionary *releaseJSON;
@property (nonatomic, copy) void (^onDismiss)(void);
@end
@implementation _SCIChangelogDetailVC
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
NSString *name = self.releaseJSON[@"name"] ?: self.releaseJSON[@"tag_name"] ?: @"?";
NSString *body = self.releaseJSON[@"body"] ?: @"";
NSString *htmlURL = self.releaseJSON[@"html_url"] ?: @"";
self.title = SCILocalized(@"What's new in RyukGram");
self.navigationItem.rightBarButtonItem =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone
target:self
action:@selector(done)];
// Tap the release-name heading to open the GitHub page.
NSString *header = htmlURL.length
? [NSString stringWithFormat:@"## [%@](%@)\n", name, htmlURL]
: [NSString stringWithFormat:@"## %@\n", name];
NSAttributedString *attrBody = sciRenderMarkdown([header stringByAppendingString:body]);
UITextView *tv = [UITextView new];
tv.editable = NO;
tv.backgroundColor = [UIColor clearColor];
tv.textContainerInset = UIEdgeInsetsMake(16, 16, 24, 16);
tv.translatesAutoresizingMaskIntoConstraints = NO;
tv.attributedText = attrBody;
tv.alwaysBounceVertical = YES;
[self.view addSubview:tv];
[NSLayoutConstraint activateConstraints:@[
[tv.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[tv.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[tv.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[tv.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
]];
}
- (void)done {
[self dismissViewControllerAnimated:YES completion:nil];
}
@end
// MARK: - Releases list view controller
@interface _SCIReleaseListVC : UITableViewController
@property (nonatomic, copy) NSArray<NSDictionary *> *releases;
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
@end
@implementation _SCIReleaseListVC
- (void)viewDidLoad {
[super viewDidLoad];
self.title = SCILocalized(@"Release notes");
self.navigationItem.rightBarButtonItem =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone
target:self
action:@selector(done)];
self.tableView.rowHeight = 60;
UIActivityIndicatorView *spin = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge];
spin.hidesWhenStopped = YES;
[spin startAnimating];
self.tableView.backgroundView = spin;
self.spinner = spin;
[self loadReleases];
}
- (void)loadReleases {
sciFetchReleaseList(^(NSArray<NSDictionary *> *arr) {
self.releases = arr ?: @[];
[self.spinner stopAnimating];
self.tableView.backgroundView = nil;
if (self.releases.count == 0) {
UILabel *empty = [UILabel new];
empty.text = SCILocalized(@"No releases");
empty.textAlignment = NSTextAlignmentCenter;
empty.textColor = [UIColor secondaryLabelColor];
empty.font = [UIFont systemFontOfSize:15];
self.tableView.backgroundView = empty;
}
[self.tableView reloadData];
});
}
- (void)done {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return self.releases.count;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"r"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"r"];
NSDictionary *rel = self.releases[ip.row];
NSString *tag = rel[@"tag_name"];
NSString *title = rel[@"name"] ?: tag;
NSMutableArray<NSString *> *tags = [NSMutableArray array];
if (ip.row == 0) [tags addObject:SCILocalized(@"latest")];
if ([tag isEqualToString:SCIVersionString]) [tags addObject:SCILocalized(@"installed")];
if (tags.count) {
title = [NSString stringWithFormat:@"%@ (%@)", title, [tags componentsJoinedByString:@", "]];
}
cell.textLabel.text = title;
NSString *published = rel[@"published_at"];
cell.detailTextLabel.text = published ? [published substringToIndex:MIN((NSUInteger)10, published.length)] : @"";
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip {
[tv deselectRowAtIndexPath:ip animated:YES];
NSDictionary *rel = self.releases[ip.row];
_SCIChangelogDetailVC *vc = [_SCIChangelogDetailVC new];
vc.releaseJSON = rel;
[self.navigationController pushViewController:vc animated:YES];
}
@end
// MARK: - Public API
@implementation SCIChangelog
+ (UIViewController *)topVCInWindow:(UIWindow *)window {
UIViewController *vc = window.rootViewController;
while (vc.presentedViewController) vc = vc.presentedViewController;
return vc;
}
+ (void)presentReleaseJSON:(NSDictionary *)json onDismiss:(void(^)(void))onDismiss fromWindow:(UIWindow *)window {
_SCIChangelogDetailVC *vc = [_SCIChangelogDetailVC new];
vc.releaseJSON = json;
vc.onDismiss = onDismiss;
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
if (@available(iOS 15.0, *)) {
UISheetPresentationController *sheet = nav.sheetPresentationController;
sheet.detents = @[
UISheetPresentationControllerDetent.mediumDetent,
UISheetPresentationControllerDetent.largeDetent,
];
sheet.prefersGrabberVisible = YES;
}
[[self topVCInWindow:window] presentViewController:nav animated:YES completion:nil];
}
+ (void)presentIfNewFromWindow:(UIWindow *)window {
if (!window) return;
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
BOOL force = [ud boolForKey:kForceShowKey];
// Fast-path: already shown for this tweak version — skip all I/O.
if (!force && [[ud stringForKey:kLastSeenVersionKey] isEqualToString:SCIVersionString]) return;
void (^show)(NSDictionary *) = ^(NSDictionary *json) {
if (!json) return;
// Mark seen on show so any dismissal path (Done, swipe) is covered.
[[NSUserDefaults standardUserDefaults] setObject:SCIVersionString forKey:kLastSeenVersionKey];
[self presentReleaseJSON:json onDismiss:nil fromWindow:window];
};
NSDictionary *cached = sciLoadCachedRelease(SCIVersionString);
if (cached) { show(cached); return; }
sciFetchRelease(SCIVersionString, ^(NSDictionary *json) { show(json); });
}
+ (void)presentAllFromViewController:(UIViewController *)host {
if (!host) return;
_SCIReleaseListVC *list = [_SCIReleaseListVC new];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:list];
nav.modalPresentationStyle = UIModalPresentationPageSheet;
if (@available(iOS 15.0, *)) {
UISheetPresentationController *sheet = nav.sheetPresentationController;
sheet.detents = @[UISheetPresentationControllerDetent.largeDetent];
sheet.prefersGrabberVisible = YES;
}
[host presentViewController:nav animated:YES completion:nil];
}
@end
+136
View File
@@ -0,0 +1,136 @@
// Live-stream tweaks — anonymous viewing + long-press heart to hide comments.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
// MARK: - Anonymous viewing
static void sciDisableViewerCountPuller(id feedbackController) {
Ivar pullerIvar = class_getInstanceVariable([feedbackController class], "_viewCountPuller");
if (!pullerIvar) return;
id puller = object_getIvar(feedbackController, pullerIvar);
if (!puller) return;
// Ivars live on the IGLiveIntervalPuller superclass.
Ivar activeIvar = NULL;
Ivar timerIvar = NULL;
for (Class c = [puller class]; c && c != [NSObject class]; c = class_getSuperclass(c)) {
if (!activeIvar) activeIvar = class_getInstanceVariable(c, "_isActive");
if (!timerIvar) timerIvar = class_getInstanceVariable(c, "_nextFetchTimer");
if (activeIvar && timerIvar) break;
}
if (activeIvar) {
ptrdiff_t off = ivar_getOffset(activeIvar);
*(BOOL *)((char *)(__bridge void *)puller + off) = NO;
}
if (timerIvar) {
id timer = object_getIvar(puller, timerIvar);
if (timer && [timer respondsToSelector:@selector(invalidate)]) {
((void(*)(id, SEL))objc_msgSend)(timer, @selector(invalidate));
}
}
}
%hook IGLiveFeedbackController
- (void)start {
%orig;
if ([SCIUtils getBoolPref:@"live_anonymous_view"]) {
sciDisableViewerCountPuller(self);
}
}
%end
// MARK: - Hide comments (session-only)
// Session-only — state resets on each new comments VC appearance.
static __weak UIViewController *gActiveLiveCommentsVC = nil;
static BOOL gCommentsHidden = NO;
static const void *kSCIHeartAttachedKey = &kSCIHeartAttachedKey;
// Only hide the scrolling collection — keep input + like usable.
static void sciHideCommentCollections(UIView *root, BOOL hide, int depth) {
if (!root || depth > 8) return;
for (UIView *sub in root.subviews) {
if ([sub isKindOfClass:[UICollectionView class]]) {
sub.alpha = hide ? 0.0 : 1.0;
sub.userInteractionEnabled = !hide;
continue;
}
sciHideCommentCollections(sub, hide, depth + 1);
}
}
static void sciApplyCommentsStateTo(UIViewController *vc) {
if (!vc || !vc.isViewLoaded) return;
sciHideCommentCollections(vc.view, gCommentsHidden, 0);
}
extern "C" void sciRefreshLiveCommentsHidden(void) {
sciApplyCommentsStateTo(gActiveLiveCommentsVC);
}
static void sciAttachLongPressToView(UIView *v);
// Heart lives in the footer's _likeButton ivar.
%hook IGLiveFooterButtonsView
- (void)layoutSubviews {
%orig;
id obj = (id)self;
Ivar iv = class_getInstanceVariable([obj class], "_likeButton");
if (!iv) return;
UIView *btn = object_getIvar(obj, iv);
if (btn) sciAttachLongPressToView(btn);
}
%end
%hook IGLiveCommentsContainerViewController
- (void)viewDidAppear:(BOOL)animated {
%orig;
gActiveLiveCommentsVC = self;
gCommentsHidden = NO;
sciApplyCommentsStateTo(self);
}
- (void)viewWillDisappear:(BOOL)animated {
%orig;
if (gActiveLiveCommentsVC == self) gActiveLiveCommentsVC = nil;
}
%end
// MARK: - Long-press heart → toggle comments
@interface SCILiveLikeLongPress : NSObject
+ (instancetype)shared;
- (void)fired:(UILongPressGestureRecognizer *)g;
@end
@implementation SCILiveLikeLongPress
+ (instancetype)shared {
static SCILiveLikeLongPress *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [SCILiveLikeLongPress new]; });
return s;
}
- (void)fired:(UILongPressGestureRecognizer *)g {
if (g.state != UIGestureRecognizerStateBegan) return;
if (![SCIUtils getBoolPref:@"live_hide_comments"]) return;
gCommentsHidden = !gCommentsHidden;
sciRefreshLiveCommentsHidden();
[SCIUtils showToastForDuration:1.0
title:gCommentsHidden ? SCILocalized(@"Comments hidden")
: SCILocalized(@"Comments shown")];
}
@end
static void sciAttachLongPressToView(UIView *v) {
if (!v || objc_getAssociatedObject(v, kSCIHeartAttachedKey)) return;
UILongPressGestureRecognizer *g = [[UILongPressGestureRecognizer alloc]
initWithTarget:[SCILiveLikeLongPress shared] action:@selector(fired:)];
g.minimumPressDuration = 0.5;
// Swallow the tap so the reactions sheet doesn't open.
g.cancelsTouchesInView = YES;
[v addGestureRecognizer:g];
objc_setAssociatedObject(v, kSCIHeartAttachedKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
+197
View File
@@ -0,0 +1,197 @@
// Fake profile stats for own profile — follower/following/post counts
// and verified badge. Counts rewrite IGStatButton labels; verified flips
// is_verified at the JSON parse layer + swizzles IGUsernameModel to catch
// cached-model renders.
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static BOOL sciFakeOn(NSString *key) { return [SCIUtils getBoolPref:key]; }
// IG format — 1,192 / 12.3K / 1.2M / 1.2B. Raw digits only; passthrough otherwise.
static NSString *sciFormatCount(NSString *raw) {
raw = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (!raw.length) return nil;
NSCharacterSet *digits = [NSCharacterSet decimalDigitCharacterSet];
for (NSUInteger i = 0; i < raw.length; i++) {
if (![digits characterIsMember:[raw characterAtIndex:i]]) return raw;
}
long long n = raw.longLongValue;
if (n < 10000) {
NSNumberFormatter *f = [NSNumberFormatter new];
f.numberStyle = NSNumberFormatterDecimalStyle;
return [f stringFromNumber:@(n)];
}
double d; NSString *suf;
if (n >= 1000000000LL) { d = n / 1000000000.0; suf = @"B"; }
else if (n >= 1000000LL) { d = n / 1000000.0; suf = @"M"; }
else { d = n / 1000.0; suf = @"K"; }
NSString *s = [NSString stringWithFormat:@"%.1f", d];
if ([s hasSuffix:@".0"]) s = [s substringToIndex:s.length - 2];
return [s stringByAppendingString:suf];
}
static NSString *sciFakeValue(NSString *valueKey) {
return sciFormatCount([[NSUserDefaults standardUserDefaults] stringForKey:valueKey]);
}
// ============ Fake counts — IGStatButton label rewrite ============
static BOOL sciButtonIsOnOwnProfile(UIView *btn) {
Class selfCellCls = NSClassFromString(@"IGProfileSimpleAvatarStatsCell");
if (!selfCellCls) return NO;
UIView *cur = btn;
while (cur && ![cur isKindOfClass:selfCellCls]) cur = cur.superview;
if (!cur) return NO;
Ivar iv = class_getInstanceVariable([cur class], "_isCurrentUser");
if (!iv) return NO;
return *(BOOL *)((char *)(__bridge void *)cur + ivar_getOffset(iv));
}
static NSString *sciFakeTextForName(NSString *name) {
if (!name) return nil;
NSString *low = name.lowercaseString;
if ([low containsString:@"follower"]) {
if (sciFakeOn(@"fake_follower_count")) return sciFakeValue(@"fake_follower_count_value");
} else if ([low containsString:@"following"]) {
if (sciFakeOn(@"fake_following_count")) return sciFakeValue(@"fake_following_count_value");
} else if ([low containsString:@"post"]) {
if (sciFakeOn(@"fake_post_count")) return sciFakeValue(@"fake_post_count_value");
}
return nil;
}
static void sciApplyFakeToButton(id btn) {
if (!sciFakeOn(@"fake_follower_count")
&& !sciFakeOn(@"fake_following_count")
&& !sciFakeOn(@"fake_post_count")) return;
Ivar nmIv = class_getInstanceVariable([btn class], "_name");
NSString *name = nmIv ? object_getIvar(btn, nmIv) : nil;
NSString *fake = sciFakeTextForName(name);
if (!fake) return;
if (!sciButtonIsOnOwnProfile(btn)) return;
Ivar lblIv = class_getInstanceVariable([btn class], "_countLabel");
UILabel *lbl = lblIv ? object_getIvar(btn, lblIv) : nil;
if ([lbl isKindOfClass:[UILabel class]]) lbl.text = fake;
}
static void (*orig_setName)(id, SEL, id);
static void new_setName(id self, SEL _cmd, id name) {
orig_setName(self, _cmd, name);
sciApplyFakeToButton(self);
}
static void (*orig_setCount)(id, SEL, id);
static void new_setCount(id self, SEL _cmd, id cfg) {
orig_setCount(self, _cmd, cfg);
sciApplyFakeToButton(self);
}
static void (*orig_layout)(id, SEL);
static void new_layout(id self, SEL _cmd) {
orig_layout(self, _cmd);
sciApplyFakeToButton(self);
}
// ============ Fake verified — JSON response rewrite ============
// PK + pref cached — read on every JSON parse.
static NSString *gSelfPK = nil;
static BOOL gFakeVerifiedOn = NO;
static BOOL sciPKMatchesSelf(id pk) {
if (!gSelfPK.length) return NO;
if ([pk isKindOfClass:[NSString class]]) return [pk isEqualToString:gSelfPK];
if ([pk isKindOfClass:[NSNumber class]]) return [[(NSNumber *)pk stringValue] isEqualToString:gSelfPK];
return NO;
}
static void sciFlipVerifiedInJSON(id obj, int depth) {
if (depth > 16) return;
if ([obj isKindOfClass:[NSMutableDictionary class]]) {
NSMutableDictionary *d = obj;
id pk = d[@"pk"] ?: d[@"strong_id__"] ?: d[@"user_id"] ?: d[@"id"];
if (sciPKMatchesSelf(pk)) d[@"is_verified"] = @YES;
for (id v in d.allValues) sciFlipVerifiedInJSON(v, depth + 1);
} else if ([obj isKindOfClass:[NSMutableArray class]]) {
for (id v in (NSMutableArray *)obj) sciFlipVerifiedInJSON(v, depth + 1);
}
}
// Belt-and-suspenders — profile header reads isVerified from a cached
// IGUsernameModel without re-parsing JSON on every refresh.
typedef BOOL (*SciIsVerifiedFn)(id, SEL);
static SciIsVerifiedFn orig_UsernameModel_isVerified = NULL;
static NSString *gSelfUsername = nil;
static BOOL new_UsernameModel_isVerified(id self, SEL _cmd) {
BOOL o = orig_UsernameModel_isVerified ? orig_UsernameModel_isVerified(self, _cmd) : NO;
if (o) return YES;
if (!gSelfUsername.length) return NO;
NSString *u = nil;
@try { u = [self valueForKey:@"username"]; } @catch (__unused id e) {}
if ([u isKindOfClass:[NSString class]] && [u isEqualToString:gSelfUsername]) return YES;
return NO;
}
static id (*orig_JSONObjectWithData)(Class, SEL, NSData *, NSJSONReadingOptions, NSError **);
static id new_JSONObjectWithData(Class self, SEL _cmd, NSData *data, NSJSONReadingOptions opts, NSError **err) {
if (!gFakeVerifiedOn) return orig_JSONObjectWithData(self, _cmd, data, opts, err);
opts |= NSJSONReadingMutableContainers;
id r = orig_JSONObjectWithData(self, _cmd, data, opts, err);
if (r) sciFlipVerifiedInJSON(r, 0);
return r;
}
__attribute__((constructor)) static void _sciFakeStatsInit(void) {
// Both feature sets gate install on launch pref + require restart —
// off means no hook at all.
BOOL anyCountOn = sciFakeOn(@"fake_follower_count")
|| sciFakeOn(@"fake_following_count")
|| sciFakeOn(@"fake_post_count");
if (anyCountOn) {
Class sb = NSClassFromString(@"IGStatButton");
if (sb) {
MSHookMessageEx(sb, @selector(setName:), (IMP)new_setName, (IMP *)&orig_setName);
MSHookMessageEx(sb, @selector(setCount:), (IMP)new_setCount, (IMP *)&orig_setCount);
MSHookMessageEx(sb, @selector(layoutSubviews), (IMP)new_layout, (IMP *)&orig_layout);
}
}
if (!sciFakeOn(@"fake_verified")) return;
gFakeVerifiedOn = YES;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
gSelfPK = [[SCIUtils currentUserPK] copy];
id session = [SCIUtils activeUserSession];
id user = nil;
@try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {}
@try { gSelfUsername = [[user valueForKey:@"username"] copy]; } @catch (__unused id e) {}
});
[[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification
object:nil queue:nil
usingBlock:^(__unused NSNotification *n) {
if (!gSelfPK.length) gSelfPK = [[SCIUtils currentUserPK] copy];
if (!gSelfUsername.length) {
id session = [SCIUtils activeUserSession];
id user = nil;
@try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {}
@try { gSelfUsername = [[user valueForKey:@"username"] copy]; } @catch (__unused id e) {}
}
}];
Class jc = object_getClass([NSJSONSerialization class]);
MSHookMessageEx(jc, @selector(JSONObjectWithData:options:error:),
(IMP)new_JSONObjectWithData, (IMP *)&orig_JSONObjectWithData);
Class um = NSClassFromString(@"IGUsernameModel");
if (um) {
Method m = class_getInstanceMethod(um, @selector(isVerified));
if (m) {
orig_UsernameModel_isVerified = (SciIsVerifiedFn)method_getImplementation(m);
method_setImplementation(m, (IMP)new_UsernameModel_isVerified);
}
}
}
+11 -40
View File
@@ -3,6 +3,7 @@
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import "../../SCIChrome.h"
#import "../../Networking/SCIInstagramAPI.h"
#import <objc/runtime.h>
#import <objc/message.h>
@@ -11,30 +12,6 @@
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) {
@@ -64,26 +41,20 @@ static void sciRenderBadge(UIViewController *vc) {
UIView *old = [statContainer viewWithTag:kFollowBadgeTag];
if (old) [old removeFromSuperview];
UILabel *badge = [[UILabel alloc] init];
NSString *text = followedBy ? SCILocalized(@"Follows you") : SCILocalized(@"Doesn't follow you");
SCIChromeLabel *badge = [[SCIChromeLabel alloc] initWithText:text];
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];
// Pinned to the leading edge so it sits flush-left on any device + RTL.
[NSLayoutConstraint activateConstraints:@[
[badge.leadingAnchor constraintEqualToAnchor:statContainer.leadingAnchor],
[badge.bottomAnchor constraintEqualToAnchor:statContainer.bottomAnchor constant:-8],
]];
}
%hook IGProfileViewController
@@ -103,8 +74,8 @@ static void sciRenderBadge(UIViewController *vc) {
@try { igUser = [self valueForKey:@"user"]; } @catch (NSException *e) {}
if (!igUser) return;
NSString *profilePK = sciPKFromUser(igUser);
NSString *myPK = sciCurrentUserPK();
NSString *profilePK = [SCIUtils pkFromIGUser:igUser];
NSString *myPK = [SCIUtils currentUserPK];
if (!profilePK || !myPK || [profilePK isEqualToString:myPK]) return;
__weak UIViewController *weakSelf = self;
@@ -0,0 +1,21 @@
#import <UIKit/UIKit.h>
#import "SCIProfileAnalyzerModels.h"
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, SCIPAListKind) {
SCIPAListKindPlain, // no action button
SCIPAListKindUnfollow, // show "Unfollow" button (you follow them)
SCIPAListKindFollow, // show "Follow" button (you don't follow them)
SCIPAListKindProfileUpdate, // displays previous → current change rows
};
@interface SCIProfileAnalyzerListViewController : UIViewController
- (instancetype)initWithTitle:(NSString *)title
users:(NSArray<SCIProfileAnalyzerUser *> *)users
kind:(SCIPAListKind)kind;
- (instancetype)initWithTitle:(NSString *)title
profileUpdates:(NSArray<SCIProfileAnalyzerProfileChange *> *)updates;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,730 @@
#import "SCIProfileAnalyzerListViewController.h"
#import "SCIProfileAnalyzerStorage.h"
#import "../../Networking/SCIInstagramAPI.h"
#import "../../Utils.h"
#import "../../SCIImageCache.h"
#import "../../Settings/SCISearchBarStyler.h"
#import "../../Localization/SCILocalization.h"
// IG throttles /friendships/ aggressively — 50/session + a 1.5s cushion
// between calls keeps us well inside the soft limit.
static const NSInteger kSCIPABatchCap = 50;
static const NSTimeInterval kSCIPABatchDelay = 1.5;
typedef NS_ENUM(NSInteger, SCIPASortMode) {
SCIPASortModeDefault, // original order from the snapshot
SCIPASortModeAZ, // username ascending
SCIPASortModeZA, // username descending
};
#pragma mark - Cell
@interface SCIPAUserCell : UITableViewCell
@property (nonatomic, strong) UIImageView *avatar;
@property (nonatomic, strong) UILabel *usernameLabel;
@property (nonatomic, strong) UIImageView *verifiedBadge;
@property (nonatomic, strong) UILabel *subtitleLabel;
@property (nonatomic, strong) UIButton *actionButton;
@property (nonatomic, strong) NSLayoutConstraint *usernameTrailingToButton;
@property (nonatomic, strong) NSLayoutConstraint *usernameTrailingToEdge;
@property (nonatomic, copy) void(^onActionTap)(SCIPAUserCell *);
@end
@implementation SCIPAUserCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
if (!self) return self;
self.selectionStyle = UITableViewCellSelectionStyleDefault;
_avatar = [UIImageView new];
_avatar.translatesAutoresizingMaskIntoConstraints = NO;
_avatar.backgroundColor = [UIColor secondarySystemBackgroundColor];
_avatar.layer.cornerRadius = 24;
_avatar.layer.masksToBounds = YES;
_avatar.contentMode = UIViewContentModeScaleAspectFill;
[self.contentView addSubview:_avatar];
_usernameLabel = [UILabel new];
_usernameLabel.translatesAutoresizingMaskIntoConstraints = NO;
_usernameLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
_usernameLabel.textColor = [UIColor labelColor];
[_usernameLabel setContentHuggingPriority:UILayoutPriorityDefaultHigh forAxis:UILayoutConstraintAxisHorizontal];
[_usernameLabel setContentCompressionResistancePriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal];
[self.contentView addSubview:_usernameLabel];
_verifiedBadge = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.seal.fill"]];
_verifiedBadge.translatesAutoresizingMaskIntoConstraints = NO;
_verifiedBadge.tintColor = [UIColor systemBlueColor];
_verifiedBadge.contentMode = UIViewContentModeScaleAspectFit;
_verifiedBadge.hidden = YES;
[_verifiedBadge setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
[self.contentView addSubview:_verifiedBadge];
_subtitleLabel = [UILabel new];
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
_subtitleLabel.font = [UIFont systemFontOfSize:13];
_subtitleLabel.textColor = [UIColor secondaryLabelColor];
_subtitleLabel.numberOfLines = 2;
[self.contentView addSubview:_subtitleLabel];
_actionButton = [UIButton buttonWithType:UIButtonTypeSystem];
_actionButton.translatesAutoresizingMaskIntoConstraints = NO;
_actionButton.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
_actionButton.layer.cornerRadius = 8;
_actionButton.contentEdgeInsets = UIEdgeInsetsMake(6, 14, 6, 14);
_actionButton.hidden = YES;
[_actionButton addTarget:self action:@selector(onAction) forControlEvents:UIControlEventTouchUpInside];
[_actionButton setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
[self.contentView addSubview:_actionButton];
_usernameTrailingToButton = [_verifiedBadge.trailingAnchor constraintLessThanOrEqualToAnchor:_actionButton.leadingAnchor constant:-10];
_usernameTrailingToEdge = [_verifiedBadge.trailingAnchor constraintLessThanOrEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor];
[NSLayoutConstraint activateConstraints:@[
[_avatar.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor],
[_avatar.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_avatar.widthAnchor constraintEqualToConstant:48],
[_avatar.heightAnchor constraintEqualToConstant:48],
[_usernameLabel.leadingAnchor constraintEqualToAnchor:_avatar.trailingAnchor constant:12],
[_usernameLabel.topAnchor constraintEqualToAnchor:_avatar.topAnchor constant:2],
[_verifiedBadge.leadingAnchor constraintEqualToAnchor:_usernameLabel.trailingAnchor constant:4],
[_verifiedBadge.centerYAnchor constraintEqualToAnchor:_usernameLabel.centerYAnchor],
[_verifiedBadge.widthAnchor constraintEqualToConstant:14],
[_verifiedBadge.heightAnchor constraintEqualToConstant:14],
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_usernameLabel.leadingAnchor],
[_subtitleLabel.topAnchor constraintEqualToAnchor:_usernameLabel.bottomAnchor constant:2],
[_subtitleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_actionButton.leadingAnchor constant:-10],
[_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-8],
[_actionButton.trailingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor],
[_actionButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
_usernameTrailingToButton,
]];
return self;
}
- (void)setActionVisible:(BOOL)visible {
self.actionButton.hidden = !visible;
self.usernameTrailingToButton.active = visible;
self.usernameTrailingToEdge.active = !visible;
}
- (void)onAction { if (self.onActionTap) self.onActionTap(self); }
- (void)prepareForReuse {
[super prepareForReuse];
self.avatar.image = nil;
self.onActionTap = nil;
self.verifiedBadge.hidden = YES;
}
@end
#pragma mark - VC
@interface SCIProfileAnalyzerListViewController () <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating, UISearchControllerDelegate>
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *allUsers;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *filteredUsers;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerProfileChange *> *allChanges;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerProfileChange *> *filteredChanges;
@property (nonatomic, assign) SCIPAListKind kind;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UISearchController *searchController;
@property (nonatomic, strong) UILabel *emptyLabel;
@property (nonatomic, strong) NSMutableSet<NSString *> *pendingPKs;
// Multi-select state
@property (nonatomic, assign) BOOL selectionMode;
@property (nonatomic, strong) NSMutableSet<NSString *> *selectedPKs;
@property (nonatomic, strong) UIView *batchBar;
@property (nonatomic, strong) UIButton *batchActionButton;
// Filter / sort state
@property (nonatomic, assign) SCIPASortMode sortMode;
@property (nonatomic, assign) BOOL filterVerifiedOnly;
@property (nonatomic, assign) BOOL filterNotVerifiedOnly;
@property (nonatomic, assign) BOOL filterPrivateOnly;
@property (nonatomic, copy) NSString *currentQuery;
@end
@implementation SCIProfileAnalyzerListViewController
- (instancetype)initWithTitle:(NSString *)title
users:(NSArray<SCIProfileAnalyzerUser *> *)users
kind:(SCIPAListKind)kind {
self = [super init];
if (!self) return self;
self.title = title;
self.kind = kind;
self.allUsers = users ?: @[];
self.filteredUsers = self.allUsers;
self.pendingPKs = [NSMutableSet set];
self.selectedPKs = [NSMutableSet set];
return self;
}
- (instancetype)initWithTitle:(NSString *)title
profileUpdates:(NSArray<SCIProfileAnalyzerProfileChange *> *)updates {
self = [super init];
if (!self) return self;
self.title = title;
self.kind = SCIPAListKindProfileUpdate;
self.allChanges = updates ?: @[];
self.filteredChanges = self.allChanges;
self.pendingPKs = [NSMutableSet set];
self.selectedPKs = [NSMutableSet set];
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemBackgroundColor];
[self setupTable];
[self setupSearch];
[self setupEmptyState];
[self setupBatchBar];
[self updateNavBar];
[self refreshCounts];
}
- (void)setupTable {
self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.rowHeight = 72;
self.tableView.separatorInset = UIEdgeInsetsMake(0, 78, 0, 0);
self.tableView.allowsMultipleSelection = NO;
[self.tableView registerClass:[SCIPAUserCell class] forCellReuseIdentifier:@"cell"];
[self.view addSubview:self.tableView];
}
- (void)setupSearch {
self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil];
self.searchController.searchResultsUpdater = self;
self.searchController.delegate = self;
self.searchController.obscuresBackgroundDuringPresentation = NO;
self.searchController.searchBar.placeholder = SCILocalized(@"Search username or name");
self.navigationItem.searchController = self.searchController;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.definesPresentationContext = YES;
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[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];
});
}
- (void)setupEmptyState {
self.emptyLabel = [UILabel new];
self.emptyLabel.text = SCILocalized(@"No results");
self.emptyLabel.textColor = [UIColor tertiaryLabelColor];
self.emptyLabel.font = [UIFont systemFontOfSize:15];
self.emptyLabel.textAlignment = NSTextAlignmentCenter;
self.emptyLabel.hidden = YES;
self.emptyLabel.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.emptyLabel];
[NSLayoutConstraint activateConstraints:@[
[self.emptyLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[self.emptyLabel.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor constant:-40],
]];
}
- (void)setupBatchBar {
// Floating capsule above the home indicator.
self.batchActionButton = [UIButton buttonWithType:UIButtonTypeSystem];
self.batchActionButton.translatesAutoresizingMaskIntoConstraints = NO;
self.batchActionButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
[self.batchActionButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
self.batchActionButton.backgroundColor = [UIColor systemRedColor];
self.batchActionButton.layer.cornerRadius = 26;
self.batchActionButton.contentEdgeInsets = UIEdgeInsetsMake(0, 28, 0, 28);
self.batchActionButton.layer.shadowColor = UIColor.blackColor.CGColor;
self.batchActionButton.layer.shadowOffset = CGSizeMake(0, 6);
self.batchActionButton.layer.shadowOpacity = 0.22;
self.batchActionButton.layer.shadowRadius = 12;
[self.batchActionButton addTarget:self action:@selector(batchActionTapped) forControlEvents:UIControlEventTouchUpInside];
self.batchActionButton.hidden = YES;
[self.view addSubview:self.batchActionButton];
self.batchBar = self.batchActionButton;
[NSLayoutConstraint activateConstraints:@[
[self.batchActionButton.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[self.batchActionButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-16],
[self.batchActionButton.heightAnchor constraintEqualToConstant:52],
[self.batchActionButton.widthAnchor constraintGreaterThanOrEqualToConstant:220],
[self.batchActionButton.widthAnchor constraintLessThanOrEqualToAnchor:self.view.widthAnchor constant:-40],
]];
}
- (BOOL)supportsBatchAction {
return self.kind == SCIPAListKindUnfollow || self.kind == SCIPAListKindFollow;
}
- (void)updateNavBar {
NSMutableArray *rights = [NSMutableArray array];
if (self.supportsBatchAction) {
NSString *t = self.selectionMode ? SCILocalized(@"Done") : SCILocalized(@"Select");
UIBarButtonItem *sel = [[UIBarButtonItem alloc] initWithTitle:t
style:UIBarButtonItemStylePlain
target:self action:@selector(toggleSelectionMode)];
[rights addObject:sel];
}
// Filled variant signals "filter/sort active".
NSString *symbol = [self hasActiveFilterOrSort]
? @"line.3.horizontal.decrease.circle.fill"
: @"line.3.horizontal.decrease.circle";
UIBarButtonItem *filter = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:symbol]
menu:[self buildFilterMenu]];
[rights addObject:filter];
self.navigationItem.rightBarButtonItems = rights;
}
- (UIMenu *)buildFilterMenu {
__weak typeof(self) weakSelf = self;
UIAction *az = [UIAction actionWithTitle:SCILocalized(@"Username A → Z")
image:[UIImage systemImageNamed:@"arrow.up"]
identifier:nil
handler:^(__kindof UIAction *_) {
weakSelf.sortMode = weakSelf.sortMode == SCIPASortModeAZ ? SCIPASortModeDefault : SCIPASortModeAZ;
[weakSelf applyFiltersAndSort];
}];
az.state = (self.sortMode == SCIPASortModeAZ) ? UIMenuElementStateOn : UIMenuElementStateOff;
UIAction *za = [UIAction actionWithTitle:SCILocalized(@"Username Z → A")
image:[UIImage systemImageNamed:@"arrow.down"]
identifier:nil
handler:^(__kindof UIAction *_) {
weakSelf.sortMode = weakSelf.sortMode == SCIPASortModeZA ? SCIPASortModeDefault : SCIPASortModeZA;
[weakSelf applyFiltersAndSort];
}];
za.state = (self.sortMode == SCIPASortModeZA) ? UIMenuElementStateOn : UIMenuElementStateOff;
UIMenu *sortGroup = [UIMenu menuWithTitle:SCILocalized(@"Sort")
image:nil identifier:nil
options:UIMenuOptionsDisplayInline
children:@[az, za]];
UIAction *verified = [UIAction actionWithTitle:SCILocalized(@"Verified only")
image:[UIImage systemImageNamed:@"checkmark.seal.fill"]
identifier:nil
handler:^(__kindof UIAction *_) {
weakSelf.filterVerifiedOnly = !weakSelf.filterVerifiedOnly;
if (weakSelf.filterVerifiedOnly) weakSelf.filterNotVerifiedOnly = NO;
[weakSelf applyFiltersAndSort];
}];
verified.state = self.filterVerifiedOnly ? UIMenuElementStateOn : UIMenuElementStateOff;
UIAction *notVerified = [UIAction actionWithTitle:SCILocalized(@"Not verified only")
image:[UIImage systemImageNamed:@"seal"]
identifier:nil
handler:^(__kindof UIAction *_) {
weakSelf.filterNotVerifiedOnly = !weakSelf.filterNotVerifiedOnly;
if (weakSelf.filterNotVerifiedOnly) weakSelf.filterVerifiedOnly = NO;
[weakSelf applyFiltersAndSort];
}];
notVerified.state = self.filterNotVerifiedOnly ? UIMenuElementStateOn : UIMenuElementStateOff;
UIAction *priv = [UIAction actionWithTitle:SCILocalized(@"Private only")
image:[UIImage systemImageNamed:@"lock.fill"]
identifier:nil
handler:^(__kindof UIAction *_) {
weakSelf.filterPrivateOnly = !weakSelf.filterPrivateOnly;
[weakSelf applyFiltersAndSort];
}];
priv.state = self.filterPrivateOnly ? UIMenuElementStateOn : UIMenuElementStateOff;
UIMenu *filterGroup = [UIMenu menuWithTitle:SCILocalized(@"Filter")
image:nil identifier:nil
options:UIMenuOptionsDisplayInline
children:@[verified, notVerified, priv]];
NSMutableArray *children = [NSMutableArray arrayWithObjects:sortGroup, filterGroup, nil];
if ([self hasActiveFilterOrSort]) {
UIAction *clear = [UIAction actionWithTitle:SCILocalized(@"Clear")
image:[UIImage systemImageNamed:@"arrow.counterclockwise"]
identifier:nil
handler:^(__kindof UIAction *_) {
weakSelf.sortMode = SCIPASortModeDefault;
weakSelf.filterVerifiedOnly = NO;
weakSelf.filterNotVerifiedOnly = NO;
weakSelf.filterPrivateOnly = NO;
[weakSelf applyFiltersAndSort];
}];
clear.attributes = UIMenuElementAttributesDestructive;
[children addObject:[UIMenu menuWithTitle:@"" image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:@[clear]]];
}
return [UIMenu menuWithChildren:children];
}
- (void)refreshCounts {
NSUInteger total = self.kind == SCIPAListKindProfileUpdate ? self.allChanges.count : self.allUsers.count;
NSUInteger shown = self.kind == SCIPAListKindProfileUpdate ? self.filteredChanges.count : self.filteredUsers.count;
self.navigationItem.prompt = [NSString stringWithFormat:SCILocalized(@"%lu of %lu"),
(unsigned long)shown, (unsigned long)total];
self.emptyLabel.hidden = shown > 0;
}
#pragma mark - Search
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
self.currentQuery = [searchController.searchBar.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
[self applyFiltersAndSort];
}
// Pipeline: search → verified/private filter → sort.
- (void)applyFiltersAndSort {
NSString *q = self.currentQuery;
BOOL hasQuery = q.length > 0;
BOOL verified = self.filterVerifiedOnly;
BOOL notVerified = self.filterNotVerifiedOnly;
BOOL priv = self.filterPrivateOnly;
NSArray *(^applyToUsers)(NSArray *) = ^NSArray *(NSArray *src) {
NSMutableArray *out = [NSMutableArray arrayWithCapacity:src.count];
for (SCIProfileAnalyzerUser *u in src) {
if (hasQuery && ![u.username localizedCaseInsensitiveContainsString:q]
&& ![u.fullName localizedCaseInsensitiveContainsString:q]) continue;
if (verified && !u.isVerified) continue;
if (notVerified && u.isVerified) continue;
if (priv && !u.isPrivate) continue;
[out addObject:u];
}
return [self sortUsers:out];
};
if (self.kind == SCIPAListKindProfileUpdate) {
NSMutableArray *out = [NSMutableArray arrayWithCapacity:self.allChanges.count];
for (SCIProfileAnalyzerProfileChange *c in self.allChanges) {
SCIProfileAnalyzerUser *u = c.current;
if (hasQuery && ![u.username localizedCaseInsensitiveContainsString:q]
&& ![u.fullName localizedCaseInsensitiveContainsString:q]) continue;
if (verified && !u.isVerified) continue;
if (notVerified && u.isVerified) continue;
if (priv && !u.isPrivate) continue;
[out addObject:c];
}
self.filteredChanges = [self sortChanges:out];
} else {
self.filteredUsers = applyToUsers(self.allUsers);
}
[self refreshCounts];
[self updateNavBar]; // refresh filter-icon "active" state
[self.tableView reloadData];
}
- (NSArray *)sortUsers:(NSArray<SCIProfileAnalyzerUser *> *)src {
if (self.sortMode == SCIPASortModeDefault) return src;
BOOL asc = (self.sortMode == SCIPASortModeAZ);
return [src sortedArrayUsingComparator:^NSComparisonResult(SCIProfileAnalyzerUser *a, SCIProfileAnalyzerUser *b) {
NSComparisonResult r = [a.username caseInsensitiveCompare:b.username ?: @""];
return asc ? r : -r;
}];
}
- (NSArray *)sortChanges:(NSArray<SCIProfileAnalyzerProfileChange *> *)src {
if (self.sortMode == SCIPASortModeDefault) return src;
BOOL asc = (self.sortMode == SCIPASortModeAZ);
return [src sortedArrayUsingComparator:^NSComparisonResult(SCIProfileAnalyzerProfileChange *a, SCIProfileAnalyzerProfileChange *b) {
NSComparisonResult r = [a.current.username caseInsensitiveCompare:b.current.username ?: @""];
return asc ? r : -r;
}];
}
- (BOOL)hasActiveFilterOrSort {
return self.filterVerifiedOnly || self.filterNotVerifiedOnly || self.filterPrivateOnly || self.sortMode != SCIPASortModeDefault;
}
#pragma mark - Table
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return self.kind == SCIPAListKindProfileUpdate ? self.filteredChanges.count : self.filteredUsers.count;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
SCIPAUserCell *cell = [tv dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
SCIProfileAnalyzerUser *user;
SCIProfileAnalyzerProfileChange *change = nil;
if (self.kind == SCIPAListKindProfileUpdate) {
change = self.filteredChanges[indexPath.row];
user = change.current;
} else {
user = self.filteredUsers[indexPath.row];
}
cell.usernameLabel.text = user.username.length ? [NSString stringWithFormat:@"@%@", user.username] : @"(unknown)";
cell.verifiedBadge.hidden = !user.isVerified;
if (self.kind == SCIPAListKindProfileUpdate) {
NSMutableArray *lines = [NSMutableArray array];
if (change.usernameChanged) {
[lines addObject:[NSString stringWithFormat:SCILocalized(@"Username: @%@ → @%@"),
change.previous.username ?: @"", change.current.username ?: @""]];
}
if (change.fullNameChanged) {
[lines addObject:[NSString stringWithFormat:SCILocalized(@"Name: %@ → %@"),
change.previous.fullName.length ? change.previous.fullName : @"",
change.current.fullName.length ? change.current.fullName : @""]];
}
if (change.profilePicChanged) [lines addObject:SCILocalized(@"Profile picture changed")];
cell.subtitleLabel.text = [lines componentsJoinedByString:@"\n"];
cell.subtitleLabel.numberOfLines = 3;
} else {
cell.subtitleLabel.text = user.fullName.length ? user.fullName : (user.isPrivate ? SCILocalized(@"Private account") : @"");
cell.subtitleLabel.numberOfLines = 1;
}
[self configureActionForCell:cell user:user];
// Selection-mode checkmark affordance
if (self.selectionMode) {
BOOL on = [self.selectedPKs containsObject:user.pk];
cell.accessoryType = on ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone;
} else {
cell.accessoryType = UITableViewCellAccessoryNone;
}
if (user.profilePicURL.length) {
NSURL *url = [NSURL URLWithString:user.profilePicURL];
NSString *pkTag = user.pk;
cell.avatar.tag = pkTag.hash;
[SCIImageCache loadImageFromURL:url completion:^(UIImage *image) {
if (cell.avatar.tag == (NSInteger)pkTag.hash) cell.avatar.image = image;
}];
} else {
cell.avatar.image = [UIImage systemImageNamed:@"person.circle.fill"];
cell.avatar.tintColor = [UIColor systemGrayColor];
}
return cell;
}
- (void)configureActionForCell:(SCIPAUserCell *)cell user:(SCIProfileAnalyzerUser *)user {
BOOL hasButton = !self.selectionMode
&& (self.kind == SCIPAListKindFollow || self.kind == SCIPAListKindUnfollow);
[cell setActionVisible:hasButton];
if (!hasButton) return;
BOOL pending = [self.pendingPKs containsObject:user.pk];
if (self.kind == SCIPAListKindUnfollow) {
[cell.actionButton setTitle:SCILocalized(@"Unfollow") forState:UIControlStateNormal];
cell.actionButton.backgroundColor = [[UIColor systemRedColor] colorWithAlphaComponent:0.12];
[cell.actionButton setTitleColor:[UIColor systemRedColor] forState:UIControlStateNormal];
} else {
[cell.actionButton setTitle:SCILocalized(@"Follow") forState:UIControlStateNormal];
cell.actionButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
[cell.actionButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
}
cell.actionButton.enabled = !pending;
cell.actionButton.alpha = pending ? 0.5 : 1.0;
__weak typeof(self) weakSelf = self;
cell.onActionTap = ^(SCIPAUserCell *c) { [weakSelf performActionForUser:user]; };
}
#pragma mark - Single-row action
- (void)performActionForUser:(SCIProfileAnalyzerUser *)user {
if ([self.pendingPKs containsObject:user.pk]) return;
if (self.kind == SCIPAListKindUnfollow) {
NSString *msg = [NSString stringWithFormat:SCILocalized(@"Unfollow @%@?"), user.username ?: @""];
UIAlertController *a = [UIAlertController alertControllerWithTitle:nil message:msg preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Unfollow") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[self sendFriendshipForUser:user follow:NO reload:YES];
}]];
[self presentViewController:a animated:YES completion:nil];
} else {
[self sendFriendshipForUser:user follow:YES reload:YES];
}
}
- (void)sendFriendshipForUser:(SCIProfileAnalyzerUser *)user follow:(BOOL)follow reload:(BOOL)reload {
[self.pendingPKs addObject:user.pk];
if (reload) [self.tableView reloadData];
void(^done)(NSDictionary *, NSError *) = ^(NSDictionary *resp, NSError *err) {
[self.pendingPKs removeObject:user.pk];
BOOL success = (err == nil) && ([resp[@"status"] isEqualToString:@"ok"] || resp[@"friendship_status"]);
if (success) {
[self persistFriendshipChangeForUser:user followed:follow];
[self removeUserFromList:user];
} else {
[SCIUtils showErrorHUDWithDescription:err.localizedDescription ?: SCILocalized(@"Request failed")];
[self.tableView reloadData];
}
};
if (follow) [SCIInstagramAPI followUserPK:user.pk completion:done];
else [SCIInstagramAPI unfollowUserPK:user.pk completion:done];
}
// Mirror in-app follow/unfollow into the cached snapshot so category counts
// + header stats update live without a rescan.
- (void)persistFriendshipChangeForUser:(SCIProfileAnalyzerUser *)user followed:(BOOL)followed {
NSString *pk = [SCIUtils currentUserPK];
SCIProfileAnalyzerSnapshot *snap = [SCIProfileAnalyzerStorage currentSnapshotForUserPK:pk];
if (!snap) return;
NSMutableArray *following = [snap.following mutableCopy] ?: [NSMutableArray array];
BOOL alreadyIn = [following containsObject:user];
if (followed && !alreadyIn) {
[following addObject:user];
snap.followingCount = MAX(0, snap.followingCount + 1);
} else if (!followed && alreadyIn) {
[following removeObject:user];
snap.followingCount = MAX(0, snap.followingCount - 1);
} else {
return;
}
snap.following = following;
[SCIProfileAnalyzerStorage updateCurrentSnapshot:snap forUserPK:pk];
}
- (void)removeUserFromList:(SCIProfileAnalyzerUser *)user {
NSMutableArray *all = [self.allUsers mutableCopy];
[all removeObject:user];
self.allUsers = all;
NSMutableArray *filt = [self.filteredUsers mutableCopy];
[filt removeObject:user];
self.filteredUsers = filt;
[self.selectedPKs removeObject:user.pk];
[self refreshCounts];
[self.tableView reloadData];
}
#pragma mark - Tap row
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tv deselectRowAtIndexPath:indexPath animated:YES];
SCIProfileAnalyzerUser *user = self.kind == SCIPAListKindProfileUpdate
? self.filteredChanges[indexPath.row].current
: self.filteredUsers[indexPath.row];
if (self.selectionMode) {
if ([self.selectedPKs containsObject:user.pk]) [self.selectedPKs removeObject:user.pk];
else [self.selectedPKs addObject:user.pk];
[self refreshBatchBar];
[tv reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
return;
}
if (!user.username.length) return;
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", user.username]];
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}
#pragma mark - Multi-select
- (void)toggleSelectionMode {
self.selectionMode = !self.selectionMode;
[self.selectedPKs removeAllObjects];
self.batchActionButton.hidden = !self.selectionMode;
// Leave room for the capsule so last-row cells don't sit under it.
self.tableView.contentInset = UIEdgeInsetsMake(0, 0, self.selectionMode ? 96 : 0, 0);
[self updateNavBar];
[self refreshBatchBar];
[self.tableView reloadData];
}
- (void)refreshBatchBar {
NSUInteger n = self.selectedPKs.count;
BOOL follow = (self.kind == SCIPAListKindFollow);
NSString *t = follow
? [NSString stringWithFormat:SCILocalized(@"Follow %lu"), (unsigned long)n]
: [NSString stringWithFormat:SCILocalized(@"Unfollow %lu"), (unsigned long)n];
[self.batchActionButton setTitle:t forState:UIControlStateNormal];
self.batchActionButton.backgroundColor = follow
? ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor])
: [UIColor systemRedColor];
self.batchActionButton.enabled = n > 0;
self.batchActionButton.alpha = n > 0 ? 1.0 : 0.5;
}
- (void)batchActionTapped {
NSUInteger n = self.selectedPKs.count;
if (!n) return;
BOOL follow = (self.kind == SCIPAListKindFollow);
NSString *verb = follow ? SCILocalized(@"Follow") : SCILocalized(@"Unfollow");
NSString *title = follow ? SCILocalized(@"Batch follow") : SCILocalized(@"Batch unfollow");
NSString *msg;
if (n > kSCIPABatchCap) {
msg = [NSString stringWithFormat:SCILocalized(@"%@ %lu accounts? The first %ld will be processed to avoid rate limits."),
verb, (unsigned long)n, (long)kSCIPABatchCap];
} else {
msg = [NSString stringWithFormat:SCILocalized(@"%@ %lu accounts? This runs sequentially with a short pause between each."),
verb, (unsigned long)n];
}
UIAlertController *a = [UIAlertController alertControllerWithTitle:title
message:msg preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
UIAlertActionStyle style = follow ? UIAlertActionStyleDefault : UIAlertActionStyleDestructive;
[a addAction:[UIAlertAction actionWithTitle:verb style:style handler:^(UIAlertAction *_) {
[self runBatchAction];
}]];
[self presentViewController:a animated:YES completion:nil];
}
- (void)runBatchAction {
NSMutableArray<SCIProfileAnalyzerUser *> *queue = [NSMutableArray array];
for (SCIProfileAnalyzerUser *u in self.allUsers) {
if ([self.selectedPKs containsObject:u.pk]) [queue addObject:u];
if (queue.count >= kSCIPABatchCap) break;
}
[self.selectedPKs removeAllObjects];
[self refreshBatchBar];
[self batchStep:queue done:0 total:queue.count];
}
- (void)batchStep:(NSMutableArray<SCIProfileAnalyzerUser *> *)queue
done:(NSUInteger)done
total:(NSUInteger)total {
BOOL follow = (self.kind == SCIPAListKindFollow);
if (!queue.count) {
NSString *finishedTitle = follow ? SCILocalized(@"Batch follow finished") : SCILocalized(@"Batch unfollow finished");
NSString *finishedSub = follow
? [NSString stringWithFormat:SCILocalized(@"%lu accounts followed"), (unsigned long)total]
: [NSString stringWithFormat:SCILocalized(@"%lu accounts unfollowed"), (unsigned long)total];
[SCIUtils showToastForDuration:2.0 title:finishedTitle subtitle:finishedSub];
self.navigationItem.prompt = nil;
[self toggleSelectionMode];
[self refreshCounts];
return;
}
SCIProfileAnalyzerUser *u = queue.firstObject;
[queue removeObjectAtIndex:0];
__weak typeof(self) weakSelf = self;
void(^handler)(NSDictionary *, NSError *) = ^(NSDictionary *resp, NSError *err) {
typeof(self) strongSelf = weakSelf;
if (!strongSelf) return;
NSUInteger nextDone = done + 1;
BOOL ok = (err == nil) && ([resp[@"status"] isEqualToString:@"ok"] || resp[@"friendship_status"]);
if (ok) {
[strongSelf persistFriendshipChangeForUser:u followed:follow];
[strongSelf removeUserFromList:u];
}
NSString *progressFmt = follow ? SCILocalized(@"Following… %lu / %lu") : SCILocalized(@"Unfollowing… %lu / %lu");
strongSelf.navigationItem.prompt = [NSString stringWithFormat:progressFmt,
(unsigned long)nextDone, (unsigned long)total];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSCIPABatchDelay * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[weakSelf batchStep:queue done:nextDone total:total];
});
};
if (follow) [SCIInstagramAPI followUserPK:u.pk completion:handler];
else [SCIInstagramAPI unfollowUserPK:u.pk completion:handler];
}
@end
@@ -0,0 +1,74 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
// Lightweight user record — what we cache per follower/following entry.
@interface SCIProfileAnalyzerUser : NSObject <NSCopying>
@property (nonatomic, copy) NSString *pk;
@property (nonatomic, copy) NSString *username;
@property (nonatomic, copy, nullable) NSString *fullName;
@property (nonatomic, copy, nullable) NSString *profilePicURL;
// Stable IG-internal ID of the current profile picture — changes only when
// the user uploads a new one. Used for reliable change detection.
@property (nonatomic, copy, nullable) NSString *profilePicID;
@property (nonatomic, assign) BOOL isPrivate;
@property (nonatomic, assign) BOOL isVerified;
+ (nullable instancetype)userFromAPIDict:(NSDictionary *)dict;
+ (nullable instancetype)userFromJSONDict:(NSDictionary *)dict;
- (NSDictionary *)toJSONDict;
@end
// One-point-in-time capture of an account's graph + self info. Persisted
// to disk as JSON; diffs between snapshots produce the report categories.
@interface SCIProfileAnalyzerSnapshot : NSObject
@property (nonatomic, strong) NSDate *scanDate;
@property (nonatomic, copy) NSString *selfPK;
@property (nonatomic, copy, nullable) NSString *selfUsername;
@property (nonatomic, copy, nullable) NSString *selfFullName;
@property (nonatomic, copy, nullable) NSString *selfProfilePicURL;
@property (nonatomic, assign) NSInteger followerCount;
@property (nonatomic, assign) NSInteger followingCount;
@property (nonatomic, assign) NSInteger mediaCount;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *followers;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *following;
+ (nullable instancetype)snapshotFromJSONDict:(NSDictionary *)dict;
- (NSDictionary *)toJSONDict;
@end
// Per-profile change entry (username/fullName/pic edited since last scan).
@interface SCIProfileAnalyzerProfileChange : NSObject
@property (nonatomic, strong) SCIProfileAnalyzerUser *previous;
@property (nonatomic, strong) SCIProfileAnalyzerUser *current;
@property (nonatomic, readonly) BOOL usernameChanged;
@property (nonatomic, readonly) BOOL fullNameChanged;
@property (nonatomic, readonly) BOOL profilePicChanged;
@end
// Derived category arrays, computed from (current, previous) snapshots.
@interface SCIProfileAnalyzerReport : NSObject
@property (nonatomic, strong, nullable) SCIProfileAnalyzerSnapshot *current;
@property (nonatomic, strong, nullable) SCIProfileAnalyzerSnapshot *previous;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *mutualFollowers;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *notFollowingYouBack;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *youDontFollowBack;
// `new*` getters are reserved by ARC's Cocoa new-family rule, hence the name.
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *recentFollowers;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *lostFollowers;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *youStartedFollowing;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *youUnfollowed;
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerProfileChange *> *profileUpdates;
+ (SCIProfileAnalyzerReport *)reportFromCurrent:(nullable SCIProfileAnalyzerSnapshot *)current
previous:(nullable SCIProfileAnalyzerSnapshot *)previous;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,212 @@
#import "SCIProfileAnalyzerModels.h"
#pragma mark - User
@implementation SCIProfileAnalyzerUser
+ (instancetype)userFromAPIDict:(NSDictionary *)d {
id pkRaw = d[@"pk"] ?: d[@"pk_id"] ?: d[@"id"];
NSString *pk = [pkRaw isKindOfClass:[NSString class]] ? pkRaw
: [pkRaw respondsToSelector:@selector(stringValue)] ? [pkRaw stringValue] : nil;
if (!pk.length) return nil;
SCIProfileAnalyzerUser *u = [self new];
u.pk = pk;
u.username = [d[@"username"] isKindOfClass:[NSString class]] ? d[@"username"] : @"";
u.fullName = [d[@"full_name"] isKindOfClass:[NSString class]] ? d[@"full_name"] : nil;
u.profilePicURL = [d[@"profile_pic_url"] isKindOfClass:[NSString class]] ? d[@"profile_pic_url"] : nil;
id pid = d[@"profile_pic_id"];
if ([pid isKindOfClass:[NSString class]]) u.profilePicID = pid;
else if ([pid respondsToSelector:@selector(stringValue)]) u.profilePicID = [pid stringValue];
u.isPrivate = [d[@"is_private"] boolValue];
u.isVerified = [d[@"is_verified"] boolValue];
return u;
}
+ (instancetype)userFromJSONDict:(NSDictionary *)d {
if (![d[@"pk"] isKindOfClass:[NSString class]]) return nil;
SCIProfileAnalyzerUser *u = [self new];
u.pk = d[@"pk"];
u.username = d[@"username"] ?: @"";
u.fullName = d[@"full_name"];
u.profilePicURL = d[@"profile_pic_url"];
u.profilePicID = d[@"profile_pic_id"];
u.isPrivate = [d[@"is_private"] boolValue];
u.isVerified = [d[@"is_verified"] boolValue];
return u;
}
- (NSDictionary *)toJSONDict {
NSMutableDictionary *d = [NSMutableDictionary dictionary];
d[@"pk"] = self.pk ?: @"";
d[@"username"] = self.username ?: @"";
if (self.fullName) d[@"full_name"] = self.fullName;
if (self.profilePicURL) d[@"profile_pic_url"] = self.profilePicURL;
if (self.profilePicID) d[@"profile_pic_id"] = self.profilePicID;
d[@"is_private"] = @(self.isPrivate);
d[@"is_verified"] = @(self.isVerified);
return d;
}
- (id)copyWithZone:(NSZone *)zone {
SCIProfileAnalyzerUser *u = [SCIProfileAnalyzerUser new];
u.pk = self.pk;
u.username = self.username;
u.fullName = self.fullName;
u.profilePicURL = self.profilePicURL;
u.profilePicID = self.profilePicID;
u.isPrivate = self.isPrivate;
u.isVerified = self.isVerified;
return u;
}
- (NSUInteger)hash { return self.pk.hash; }
- (BOOL)isEqual:(id)other {
if (![other isKindOfClass:[SCIProfileAnalyzerUser class]]) return NO;
return [self.pk isEqualToString:((SCIProfileAnalyzerUser *)other).pk];
}
@end
#pragma mark - Snapshot
@implementation SCIProfileAnalyzerSnapshot
+ (instancetype)snapshotFromJSONDict:(NSDictionary *)d {
if (!d[@"self_pk"]) return nil;
SCIProfileAnalyzerSnapshot *s = [self new];
s.scanDate = [NSDate dateWithTimeIntervalSince1970:[d[@"scan_date"] doubleValue]];
s.selfPK = d[@"self_pk"];
s.selfUsername = d[@"self_username"];
s.selfFullName = d[@"self_full_name"];
s.selfProfilePicURL = d[@"self_profile_pic_url"];
s.followerCount = [d[@"follower_count"] integerValue];
s.followingCount = [d[@"following_count"] integerValue];
s.mediaCount = [d[@"media_count"] integerValue];
NSMutableArray *f = [NSMutableArray array];
for (NSDictionary *u in d[@"followers"]) {
SCIProfileAnalyzerUser *user = [SCIProfileAnalyzerUser userFromJSONDict:u];
if (user) [f addObject:user];
}
s.followers = f;
NSMutableArray *g = [NSMutableArray array];
for (NSDictionary *u in d[@"following"]) {
SCIProfileAnalyzerUser *user = [SCIProfileAnalyzerUser userFromJSONDict:u];
if (user) [g addObject:user];
}
s.following = g;
return s;
}
- (NSDictionary *)toJSONDict {
NSMutableArray *f = [NSMutableArray arrayWithCapacity:self.followers.count];
for (SCIProfileAnalyzerUser *u in self.followers) [f addObject:[u toJSONDict]];
NSMutableArray *g = [NSMutableArray arrayWithCapacity:self.following.count];
for (SCIProfileAnalyzerUser *u in self.following) [g addObject:[u toJSONDict]];
return @{
@"scan_date": @([self.scanDate timeIntervalSince1970]),
@"self_pk": self.selfPK ?: @"",
@"self_username": self.selfUsername ?: @"",
@"self_full_name": self.selfFullName ?: @"",
@"self_profile_pic_url": self.selfProfilePicURL ?: @"",
@"follower_count": @(self.followerCount),
@"following_count": @(self.followingCount),
@"media_count": @(self.mediaCount),
@"followers": f,
@"following": g,
};
}
@end
#pragma mark - Profile change
@implementation SCIProfileAnalyzerProfileChange
- (BOOL)usernameChanged { return ![self.previous.username isEqualToString:self.current.username]; }
- (BOOL)fullNameChanged { return ![(self.previous.fullName ?: @"") isEqualToString:(self.current.fullName ?: @"")]; }
// Compare profile_pic_id (stable per pic; changes only on upload). URL
// diffing was unusable — IG rotates the CDN host + path hash per request.
// Skip when either side is missing the id (old snapshots pre-feature).
- (BOOL)profilePicChanged {
NSString *a = self.previous.profilePicID;
NSString *b = self.current.profilePicID;
if (!a.length || !b.length) return NO;
return ![a isEqualToString:b];
}
@end
#pragma mark - Report
@implementation SCIProfileAnalyzerReport
static NSArray *sciSubtract(NSArray *a, NSSet *bSet) {
if (!a.count) return @[];
NSMutableArray *out = [NSMutableArray arrayWithCapacity:a.count];
for (SCIProfileAnalyzerUser *u in a) if (![bSet containsObject:u]) [out addObject:u];
return out;
}
static NSArray *sciIntersect(NSArray *a, NSSet *bSet) {
if (!a.count) return @[];
NSMutableArray *out = [NSMutableArray arrayWithCapacity:a.count];
for (SCIProfileAnalyzerUser *u in a) if ([bSet containsObject:u]) [out addObject:u];
return out;
}
+ (SCIProfileAnalyzerReport *)reportFromCurrent:(SCIProfileAnalyzerSnapshot *)current
previous:(SCIProfileAnalyzerSnapshot *)previous {
SCIProfileAnalyzerReport *r = [self new];
r.current = current;
r.previous = previous;
r.mutualFollowers = @[];
r.notFollowingYouBack = @[];
r.youDontFollowBack = @[];
r.recentFollowers = @[];
r.lostFollowers = @[];
r.youStartedFollowing = @[];
r.youUnfollowed = @[];
r.profileUpdates = @[];
if (!current) return r;
NSSet *followersSet = [NSSet setWithArray:current.followers];
NSSet *followingSet = [NSSet setWithArray:current.following];
r.mutualFollowers = sciIntersect(current.followers, followingSet);
r.notFollowingYouBack = sciSubtract(current.following, followersSet);
r.youDontFollowBack = sciSubtract(current.followers, followingSet);
if (previous) {
NSSet *prevFollowers = [NSSet setWithArray:previous.followers];
NSSet *prevFollowing = [NSSet setWithArray:previous.following];
r.recentFollowers = sciSubtract(current.followers, prevFollowers);
r.lostFollowers = sciSubtract(previous.followers, followersSet);
r.youStartedFollowing = sciSubtract(current.following, prevFollowing);
r.youUnfollowed = sciSubtract(previous.following, followingSet);
// Profile updates: same pk in both snapshots, any field differs.
NSMutableDictionary *prevByPK = [NSMutableDictionary dictionary];
for (SCIProfileAnalyzerUser *u in previous.followers) prevByPK[u.pk] = u;
for (SCIProfileAnalyzerUser *u in previous.following) prevByPK[u.pk] = u;
NSMutableArray *updates = [NSMutableArray array];
NSMutableSet *seen = [NSMutableSet set];
NSArray *currentAll = [current.followers arrayByAddingObjectsFromArray:current.following];
for (SCIProfileAnalyzerUser *u in currentAll) {
if ([seen containsObject:u.pk]) continue;
[seen addObject:u.pk];
SCIProfileAnalyzerUser *prev = prevByPK[u.pk];
if (!prev) continue;
SCIProfileAnalyzerProfileChange *ch = [SCIProfileAnalyzerProfileChange new];
ch.previous = prev;
ch.current = u;
if (ch.usernameChanged || ch.fullNameChanged || ch.profilePicChanged) [updates addObject:ch];
}
r.profileUpdates = updates;
}
return r;
}
@end
@@ -0,0 +1,36 @@
#import <Foundation/Foundation.h>
#import "SCIProfileAnalyzerModels.h"
NS_ASSUME_NONNULL_BEGIN
typedef NS_ENUM(NSInteger, SCIProfileAnalyzerError) {
SCIProfileAnalyzerErrorNoSession = 1,
SCIProfileAnalyzerErrorTooManyFollowers,
SCIProfileAnalyzerErrorNetwork,
SCIProfileAnalyzerErrorCancelled,
};
// Hard cap — beyond this follower count we refuse to run. Each followers
// page returns ~25-50 users so large accounts hit IG rate limits fast.
extern const NSInteger SCIProfileAnalyzerMaxFollowerCount;
typedef void(^SCIPAProgress)(NSString *status, double fraction);
typedef void(^SCIPACompletion)(SCIProfileAnalyzerSnapshot * _Nullable snapshot, NSError * _Nullable error);
// Fires once, right after the self-user-info call returns. Lets the UI
// paint the header immediately instead of waiting for the full run to finish.
typedef void(^SCIPAHeaderInfo)(NSDictionary *userInfo);
@interface SCIProfileAnalyzerService : NSObject
@property (nonatomic, readonly) BOOL isRunning;
+ (instancetype)sharedService;
- (void)runForSelfWithHeaderInfo:(nullable SCIPAHeaderInfo)headerInfo
progress:(SCIPAProgress)progress
completion:(SCIPACompletion)completion;
- (void)cancel;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,213 @@
#import "SCIProfileAnalyzerService.h"
#import "../../Networking/SCIInstagramAPI.h"
#import "../../Utils.h"
const NSInteger SCIProfileAnalyzerMaxFollowerCount = 13000;
#define SCI_PA_PAGE_DELAY_S 0.25 // small pause between pages — lightweight rate cushion
@interface SCIProfileAnalyzerService () {
@public
NSInteger _expectedFollowers;
NSInteger _expectedFollowing;
}
@property (nonatomic, assign) BOOL cancelled;
@property (nonatomic, assign) BOOL isRunning;
@end
@implementation SCIProfileAnalyzerService
+ (instancetype)sharedService {
static SCIProfileAnalyzerService *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [self new]; });
return s;
}
- (void)cancel { self.cancelled = YES; }
- (void)finishWithSnapshot:(SCIProfileAnalyzerSnapshot *)s error:(NSError *)e completion:(SCIPACompletion)completion {
self.isRunning = NO;
self.cancelled = NO;
if (completion) dispatch_async(dispatch_get_main_queue(), ^{ completion(s, e); });
}
- (NSError *)errorWithCode:(SCIProfileAnalyzerError)code message:(NSString *)msg {
return [NSError errorWithDomain:@"SCIProfileAnalyzer" code:code
userInfo:@{ NSLocalizedDescriptionKey: msg ?: @"" }];
}
- (void)runForSelfWithHeaderInfo:(SCIPAHeaderInfo)headerInfo
progress:(SCIPAProgress)progress
completion:(SCIPACompletion)completion {
if (self.isRunning) {
if (completion) completion(nil, [self errorWithCode:SCIProfileAnalyzerErrorCancelled
message:SCILocalized(@"Another analysis is already running")]);
return;
}
self.isRunning = YES;
self.cancelled = NO;
NSString *selfPK = [SCIUtils currentUserPK];
if (!selfPK.length) {
[self finishWithSnapshot:nil
error:[self errorWithCode:SCIProfileAnalyzerErrorNoSession message:SCILocalized(@"No active Instagram session found")]
completion:completion];
return;
}
__weak typeof(self) weakSelf = self;
[self reportProgress:progress status:SCILocalized(@"Fetching profile info…") fraction:0.02];
[SCIInstagramAPI sendRequestWithMethod:@"GET"
path:[NSString stringWithFormat:@"users/%@/info/", selfPK]
body:nil
completion:^(NSDictionary *resp, NSError *error) {
typeof(self) strongSelf = weakSelf;
if (!strongSelf) return;
if (strongSelf.cancelled) {
[strongSelf finishWithSnapshot:nil error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]
completion:completion];
return;
}
NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil;
if (!user) {
[strongSelf finishWithSnapshot:nil
error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorNetwork message:SCILocalized(@"Couldn't fetch profile information")]
completion:completion];
return;
}
NSInteger followerCount = [user[@"follower_count"] integerValue];
if (followerCount > SCIProfileAnalyzerMaxFollowerCount) {
[strongSelf finishWithSnapshot:nil
error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorTooManyFollowers
message:SCILocalized(@"Too many followers to analyze")]
completion:completion];
return;
}
SCIProfileAnalyzerSnapshot *snap = [SCIProfileAnalyzerSnapshot new];
snap.selfPK = selfPK;
snap.selfUsername = user[@"username"];
snap.selfFullName = user[@"full_name"];
snap.selfProfilePicURL = user[@"profile_pic_url"];
snap.followerCount = followerCount;
snap.followingCount = [user[@"following_count"] integerValue];
snap.mediaCount = [user[@"media_count"] integerValue];
snap.scanDate = [NSDate date];
strongSelf->_expectedFollowers = followerCount;
strongSelf->_expectedFollowing = snap.followingCount;
if (headerInfo) dispatch_async(dispatch_get_main_queue(), ^{ headerInfo(user); });
[strongSelf fetchFollowersForPK:selfPK snapshot:snap progress:progress completion:completion];
}];
}
- (void)reportProgress:(SCIPAProgress)p status:(NSString *)s fraction:(double)f {
if (!p) return;
dispatch_async(dispatch_get_main_queue(), ^{ p(s, f); });
}
#pragma mark - Paginated fetchers
- (void)fetchFollowersForPK:(NSString *)pk
snapshot:(SCIProfileAnalyzerSnapshot *)snap
progress:(SCIPAProgress)progress
completion:(SCIPACompletion)completion {
NSMutableArray *acc = [NSMutableArray array];
[self pagePath:[NSString stringWithFormat:@"friendships/%@/followers/", pk]
acc:acc
maxId:nil
total:snap.followerCount
stage:@"followers"
progress:progress
completion:^(NSArray *users, NSError *error) {
if (error || self.cancelled) {
[self finishWithSnapshot:nil error:error ?: [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]
completion:completion];
return;
}
snap.followers = users;
[self fetchFollowingForPK:pk snapshot:snap progress:progress completion:completion];
}];
}
- (void)fetchFollowingForPK:(NSString *)pk
snapshot:(SCIProfileAnalyzerSnapshot *)snap
progress:(SCIPAProgress)progress
completion:(SCIPACompletion)completion {
NSMutableArray *acc = [NSMutableArray array];
[self pagePath:[NSString stringWithFormat:@"friendships/%@/following/", pk]
acc:acc
maxId:nil
total:snap.followingCount
stage:@"following"
progress:progress
completion:^(NSArray *users, NSError *error) {
if (error || self.cancelled) {
[self finishWithSnapshot:nil error:error ?: [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]
completion:completion];
return;
}
snap.following = users;
[self finishWithSnapshot:snap error:nil completion:completion];
}];
}
- (void)pagePath:(NSString *)basePath
acc:(NSMutableArray *)acc
maxId:(NSString *)maxId
total:(NSInteger)total
stage:(NSString *)stage
progress:(SCIPAProgress)progress
completion:(void(^)(NSArray *users, NSError *error))completion {
if (self.cancelled) {
completion(nil, [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]);
return;
}
NSString *path = maxId.length ? [NSString stringWithFormat:@"%@?max_id=%@", basePath, maxId] : basePath;
__weak typeof(self) weakSelf = self;
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *resp, NSError *error) {
typeof(self) strongSelf = weakSelf;
if (!strongSelf) return;
if (error) { completion(nil, [strongSelf errorWithCode:SCIProfileAnalyzerErrorNetwork message:error.localizedDescription]); return; }
NSArray *users = resp[@"users"];
if ([users isKindOfClass:[NSArray class]]) {
for (NSDictionary *d in users) {
SCIProfileAnalyzerUser *u = [SCIProfileAnalyzerUser userFromAPIDict:d];
if (u) [acc addObject:u];
}
}
// Weight each stage by its share of expected work so the ring moves
// proportionally regardless of follower/following ratio. 3% reserved
// up front for the initial user-info call.
NSInteger followerTarget = strongSelf->_expectedFollowers;
NSInteger followingTarget = strongSelf->_expectedFollowing;
double total0 = MAX(1, followerTarget + followingTarget);
double stageWeight = ([stage isEqualToString:@"followers"] ? followerTarget : followingTarget) / total0;
double stageOffset = ([stage isEqualToString:@"followers"] ? 0.0 : (double)followerTarget / total0);
double stageLocal = total > 0 ? MIN(1.0, (double)acc.count / (double)total) : 0;
double frac = 0.03 + (stageOffset + stageLocal * stageWeight) * 0.97;
NSString *fmt = [stage isEqualToString:@"followers"]
? SCILocalized(@"Fetching followers (%lu/%ld)…")
: SCILocalized(@"Fetching following (%lu/%ld)…");
NSString *label = [NSString stringWithFormat:fmt, (unsigned long)acc.count, (long)total];
[strongSelf reportProgress:progress status:label fraction:frac];
id next = resp[@"next_max_id"];
NSString *nextMax = [next isKindOfClass:[NSString class]] ? next : ([next respondsToSelector:@selector(stringValue)] ? [next stringValue] : nil);
if (!nextMax.length || strongSelf.cancelled) {
completion(acc, strongSelf.cancelled ? [strongSelf errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")] : nil);
return;
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SCI_PA_PAGE_DELAY_S * NSEC_PER_SEC)),
dispatch_get_main_queue(), ^{
[strongSelf pagePath:basePath acc:acc maxId:nextMax total:total stage:stage progress:progress completion:completion];
});
}];
}
@end
@@ -0,0 +1,41 @@
#import <Foundation/Foundation.h>
#import "SCIProfileAnalyzerModels.h"
NS_ASSUME_NONNULL_BEGIN
// Posted on every save/update/reset. userInfo carries @"user_pk".
extern NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification;
// Per-account on-disk store: current + previous snapshots (for since-last-scan
// diffs), an optional baseline for cumulative tracking, and a lightweight
// header cache keyed by PK.
@interface SCIProfileAnalyzerStorage : NSObject
+ (nullable SCIProfileAnalyzerSnapshot *)currentSnapshotForUserPK:(NSString *)userPK;
+ (nullable SCIProfileAnalyzerSnapshot *)previousSnapshotForUserPK:(NSString *)userPK;
+ (nullable SCIProfileAnalyzerSnapshot *)baselineSnapshotForUserPK:(NSString *)userPK;
+ (BOOL)saveBaselineSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK;
+ (void)clearBaselineForUserPK:(NSString *)userPK;
// Rotates current → previous, then writes the new current.
+ (BOOL)saveSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK;
// Overwrites current without touching previous — keeps the diff baseline
// intact across in-app follow/unfollow mutations.
+ (BOOL)updateCurrentSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK;
+ (void)resetForUserPK:(NSString *)userPK;
+ (void)resetAll;
// Self-profile summary (username, name, counts, pic) cached so the header
// paints on cold launch without a /users/{pk}/info/ call.
+ (nullable NSDictionary *)headerInfoForUserPK:(NSString *)userPK;
+ (void)saveHeaderInfo:(NSDictionary *)info forUserPK:(NSString *)userPK;
// Backup/Restore hooks — opaque pk-keyed JSON blob.
+ (NSDictionary *)exportedDict;
+ (BOOL)importFromDict:(NSDictionary *)dict;
@end
NS_ASSUME_NONNULL_END
@@ -0,0 +1,136 @@
#import "SCIProfileAnalyzerStorage.h"
NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification = @"SCIProfileAnalyzerDataDidChangeNotification";
@implementation SCIProfileAnalyzerStorage
static NSString *const kSCIPAStorageDir = @"RyukGram/ProfileAnalyzer";
static void sciPostDataChanged(NSString *userPK) {
dispatch_async(dispatch_get_main_queue(), ^{
[[NSNotificationCenter defaultCenter] postNotificationName:SCIProfileAnalyzerDataDidChangeNotification
object:nil
userInfo:userPK.length ? @{ @"user_pk": userPK } : @{}];
});
}
static NSString *sciStorageDir(void) {
NSArray *roots = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES);
NSString *dir = [roots.firstObject stringByAppendingPathComponent:kSCIPAStorageDir];
[[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
return dir;
}
static NSString *sciPath(NSString *userPK, NSString *slot) {
NSString *safePK = userPK.length ? userPK : @"anon";
return [sciStorageDir() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.%@.json", safePK, slot]];
}
static NSDictionary *sciReadJSON(NSString *path) {
NSData *data = [NSData dataWithContentsOfFile:path];
if (!data.length) return nil;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
return [obj isKindOfClass:[NSDictionary class]] ? obj : nil;
}
static BOOL sciWriteJSON(NSString *path, NSDictionary *dict) {
NSError *err = nil;
NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&err];
if (!data) return NO;
return [data writeToFile:path atomically:YES];
}
+ (SCIProfileAnalyzerSnapshot *)currentSnapshotForUserPK:(NSString *)userPK {
return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"current"))];
}
+ (SCIProfileAnalyzerSnapshot *)previousSnapshotForUserPK:(NSString *)userPK {
return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"previous"))];
}
+ (SCIProfileAnalyzerSnapshot *)baselineSnapshotForUserPK:(NSString *)userPK {
return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"baseline"))];
}
+ (BOOL)saveBaselineSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK {
if (!snapshot) return NO;
BOOL ok = sciWriteJSON(sciPath(userPK, @"baseline"), [snapshot toJSONDict]);
if (ok) sciPostDataChanged(userPK);
return ok;
}
+ (void)clearBaselineForUserPK:(NSString *)userPK {
[[NSFileManager defaultManager] removeItemAtPath:sciPath(userPK, @"baseline") error:nil];
sciPostDataChanged(userPK);
}
+ (BOOL)saveSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK {
if (!snapshot) return NO;
NSString *cur = sciPath(userPK, @"current");
NSString *prev = sciPath(userPK, @"previous");
NSFileManager *fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:cur]) {
[fm removeItemAtPath:prev error:nil];
[fm moveItemAtPath:cur toPath:prev error:nil];
}
BOOL ok = sciWriteJSON(cur, [snapshot toJSONDict]);
if (ok) sciPostDataChanged(userPK);
return ok;
}
+ (BOOL)updateCurrentSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK {
if (!snapshot) return NO;
BOOL ok = sciWriteJSON(sciPath(userPK, @"current"), [snapshot toJSONDict]);
if (ok) sciPostDataChanged(userPK);
return ok;
}
+ (void)resetForUserPK:(NSString *)userPK {
NSFileManager *fm = [NSFileManager defaultManager];
[fm removeItemAtPath:sciPath(userPK, @"current") error:nil];
[fm removeItemAtPath:sciPath(userPK, @"previous") error:nil];
[fm removeItemAtPath:sciPath(userPK, @"baseline") error:nil];
sciPostDataChanged(userPK);
}
+ (void)resetAll {
[[NSFileManager defaultManager] removeItemAtPath:sciStorageDir() error:nil];
sciPostDataChanged(nil);
}
+ (NSDictionary *)headerInfoForUserPK:(NSString *)userPK {
return sciReadJSON(sciPath(userPK, @"header"));
}
+ (void)saveHeaderInfo:(NSDictionary *)info forUserPK:(NSString *)userPK {
if (!info.count) return;
NSMutableDictionary *stored = [info mutableCopy];
stored[@"cached_at"] = @([[NSDate date] timeIntervalSince1970]);
sciWriteJSON(sciPath(userPK, @"header"), stored);
}
+ (NSDictionary *)exportedDict {
NSMutableDictionary *out = [NSMutableDictionary dictionary];
NSFileManager *fm = [NSFileManager defaultManager];
for (NSString *name in [fm contentsOfDirectoryAtPath:sciStorageDir() error:nil]) {
NSDictionary *d = sciReadJSON([sciStorageDir() stringByAppendingPathComponent:name]);
if (d) out[name] = d;
}
return out;
}
+ (BOOL)importFromDict:(NSDictionary *)dict {
if (![dict isKindOfClass:[NSDictionary class]] || !dict.count) return NO;
[self resetAll];
NSString *dir = sciStorageDir();
for (NSString *name in dict) {
if (![name hasSuffix:@".json"]) continue;
NSDictionary *d = dict[name];
if (![d isKindOfClass:[NSDictionary class]]) continue;
sciWriteJSON([dir stringByAppendingPathComponent:name], d);
}
return YES;
}
@end
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIProfileAnalyzerViewController : UIViewController
@end
@@ -0,0 +1,990 @@
#import "SCIProfileAnalyzerViewController.h"
#import "SCIProfileAnalyzerModels.h"
#import "SCIProfileAnalyzerStorage.h"
#import "SCIProfileAnalyzerService.h"
#import "SCIProfileAnalyzerListViewController.h"
#import "../../Utils.h"
#import "../../SCIImageCache.h"
#import "../../Networking/SCIInstagramAPI.h"
#import "../../Localization/SCILocalization.h"
#import <objc/runtime.h>
extern NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification;
#pragma mark - Category descriptor
typedef NS_ENUM(NSInteger, SCIPACategory) {
SCIPACategoryMutual,
SCIPACategoryNotFollowingBack,
SCIPACategoryDontFollowBack,
SCIPACategoryNewFollowers,
SCIPACategoryLostFollowers,
SCIPACategoryYouStartedFollowing,
SCIPACategoryYouUnfollowed,
SCIPACategoryProfileUpdates,
};
@interface SCIPACategoryDescriptor : NSObject
@property (nonatomic, assign) SCIPACategory category;
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *subtitle;
@property (nonatomic, copy) NSString *symbol;
@property (nonatomic, strong) UIColor *color;
@property (nonatomic, assign) NSInteger count;
@property (nonatomic, assign) BOOL requiresPrevious;
@end
@implementation SCIPACategoryDescriptor @end
#pragma mark - Avatar with progress ring
@interface SCIPAAvatarRingView : UIView
@property (nonatomic, strong) UIImageView *imageView;
@property (nonatomic, strong) CAShapeLayer *trackLayer;
@property (nonatomic, strong) CAShapeLayer *progressLayer;
@property (nonatomic, assign) double progress; // 0..1
@property (nonatomic, assign) BOOL showProgress;
@end
@implementation SCIPAAvatarRingView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (!self) return self;
_trackLayer = [CAShapeLayer layer];
_trackLayer.fillColor = UIColor.clearColor.CGColor;
_trackLayer.strokeColor = [UIColor systemGray5Color].CGColor;
_trackLayer.lineWidth = 3.5;
_trackLayer.hidden = YES;
[self.layer addSublayer:_trackLayer];
_progressLayer = [CAShapeLayer layer];
_progressLayer.fillColor = UIColor.clearColor.CGColor;
_progressLayer.strokeColor = ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]).CGColor;
_progressLayer.lineWidth = 3.5;
_progressLayer.lineCap = kCALineCapRound;
_progressLayer.strokeEnd = 0;
_progressLayer.hidden = YES;
[self.layer addSublayer:_progressLayer];
_imageView = [UIImageView new];
_imageView.contentMode = UIViewContentModeScaleAspectFill;
_imageView.backgroundColor = [UIColor secondarySystemBackgroundColor];
_imageView.layer.masksToBounds = YES;
_imageView.image = [UIImage systemImageNamed:@"person.circle.fill"];
_imageView.tintColor = [UIColor systemGrayColor];
[self addSubview:_imageView];
return self;
}
- (void)layoutSubviews {
[super layoutSubviews];
CGFloat size = MIN(self.bounds.size.width, self.bounds.size.height);
if (size < 16) return; // transient tiny bounds during transitions
CGFloat inset = 7;
CGRect imgFrame = CGRectInset(CGRectMake(0, 0, size, size), inset, inset);
self.imageView.frame = imgFrame;
self.imageView.layer.cornerRadius = imgFrame.size.width / 2.0;
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(size / 2.0, size / 2.0)
radius:size / 2.0 - 2
startAngle:-M_PI_2
endAngle:-M_PI_2 + 2 * M_PI
clockwise:YES];
self.trackLayer.frame = self.bounds;
self.progressLayer.frame = self.bounds;
self.trackLayer.path = path.CGPath;
self.progressLayer.path = path.CGPath;
}
- (void)setProgress:(double)progress {
_progress = MAX(0, MIN(1, progress));
[CATransaction begin];
[CATransaction setAnimationDuration:0.25];
self.progressLayer.strokeEnd = _progress;
[CATransaction commit];
}
- (void)setShowProgress:(BOOL)show {
_showProgress = show;
self.trackLayer.hidden = !show;
self.progressLayer.hidden = !show;
if (show) self.progressLayer.strokeEnd = _progress;
}
@end
#pragma mark - Header
@interface SCIPAHeaderView : UIView
@property (nonatomic, strong) SCIPAAvatarRingView *avatar;
@property (nonatomic, strong) UILabel *fullNameLabel;
@property (nonatomic, strong) UILabel *usernameLabel;
@property (nonatomic, strong) UIStackView *statsRow;
@property (nonatomic, strong) UILabel *scanDateLabel;
@property (nonatomic, strong) UILabel *warningLabel;
@property (nonatomic, strong) UIButton *scanButton;
@property (nonatomic, strong) UILabel *progressLabel;
@end
@implementation SCIPAHeaderView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (!self) return self;
self.backgroundColor = [UIColor secondarySystemBackgroundColor];
self.layer.cornerRadius = 18;
_avatar = [[SCIPAAvatarRingView alloc] init];
_avatar.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:_avatar];
_fullNameLabel = [UILabel new];
_fullNameLabel.translatesAutoresizingMaskIntoConstraints = NO;
_fullNameLabel.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold];
_fullNameLabel.textColor = [UIColor labelColor];
_fullNameLabel.textAlignment = NSTextAlignmentCenter;
[self addSubview:_fullNameLabel];
_usernameLabel = [UILabel new];
_usernameLabel.translatesAutoresizingMaskIntoConstraints = NO;
_usernameLabel.font = [UIFont systemFontOfSize:14];
_usernameLabel.textColor = [UIColor secondaryLabelColor];
_usernameLabel.textAlignment = NSTextAlignmentCenter;
[self addSubview:_usernameLabel];
_statsRow = [[UIStackView alloc] init];
_statsRow.translatesAutoresizingMaskIntoConstraints = NO;
_statsRow.axis = UILayoutConstraintAxisHorizontal;
_statsRow.distribution = UIStackViewDistributionFillEqually;
_statsRow.spacing = 0;
[self addSubview:_statsRow];
_scanDateLabel = [UILabel new];
_scanDateLabel.translatesAutoresizingMaskIntoConstraints = NO;
_scanDateLabel.font = [UIFont systemFontOfSize:12];
_scanDateLabel.textColor = [UIColor tertiaryLabelColor];
_scanDateLabel.textAlignment = NSTextAlignmentCenter;
[self addSubview:_scanDateLabel];
_warningLabel = [UILabel new];
_warningLabel.translatesAutoresizingMaskIntoConstraints = NO;
_warningLabel.font = [UIFont systemFontOfSize:12];
_warningLabel.textColor = [UIColor systemOrangeColor];
_warningLabel.numberOfLines = 0;
_warningLabel.textAlignment = NSTextAlignmentCenter;
_warningLabel.hidden = YES;
[self addSubview:_warningLabel];
_scanButton = [UIButton buttonWithType:UIButtonTypeSystem];
_scanButton.translatesAutoresizingMaskIntoConstraints = NO;
_scanButton.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
_scanButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
[_scanButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
_scanButton.layer.cornerRadius = 18;
_scanButton.contentEdgeInsets = UIEdgeInsetsMake(0, 22, 0, 22);
[_scanButton setTitle:SCILocalized(@"Run analysis") forState:UIControlStateNormal];
[self addSubview:_scanButton];
_progressLabel = [UILabel new];
_progressLabel.translatesAutoresizingMaskIntoConstraints = NO;
_progressLabel.font = [UIFont systemFontOfSize:12];
_progressLabel.textColor = [UIColor secondaryLabelColor];
_progressLabel.textAlignment = NSTextAlignmentCenter;
_progressLabel.hidden = YES;
[self addSubview:_progressLabel];
[NSLayoutConstraint activateConstraints:@[
[_avatar.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[_avatar.topAnchor constraintEqualToAnchor:self.topAnchor constant:18],
[_avatar.widthAnchor constraintEqualToConstant:96],
[_avatar.heightAnchor constraintEqualToConstant:96],
[_fullNameLabel.topAnchor constraintEqualToAnchor:_avatar.bottomAnchor constant:10],
[_fullNameLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
[_fullNameLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
[_usernameLabel.topAnchor constraintEqualToAnchor:_fullNameLabel.bottomAnchor constant:2],
[_usernameLabel.leadingAnchor constraintEqualToAnchor:_fullNameLabel.leadingAnchor],
[_usernameLabel.trailingAnchor constraintEqualToAnchor:_fullNameLabel.trailingAnchor],
[_statsRow.topAnchor constraintEqualToAnchor:_usernameLabel.bottomAnchor constant:14],
[_statsRow.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12],
[_statsRow.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
[_statsRow.heightAnchor constraintEqualToConstant:44],
[_scanDateLabel.topAnchor constraintEqualToAnchor:_statsRow.bottomAnchor constant:10],
[_scanDateLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
[_scanDateLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
[_warningLabel.topAnchor constraintEqualToAnchor:_scanDateLabel.bottomAnchor constant:6],
[_warningLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:20],
[_warningLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-20],
[_scanButton.topAnchor constraintEqualToAnchor:_warningLabel.bottomAnchor constant:12],
[_scanButton.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
[_scanButton.heightAnchor constraintEqualToConstant:36],
[_scanButton.widthAnchor constraintGreaterThanOrEqualToConstant:160],
[_progressLabel.topAnchor constraintEqualToAnchor:_scanButton.bottomAnchor constant:6],
[_progressLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
[_progressLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
[_progressLabel.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-16],
]];
return self;
}
- (void)setStatsLabelsPosts:(NSString *)posts followers:(NSString *)followers following:(NSString *)following {
for (UIView *v in self.statsRow.arrangedSubviews) [self.statsRow removeArrangedSubview:v], [v removeFromSuperview];
[self.statsRow addArrangedSubview:[self statColumn:posts caption:SCILocalized(@"Posts")]];
[self.statsRow addArrangedSubview:[self statColumn:followers caption:SCILocalized(@"Followers")]];
[self.statsRow addArrangedSubview:[self statColumn:following caption:SCILocalized(@"Following")]];
}
- (UIView *)statColumn:(NSString *)value caption:(NSString *)caption {
UIView *w = [UIView new];
UILabel *v = [UILabel new];
v.translatesAutoresizingMaskIntoConstraints = NO;
v.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
v.textColor = [UIColor labelColor];
v.textAlignment = NSTextAlignmentCenter;
v.text = value;
[w addSubview:v];
UILabel *c = [UILabel new];
c.translatesAutoresizingMaskIntoConstraints = NO;
c.font = [UIFont systemFontOfSize:12];
c.textColor = [UIColor secondaryLabelColor];
c.textAlignment = NSTextAlignmentCenter;
c.text = caption;
[w addSubview:c];
[NSLayoutConstraint activateConstraints:@[
[v.topAnchor constraintEqualToAnchor:w.topAnchor],
[v.leadingAnchor constraintEqualToAnchor:w.leadingAnchor],
[v.trailingAnchor constraintEqualToAnchor:w.trailingAnchor],
[c.topAnchor constraintEqualToAnchor:v.bottomAnchor constant:1],
[c.leadingAnchor constraintEqualToAnchor:w.leadingAnchor],
[c.trailingAnchor constraintEqualToAnchor:w.trailingAnchor],
[c.bottomAnchor constraintEqualToAnchor:w.bottomAnchor],
]];
return w;
}
@end
#pragma mark - Category cell
@interface SCIPACategoryCell : UITableViewCell
@property (nonatomic, strong) UIView *iconBadge;
@property (nonatomic, strong) UIImageView *iconView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *subtitleLabel;
@property (nonatomic, strong) UILabel *countLabel;
@end
@implementation SCIPACategoryCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)rid {
self = [super initWithStyle:style reuseIdentifier:rid];
if (!self) return self;
self.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
_iconBadge = [UIView new];
_iconBadge.translatesAutoresizingMaskIntoConstraints = NO;
_iconBadge.layer.cornerRadius = 8;
[self.contentView addSubview:_iconBadge];
_iconView = [UIImageView new];
_iconView.translatesAutoresizingMaskIntoConstraints = NO;
_iconView.contentMode = UIViewContentModeScaleAspectFit;
_iconView.tintColor = [UIColor whiteColor];
[_iconBadge addSubview:_iconView];
_titleLabel = [UILabel new];
_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
_titleLabel.font = [UIFont systemFontOfSize:16];
[self.contentView addSubview:_titleLabel];
_subtitleLabel = [UILabel new];
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
_subtitleLabel.font = [UIFont systemFontOfSize:12];
_subtitleLabel.textColor = [UIColor tertiaryLabelColor];
[self.contentView addSubview:_subtitleLabel];
_countLabel = [UILabel new];
_countLabel.translatesAutoresizingMaskIntoConstraints = NO;
_countLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
_countLabel.textColor = [UIColor secondaryLabelColor];
_countLabel.textAlignment = NSTextAlignmentRight;
[self.contentView addSubview:_countLabel];
[NSLayoutConstraint activateConstraints:@[
[_iconBadge.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor],
[_iconBadge.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_iconBadge.widthAnchor constraintEqualToConstant:32],
[_iconBadge.heightAnchor constraintEqualToConstant:32],
[_iconView.centerXAnchor constraintEqualToAnchor:_iconBadge.centerXAnchor],
[_iconView.centerYAnchor constraintEqualToAnchor:_iconBadge.centerYAnchor],
[_iconView.widthAnchor constraintEqualToConstant:18],
[_iconView.heightAnchor constraintEqualToConstant:18],
[_titleLabel.leadingAnchor constraintEqualToAnchor:_iconBadge.trailingAnchor constant:12],
[_titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10],
[_titleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_countLabel.leadingAnchor constant:-8],
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_titleLabel.leadingAnchor],
[_subtitleLabel.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:1],
[_subtitleLabel.trailingAnchor constraintEqualToAnchor:_titleLabel.trailingAnchor],
[_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-10],
[_countLabel.trailingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor],
[_countLabel.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_countLabel.widthAnchor constraintGreaterThanOrEqualToConstant:40],
]];
return self;
}
@end
#pragma mark - Main VC
@interface SCIProfileAnalyzerViewController () <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UIView *headerContainer;
@property (nonatomic, strong) SCIPAHeaderView *headerView;
@property (nonatomic, strong) SCIProfileAnalyzerReport *report;
@property (nonatomic, strong) NSArray<SCIPACategoryDescriptor *> *categories;
@property (nonatomic, assign) BOOL running;
@property (nonatomic, copy) NSString *lastHeaderPK;
@property (nonatomic, assign) BOOL pendingHeaderFetch;
@end
@implementation SCIProfileAnalyzerViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
self.title = SCILocalized(@"Profile Analyzer");
self.navigationItem.titleView = [self buildTitleViewWithBeta];
[self setupTable];
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(analyzerDataChanged:)
name:SCIProfileAnalyzerDataDidChangeNotification
object:nil];
}
- (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; }
- (void)analyzerDataChanged:(NSNotification *)note {
if (!self.isViewLoaded || !self.view.window) return;
NSString *pk = note.userInfo[@"user_pk"];
NSString *current = [SCIUtils currentUserPK];
if (pk.length && current.length && ![pk isEqualToString:current]) return;
@try {
[self loadCachedReport];
SCIProfileAnalyzerSnapshot *cur = self.report.current;
if (cur) {
[self.headerView setStatsLabelsPosts:[self compactNumber:cur.mediaCount]
followers:[self compactNumber:cur.followerCount]
following:[self compactNumber:cur.followingCount]];
}
} @catch (__unused NSException *e) {}
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
// Cheap disk + set math; safe during push.
@try { [self loadCachedReport]; } @catch (__unused NSException *e) {}
}
- (void)viewDidAppear:(BOOL)animated {
[super viewDidAppear:animated];
// Header merge + any network fetch wait until the transition settles.
@try { [self loadHeaderLayered]; } @catch (__unused NSException *e) {}
if (self.pendingHeaderFetch) {
self.pendingHeaderFetch = NO;
@try { [self fetchAndCacheHeader]; } @catch (__unused NSException *e) {}
}
}
- (UIView *)buildTitleViewWithBeta {
UILabel *title = [UILabel new];
title.text = SCILocalized(@"Profile Analyzer");
title.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
title.textColor = [UIColor labelColor];
UILabel *beta = [UILabel new];
beta.text = @" BETA ";
beta.font = [UIFont systemFontOfSize:10 weight:UIFontWeightHeavy];
beta.textColor = [UIColor whiteColor];
beta.backgroundColor = [UIColor systemOrangeColor];
beta.layer.cornerRadius = 5;
beta.layer.masksToBounds = YES;
beta.textAlignment = NSTextAlignmentCenter;
UIStackView *row = [[UIStackView alloc] initWithArrangedSubviews:@[title, beta]];
row.axis = UILayoutConstraintAxisHorizontal;
row.alignment = UIStackViewAlignmentCenter;
row.spacing = 6;
return row;
}
- (void)setupTable {
self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped];
self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.sectionHeaderTopPadding = 0;
self.tableView.rowHeight = UITableViewAutomaticDimension;
self.tableView.estimatedRowHeight = 60;
[self.tableView registerClass:[SCIPACategoryCell class] forCellReuseIdentifier:@"cat"];
UIRefreshControl *rc = [UIRefreshControl new];
[rc addTarget:self action:@selector(pullToRefreshProfile:) forControlEvents:UIControlEventValueChanged];
self.tableView.refreshControl = rc;
[self.view addSubview:self.tableView];
[self buildTableHeader];
}
// Pull-to-refresh: re-fetch just the self-profile (/users/{pk}/info/) so the
// header reflects IG's truth on demand. No rescan, no data reset.
- (void)pullToRefreshProfile:(UIRefreshControl *)sender {
NSString *pk = [SCIUtils currentUserPK];
if (!pk.length) { [sender endRefreshing]; return; }
__weak typeof(self) weakSelf = self;
[SCIInstagramAPI sendRequestWithMethod:@"GET"
path:[NSString stringWithFormat:@"users/%@/info/", pk]
body:nil
completion:^(NSDictionary *resp, NSError *error) {
NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil;
if (user.count) {
[SCIProfileAnalyzerStorage saveHeaderInfo:user forUserPK:pk];
typeof(self) strongSelf = weakSelf;
if (strongSelf.isViewLoaded && strongSelf.view.window) {
[strongSelf paintHeaderFromUserInfo:user];
[strongSelf applyFollowerLimitGateFor:[user[@"follower_count"] integerValue]];
}
}
[sender endRefreshing];
}];
}
- (void)buildTableHeader {
// tableHeaderView is frame-driven; let viewWillLayoutSubviews set width.
self.headerContainer = [UIView new];
self.headerContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth;
self.headerView = [[SCIPAHeaderView alloc] init];
self.headerView.translatesAutoresizingMaskIntoConstraints = NO;
[self.headerContainer addSubview:self.headerView];
[self.headerView.scanButton addTarget:self action:@selector(analyzeTapped) forControlEvents:UIControlEventTouchUpInside];
[NSLayoutConstraint activateConstraints:@[
[self.headerView.topAnchor constraintEqualToAnchor:self.headerContainer.topAnchor constant:12],
[self.headerView.leadingAnchor constraintEqualToAnchor:self.headerContainer.leadingAnchor constant:16],
[self.headerView.trailingAnchor constraintEqualToAnchor:self.headerContainer.trailingAnchor constant:-16],
[self.headerView.bottomAnchor constraintEqualToAnchor:self.headerContainer.bottomAnchor constant:-4],
]];
}
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
if (!self.headerContainer) return;
CGFloat w = self.tableView.bounds.size.width;
if (w < 1) return;
// Resolve internal height against the tableView's width.
self.headerContainer.frame = CGRectMake(0, 0, w, 1);
[self.headerContainer setNeedsLayout];
[self.headerContainer layoutIfNeeded];
CGFloat h = [self.headerContainer systemLayoutSizeFittingSize:CGSizeMake(w, UILayoutFittingCompressedSize.height)
withHorizontalFittingPriority:UILayoutPriorityRequired
verticalFittingPriority:UILayoutPriorityFittingSizeLevel].height;
CGRect target = CGRectMake(0, 0, w, h);
if (!CGRectEqualToRect(self.headerContainer.frame, target)) {
self.headerContainer.frame = target;
self.tableView.tableHeaderView = self.headerContainer;
} else if (self.tableView.tableHeaderView != self.headerContainer) {
self.tableView.tableHeaderView = self.headerContainer;
}
}
#pragma mark - Header resolution (IG memory → our cache → network)
// Layered header lookup: IG fieldCache → on-disk cache → network (only when
// neither source has usable counts). Results get persisted so cold relaunch
// is offline.
- (void)loadHeaderLayered {
NSString *pk = [SCIUtils currentUserPK];
self.lastHeaderPK = pk;
NSDictionary *live = [self liveSelfInfoFromSession];
NSMutableDictionary *cached = [[SCIProfileAnalyzerStorage headerInfoForUserPK:pk] mutableCopy]
?: [NSMutableDictionary dictionary];
SCIProfileAnalyzerSnapshot *snap = self.report.current;
// Hybrid reconciliation for following_count:
// * snapshot.followingCount captures in-app follow/unfollow mutations.
// * IG's fieldCache only refreshes when the user visits own profile.
// We store the last fieldCache value we saw; when it moves, IG refreshed
// and is authoritative — we align the snapshot to match. Otherwise the
// snapshot (possibly just mutated) wins so unfollows show up live.
NSNumber *liveFollowing = live[@"following_count"];
NSNumber *lastSeenFollowing = cached[@"last_synced_following_count"];
BOOL fieldCacheRefreshed = liveFollowing && (!lastSeenFollowing || ![liveFollowing isEqual:lastSeenFollowing]);
if (fieldCacheRefreshed) {
cached[@"following_count"] = liveFollowing;
cached[@"last_synced_following_count"] = liveFollowing;
if (snap && snap.followingCount != liveFollowing.integerValue) {
snap.followingCount = liveFollowing.integerValue;
[SCIProfileAnalyzerStorage updateCurrentSnapshot:snap forUserPK:pk];
}
} else if (snap && snap.followingCount > 0) {
cached[@"following_count"] = @(snap.followingCount);
} else if (liveFollowing) {
cached[@"following_count"] = liveFollowing;
}
// Non-mutable-in-app fields: fieldCache wins when present.
for (NSString *k in @[@"username", @"full_name", @"profile_pic_url",
@"profile_pic_id", @"follower_count", @"media_count"]) {
if (live[k]) cached[k] = live[k];
}
// Fallbacks from snapshot if fieldCache lacks them entirely.
if (snap && !cached[@"follower_count"] && snap.followerCount > 0) cached[@"follower_count"] = @(snap.followerCount);
if (snap && !cached[@"media_count"] && snap.mediaCount > 0) cached[@"media_count"] = @(snap.mediaCount);
if (cached[@"username"] || [cached[@"follower_count"] integerValue] > 0) {
[self paintHeaderFromUserInfo:cached];
[self applyFollowerLimitGateFor:[cached[@"follower_count"] integerValue]];
} else if (!snap) {
self.headerView.fullNameLabel.text = SCILocalized(@"No scan yet");
self.headerView.usernameLabel.text = @"";
[self.headerView setStatsLabelsPosts:@"" followers:@"" following:@""];
}
BOOL haveCounts = [cached[@"follower_count"] integerValue] > 0
|| [cached[@"following_count"] integerValue] > 0
|| [cached[@"media_count"] integerValue] > 0;
if (haveCounts) {
[SCIProfileAnalyzerStorage saveHeaderInfo:cached forUserPK:pk];
} else {
// Defer to next runloop so the push transition can complete before
// any completion-block layout mutations.
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
if (weakSelf.isViewLoaded && weakSelf.view.window) {
[weakSelf fetchAndCacheHeader];
} else {
weakSelf.pendingHeaderFetch = YES;
}
});
}
}
- (NSDictionary *)liveSelfInfoFromSession {
id session = [SCIUtils activeUserSession];
id igUser = nil;
@try { if ([session respondsToSelector:@selector(user)]) igUser = [session valueForKey:@"user"]; } @catch (__unused id e) {}
NSDictionary *fc = [self fieldCacheForUser:igUser];
NSMutableDictionary *out = [NSMutableDictionary dictionary];
for (NSString *k in @[@"username", @"full_name", @"profile_pic_url",
@"follower_count", @"following_count", @"media_count"]) {
if (fc[k]) out[k] = fc[k];
}
return out;
}
- (void)fetchAndCacheHeader {
NSString *pk = self.lastHeaderPK;
if (!pk.length) return;
__weak typeof(self) weakSelf = self;
[SCIInstagramAPI sendRequestWithMethod:@"GET"
path:[NSString stringWithFormat:@"users/%@/info/", pk]
body:nil
completion:^(NSDictionary *resp, NSError *error) {
NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil;
if (!user.count) return;
[SCIProfileAnalyzerStorage saveHeaderInfo:user forUserPK:pk];
typeof(self) strongSelf = weakSelf;
// Drop UI updates if the VC left the window between send + callback.
if (!strongSelf.isViewLoaded || !strongSelf.view.window) return;
[strongSelf paintHeaderFromUserInfo:user];
[strongSelf applyFollowerLimitGateFor:[user[@"follower_count"] integerValue]];
}];
}
- (void)applyFollowerLimitGateFor:(NSInteger)followers {
if (followers > SCIProfileAnalyzerMaxFollowerCount) {
self.headerView.warningLabel.hidden = NO;
self.headerView.warningLabel.text = [NSString stringWithFormat:
SCILocalized(@"Follower count exceeds %ld — analysis disabled to avoid rate limits."),
(long)SCIProfileAnalyzerMaxFollowerCount];
self.headerView.scanButton.enabled = NO;
self.headerView.scanButton.alpha = 0.5;
}
}
- (NSDictionary *)fieldCacheForUser:(id)user {
if (!user) return @{};
Ivar iv = NULL;
for (Class c = [user class]; c && !iv; c = class_getSuperclass(c))
iv = class_getInstanceVariable(c, "_fieldCache");
if (!iv) return @{};
id d = object_getIvar(user, iv);
return [d isKindOfClass:[NSDictionary class]] ? d : @{};
}
- (NSString *)compactNumber:(NSInteger)n {
if (n < 1000) return [NSString stringWithFormat:@"%ld", (long)n];
if (n < 10000) return [NSString stringWithFormat:@"%.1fK", n / 1000.0];
if (n < 1000000) return [NSString stringWithFormat:@"%ldK", (long)(n / 1000)];
return [NSString stringWithFormat:@"%.1fM", n / 1000000.0];
}
#pragma mark - Data
- (void)loadCachedReport {
NSString *pk = [SCIUtils currentUserPK];
SCIProfileAnalyzerSnapshot *cur = [SCIProfileAnalyzerStorage currentSnapshotForUserPK:pk];
SCIProfileAnalyzerSnapshot *prev = [SCIProfileAnalyzerStorage previousSnapshotForUserPK:pk];
SCIProfileAnalyzerSnapshot *base = [SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk];
// Baseline wins when present; the toggle only drives its lifecycle.
SCIProfileAnalyzerSnapshot *diffAgainst = base ?: prev;
self.report = [SCIProfileAnalyzerReport reportFromCurrent:cur previous:diffAgainst];
[self rebuildCategories];
[self refreshHeader];
[self.tableView reloadData];
}
- (void)rebuildCategories {
SCIProfileAnalyzerReport *r = self.report;
NSArray<SCIPACategoryDescriptor *> *(^build)(void) = ^NSArray *{
SCIPACategoryDescriptor *(^make)(SCIPACategory, NSString *, NSString *, NSString *, UIColor *, NSInteger, BOOL) =
^SCIPACategoryDescriptor *(SCIPACategory c, NSString *t, NSString *s, NSString *sym, UIColor *col, NSInteger count, BOOL needsPrev) {
SCIPACategoryDescriptor *d = [SCIPACategoryDescriptor new];
d.category = c; d.title = t; d.subtitle = s; d.symbol = sym; d.color = col;
d.count = count; d.requiresPrevious = needsPrev;
return d;
};
return @[
make(SCIPACategoryMutual, SCILocalized(@"Mutual followers"),
SCILocalized(@"You both follow each other"),
@"person.2.fill", [UIColor systemBlueColor], r.mutualFollowers.count, NO),
make(SCIPACategoryNotFollowingBack, SCILocalized(@"Not following you back"),
SCILocalized(@"You follow them, they don't follow back"),
@"person.fill.xmark", [UIColor systemOrangeColor], r.notFollowingYouBack.count, NO),
make(SCIPACategoryDontFollowBack, SCILocalized(@"You don't follow back"),
SCILocalized(@"They follow you, you don't follow back"),
@"person.fill.questionmark", [UIColor systemTealColor], r.youDontFollowBack.count, NO),
make(SCIPACategoryNewFollowers, SCILocalized(@"New followers"),
SCILocalized(@"Gained since last scan"),
@"person.fill.badge.plus", [UIColor systemGreenColor], r.recentFollowers.count, YES),
make(SCIPACategoryLostFollowers, SCILocalized(@"Lost followers"),
SCILocalized(@"Unfollowed you since last scan"),
@"person.fill.badge.minus", [UIColor systemRedColor], r.lostFollowers.count, YES),
make(SCIPACategoryYouStartedFollowing, SCILocalized(@"You started following"),
SCILocalized(@"Since last scan"),
@"arrow.up.forward.circle.fill", [UIColor systemIndigoColor], r.youStartedFollowing.count, YES),
make(SCIPACategoryYouUnfollowed, SCILocalized(@"You unfollowed"),
SCILocalized(@"Since last scan"),
@"arrow.down.backward.circle.fill", [UIColor systemPurpleColor], r.youUnfollowed.count, YES),
make(SCIPACategoryProfileUpdates, SCILocalized(@"Profile updates"),
SCILocalized(@"Username, name or picture changes"),
@"person.crop.circle.badge.exclamationmark", [UIColor systemPinkColor], r.profileUpdates.count, YES),
];
};
self.categories = build();
}
// Snapshot-backed paint: only scan-date + warning. Identity + stats + avatar
// are owned by loadHeaderLayered so fieldCache always wins.
- (void)refreshHeader {
self.headerView.scanDateLabel.text = self.report.current
? [self scanDateText]
: SCILocalized(@"Run your first analysis");
[self refreshWarning];
}
- (NSString *)scanDateText {
if (!self.report.current.scanDate) return @"";
NSDateFormatter *f = [NSDateFormatter new];
f.dateStyle = NSDateFormatterMediumStyle;
f.timeStyle = NSDateFormatterShortStyle;
NSString *when = [f stringFromDate:self.report.current.scanDate];
if (self.report.previous) return [NSString stringWithFormat:SCILocalized(@"Last scan: %@"), when];
return [NSString stringWithFormat:SCILocalized(@"First scan: %@"), when];
}
- (void)refreshWarning {
SCIProfileAnalyzerSnapshot *cur = self.report.current;
NSInteger followers = cur ? cur.followerCount
: [[self fieldCacheForUser:[[SCIUtils activeUserSession] valueForKey:@"user"]][@"follower_count"] integerValue];
if (followers > SCIProfileAnalyzerMaxFollowerCount) {
self.headerView.warningLabel.hidden = NO;
self.headerView.warningLabel.text = [NSString stringWithFormat:
SCILocalized(@"Follower count exceeds %ld — analysis disabled to avoid rate limits."),
(long)SCIProfileAnalyzerMaxFollowerCount];
self.headerView.scanButton.enabled = NO;
self.headerView.scanButton.alpha = 0.5;
} else {
self.headerView.warningLabel.hidden = YES;
self.headerView.scanButton.enabled = !self.running;
self.headerView.scanButton.alpha = self.running ? 0.5 : 1.0;
}
[self.view setNeedsLayout];
}
#pragma mark - Actions
- (void)analyzeTapped {
if (self.running) { [[SCIProfileAnalyzerService sharedService] cancel]; return; }
self.running = YES;
self.headerView.progressLabel.hidden = NO;
self.headerView.progressLabel.text = SCILocalized(@"Starting…");
[self.headerView.avatar setShowProgress:YES];
self.headerView.avatar.progress = 0;
[self.headerView.scanButton setTitle:SCILocalized(@"Cancel") forState:UIControlStateNormal];
self.headerView.scanButton.backgroundColor = [UIColor systemRedColor];
[self.view setNeedsLayout];
__weak typeof(self) weakSelf = self;
[[SCIProfileAnalyzerService sharedService] runForSelfWithHeaderInfo:^(NSDictionary *userInfo) {
// Paint the header the moment user-info returns — before follower fetch.
[weakSelf paintHeaderFromUserInfo:userInfo];
} progress:^(NSString *status, double fraction) {
weakSelf.headerView.progressLabel.text = status;
weakSelf.headerView.avatar.progress = fraction;
} completion:^(SCIProfileAnalyzerSnapshot *snapshot, NSError *error) {
[weakSelf onAnalysisFinished:snapshot error:error];
}];
}
- (void)paintHeaderFromUserInfo:(NSDictionary *)user {
NSString *username = user[@"username"];
NSString *fullName = user[@"full_name"];
NSString *picURL = user[@"profile_pic_url"];
NSInteger followers = [user[@"follower_count"] integerValue];
NSInteger following = [user[@"following_count"] integerValue];
NSInteger posts = [user[@"media_count"] integerValue];
self.headerView.fullNameLabel.text = fullName.length ? fullName : (username.length ? username : SCILocalized(@"No scan yet"));
self.headerView.usernameLabel.text = username.length ? [NSString stringWithFormat:@"@%@", username] : @"";
[self.headerView setStatsLabelsPosts:[self compactNumber:posts]
followers:[self compactNumber:followers]
following:[self compactNumber:following]];
if (picURL.length) {
__weak UIImageView *iv = self.headerView.avatar.imageView;
[SCIImageCache loadImageFromURL:[NSURL URLWithString:picURL] completion:^(UIImage *img) {
if (img) iv.image = img;
}];
}
}
- (void)onAnalysisFinished:(SCIProfileAnalyzerSnapshot *)snapshot error:(NSError *)error {
self.running = NO;
self.headerView.progressLabel.hidden = YES;
[self.headerView.avatar setShowProgress:NO];
[self.headerView.scanButton setTitle:SCILocalized(@"Run analysis") forState:UIControlStateNormal];
self.headerView.scanButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
[self.view setNeedsLayout];
if (error && error.code == SCIProfileAnalyzerErrorTooManyFollowers) {
[self alertTitle:SCILocalized(@"Too many followers")
message:[NSString stringWithFormat:SCILocalized(@"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits."),
(long)SCIProfileAnalyzerMaxFollowerCount]];
return;
}
if (error && error.code != SCIProfileAnalyzerErrorCancelled) {
[self alertTitle:SCILocalized(@"Analysis failed") message:error.localizedDescription ?: @""];
return;
}
if (!snapshot) { [self loadCachedReport]; return; }
NSString *pk = [SCIUtils currentUserPK];
[SCIProfileAnalyzerStorage saveSnapshot:snapshot forUserPK:pk];
// Baseline lifecycle lives at scan boundaries so flipping the toggle
// mid-session doesn't wipe what's on screen.
BOOL accumulate = [SCIUtils getBoolPref:@"profile_analyzer_accumulate"];
BOOL baselineExists = [SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk] != nil;
if (accumulate && !baselineExists) {
[SCIProfileAnalyzerStorage saveBaselineSnapshot:snapshot forUserPK:pk];
} else if (!accumulate && baselineExists) {
[SCIProfileAnalyzerStorage clearBaselineForUserPK:pk];
}
[SCIProfileAnalyzerStorage saveHeaderInfo:@{
@"username": snapshot.selfUsername ?: @"",
@"full_name": snapshot.selfFullName ?: @"",
@"profile_pic_url": snapshot.selfProfilePicURL ?: @"",
@"follower_count": @(snapshot.followerCount),
@"following_count": @(snapshot.followingCount),
@"media_count": @(snapshot.mediaCount),
} forUserPK:pk];
[self loadCachedReport];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Analysis complete")
subtitle:[NSString stringWithFormat:SCILocalized(@"%lu followers · %lu following"),
(unsigned long)snapshot.followers.count, (unsigned long)snapshot.following.count]];
}
- (void)resetTapped {
UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Reset analyzer data?")
message:SCILocalized(@"Removes cached snapshots for this account. You'll lose since-last-scan diffs.")
preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Reset") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIProfileAnalyzerStorage resetForUserPK:[SCIUtils currentUserPK]];
[self loadCachedReport];
}]];
[self presentViewController:a animated:YES completion:nil];
}
- (void)infoTapped {
NSString *body = [@[
SCILocalized(@"First scan: we collect your followers and following lists and save them locally."),
SCILocalized(@"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates."),
SCILocalized(@"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon."),
SCILocalized(@"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app."),
SCILocalized(@"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk."),
] componentsJoinedByString:@"\n\n"];
UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"About Profile Analyzer") message:body preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:a animated:YES completion:nil];
}
- (void)alertTitle:(NSString *)title message:(NSString *)msg {
UIAlertController *a = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleDefault handler:nil]];
[self presentViewController:a animated:YES completion:nil];
}
#pragma mark - Table
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 3; }
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
if (section == 0) return (NSInteger)self.categories.count;
if (section == 1) return 1; // Preferences: keep-changes toggle
return 2; // Actions: About + Reset
}
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
if (section == 0) return SCILocalized(@"Categories");
if (section == 1) return SCILocalized(@"Preferences");
return @"";
}
- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)section {
if (section == 1) return SCILocalized(@"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans.");
return nil;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.section == 1) return [self preferencesCellForRow:indexPath.row tableView:tv];
if (indexPath.section == 2) return [self actionCellForRow:indexPath.row tableView:tv];
SCIPACategoryCell *cell = [tv dequeueReusableCellWithIdentifier:@"cat" forIndexPath:indexPath];
SCIPACategoryDescriptor *d = self.categories[indexPath.row];
BOOL waitingForPrev = d.requiresPrevious && !self.report.previous;
BOOL hasReport = self.report.current != nil;
BOOL disabled = waitingForPrev || !hasReport || d.count == 0;
cell.titleLabel.text = d.title;
if (waitingForPrev) {
cell.subtitleLabel.text = SCILocalized(@"Available after your next scan");
} else if (!hasReport) {
cell.subtitleLabel.text = d.subtitle;
} else {
cell.subtitleLabel.text = d.subtitle;
}
cell.countLabel.text = (waitingForPrev || !hasReport) ? @"" : [NSString stringWithFormat:@"%ld", (long)d.count];
cell.iconBadge.backgroundColor = disabled ? [UIColor systemGray3Color] : d.color;
cell.iconView.image = [UIImage systemImageNamed:d.symbol];
cell.contentView.alpha = disabled ? 0.5 : 1.0;
cell.selectionStyle = disabled ? UITableViewCellSelectionStyleNone : UITableViewCellSelectionStyleDefault;
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tv deselectRowAtIndexPath:indexPath animated:YES];
if (indexPath.section == 1) return; // toggle row handles its own tap
if (indexPath.section == 2) {
if (indexPath.row == 0) [self infoTapped];
else [self resetTapped];
return;
}
SCIPACategoryDescriptor *d = self.categories[indexPath.row];
if (d.requiresPrevious && !self.report.previous) return;
if (!self.report.current) return;
if (d.count == 0) return;
[self.navigationController pushViewController:[self listVCForCategory:d] animated:YES];
}
- (UITableViewCell *)preferencesCellForRow:(NSInteger)row tableView:(UITableView *)tv {
static NSString *rid = @"pref";
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
cell.textLabel.text = SCILocalized(@"Keep scan history");
cell.imageView.image = [UIImage systemImageNamed:@"clock.arrow.circlepath"];
cell.imageView.tintColor = [UIColor systemIndigoColor];
UISwitch *sw = [UISwitch new];
sw.on = [SCIUtils getBoolPref:@"profile_analyzer_accumulate"];
sw.onTintColor = [SCIUtils SCIColor_Primary];
[sw addTarget:self action:@selector(accumulateToggled:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
return cell;
}
- (void)accumulateToggled:(UISwitch *)sw {
[[NSUserDefaults standardUserDefaults] setBool:sw.isOn forKey:@"profile_analyzer_accumulate"];
NSString *pk = [SCIUtils currentUserPK];
if (sw.isOn) {
// Promote the current snapshot to baseline immediately.
if (![SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk] && self.report.current) {
[SCIProfileAnalyzerStorage saveBaselineSnapshot:self.report.current forUserPK:pk];
[self loadCachedReport];
}
}
// Flipping off is deferred — the baseline is dropped on the next scan.
}
- (UITableViewCell *)actionCellForRow:(NSInteger)row tableView:(UITableView *)tv {
static NSString *rid = @"action";
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
cell.accessoryType = UITableViewCellAccessoryNone;
cell.imageView.contentMode = UIViewContentModeCenter;
if (row == 0) {
cell.textLabel.text = SCILocalized(@"About Profile Analyzer");
cell.textLabel.textColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
cell.imageView.image = [UIImage systemImageNamed:@"info.circle"];
cell.imageView.tintColor = cell.textLabel.textColor;
} else {
cell.textLabel.text = SCILocalized(@"Reset analyzer data");
cell.textLabel.textColor = [UIColor systemRedColor];
cell.imageView.image = [UIImage systemImageNamed:@"trash"];
cell.imageView.tintColor = [UIColor systemRedColor];
}
return cell;
}
- (UIViewController *)listVCForCategory:(SCIPACategoryDescriptor *)d {
SCIProfileAnalyzerReport *r = self.report;
switch (d.category) {
case SCIPACategoryMutual:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.mutualFollowers kind:SCIPAListKindPlain];
case SCIPACategoryNotFollowingBack:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.notFollowingYouBack kind:SCIPAListKindUnfollow];
case SCIPACategoryDontFollowBack:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youDontFollowBack kind:SCIPAListKindFollow];
case SCIPACategoryNewFollowers:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.recentFollowers kind:SCIPAListKindPlain];
case SCIPACategoryLostFollowers:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.lostFollowers kind:SCIPAListKindPlain];
case SCIPACategoryYouStartedFollowing:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youStartedFollowing kind:SCIPAListKindUnfollow];
case SCIPACategoryYouUnfollowed:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youUnfollowed kind:SCIPAListKindFollow];
case SCIPACategoryProfileUpdates:
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title profileUpdates:r.profileUpdates];
}
}
@end
+59
View File
@@ -160,6 +160,8 @@ static void sciForceUnmuteCell(id videoCell) {
}
}
%group ReelsPauseModeGroup
%hook IGSundialViewerVideoCell
// hidden=YES on play; IG resets it on the next pause.
- (void)sundialVideoPlaybackViewDidStartPlaying:(id)view {
@@ -196,6 +198,57 @@ static void sciForceUnmuteCell(id videoCell) {
}
%end
// ============ PHOTO REELS: TAP-TO-MUTE ============
// Skip IG's single-tap delegate on photo cells and drive the mute via the
// same hardware-switch notification StoryAudioToggle uses.
extern "C" void sciToggleStoryAudio(void);
static BOOL sciIsPhotoMuteEnabled(void) {
return sciIsPausePlayMode() && [SCIUtils getBoolPref:@"reels_photo_tap_mute"];
}
%hook IGSundialViewerPhotoCell
- (void)gestureController:(id)gc didObserveSingleTap:(id)tap {
if (sciIsPhotoMuteEnabled()) { sciToggleStoryAudio(); return; }
%orig;
}
%end
%hook IGSundialViewerCarouselPhotoCell
- (void)gestureController:(id)gc didObserveSingleTap:(id)tap {
if (sciIsPhotoMuteEnabled()) { sciToggleStoryAudio(); return; }
%orig;
}
%end
// Carousels route the tap through the outer cell, so hijack there too —
// but only when the visible page is a photo. Video pages keep %orig.
%hook IGSundialViewerCarouselCell
- (void)gestureController:(id)gc didObserveSingleTap:(id)tap {
if (!sciIsPhotoMuteEnabled()) { %orig; return; }
BOOL hasVideo = NO, hasPhoto = NO;
NSMutableArray<UIView *> *stack = [NSMutableArray arrayWithObject:self];
for (int d = 0; d < 6 && stack.count && !hasVideo; d++) {
NSMutableArray<UIView *> *next = [NSMutableArray array];
for (UIView *sub in stack) {
NSString *cls = NSStringFromClass([sub class]);
if ([cls isEqualToString:@"IGSundialViewerCarouselVideoCell"]) {
if (!CGRectIsEmpty(CGRectIntersection(sub.bounds, self.bounds)) &&
sub.window) hasVideo = YES;
} else if ([cls isEqualToString:@"IGSundialViewerCarouselPhotoCell"]) {
if (!CGRectIsEmpty(CGRectIntersection(sub.bounds, self.bounds)) &&
sub.window) hasPhoto = YES;
}
for (UIView *s in sub.subviews) [next addObject:s];
}
stack = next;
}
if (hasPhoto && !hasVideo) { sciToggleStoryAudio(); return; }
%orig;
}
%end
// ============ UFI: SYNC DOWNLOAD BUTTON + SETUP KVO ============
%hook IGSundialViewerVerticalUFI
@@ -309,9 +362,15 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
}
%end
%end // ReelsPauseModeGroup
// ============ RUNTIME HOOKS ============
%ctor {
if (![[SCIUtils getStringPref:@"reels_tap_control"] isEqualToString:@"pause"]) return;
%init(ReelsPauseModeGroup);
Class toggleClass = objc_getClass("IGSundialPlaybackToggle.IGSundialPlaybackToggleView");
if (toggleClass) {
MSHookMessageEx(toggleClass, @selector(didMoveToSuperview),
+601
View File
@@ -0,0 +1,601 @@
// Reveal poll/slider vote counts and quiz correct answers on story/reel
// stickers, plus force the legacy Quiz sticker back into the composer tray.
//
// Prefs:
// stories_show_poll_votes_count / stories_show_quiz_answer
// reels_show_poll_votes_count / reels_show_quiz_answer
// force_enable_quiz_sticker
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../StoriesAndMessages/StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
// ============ Runtime helpers ============
static id sciCallMaybe(id obj, NSString *selName) {
SEL sel = NSSelectorFromString(selName);
if (!obj || ![obj respondsToSelector:sel]) return nil;
@try { return ((id(*)(id,SEL))objc_msgSend)(obj, sel); }
@catch (__unused id e) { return nil; }
}
static NSArray *sciArrayIvar(id obj, const char *name) {
if (!obj || !name) return nil;
Class cls = [obj class];
while (cls && cls != [NSObject class]) {
Ivar iv = class_getInstanceVariable(cls, name);
if (iv) {
id v = object_getIvar(obj, iv);
return [v isKindOfClass:[NSArray class]] ? (NSArray *)v : nil;
}
cls = class_getSuperclass(cls);
}
return nil;
}
// ============ Context detection (stories vs reels) ============
// Reels surface via IGSundialFeedViewController and also via contextual
// feeds (profile reels) that host Sundial-prefixed cells.
static BOOL sciIsInReelsContext(UIView *anchor) {
Class reelCls = NSClassFromString(@"IGSundialFeedViewController");
for (UIResponder *r = anchor; r; r = r.nextResponder) {
if (reelCls && [r isKindOfClass:reelCls]) return YES;
if ([NSStringFromClass([r class]) hasPrefix:@"IGSundial"]) return YES;
}
return NO;
}
static BOOL sciPrefShowPollCounts(UIView *anchor) {
return [SCIUtils getBoolPref:
sciIsInReelsContext(anchor)
? @"reels_show_poll_votes_count"
: @"stories_show_poll_votes_count"];
}
static BOOL sciPrefShowQuizAnswer(UIView *anchor) {
return [SCIUtils getBoolPref:
sciIsInReelsContext(anchor)
? @"reels_show_quiz_answer"
: @"stories_show_quiz_answer"];
}
// ============ Media lookup ============
static UIViewController *sciFindAnyStoryViewerVC(UIView *start) {
Class target = NSClassFromString(@"IGStoryViewerViewController");
if (!target) return nil;
for (UIResponder *r = start; r; r = r.nextResponder) {
if ([r isKindOfClass:target]) return (UIViewController *)r;
}
if (sciActiveStoryViewerVC) return sciActiveStoryViewerVC;
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *w in ((UIWindowScene *)scene).windows) {
NSMutableArray *stack = [NSMutableArray array];
if (w.rootViewController) [stack addObject:w.rootViewController];
while (stack.count) {
UIViewController *cur = stack.lastObject;
[stack removeLastObject];
if ([cur isKindOfClass:target]) return cur;
for (UIViewController *child in cur.childViewControllers) [stack addObject:child];
if (cur.presentedViewController) [stack addObject:cur.presentedViewController];
}
}
}
return nil;
}
static IGMedia *sciCurrentStoryMedia(UIView *anchor) {
UIViewController *vc = sciFindAnyStoryViewerVC(anchor);
if (!vc) return nil;
IGMedia *media = nil;
@try {
id vm = sciCall(vc, @selector(currentViewModel));
id item = sciCall1(vc, @selector(currentStoryItemForViewModel:), vm);
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) media = (IGMedia *)item;
else media = sciExtractMediaFromItem(item);
} @catch (__unused id e) {}
return media;
}
// Walks the responder chain probing common getters for an IGMedia — covers
// reel cells where no story viewer VC is in the chain.
static IGMedia *sciFindMediaFromAnchor(UIView *anchor) {
IGMedia *m = sciCurrentStoryMedia(anchor);
if (m) return m;
Class mediaCls = NSClassFromString(@"IGMedia");
if (!mediaCls) return nil;
NSArray *probes = @[@"media", @"post", @"feedItem", @"igMedia", @"storyItem",
@"item", @"model", @"backingModel", @"storyMedia",
@"currentMedia", @"currentMediaItem", @"currentStoryItem",
@"mediaModel", @"mediaItem"];
for (UIResponder *r = anchor; r; r = r.nextResponder) {
for (NSString *sel in probes) {
id v = sciCallMaybe(r, sel);
if ([v isKindOfClass:mediaCls]) return (IGMedia *)v;
IGMedia *nested = sciExtractMediaFromItem(v);
if (nested) return nested;
}
}
return nil;
}
// View-local sticker models zero their tallies for unvoted viewers; the real
// counts live on IGMedia.{storyPolls,storyQuizs,storySliders} — match by pk.
static id sciAuthoritativeSticker(UIView *anchor, NSString *arrayKey, NSString *innerKey, id viewModel, NSString *idKey) {
IGMedia *media = sciFindMediaFromAnchor(anchor);
if (!media) return nil;
NSArray *arr = sciCallMaybe(media, arrayKey);
if (![arr isKindOfClass:[NSArray class]]) return nil;
NSString *viewId = idKey ? [sciCallMaybe(viewModel, idKey) description] : nil;
for (id entry in arr) {
id sticker = sciCallMaybe(entry, innerKey);
if (!sticker) continue;
if (viewId.length) {
NSString *stickerId = [sciCallMaybe(sticker, idKey) description];
if ([stickerId isEqualToString:viewId]) return sticker;
}
}
if (arr.count > 0) {
id sticker = sciCallMaybe(arr[0], innerKey);
if (sticker) return sticker;
}
return nil;
}
static NSInteger sciHighestTallyIndex(NSArray *tallies) {
NSInteger best = -1, bestCount = 0;
for (NSUInteger i = 0; i < tallies.count; i++) {
NSInteger c = [(NSNumber *)sciCallMaybe(tallies[i], @"totalCount") integerValue];
if (c > bestCount) { best = (NSInteger)i; bestCount = c; }
}
return best;
}
// ============ Editing/composer detection ============
static BOOL sciIsStickerEditing(UIView *v) {
Class cls = [v class];
while (cls && cls != [NSObject class]) {
const char *names[] = { "_isEditing", "_editing" };
for (size_t k = 0; k < sizeof(names)/sizeof(names[0]); k++) {
Ivar iv = class_getInstanceVariable(cls, names[k]);
if (!iv) continue;
ptrdiff_t off = ivar_getOffset(iv);
BOOL val = NO;
memcpy(&val, (uint8_t *)(__bridge void *)v + off, sizeof(val));
if (val) return YES;
}
cls = class_getSuperclass(cls);
}
NSArray *composers = @[@"IGStoryStickerTrayViewController",
@"IGStoryPostCaptureEditingViewController",
@"IGStoryMediaCompositionEditingViewController"];
for (UIResponder *r = v; r; r = r.nextResponder) {
NSString *cn = NSStringFromClass([r class]);
for (NSString *c in composers) if ([cn isEqualToString:c]) return YES;
}
return NO;
}
// Keeps overlays in sync with the current item on story/reel nav.
static void sciForceRelayoutStickers(UIView *root) {
if (!root) return;
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
Class pollV2 = NSClassFromString(@"IGPollStickerV2View");
Class pollV1 = NSClassFromString(@"IGPollStickerView");
Class slider = NSClassFromString(@"IGSliderStickerView");
Class quiz = NSClassFromString(@"IGQuizStickerView");
while (stack.count) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ((pollV2 && [v isKindOfClass:pollV2]) ||
(pollV1 && [v isKindOfClass:pollV1]) ||
(slider && [v isKindOfClass:slider]) ||
(quiz && [v isKindOfClass:quiz])) {
[v setNeedsLayout];
[v layoutIfNeeded];
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
}
// Sticker views often lay out once with zero bounds / no cells; retries
// catch the settled state without relying on a second layoutSubviews.
static void sciScheduleRetries(UIView *view, SEL action) {
__weak UIView *weak = view;
NSArray *delays = @[@0.1, @0.3, @0.7];
for (NSNumber *d in delays) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(d.doubleValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
UIView *s = weak;
if (s && s.window) ((void(*)(id,SEL))objc_msgSend)(s, action);
});
}
}
// ============ Overlay badges / highlight ============
static const char kSciPollBadgeKey = 0;
static const char kSciSliderBadgeKey = 0;
static const char kSciQuizHighlightKey = 0;
static UILabel *sciMakeBadge(void) {
UILabel *b = [[UILabel alloc] init];
b.font = [UIFont systemFontOfSize:13 weight:UIFontWeightBold];
b.textColor = [UIColor whiteColor];
b.backgroundColor = [UIColor colorWithRed:0.0 green:0.45 blue:0.95 alpha:0.92];
b.textAlignment = NSTextAlignmentCenter;
b.layer.cornerRadius = 10;
b.clipsToBounds = YES;
b.userInteractionEnabled = NO;
return b;
}
static void sciAttachPollCountBadge(UIView *optionView, NSInteger count, double total) {
UILabel *badge = objc_getAssociatedObject(optionView, &kSciPollBadgeKey);
if (!badge) {
badge = sciMakeBadge();
[optionView addSubview:badge];
objc_setAssociatedObject(optionView, &kSciPollBadgeKey, badge, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
badge.text = total > 0
? [NSString stringWithFormat:@" %ld · %.0f%% ", (long)count, 100.0 * (double)count / total]
: [NSString stringWithFormat:@" %ld ", (long)count];
[badge sizeToFit];
CGSize sz = badge.bounds.size;
sz.width += 10;
sz.height = MAX(sz.height + 4, 22);
CGRect b = optionView.bounds;
badge.frame = CGRectMake(b.size.width - sz.width - 4, -sz.height * 0.35, sz.width, sz.height);
badge.layer.zPosition = 1000;
[optionView bringSubviewToFront:badge];
optionView.clipsToBounds = NO;
}
static void sciAttachSliderBadge(UIView *sliderView, NSUInteger count, double avg) {
UILabel *badge = objc_getAssociatedObject(sliderView, &kSciSliderBadgeKey);
if (!badge) {
badge = sciMakeBadge();
[sliderView addSubview:badge];
objc_setAssociatedObject(sliderView, &kSciSliderBadgeKey, badge, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
badge.text = [NSString stringWithFormat:@" %lu votes · avg %.0f%% ",
(unsigned long)count, avg * 100.0];
[badge sizeToFit];
CGSize sz = badge.bounds.size;
sz.height = MAX(sz.height, 18);
CGRect b = sliderView.bounds;
badge.frame = CGRectMake((b.size.width - sz.width) * 0.5, -sz.height - 4, sz.width, sz.height);
[sliderView bringSubviewToFront:badge];
}
static void sciAttachQuizHighlight(UIView *optionView, CGFloat cornerRadius) {
CAShapeLayer *hl = objc_getAssociatedObject(optionView, &kSciQuizHighlightKey);
if (!hl) {
hl = [CAShapeLayer layer];
UIColor *green = [UIColor colorWithRed:0.24 green:0.76 blue:0.38 alpha:1.0];
hl.fillColor = [green colorWithAlphaComponent:0.35].CGColor;
hl.strokeColor = green.CGColor;
hl.lineWidth = 2.0;
hl.zPosition = 50;
[optionView.layer addSublayer:hl];
objc_setAssociatedObject(optionView, &kSciQuizHighlightKey, hl, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}
CGRect b = CGRectInset(optionView.bounds, 1.0, 1.0);
hl.frame = optionView.bounds;
hl.path = cornerRadius > 0
? [UIBezierPath bezierPathWithRoundedRect:b cornerRadius:cornerRadius].CGPath
: [UIBezierPath bezierPathWithRect:b].CGPath;
}
static void sciRemovePollCountBadge(UIView *v) {
UILabel *b = objc_getAssociatedObject(v, &kSciPollBadgeKey);
if (b) { [b removeFromSuperview]; objc_setAssociatedObject(v, &kSciPollBadgeKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
}
static void sciRemoveSliderBadge(UIView *v) {
UILabel *b = objc_getAssociatedObject(v, &kSciSliderBadgeKey);
if (b) { [b removeFromSuperview]; objc_setAssociatedObject(v, &kSciSliderBadgeKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
}
static void sciRemoveQuizHighlight(UIView *v) {
CAShapeLayer *l = objc_getAssociatedObject(v, &kSciQuizHighlightKey);
if (l) { [l removeFromSuperlayer]; objc_setAssociatedObject(v, &kSciQuizHighlightKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
}
// ============ Poll reveal (V2 + legacy) ============
static void sciApplyPollReveal(UIView *pollView, NSArray *opts) {
BOOL showCounts = sciPrefShowPollCounts(pollView);
BOOL showWinner = sciPrefShowQuizAnswer(pollView);
BOOL editing = sciIsStickerEditing(pollView);
if ((!showCounts && !showWinner) || editing) {
for (UIView *opt in opts) {
if (![opt isKindOfClass:[UIView class]]) continue;
sciRemovePollCountBadge(opt);
sciRemoveQuizHighlight(opt);
}
return;
}
id viewModel = sciCallMaybe(pollView, @"igapiStickerModel") ?: sciCallMaybe(pollView, @"exportModel");
id model = sciAuthoritativeSticker(pollView, @"storyPolls", @"pollSticker", viewModel, @"pollId") ?: viewModel;
NSArray *tallies = sciCallMaybe(model, @"tallies");
if (![tallies isKindOfClass:[NSArray class]]) tallies = nil;
double total = [(NSNumber *)sciCallMaybe(model, @"totalVotes") doubleValue];
NSNumber *correctAnswer = sciCallMaybe(model, @"correctAnswer");
NSInteger winnerIdx = correctAnswer ? correctAnswer.integerValue : sciHighestTallyIndex(tallies ?: @[]);
// V2 poll preallocates up to 4 option views; only render on real slots.
NSUInteger realOptCount = tallies ? tallies.count : 0;
for (NSUInteger i = 0; i < opts.count; i++) {
UIView *opt = opts[i];
if (![opt isKindOfClass:[UIView class]]) continue;
if (i >= realOptCount) {
sciRemovePollCountBadge(opt);
sciRemoveQuizHighlight(opt);
continue;
}
if (showCounts) {
NSInteger c = [(NSNumber *)sciCallMaybe(tallies[i], @"totalCount") integerValue];
sciAttachPollCountBadge(opt, c, total);
} else {
sciRemovePollCountBadge(opt);
}
if (showWinner && winnerIdx >= 0 && (NSInteger)i == winnerIdx) {
sciAttachQuizHighlight(opt, 0.0);
} else {
sciRemoveQuizHighlight(opt);
}
}
}
///////////////////////////////////////////////////////////////////////////////
// STORIES //
///////////////////////////////////////////////////////////////////////////////
%hook IGStoryViewerViewController
- (void)viewDidLayoutSubviews {
%orig;
sciForceRelayoutStickers(((UIViewController *)self).view);
}
- (void)viewDidAppear:(BOOL)animated {
%orig;
dispatch_async(dispatch_get_main_queue(), ^{
sciForceRelayoutStickers(((UIViewController *)self).view);
});
}
%end
// Force-inject IGQuizStickerTrayModel into the composer tray (IG keeps the
// class + handler wired but filtered it out of the picker).
static IGQuizStickerTrayModel *sciMakeQuizTrayModel(id neighborModel) {
Class cls = NSClassFromString(@"IGQuizStickerTrayModel");
if (!cls) return nil;
id quiz = [[cls alloc] init];
if (!quiz) return nil;
@try {
id section = sciCallMaybe(neighborModel, @"stickerSection");
if (section && [quiz respondsToSelector:@selector(setStickerSection:)]) {
[(IGQuizStickerTrayModel *)quiz setStickerSection:section];
}
} @catch (__unused id e) {}
if ([quiz respondsToSelector:@selector(setPrompts:)]) {
[(IGQuizStickerTrayModel *)quiz setPrompts:@[]];
}
return quiz;
}
%hook IGStoryStickerDataSourceImpl
- (NSArray *)items {
NSArray *orig = %orig;
if (!orig || ![SCIUtils getBoolPref:@"force_enable_quiz_sticker"]) return orig;
for (id m in orig) {
if ([NSStringFromClass([m class]) rangeOfString:@"Quiz" options:NSCaseInsensitiveSearch].location != NSNotFound) {
return orig;
}
}
// Slot quiz next to the poll tray model so it lands in the interactive row.
NSUInteger insertIdx = NSNotFound;
id neighbor = nil;
for (NSUInteger i = 0; i < orig.count; i++) {
NSString *cn = NSStringFromClass([orig[i] class]);
if ([cn isEqualToString:@"IGPollStickerV2TrayModel"] ||
[cn isEqualToString:@"IGPollStickerTrayModel"]) {
insertIdx = i + 1;
neighbor = orig[i];
break;
}
}
if (insertIdx == NSNotFound) {
for (NSUInteger i = 0; i < orig.count; i++) {
if ([NSStringFromClass([orig[i] class]) isEqualToString:@"IGQuestionAnswerStickerModel"]) {
insertIdx = i + 1;
neighbor = orig[i];
break;
}
}
}
IGQuizStickerTrayModel *quiz = sciMakeQuizTrayModel(neighbor);
if (!quiz) return orig;
NSMutableArray *mutated = [orig mutableCopy];
if (insertIdx == NSNotFound) insertIdx = mutated.count;
[mutated insertObject:quiz atIndex:insertIdx];
return mutated;
}
%end
///////////////////////////////////////////////////////////////////////////////
// REELS //
///////////////////////////////////////////////////////////////////////////////
%hook IGSundialFeedViewController
- (void)viewDidLayoutSubviews {
%orig;
sciForceRelayoutStickers(((UIViewController *)self).view);
}
%end
///////////////////////////////////////////////////////////////////////////////
// STICKER VIEW HOOKS — shared by stories + reels //
///////////////////////////////////////////////////////////////////////////////
// IGPollStickerV2View
%hook IGPollStickerV2View
%new
- (void)sci_applyPollReveal {
sciApplyPollReveal(self, sciArrayIvar(self, "_optionViews") ?: @[]);
}
- (void)layoutSubviews {
%orig;
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyPollReveal));
}
- (void)didMoveToWindow {
%orig;
if (self.window) sciScheduleRetries(self, @selector(sci_applyPollReveal));
}
%end
// IGPollStickerView (legacy)
%hook IGPollStickerView
%new
- (void)sci_applyPollReveal {
NSArray *opts = sciArrayIvar(self, "_optionViews")
?: sciArrayIvar(self, "_voteOptionViews")
?: sciArrayIvar(self, "_options");
sciApplyPollReveal(self, opts ?: @[]);
}
- (void)layoutSubviews {
%orig;
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyPollReveal));
}
- (void)didMoveToWindow {
%orig;
if (self.window) sciScheduleRetries(self, @selector(sci_applyPollReveal));
}
%end
// IGSliderStickerView
%hook IGSliderStickerView
%new
- (void)sci_applySliderReveal {
if (!sciPrefShowPollCounts(self) || sciIsStickerEditing(self)) {
sciRemoveSliderBadge(self);
return;
}
NSUInteger count = 0;
double avg = 0.0;
id model = sciCallMaybe(self, @"igapiStickerModel") ?: sciCallMaybe(self, @"exportModel");
if (model) {
count = [(NSNumber *)sciCallMaybe(model, @"sliderVoteCount") unsignedIntegerValue];
avg = [(NSNumber *)sciCallMaybe(model, @"sliderVoteAverage") doubleValue];
}
if (count == 0 && avg == 0.0) {
Ivar vc = class_getInstanceVariable([self class], "_voteCount");
if (vc) memcpy(&count, (uint8_t *)(__bridge void *)self + ivar_getOffset(vc), sizeof(count));
Ivar va = class_getInstanceVariable([self class], "_averageVote");
if (va) avg = [(NSNumber *)object_getIvar(self, va) doubleValue];
}
sciAttachSliderBadge(self, count, avg);
}
- (void)didMoveToWindow {
%orig;
if (self.window) sciScheduleRetries(self, @selector(sci_applySliderReveal));
}
- (void)layoutSubviews {
%orig;
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applySliderReveal));
}
// Refresh after the vote posts — count/average land on the ivars async.
- (void)emojiSliderDidEndSliding:(id)arg {
%orig;
sciScheduleRetries(self, @selector(sci_applySliderReveal));
}
%end
// IGQuizStickerView
%hook IGQuizStickerView
%new
- (void)sci_applyQuizReveal {
BOOL showWinner = sciPrefShowQuizAnswer(self);
BOOL editing = sciIsStickerEditing(self);
UICollectionView *cv = nil;
Ivar cvIvar = class_getInstanceVariable([self class], "_optionsCollectionView");
if (cvIvar) {
id v = object_getIvar(self, cvIvar);
if ([v isKindOfClass:[UICollectionView class]]) cv = (UICollectionView *)v;
}
// Populate visibleCells before we walk them; IG also ships quiz
// interaction off on the consumption path, so restore it.
if (cv) { [cv setNeedsLayout]; [cv layoutIfNeeded]; cv.userInteractionEnabled = YES; }
self.userInteractionEnabled = YES;
NSArray *cells = cv ? cv.visibleCells : @[];
if (!showWinner || editing) {
for (UIView *cell in cells) sciRemoveQuizHighlight(cell);
return;
}
id viewModel = sciCallMaybe(self, @"igapiStickerModel") ?: sciCallMaybe(self, @"exportModel");
id model = sciAuthoritativeSticker(self, @"storyQuizs", @"quizSticker", viewModel, @"quizId") ?: viewModel;
NSNumber *correct = sciCallMaybe(model, @"correctAnswer");
NSInteger winnerIdx = correct ? correct.integerValue : -1;
// Quiz cell corner radius lives on a sublayer; hardcode to match.
for (UICollectionViewCell *cell in cells) {
if (![cell isKindOfClass:[UICollectionViewCell class]]) continue;
NSIndexPath *ip = cv ? [cv indexPathForCell:cell] : nil;
NSInteger i = ip ? ip.row : -1;
if (i < 0) continue;
if (winnerIdx >= 0 && i == winnerIdx) {
sciAttachQuizHighlight(cell, 18.0);
} else {
sciRemoveQuizHighlight(cell);
}
}
}
- (void)layoutSubviews {
%orig;
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyQuizReveal));
sciScheduleRetries(self, @selector(sci_applyQuizReveal));
}
- (void)didMoveToWindow {
%orig;
if (self.window) sciScheduleRetries(self, @selector(sci_applyQuizReveal));
}
%end
@@ -0,0 +1,269 @@
// DM disappearing-media overlay buttons — action / eye / audio (tags 13421344).
// Hooks IGDirectVisualMessageViewerController directly; reads only dm_visual_* prefs.
#import "OverlayHelpers.h"
#import "../../SCIChrome.h"
#import "../../ActionButton/SCIMediaViewer.h"
// Per-button weak ref to the owning DM VC so handlers skip the responder walk.
static const void *kSCIDMOwnerVCKey = &kSCIDMOwnerVCKey;
// MARK: - Menu item builders
static NSArray<UIMenuElement *> *sciDMActionMenuItems(UIViewController *dmVC, UIView *sourceView) {
__weak UIView *weakSource = sourceView;
return @[
[UIAction actionWithTitle:SCILocalized(@"Expand")
image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"]
identifier:nil
handler:^(UIAction *a) { sciDMExpandMedia(dmVC); }],
[UIAction actionWithTitle:SCILocalized(@"Messages settings")
image:[UIImage systemImageNamed:@"gearshape"]
identifier:nil
handler:^(UIAction *a) { sciOpenMessagesSettings(weakSource); }],
[UIAction actionWithTitle:SCILocalized(@"Download and share")
image:[UIImage systemImageNamed:@"square.and.arrow.up"]
identifier:nil
handler:^(UIAction *a) { sciDMShareMedia(dmVC); }],
[UIAction actionWithTitle:SCILocalized(@"Download to Photos")
image:[UIImage systemImageNamed:@"square.and.arrow.down"]
identifier:nil
handler:^(UIAction *a) { sciDMDownloadMedia(dmVC); }],
];
}
static NSArray<UIMenuElement *> *sciDMEyeMenuItems(UIViewController *dmVC, UIView *sourceView) {
__weak UIView *weakSource = sourceView;
return @[
[UIAction actionWithTitle:SCILocalized(@"Mark as viewed")
image:[UIImage systemImageNamed:@"eye"]
identifier:nil
handler:^(UIAction *a) { sciDMMarkCurrentAsViewed(dmVC); }],
[UIAction actionWithTitle:SCILocalized(@"Messages settings")
image:[UIImage systemImageNamed:@"gearshape"]
identifier:nil
handler:^(UIAction *a) { sciOpenMessagesSettings(weakSource); }],
];
}
static void sciDMApplyTapMenu(UIButton *btn, __weak UIViewController *weakDMVC) {
__weak UIButton *weakBtn = btn;
UIDeferredMenuElement *deferred = [UIDeferredMenuElement elementWithUncachedProvider:
^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
UIViewController *dmVC = weakDMVC;
UIButton *strongBtn = weakBtn;
if (!dmVC || !strongBtn) { completion(@[]); return; }
completion(sciDMActionMenuItems(dmVC, strongBtn));
}];
btn.menu = [UIMenu menuWithChildren:@[deferred]];
btn.showsMenuAsPrimaryAction = YES;
}
// MARK: - Button delegate (tap handlers)
@interface SCIDMButtonDelegate : NSObject
+ (instancetype)shared;
- (void)actionTapped:(UIButton *)sender;
- (void)eyeTapped:(UIButton *)sender;
- (void)audioTapped:(UIButton *)sender;
@end
@implementation SCIDMButtonDelegate
+ (instancetype)shared {
static SCIDMButtonDelegate *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [SCIDMButtonDelegate new]; });
return s;
}
- (UIViewController *)ownerForButton:(UIView *)btn {
return objc_getAssociatedObject(btn, kSCIDMOwnerVCKey);
}
// Default-tap path (pref != menu).
- (void)actionTapped:(UIButton *)sender {
UIViewController *dmVC = [self ownerForButton:sender];
if (!dmVC) return;
NSString *tap = [SCIUtils getStringPref:@"dm_visual_action_default"];
if ([tap isEqualToString:@"expand"]) sciDMExpandMedia(dmVC);
else if ([tap isEqualToString:@"download_share"]) sciDMShareMedia(dmVC);
else if ([tap isEqualToString:@"download_photos"]) sciDMDownloadMedia(dmVC);
}
- (void)eyeTapped:(UIButton *)sender {
UIViewController *dmVC = [self ownerForButton:sender];
if (!dmVC) return;
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
sciDMMarkCurrentAsViewed(dmVC);
}
- (void)audioTapped:(SCIChromeButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[haptic impactOccurred];
sciToggleStoryAudio();
sender.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
}
@end
// MARK: - Long-press menu builder
// UIButton.menu + showsMenuAsPrimaryAction=NO is iOS's native pattern for
// "tap fires action, long-press shows menu". Compose a UIDeferredMenuElement
// so the menu rebuilds per presentation — owner lookup stays fresh.
static void sciDMAttachLongPressMenu(SCIChromeButton *btn, NSInteger tag) {
__weak SCIChromeButton *weakBtn = btn;
UIDeferredMenuElement *deferred = [UIDeferredMenuElement elementWithUncachedProvider:
^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
SCIChromeButton *strongBtn = weakBtn;
UIViewController *dmVC = strongBtn ? objc_getAssociatedObject(strongBtn, kSCIDMOwnerVCKey) : nil;
if (!dmVC) { completion(@[]); return; }
NSArray<UIMenuElement *> *items = (tag == SCI_DM_ACTION_TAG)
? sciDMActionMenuItems(dmVC, strongBtn)
: sciDMEyeMenuItems(dmVC, strongBtn);
completion(items);
}];
btn.menu = [UIMenu menuWithChildren:@[deferred]];
btn.showsMenuAsPrimaryAction = NO;
}
// MARK: - Overlay injection
static void sciDMInstallButtons(UIViewController *dmVC) {
if (!dmVC || !dmVC.isViewLoaded) return;
UIView *overlay = sciFindOverlayInView(dmVC.view);
if (!overlay) return;
// Kill any story-tag injections from the shared overlay hook.
UIView *sA = [overlay viewWithTag:SCI_STORY_ACTION_TAG]; if (sA) [sA removeFromSuperview];
UIView *sE = [overlay viewWithTag:SCI_STORY_EYE_TAG]; if (sE) [sE removeFromSuperview];
UIView *sU = [overlay viewWithTag:SCI_STORY_AUDIO_TAG]; if (sU) [sU removeFromSuperview];
SCIDMButtonDelegate *dg = [SCIDMButtonDelegate shared];
// --- Action button (tag 1342) ---
UIView *staleAction = [overlay viewWithTag:SCI_DM_ACTION_TAG];
if (staleAction) [staleAction removeFromSuperview];
if ([SCIUtils getBoolPref:@"dm_visual_action_button"]) {
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:18 diameter:36];
btn.tag = SCI_DM_ACTION_TAG;
objc_setAssociatedObject(btn, kSCIDMOwnerVCKey, dmVC, OBJC_ASSOCIATION_ASSIGN);
[overlay addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.trailingAnchor constraintEqualToAnchor:overlay.trailingAnchor constant:-12],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
NSString *defaultTap = [SCIUtils getStringPref:@"dm_visual_action_default"];
if (!defaultTap.length || [defaultTap isEqualToString:@"menu"]) {
sciDMApplyTapMenu(btn, dmVC);
} else {
// Tap = default action, long-press = full menu.
[btn addTarget:dg action:@selector(actionTapped:) forControlEvents:UIControlEventTouchUpInside];
sciDMAttachLongPressMenu(btn, SCI_DM_ACTION_TAG);
}
}
// --- Eye / mark-as-viewed (tag 1343) ---
UIView *staleEye = [overlay viewWithTag:SCI_DM_EYE_TAG];
if (staleEye) [staleEye removeFromSuperview];
if ([SCIUtils getBoolPref:@"dm_visual_seen_button"]) {
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"eye" pointSize:18 diameter:36];
btn.tag = SCI_DM_EYE_TAG;
objc_setAssociatedObject(btn, kSCIDMOwnerVCKey, dmVC, OBJC_ASSOCIATION_ASSIGN);
[btn addTarget:dg action:@selector(eyeTapped:) forControlEvents:UIControlEventTouchUpInside];
sciDMAttachLongPressMenu(btn, SCI_DM_EYE_TAG);
[overlay addSubview:btn];
UIView *anchor = [overlay viewWithTag:SCI_DM_ACTION_TAG];
if (anchor) {
[NSLayoutConstraint activateConstraints:@[
[btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor],
[btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
} else {
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.trailingAnchor constraintEqualToAnchor:overlay.trailingAnchor constant:-12],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
}
}
// --- Audio toggle (tag 1344) ---
UIView *staleAudio = [overlay viewWithTag:SCI_DM_AUDIO_TAG];
if (staleAudio) [staleAudio removeFromSuperview];
sciInitStoryAudioState();
if ([SCIUtils getBoolPref:@"dm_visual_audio_toggle"]) {
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:icon pointSize:14 diameter:28];
btn.tag = SCI_DM_AUDIO_TAG;
[btn addTarget:dg action:@selector(audioTapped:) forControlEvents:UIControlEventTouchUpInside];
[overlay addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.leadingAnchor constraintEqualToAnchor:overlay.leadingAnchor constant:12],
[btn.widthAnchor constraintEqualToConstant:28],
[btn.heightAnchor constraintEqualToConstant:28]
]];
}
}
// Rebuild only when an enabled button is missing — handles overlay recycling.
static void sciDMEnsureButtons(UIViewController *dmVC) {
if (!dmVC || !dmVC.isViewLoaded) return;
UIView *overlay = sciFindOverlayInView(dmVC.view);
if (!overlay) return;
BOOL needAction = [SCIUtils getBoolPref:@"dm_visual_action_button"] && ![overlay viewWithTag:SCI_DM_ACTION_TAG];
BOOL needEye = [SCIUtils getBoolPref:@"dm_visual_seen_button"] && ![overlay viewWithTag:SCI_DM_EYE_TAG];
BOOL needAudio = [SCIUtils getBoolPref:@"dm_visual_audio_toggle"] && ![overlay viewWithTag:SCI_DM_AUDIO_TAG];
if (needAction || needEye || needAudio) sciDMInstallButtons(dmVC);
}
// MARK: - VC hook
%group DMOverlayGroup
%hook IGDirectVisualMessageViewerController
- (void)viewDidAppear:(BOOL)animated {
%orig;
sciDMInstallButtons(self);
}
- (void)viewDidLayoutSubviews {
%orig;
sciDMEnsureButtons(self);
}
- (void)viewWillDisappear:(BOOL)animated {
%orig;
if (!self.isViewLoaded) return;
UIView *overlay = sciFindOverlayInView(self.view);
if (!overlay) return;
UIView *a = [overlay viewWithTag:SCI_DM_ACTION_TAG]; if (a) [a removeFromSuperview];
UIView *e = [overlay viewWithTag:SCI_DM_EYE_TAG]; if (e) [e removeFromSuperview];
UIView *u = [overlay viewWithTag:SCI_DM_AUDIO_TAG]; if (u) [u removeFromSuperview];
}
%end
%end // DMOverlayGroup
%ctor {
if ([SCIUtils getBoolPref:@"dm_visual_action_button"] ||
[SCIUtils getBoolPref:@"dm_visual_seen_button"] ||
[SCIUtils getBoolPref:@"dm_visual_audio_toggle"]) {
%init(DMOverlayGroup);
}
}
@@ -157,7 +157,7 @@ void sciTriggerStoryMarkSeen(UIViewController *storyVC) {
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
if (!overlayCls) return;
SEL markSel = @selector(sciMarkSeenTapped:);
SEL markSel = @selector(sciStoryMarkSeenTapped:);
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
@@ -24,6 +24,8 @@ static BOOL sciPlatterContainsHiddenButton(UIView *platter) {
return NO;
}
%group HideCallButtonsGroup
// Block taps in case a hidden button still receives hit-test events during transitions.
%hook IGDirectThreadCallButtonsCoordinator
- (void)_didTapAudioButton:(id)arg1 {
@@ -88,3 +90,12 @@ static void sciRepackPlatters(UIView *container) {
}
}
%end
%end // HideCallButtonsGroup
%ctor {
if ([SCIUtils getBoolPref:@"hide_voice_call_button"] ||
[SCIUtils getBoolPref:@"hide_video_call_button"]) {
%init(HideCallButtonsGroup);
}
}
+48 -36
View File
@@ -97,8 +97,8 @@ static UIImage *sciGIFImageFromCell(UIView *cell) {
return nil;
}
// Get audio URL from the cell's view model
static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) {
// Audio track from the note cell's view model. 426 added launcherSet.
static id sciAudioTrackFromCell(UIView *cell) {
if (!cell) return nil;
Ivar vmIvar = class_getInstanceVariable([cell class], "viewModel");
if (!vmIvar) vmIvar = class_getInstanceVariable([cell class], "_viewModel");
@@ -106,41 +106,48 @@ static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) {
id vm = object_getIvar(cell, vmIvar);
if (!vm) return nil;
SEL audioSel = NSSelectorFromString(@"audioTrackWithUserMap:");
if (![vm respondsToSelector:audioSel]) return nil;
SEL audioSel2 = NSSelectorFromString(@"audioTrackWithUserMap:launcherSet:");
SEL audioSel1 = NSSelectorFromString(@"audioTrackWithUserMap:");
@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) {}
}
if ([vm respondsToSelector:audioSel2]) {
id session = [SCIUtils activeUserSession];
id launcher = nil;
@try { launcher = session ? [session valueForKey:@"launcherSet"] : nil; } @catch (__unused id e) {}
return ((id(*)(id,SEL,id,id))objc_msgSend)(vm, audioSel2, nil, launcher);
}
if ([vm respondsToSelector:audioSel1]) {
return ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel1, nil);
}
} @catch (__unused id e) {}
return nil;
}
// Pull URL from the track's IGAsyncTask — sync if cached, else async.
static void sciResolveAudioURL(id track, void (^completion)(NSURL *)) {
if (!track || !completion) { if (completion) completion(nil); return; }
id task = nil;
@try {
if ([track respondsToSelector:@selector(audioFileURLTask)])
task = ((id(*)(id,SEL))objc_msgSend)(track, @selector(audioFileURLTask));
} @catch (__unused id e) {}
if (!task) { completion(nil); return; }
@try {
id res = [task valueForKey:@"result"];
if ([res isKindOfClass:[NSURL class]]) { completion(res); return; }
} @catch (__unused id e) {}
SEL onSuccess = NSSelectorFromString(@"onSuccess:");
if (![task respondsToSelector:onSuccess]) { completion(nil); return; }
void (^cb)(id) = ^(id resolved) {
NSURL *u = [resolved isKindOfClass:[NSURL class]] ? resolved : nil;
dispatch_async(dispatch_get_main_queue(), ^{ completion(u); });
};
@try {
((void(*)(id,SEL,id))objc_msgSend)(task, onSuccess, cb);
} @catch (__unused id e) { completion(nil); }
}
static SCIDownloadDelegate *sciNoteDl = nil;
static void (*orig_present)(UIViewController *, SEL, UIViewController *, BOOL, id);
@@ -254,13 +261,18 @@ static void hook_present(UIViewController *self, SEL _cmd, UIViewController *vc,
}]];
}
// Audio (style=1): download from audioFileURL
NSURL *audioURL = sciAudioURLFromCell(cell, note);
if (audioURL) {
id audioTrack = sciAudioTrackFromCell(cell);
if (audioTrack) {
[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];
sciResolveAudioURL(audioTrack, ^(NSURL *audioURL) {
if (!audioURL) {
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Audio URL not available")];
return;
}
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:NO];
[sciNoteDl downloadFileWithURL:audioURL fileExtension:@"m4a" hudLabel:nil];
});
}]];
}
@@ -1,649 +0,0 @@
// 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;
extern "C" NSMutableSet *sciAllowedSeenPKs;
extern "C" void sciAllowSeenForPK(id);
extern "C" BOOL sciIsCurrentStoryOwnerExcluded(void);
extern "C" NSDictionary *sciCurrentStoryOwnerInfo(void);
extern "C" NSDictionary *sciOwnerInfoForView(UIView *view);
extern "C" BOOL sciStorySeenToggleEnabled;
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC);
extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC);
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
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 *);
// ── 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;
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
if (!msg) return nil;
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil;
id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil;
if (!visMedia) return nil;
// 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
// ============ Button injection ============
- (void)didMoveToSuperview {
%orig;
if (!self.superview) return;
// 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:@"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;
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
SCIActionMediaProvider storyProvider = ^id (UIView *sourceView) {
// DM disappearing message — handle directly for tap actions
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);
};
[SCIActionButton configureButton:btn
context:SCIActionContextStories
prefKey:@"stories_action_default"
mediaProvider:storyProvider];
// When configureButton chose "menu" mode, override with our custom
// deferred menu that handles both DM and story contexts.
if (btn.showsMenuAsPrimaryAction) {
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 {
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);
}
}]
]];
}
// 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
sciInitStoryAudioState();
if ([SCIUtils getBoolPref:@"story_audio_toggle"] && ![self viewWithTag:1341]) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1341;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
[btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = [UIColor whiteColor];
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 14;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciAudioToggleTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12],
[btn.widthAnchor constraintEqualToConstant:28],
[btn.heightAnchor constraintEqualToConstant:28]
]];
}
// Seen button — deferred so the responder chain is wired up
__weak UIView *weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
UIView *s = weakSelf;
if (s && s.superview) ((void(*)(id, SEL))objc_msgSend)(s, @selector(sciRefreshSeenButton));
});
}
// ============ 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];
if (!btn) return;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
[btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
}
// Rebuilds the eye button (tag 1339). Visible only when the story is
// actively blocked for this owner. List management lives in the hold menu
// and the ellipsis action menu.
%new - (void)sciRefreshSeenButton {
BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"];
if (!seenBlockingOn) return;
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
NSString *ownerPK = ownerInfo[@"pk"] ?: @"";
BOOL excluded = ownerPK.length && [SCIExcludedStoryUsers isUserPKExcluded:ownerPK];
UIButton *existing = (UIButton *)[self viewWithTag:1339];
// Not blocked → no eye button.
if (excluded) { [existing removeFromSuperview]; return; }
BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"];
NSString *symName;
UIColor *tint;
if (toggleMode) {
symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
} else {
symName = @"eye"; tint = [UIColor whiteColor];
}
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
if (existing) {
[existing setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal];
existing.tintColor = tint;
return;
}
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
btn.tag = 1339;
[btn setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal];
btn.tintColor = tint;
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
btn.layer.cornerRadius = 18;
btn.clipsToBounds = YES;
btn.translatesAutoresizingMaskIntoConstraints = NO;
[btn addTarget:self action:@selector(sciSeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc]
initWithTarget:self action:@selector(sciSeenButtonLongPressed:)];
lp.minimumPressDuration = 0.4;
[btn addGestureRecognizer:lp];
[self addSubview:btn];
UIView *anchor = [self viewWithTag:1340];
if (anchor) {
[NSLayoutConstraint activateConstraints:@[
[btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor],
[btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
} else {
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
}
}
// Refresh when story owner changes or audio state changes
- (void)layoutSubviews {
%orig;
static char kLastPKKey;
static char kLastExclKey;
static char kLastAudioKey;
// Audio button: check if state changed
UIButton *audioBtn = (UIButton *)[self viewWithTag:1341];
if (audioBtn) {
BOOL audioOn = sciIsStoryAudioEnabled();
NSNumber *prevAudio = objc_getAssociatedObject(self, &kLastAudioKey);
if (!prevAudio || [prevAudio boolValue] != audioOn) {
objc_setAssociatedObject(self, &kLastAudioKey, @(audioOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshAudioButton));
}
}
// Seen button: check if owner/exclusion changed
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
NSDictionary *info = sciOwnerInfoForView(self);
NSString *pk = info[@"pk"] ?: @"";
BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk];
NSString *prev = objc_getAssociatedObject(self, &kLastPKKey);
NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey);
BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded);
if (!changed) return;
objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
}
// ============ Audio toggle handler ============
%new - (void)sciAudioToggleTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[haptic impactOccurred];
sciToggleStoryAudio();
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
[sender setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
}
// ============ Seen button tap ============
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
// Toggle mode
if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) {
sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled;
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 ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")];
return;
}
// Button mode: mark seen once
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender);
}
// ============ Seen button long-press menu ============
%new - (void)sciSeenButtonLongPressed:(UILongPressGestureRecognizer *)gr {
if (gr.state != UIGestureRecognizerStateBegan) return;
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; } }
}
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
NSString *pk = ownerInfo[@"pk"];
NSString *username = ownerInfo[@"username"] ?: @"";
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
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:SCILocalized(@"Mark seen") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn);
resume();
}]];
if (pk) {
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 ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
resume();
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:^(UIAlertAction *_) {
resume();
}]];
sheet.popoverPresentationController.sourceView = btn;
sheet.popoverPresentationController.sourceRect = btn.bounds;
[host presentViewController:sheet animated:YES completion:nil];
}
// ============ Mark seen handler ============
%new - (void)sciMarkSeenTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
if (sender) {
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
}
@try {
// Story path
UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController");
if (storyVC) {
id sectionCtrl = sciFindSectionController(storyVC);
id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil;
if (!storyItem) storyItem = sciGetCurrentStoryItem(self);
IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem);
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; }
sciAllowSeenForPK(media);
sciSeenBypassActive = YES;
SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:);
if ([storyVC respondsToSelector:delegateSel]) {
typedef void (*Func)(id, SEL, id, id);
((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media);
}
if (sectionCtrl) {
SEL markSel = NSSelectorFromString(@"markItemAsSeen:");
if ([sectionCtrl respondsToSelector:markSel])
((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media);
}
id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager));
id vm = sciCall(storyVC, @selector(currentViewModel));
if (seenManager && vm) {
SEL setSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:");
if ([seenManager respondsToSelector:setSel]) {
id mediaPK = sciCall(media, @selector(pk));
id reelPK = sciCall(vm, NSSelectorFromString(@"reelPK"));
if (!reelPK) reelPK = sciCall(vm, @selector(pk));
if (mediaPK && reelPK) {
typedef void (*SetFunc)(id, SEL, id, id);
((SetFunc)objc_msgSend)(seenManager, setSel, mediaPK, reelPK);
}
}
}
sciSeenBypassActive = NO;
[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) {
__block id secCtrl = sectionCtrl;
__weak __typeof(self) weakSelf = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciAdvanceBypassActive = YES;
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
if ([secCtrl respondsToSelector:advSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil;
id sc2 = vc2 ? sciFindSectionController(vc2) : nil;
if (sc2) {
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
if ([sc2 respondsToSelector:resumeSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
}
sciAdvanceBypassActive = NO;
});
});
}
return;
}
// DM visual message path
UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController");
if (dmVC) {
extern BOOL dmVisualMsgsViewedButtonEnabled;
BOOL wasEnabled = dmVisualMsgsViewedButtonEnabled;
dmVisualMsgsViewedButtonEnabled = YES;
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
Ivar erIvar = class_getInstanceVariable([dmVC class], "_eventResponders");
NSArray *responders = erIvar ? object_getIvar(dmVC, erIvar) : nil;
if (responders && msg) {
for (id resp in responders) {
SEL beginSel = @selector(visualMessageViewerController:didBeginPlaybackForVisualMessage:atIndex:);
if ([resp respondsToSelector:beginSel]) {
typedef void (*Fn)(id, SEL, id, id, NSInteger);
((Fn)objc_msgSend)(resp, beginSel, dmVC, msg, 0);
}
SEL endSel = @selector(visualMessageViewerController:didEndPlaybackForVisualMessage:atIndex:mediaCurrentTime:forNavType:);
if ([resp respondsToSelector:endSel]) {
typedef void (*Fn)(id, SEL, id, id, NSInteger, CGFloat, NSInteger);
((Fn)objc_msgSend)(resp, endSel, dmVC, msg, 0, 0.0, 0);
}
}
}
SEL dismissSel = NSSelectorFromString(@"_didTapHeaderViewDismissButton:");
if ([dmVC respondsToSelector:dismissSel])
((void(*)(id,SEL,id))objc_msgSend)(dmVC, dismissSel, nil);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
dmVisualMsgsViewedButtonEnabled = wasEnabled;
});
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")];
return;
}
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]];
}
}
%end
// ============ Chrome alpha sync ============
static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) return;
UIView *cur = self_;
while (cur) {
for (UIView *sib in cur.superview.subviews) {
if (![sib isKindOfClass:overlayCls]) continue;
UIView *seen = [sib viewWithTag:1339];
UIView *dl = [sib viewWithTag:1340];
UIView *audio = [sib viewWithTag:1341];
if (seen) seen.alpha = alpha;
if (dl) dl.alpha = alpha;
if (audio) audio.alpha = alpha;
return;
}
cur = cur.superview;
}
}
%hook IGStoryFullscreenHeaderView
- (void)setAlpha:(CGFloat)alpha {
%orig;
sciSyncStoryButtonsAlpha((UIView *)self, alpha);
}
%end
@@ -0,0 +1,46 @@
// Shared helpers for StoryOverlayButtons.xm and DMOverlayButtons.xm.
#import "StoryHelpers.h"
// Disjoint tag spaces so viewWithTag: can't cross-hit between surfaces.
#define SCI_STORY_EYE_TAG 1339
#define SCI_STORY_ACTION_TAG 1340
#define SCI_STORY_AUDIO_TAG 1341
#define SCI_DM_ACTION_TAG 1342
#define SCI_DM_EYE_TAG 1343
#define SCI_DM_AUDIO_TAG 1344
#ifdef __cplusplus
extern "C" {
#endif
// From StoryAudioToggle.xm.
void sciToggleStoryAudio(void);
BOOL sciIsStoryAudioEnabled(void);
void sciInitStoryAudioState(void);
#ifdef __cplusplus
}
#endif
extern BOOL dmVisualMsgsViewedButtonEnabled;
#ifdef __cplusplus
extern "C" {
#endif
// Context detection / view lookup.
BOOL sciOverlayIsInDMContext(UIView *overlay);
UIView * _Nullable sciFindOverlayInView(UIView *root);
// DM disappearing-media actions.
NSURL * _Nullable sciDMMediaURL(UIViewController *dmVC, BOOL *outIsVideo);
void sciDMExpandMedia(UIViewController *dmVC);
void sciDMShareMedia(UIViewController *dmVC);
void sciDMDownloadMedia(UIViewController *dmVC);
void sciDMMarkCurrentAsViewed(UIViewController *dmVC);
// Opens RyukGram settings on the Messages tab.
void sciOpenMessagesSettings(UIView *source);
#ifdef __cplusplus
}
#endif
@@ -0,0 +1,163 @@
#import "OverlayHelpers.h"
#import "../../ActionButton/SCIMediaViewer.h"
#import "../../Downloader/Download.h"
// MARK: - Context detection
BOOL sciOverlayIsInDMContext(UIView *overlay) {
Class dmCls = NSClassFromString(@"IGDirectVisualMessageViewerController");
if (!dmCls) return NO;
UIResponder *r = overlay.nextResponder;
while (r) {
if ([r isKindOfClass:dmCls]) return YES;
r = r.nextResponder;
}
// Fallback: _gestureDelegate ivar is the DM VC in DM contexts.
static Ivar gdIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (c) gdIvar = class_getInstanceVariable(c, "_gestureDelegate");
});
if (gdIvar) {
id d = object_getIvar(overlay, gdIvar);
if (d && [d isKindOfClass:dmCls]) return YES;
}
return NO;
}
UIView *sciFindOverlayInView(UIView *root) {
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls || !root) return nil;
if ([root isKindOfClass:overlayCls]) return root;
for (UIView *sub in root.subviews) {
UIView *found = sciFindOverlayInView(sub);
if (found) return found;
}
return nil;
}
// MARK: - DM media URL
NSURL *sciDMMediaURL(UIViewController *dmVC, BOOL *outIsVideo) {
if (!dmVC) return nil;
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
if (!msg) return nil;
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil;
id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil;
if (!visMedia) return nil;
@try {
id rawVideo = [msg valueForKey:@"rawVideo"];
if (rawVideo) {
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
if (url) { if (outIsVideo) *outIsVideo = YES; return url; }
}
} @catch (__unused NSException *e) {}
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;
}
// MARK: - DM actions
// Strong refs — SCIDownloadDelegate needs to outlive the download.
static SCIDownloadDelegate *sciDMShareDelegate = nil;
static SCIDownloadDelegate *sciDMDownloadDelegate = nil;
void sciDMExpandMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDMMediaURL(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];
}
void sciDMShareMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDMMediaURL(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];
}
void sciDMDownloadMedia(UIViewController *dmVC) {
BOOL isVideo = NO;
NSURL *url = sciDMMediaURL(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];
}
// Flips dmVisualMsgsViewedButtonEnabled for ~1s so VisualMsgModifier lets the
// begin/end playback callbacks through, then restores.
void sciDMMarkCurrentAsViewed(UIViewController *dmVC) {
if (!dmVC) return;
BOOL wasEnabled = dmVisualMsgsViewedButtonEnabled;
dmVisualMsgsViewedButtonEnabled = YES;
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
Ivar erIvar = class_getInstanceVariable([dmVC class], "_eventResponders");
NSArray *responders = erIvar ? object_getIvar(dmVC, erIvar) : nil;
if (responders && msg) {
for (id resp in responders) {
SEL beginSel = @selector(visualMessageViewerController:didBeginPlaybackForVisualMessage:atIndex:);
if ([resp respondsToSelector:beginSel]) {
typedef void (*Fn)(id, SEL, id, id, NSInteger);
((Fn)objc_msgSend)(resp, beginSel, dmVC, msg, 0);
}
SEL endSel = @selector(visualMessageViewerController:didEndPlaybackForVisualMessage:atIndex:mediaCurrentTime:forNavType:);
if ([resp respondsToSelector:endSel]) {
typedef void (*Fn)(id, SEL, id, id, NSInteger, CGFloat, NSInteger);
((Fn)objc_msgSend)(resp, endSel, dmVC, msg, 0, 0.0, 0);
}
}
}
SEL dismissSel = NSSelectorFromString(@"_didTapHeaderViewDismissButton:");
if ([dmVC respondsToSelector:dismissSel]) {
((void(*)(id,SEL,id))objc_msgSend)(dmVC, dismissSel, nil);
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
dmVisualMsgsViewedButtonEnabled = wasEnabled;
});
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")];
}
// MARK: - Settings shortcut
void sciOpenMessagesSettings(UIView *source) {
UIWindow *win = source.window;
if (!win) {
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
for (UIWindow *w in ((UIWindowScene *)scene).windows) {
if (w.isKeyWindow) { win = w; break; }
}
if (win) break;
}
}
if (!win) return;
[SCIUtils showSettingsVC:win atTopLevelEntry:SCILocalized(@"Messages")];
}
@@ -63,7 +63,7 @@ static void sciMarkSeen(NSString *prefKey) {
if (!prefKey || ![SCIUtils getBoolPref:prefKey]) return;
UIView *overlay = sciFindOverlay(sciActiveStoryVC);
if (!overlay) return;
SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:");
SEL sel = NSSelectorFromString(@"sciStoryMarkSeenTapped:");
if ([overlay respondsToSelector:sel])
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
}
+32 -21
View File
@@ -1,6 +1,7 @@
#import "../../InstagramHeaders.h"
#import "../../Tweak.h"
#import "../../Utils.h"
#import "../../SCIChrome.h"
#import "SCIExcludedThreads.h"
#import <objc/runtime.h>
#import <objc/message.h>
@@ -309,11 +310,13 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
BOOL navInList = navThreadId && [SCIExcludedThreads isInList:navThreadId];
if ([SCIUtils getBoolPref:@"remove_lastseen"] && !navExcluded) {
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"eye"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
SCIChromeButton *inner = nil;
UIBarButtonItem *seenButton = SCIChromeBarButtonItem(@"eye", 18, self, @selector(seenButtonHandler:), &inner);
seenButton.accessibilityIdentifier = @"sci-seen-btn";
if (sciIsSeenToggleMode())
[seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
seenButton.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
UIColor *tint = UIColor.labelColor;
if (sciIsSeenToggleMode()) tint = dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
inner.iconTint = tint;
inner.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
[new_items addObject:seenButton];
}
@@ -328,25 +331,23 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
BOOL showAddBtn = blockSelected && !navInList;
if (showListButton && (showRemoveBtn || showAddBtn)) {
SEL action = showRemoveBtn ? @selector(sciUnexcludeButtonHandler:) : @selector(sciAddToListHandler:);
UIBarButtonItem *listBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:showRemoveBtn ? @"eye.slash.fill" : @"eye.slash"]
style:UIBarButtonItemStylePlain
target:self
action:action];
NSString *sym = showRemoveBtn ? @"eye.slash.fill" : @"eye.slash";
SCIChromeButton *inner = nil;
UIBarButtonItem *listBtn = SCIChromeBarButtonItem(sym, 18, self, action, &inner);
listBtn.accessibilityIdentifier = @"sci-unex-btn";
listBtn.tintColor = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
listBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
inner.iconTint = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
inner.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
[new_items addObject:listBtn];
}
// 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:)];
NSString *sym = dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill";
SCIChromeButton *inner = nil;
UIBarButtonItem *replayBtn = SCIChromeBarButtonItem(sym, 18, self, @selector(sciReplayToggleHandler:), &inner);
replayBtn.accessibilityIdentifier = @"sci-visual-btn";
replayBtn.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
inner.iconTint = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
[new_items addObject:replayBtn];
}
@@ -355,10 +356,14 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
// ============ MESSAGES SEEN BUTTON ============
%new - (void)seenButtonHandler:(UIBarButtonItem *)sender {
%new - (void)seenButtonHandler:(id)sender {
UIBarButtonItem *barItem = [sender isKindOfClass:[UIBarButtonItem class]] ? (UIBarButtonItem *)sender : nil;
SCIChromeButton *inner = [sender isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)sender : SCIChromeButtonForBarItem(barItem);
if (sciIsSeenToggleMode()) {
dmSeenToggleEnabled = !dmSeenToggleEnabled;
[sender setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
UIColor *tint = dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
if (inner) inner.iconTint = tint;
else [barItem setTintColor:tint];
if (dmSeenToggleEnabled) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
@@ -377,13 +382,19 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
// Rebuild menu so toggle text updates
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *tid = sciThreadIdForVC(navNearestVC);
sender.menu = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window);
UIMenu *rebuilt = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window);
if (inner) inner.menu = rebuilt;
else if (barItem) barItem.menu = rebuilt;
}
%new - (void)sciReplayToggleHandler:(UIBarButtonItem *)sender {
%new - (void)sciReplayToggleHandler:(id)sender {
UIBarButtonItem *barItem = [sender isKindOfClass:[UIBarButtonItem class]] ? (UIBarButtonItem *)sender : nil;
SCIChromeButton *inner = [sender isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)sender : SCIChromeButtonForBarItem(barItem);
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
sender.image = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
sender.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
NSString *sym = dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill";
UIColor *tint = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
if (inner) { inner.symbolName = sym; inner.iconTint = tint; }
else if (barItem) { barItem.image = [UIImage systemImageNamed:sym]; barItem.tintColor = tint; }
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
}
@@ -1,5 +1,7 @@
// Story audio mute/unmute toggle. Posts mute-switch-state-changed to toggle
// IG's audio. Reads _audioEnabled on IGAudioStatusAnnouncer for icon state.
// Story audio mute/unmute toggle.
// Flips IGAudioStatusAnnouncer private state then fans out to listeners
// via the two IGUltralightAnnouncer sub-forwarders (426 dropped the old
// mute-switch notification).
#import <AVFoundation/AVFoundation.h>
#import "StoryHelpers.h"
@@ -12,14 +14,34 @@ extern "C" void sciRefreshAllVisibleOverlays(UIViewController *);
static id sciAudioAnnouncer = nil;
static id sciReadIvar(id obj, const char *name) {
if (!obj) return nil;
Ivar iv = class_getInstanceVariable([obj class], name);
if (!iv) return nil;
return object_getIvar(obj, iv);
}
static BOOL sciIGAudioEnabled(void) {
if (!sciAudioAnnouncer) return NO;
SEL s = NSSelectorFromString(@"isAudioEnabledForSoundBehavior:");
if ([sciAudioAnnouncer respondsToSelector:s]) {
typedef BOOL (*Fn)(id, SEL, NSInteger);
return ((Fn)objc_msgSend)(sciAudioAnnouncer, s, 1);
}
Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled");
if (!ivar) return NO;
ptrdiff_t offset = ivar_getOffset(ivar);
return *(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset);
}
static void sciWriteAudioEnabled(BOOL value) {
if (!sciAudioAnnouncer) return;
Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled");
if (!ivar) return;
ptrdiff_t offset = ivar_getOffset(ivar);
*(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset) = value;
}
// ============ Volume KVO ============
@interface _SciVolumeObserver : NSObject
@@ -41,12 +63,29 @@ extern "C" {
BOOL sciStoryAudioBypass = NO;
void sciToggleStoryAudio(void) {
if (!sciAudioAnnouncer) return;
BOOL on = sciIGAudioEnabled();
BOOL wanted = !on;
sciStoryAudioBypass = YES;
[[NSNotificationCenter defaultCenter]
postNotificationName:@"mute-switch-state-changed"
object:nil
userInfo:@{@"mute-state": @(on ? 0 : 1)}];
sciWriteAudioEnabled(wanted);
// 2 = user-enabled, 1 = user-disabled.
Ivar stickIv = class_getInstanceVariable([sciAudioAnnouncer class], "_stickySoundState");
if (stickIv) {
ptrdiff_t off = ivar_getOffset(stickIv);
NSInteger *p = (NSInteger *)((char *)(__bridge void *)sciAudioAnnouncer + off);
*p = wanted ? 2 : 1;
}
SEL notify = NSSelectorFromString(@"audioStatusDidChangeIsAudioEnabled:forReason:");
typedef void (*NotifyFn)(id, SEL, BOOL, NSInteger);
id subA = sciReadIvar(sciAudioAnnouncer, "_announcerForDefaultBehaviors");
id subB = sciReadIvar(sciAudioAnnouncer, "_announcerForIgnoreUserPreferenceAndMatchDeviceState");
if (subA) ((NotifyFn)objc_msgSend)(subA, notify, wanted, 0);
if (subB) ((NotifyFn)objc_msgSend)(subB, notify, wanted, 0);
sciStoryAudioBypass = NO;
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}
@@ -31,9 +31,11 @@ static void new_sciStoryLikeTap(id self, SEL _cmd, id button) {
}
%ctor {
Class cls = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
Class cls = NSClassFromString(@"_TtC22IGStoryLikesController38IGStoryLikesInteractionControllingImpl");
if (!cls) cls = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
if (!cls) return;
SEL sel = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
SEL sel = NSSelectorFromString(@"handleStoryLikeTapWith:");
if (!class_getInstanceMethod(cls, sel)) sel = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
if (!class_getInstanceMethod(cls, sel)) return;
MSHookMessageEx(cls, sel, (IMP)new_sciStoryLikeTap, (IMP *)&orig_sciStoryLikeTap);
}
@@ -3,6 +3,7 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../SCIImageCache.h"
#import "../../Networking/SCIInstagramAPI.h"
#import "StoryHelpers.h"
#import <objc/runtime.h>
@@ -358,21 +359,15 @@ static NSDictionary *sciMentionUserInfo(id mention) {
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];
[SCIImageCache loadImageFromURL:picURL completion:^(UIImage *img) {
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; }
});
});
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];
@@ -0,0 +1,495 @@
// Story overlay buttons — action / audio / eye (tags 13391341).
// Early-exits in DM context; DMOverlayButtons.xm handles that surface.
#import "OverlayHelpers.h"
#import "SCIExcludedStoryUsers.h"
#import "../../SCIChrome.h"
#import "../../ActionButton/SCIActionButton.h"
#import "../../ActionButton/SCIMediaActions.h"
#import "../../ActionButton/SCIActionMenu.h"
#import "../../Downloader/Download.h"
extern "C" BOOL sciSeenBypassActive;
extern "C" BOOL sciAdvanceBypassActive;
extern "C" void sciAllowSeenForPK(id);
extern "C" BOOL sciStorySeenToggleEnabled;
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC);
extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC);
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
extern "C" NSDictionary *sciOwnerInfoForView(UIView *view);
extern "C" void sciShowStoryMentions(UIViewController *, UIView *);
// MARK: - 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);
}
}
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);
}
}
// MARK: - Overlay hook
%group StoryOverlayGroup
%hook IGStoryFullscreenOverlayView
- (void)didMoveToSuperview {
%orig;
if (!self.superview) return;
// Strip stale tags up-front so nothing flashes when this overlay
// turns out to belong to a DM viewer.
UIView *sA = [self viewWithTag:SCI_STORY_ACTION_TAG]; if (sA) [sA removeFromSuperview];
UIView *sE = [self viewWithTag:SCI_STORY_EYE_TAG]; if (sE) [sE removeFromSuperview];
UIView *sU = [self viewWithTag:SCI_STORY_AUDIO_TAG]; if (sU) [sU removeFromSuperview];
// Defer one tick — responder chain isn't complete yet, so the DM
// context check needs to run after the current runloop iteration.
__weak __typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf || !strongSelf.superview) return;
if (sciOverlayIsInDMContext(strongSelf)) return;
((void(*)(id, SEL))objc_msgSend)(strongSelf, @selector(sciInstallStoryOverlayButtons));
});
}
%new - (void)sciInstallStoryOverlayButtons {
if (!self.superview) return;
// --- Action button (tag 1340) ---
UIView *staleAction = [self viewWithTag:SCI_STORY_ACTION_TAG];
if (staleAction) {
@try { [staleAction removeObserver:self forKeyPath:@"highlighted"]; } @catch (__unused id e) {}
[staleAction removeFromSuperview];
}
if ([SCIUtils getBoolPref:@"stories_action_button"]) {
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:18 diameter:36];
btn.tag = SCI_STORY_ACTION_TAG;
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
SCIActionMediaProvider provider = ^id (UIView *sourceView) {
sciPauseStoryPlayback(sourceView);
id item = sciGetCurrentStoryItem(sourceView);
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) return item;
id extracted = sciExtractMediaFromItem(item);
return extracted ?: (id)kCFNull;
};
[SCIActionButton configureButton:btn
context:SCIActionContextStories
prefKey:@"stories_action_default"
mediaProvider:provider];
// Resume playback when the native UIMenu dismisses.
[btn addObserver:self forKeyPath:@"highlighted"
options:NSKeyValueObservingOptionNew context:NULL];
// Reel items provider — used by SCIMediaActions for "download all".
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;
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) {}
}
}
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; }
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 (tag 1341) ---
UIView *staleAudio = [self viewWithTag:SCI_STORY_AUDIO_TAG];
if (staleAudio) [staleAudio removeFromSuperview];
sciInitStoryAudioState();
if ([SCIUtils getBoolPref:@"story_audio_toggle"]) {
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:icon pointSize:14 diameter:28];
btn.tag = SCI_STORY_AUDIO_TAG;
[btn addTarget:self action:@selector(sciStoryAudioToggleTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12],
[btn.widthAnchor constraintEqualToConstant:28],
[btn.heightAnchor constraintEqualToConstant:28]
]];
}
// --- Eye / mark-seen (tag 1339) ---
// layoutSubviews can fire between the tick-0 strip and now, creating
// the eye with fallback constraints before the action exists. Drop it
// so the refresh rebuilds it anchored to the action button.
UIView *staleEye = [self viewWithTag:SCI_STORY_EYE_TAG];
if (staleEye) [staleEye removeFromSuperview];
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
}
// MARK: - Action button menu-dismiss 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);
}
}
// MARK: - Audio toggle
%new - (void)sciStoryAudioToggleTapped:(SCIChromeButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[haptic impactOccurred];
sciToggleStoryAudio();
sender.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
}
%new - (void)sciRefreshStoryAudioButton {
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:SCI_STORY_AUDIO_TAG];
if (![btn isKindOfClass:[SCIChromeButton class]]) return;
btn.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
}
// MARK: - Seen eye button
// Visible only when no_seen_receipt is on and the owner isn't excluded.
%new - (void)sciRefreshSeenButton {
BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"];
if (!seenBlockingOn) { UIView *old = [self viewWithTag:SCI_STORY_EYE_TAG]; if (old) [old removeFromSuperview]; return; }
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
NSString *ownerPK = ownerInfo[@"pk"] ?: @"";
BOOL excluded = ownerPK.length && [SCIExcludedStoryUsers isUserPKExcluded:ownerPK];
SCIChromeButton *existing = (SCIChromeButton *)[self viewWithTag:SCI_STORY_EYE_TAG];
if (![existing isKindOfClass:[SCIChromeButton class]]) existing = nil;
if (excluded) { if (existing) [existing removeFromSuperview]; return; }
BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"];
NSString *symName;
UIColor *tint;
if (toggleMode) {
symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
} else {
symName = @"eye"; tint = [UIColor whiteColor];
}
if (existing) {
existing.symbolName = symName;
existing.iconTint = tint;
return;
}
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:symName pointSize:18 diameter:36];
btn.tag = SCI_STORY_EYE_TAG;
btn.iconTint = tint;
[btn addTarget:self action:@selector(sciStorySeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
// Long-press → context menu (positions itself next to the button).
UIContextMenuInteraction *ix = [[UIContextMenuInteraction alloc] initWithDelegate:(id<UIContextMenuInteractionDelegate>)self];
[btn addInteraction:ix];
[self addSubview:btn];
UIView *anchor = [self viewWithTag:SCI_STORY_ACTION_TAG];
if (anchor) {
[NSLayoutConstraint activateConstraints:@[
[btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor],
[btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
} else {
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
}
}
// MARK: - Owner / audio refresh on layout
- (void)layoutSubviews {
%orig;
static char kLastPKKey;
static char kLastExclKey;
static char kLastAudioKey;
UIButton *audioBtn = (UIButton *)[self viewWithTag:SCI_STORY_AUDIO_TAG];
if (audioBtn) {
BOOL audioOn = sciIsStoryAudioEnabled();
NSNumber *prevAudio = objc_getAssociatedObject(self, &kLastAudioKey);
if (!prevAudio || [prevAudio boolValue] != audioOn) {
objc_setAssociatedObject(self, &kLastAudioKey, @(audioOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshStoryAudioButton));
}
}
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
NSDictionary *info = sciOwnerInfoForView(self);
NSString *pk = info[@"pk"] ?: @"";
BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk];
NSString *prev = objc_getAssociatedObject(self, &kLastPKKey);
NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey);
BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded);
if (!changed) return;
objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC);
objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
}
// MARK: - Seen button tap handlers
%new - (void)sciStorySeenButtonTapped:(SCIChromeButton *)sender {
if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) {
sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled;
sender.symbolName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
sender.iconTint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")];
return;
}
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciStoryMarkSeenTapped:), sender);
}
// Long-press menu — rebuilt per display so owner/exclusion is always fresh.
%new - (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
configurationForMenuAtLocation:(CGPoint)location {
__weak __typeof(self) weakSelf = self;
return [UIContextMenuConfiguration
configurationWithIdentifier:nil
previewProvider:nil
actionProvider:^UIMenu * _Nullable(NSArray<UIMenuElement *> * _Nonnull suggested) {
__strong __typeof(weakSelf) strongSelf = weakSelf;
if (!strongSelf) return nil;
NSDictionary *ownerInfo = sciOwnerInfoForView(strongSelf);
NSString *pk = ownerInfo[@"pk"];
NSString *username = ownerInfo[@"username"] ?: @"";
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk];
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
NSMutableArray<UIMenuElement *> *items = [NSMutableArray array];
[items addObject:[UIAction actionWithTitle:SCILocalized(@"Mark seen")
image:[UIImage systemImageNamed:@"eye"]
identifier:nil
handler:^(UIAction *a) {
((void(*)(id, SEL, id))objc_msgSend)(strongSelf, @selector(sciStoryMarkSeenTapped:), nil);
}]];
if (pk) {
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;
NSString *img = inList ? @"minus.circle" : @"eye.slash";
UIAction *excl = [UIAction actionWithTitle:t
image:[UIImage systemImageNamed:img]
identifier:nil
handler:^(UIAction *a) {
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[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 ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}];
if (inList) excl.attributes = UIMenuElementAttributesDestructive;
[items addObject:excl];
}
return [UIMenu menuWithTitle:@"" children:items];
}];
}
%new - (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
willDisplayMenuForConfiguration:(UIContextMenuConfiguration *)configuration
animator:(id<UIContextMenuInteractionAnimating>)animator {
sciPauseStoryPlayback(self);
}
%new - (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
willEndForConfiguration:(UIContextMenuConfiguration *)configuration
animator:(id<UIContextMenuInteractionAnimating>)animator {
__weak __typeof(self) weakSelf = self;
void (^resume)(void) = ^{ if (weakSelf) sciResumeStoryPlayback(weakSelf); };
if (animator) [animator addCompletion:resume];
else resume();
}
%new - (void)sciStoryMarkSeenTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
if (sender) {
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
}
@try {
UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController");
if (!storyVC) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")]; return; }
id sectionCtrl = sciFindSectionController(storyVC);
id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil;
if (!storyItem) storyItem = sciGetCurrentStoryItem(self);
IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem);
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; }
sciAllowSeenForPK(media);
sciSeenBypassActive = YES;
SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:);
if ([storyVC respondsToSelector:delegateSel]) {
typedef void (*Func)(id, SEL, id, id);
((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media);
}
if (sectionCtrl) {
SEL markSel = NSSelectorFromString(@"markItemAsSeen:");
if ([sectionCtrl respondsToSelector:markSel])
((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media);
}
id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager));
id vm = sciCall(storyVC, @selector(currentViewModel));
if (seenManager && vm) {
SEL setSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:");
if ([seenManager respondsToSelector:setSel]) {
id mediaPK = sciCall(media, @selector(pk));
id reelPK = sciCall(vm, NSSelectorFromString(@"reelPK"));
if (!reelPK) reelPK = sciCall(vm, @selector(pk));
if (mediaPK && reelPK) {
typedef void (*SetFunc)(id, SEL, id, id);
((SetFunc)objc_msgSend)(seenManager, setSel, mediaPK, reelPK);
}
}
}
sciSeenBypassActive = NO;
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked as seen") subtitle:SCILocalized(@"Will sync when leaving stories")];
if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) {
__block id secCtrl = sectionCtrl;
__weak __typeof(self) weakSelf = self;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciAdvanceBypassActive = YES;
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
if ([secCtrl respondsToSelector:advSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
__strong __typeof(weakSelf) strongSelf = weakSelf;
UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil;
id sc2 = vc2 ? sciFindSectionController(vc2) : nil;
if (sc2) {
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
if ([sc2 respondsToSelector:resumeSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
}
sciAdvanceBypassActive = NO;
});
});
}
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]];
}
}
%end
// MARK: - Chrome alpha sync (story only)
static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) return;
UIView *cur = self_;
while (cur) {
for (UIView *sib in cur.superview.subviews) {
if (![sib isKindOfClass:overlayCls]) continue;
UIView *seen = [sib viewWithTag:SCI_STORY_EYE_TAG];
UIView *act = [sib viewWithTag:SCI_STORY_ACTION_TAG];
UIView *audio = [sib viewWithTag:SCI_STORY_AUDIO_TAG];
if (seen) seen.alpha = alpha;
if (act) act.alpha = alpha;
if (audio) audio.alpha = alpha;
return;
}
cur = cur.superview;
}
}
%hook IGStoryFullscreenHeaderView
- (void)setAlpha:(CGFloat)alpha {
%orig;
sciSyncStoryButtonsAlpha((UIView *)self, alpha);
}
%end
%end // StoryOverlayGroup
%ctor {
if ([SCIUtils getBoolPref:@"stories_action_button"] ||
[SCIUtils getBoolPref:@"story_audio_toggle"] ||
[SCIUtils getBoolPref:@"no_seen_receipt"]) {
%init(StoryOverlayGroup);
}
}
+24
View File
@@ -0,0 +1,24 @@
// Force IG into dark appearance regardless of iOS setting.
#import "../../Utils.h"
%group ForceDarkModeGroup
%hook UIWindow
- (void)makeKeyAndVisible {
%orig;
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
}
- (void)becomeKeyWindow {
%orig;
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
}
%end
%end
%ctor {
if ([SCIUtils getBoolPref:@"theme_force_dark"]) {
%init(ForceDarkModeGroup);
}
}
+62
View File
@@ -0,0 +1,62 @@
// Replace IG's dark-gray surfaces with pure black.
//
// Swaps any near-black fill (RGB all < 0.13, alpha >= 0.9) for #000000.
// RyukGram's own surfaces opt out by painting above the threshold or with
// alpha < 0.9 — see SCIOLEDSurface.xm.
#import "../../Utils.h"
static inline BOOL sciOLEDShouldReplace(UIColor *color) {
if (!color) return NO;
CGFloat r = 0, g = 0, b = 0, a = 0;
if (![color getRed:&r green:&g blue:&b alpha:&a]) {
CGFloat w = 0;
if ([color getWhite:&w alpha:&a]) {
return (a >= 0.9 && w < 0.13);
}
return NO;
}
return (a >= 0.9 && r < 0.13 && g < 0.13 && b < 0.13);
}
%group FullOLEDGroup
%hook UIView
- (void)setBackgroundColor:(UIColor *)color {
if (sciOLEDShouldReplace(color)) {
%orig([UIColor blackColor]);
return;
}
%orig;
}
%end
%hook CAGradientLayer
- (void)setColors:(NSArray *)colors {
if (colors.count >= 1) {
BOOL allDark = YES;
for (id raw in colors) {
CGColorRef cg = (__bridge CGColorRef)raw;
if (!cg) { allDark = NO; break; }
UIColor *c = [UIColor colorWithCGColor:cg];
if (!sciOLEDShouldReplace(c)) { allDark = NO; break; }
}
if (allDark) {
id black = (id)[UIColor blackColor].CGColor;
NSMutableArray *flat = [NSMutableArray arrayWithCapacity:colors.count];
for (NSUInteger i = 0; i < colors.count; i++) [flat addObject:black];
%orig(flat);
return;
}
}
%orig;
}
%end
%end
%ctor {
if ([SCIUtils getBoolPref:@"theme_full_oled"]) {
%init(FullOLEDGroup);
}
}
+56
View File
@@ -0,0 +1,56 @@
// Keyboard appearance override for IG's text inputs.
// Modes: "off" / "dark" / "oled".
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
static inline BOOL sciKeyboardOLED(void) {
return [[[NSUserDefaults standardUserDefaults] stringForKey:@"theme_keyboard"] isEqualToString:@"oled"];
}
%group KeyboardThemeDarkGroup
%hook UITextField
- (BOOL)becomeFirstResponder {
self.keyboardAppearance = UIKeyboardAppearanceDark;
return %orig;
}
%end
%hook UITextView
- (BOOL)becomeFirstResponder {
self.keyboardAppearance = UIKeyboardAppearanceDark;
return %orig;
}
%end
%end
%group KeyboardThemeOLEDGroup
%hook UIKBBackdropView
- (void)layoutSubviews {
%orig;
self.backgroundColor = [UIColor blackColor];
for (UIView *sub in self.subviews) sub.backgroundColor = [UIColor blackColor];
}
%end
%hook UIKBKeyplaneChargedView
- (void)layoutSubviews {
%orig;
self.backgroundColor = [UIColor blackColor];
}
%end
%end
%ctor {
NSString *mode = [[NSUserDefaults standardUserDefaults] stringForKey:@"theme_keyboard"];
if ([mode isEqualToString:@"dark"] || [mode isEqualToString:@"oled"]) {
%init(KeyboardThemeDarkGroup);
if (sciKeyboardOLED()) {
%init(KeyboardThemeOLEDGroup);
}
}
}
+43
View File
@@ -0,0 +1,43 @@
// Pure-black DM thread background + incoming message bubbles.
// IGDirectThreadBackgroundImageView / IGDirectMessageBubbleView declared in InstagramHeaders.h.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
%group OLEDChatThemeGroup
%hook IGDirectThreadBackgroundImageView
- (void)layoutSubviews {
%orig;
self.image = nil;
self.backgroundColor = [UIColor blackColor];
}
- (void)setImage:(UIImage *)image {
%orig(nil);
self.backgroundColor = [UIColor blackColor];
}
- (void)setBackgroundColor:(UIColor *)color {
%orig([UIColor blackColor]);
}
%end
%hook IGDirectMessageBubbleView
- (void)layoutSubviews {
%orig;
CGFloat r = 0, g = 0, b = 0, a = 0;
if ([self.backgroundColor getRed:&r green:&g blue:&b alpha:&a]) {
// Leave tinted outgoing bubbles (blue/purple) alone.
if (a >= 0.9 && r < 0.2 && g < 0.2 && b < 0.2) {
self.backgroundColor = [UIColor blackColor];
}
}
}
%end
%end
%ctor {
if ([SCIUtils getBoolPref:@"theme_oled_chat"]) {
%init(OLEDChatThemeGroup);
}
}
+58
View File
@@ -0,0 +1,58 @@
// Keep RyukGram's table-view surfaces visible under Full OLED.
//
// Grouped-inset cells default to #1C1C1E which Full OLED blackens. Repaint
// SCI*-owned cells at ~#121212 (alpha 0.89 passes the hook's a >= 0.9 gate)
// on attach, so settings + Profile Analyzer stay readable on black.
#import "../../Utils.h"
#import <objc/runtime.h>
static inline BOOL sciOLEDSurfaceInRyukGram(UIView *view) {
UIResponder *r = view;
while (r) {
const char *name = class_getName([r class]);
if (name && name[0] == 'S' && name[1] == 'C' && name[2] == 'I') return YES;
r = r.nextResponder;
}
return NO;
}
static UIColor *sciOLEDSurfaceTone(void) {
static UIColor *tone;
static dispatch_once_t once;
dispatch_once(&once, ^{ tone = [UIColor colorWithWhite:0.08 alpha:0.89]; });
return tone;
}
%group OLEDSurfaceGroup
%hook UITableViewCell
- (void)didMoveToSuperview {
%orig;
if (!self.superview) return;
if (!sciOLEDSurfaceInRyukGram((UIView *)self)) return;
UIColor *tone = sciOLEDSurfaceTone();
UIBackgroundConfiguration *bg = [UIBackgroundConfiguration listGroupedCellConfiguration];
bg.backgroundColor = tone;
self.backgroundConfiguration = bg;
self.backgroundColor = tone;
self.contentView.backgroundColor = tone;
}
%end
%hook UITableViewHeaderFooterView
- (void)didMoveToSuperview {
%orig;
if (!self.superview) return;
if (!sciOLEDSurfaceInRyukGram((UIView *)self)) return;
self.backgroundConfiguration = [UIBackgroundConfiguration clearConfiguration];
}
%end
%end
%ctor {
if ([SCIUtils getBoolPref:@"theme_full_oled"]) {
%init(OLEDSurfaceGroup);
}
}
+58
View File
@@ -43,6 +43,12 @@
@interface IGExploreGridViewController : IGViewController
@end
@interface IGExploreViewController : IGViewController
@end
@interface IGExploreSearchTitleView : UIView
@end
@interface UIImage ()
- (NSString *)ig_imageName;
@end
@@ -164,6 +170,15 @@
- (void)addLongPressGestureRecognizer; // new
@end
@interface IGSundialViewerPhotoCell : UIView
@end
@interface IGSundialViewerCarouselPhotoCell : UIView
@end
@interface IGSundialViewerCarouselCell : UIView
@end
@interface IGImageProgressView : UIView
@property(retain, nonatomic) IGImageSpecifier *imageSpecifier;
@end
@@ -538,6 +553,14 @@
// IG's UINavigationBar subclass — hosts the iOS 26 liquid-glass platter layout.
@interface IGNavigationBar : UINavigationBar @end
// DM thread background + message bubble views — OLED chat theme.
@interface IGDirectThreadBackgroundImageView : UIImageView @end
@interface IGDirectMessageBubbleView : UIView @end
// UIKit-private keyboard classes — OLED keyboard theme.
@interface UIKBBackdropView : UIView @end
@interface UIKBKeyplaneChargedView : UIView @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;
@@ -674,4 +697,39 @@ typedef FLEXAlertAction * _Nonnull (^FLEXAlertActionHandler)(void(^handler)(NSAr
- (void)showExplorer;
- (void)hideExplorer;
- (void)toggleExplorer;
@end
// IGLive classes — discovered via runtime ivar/method dump.
@interface IGLiveFeedbackController : NSObject
- (void)start;
- (void)stop;
@end
@interface IGLiveCommentsContainerViewController : UIViewController
- (void)setIsHidden:(BOOL)hidden;
- (void)setDisabled:(BOOL)disabled;
@end
// Story/reel sticker views — data accessors resolved at runtime.
@interface IGQuizStickerView : UIView
@end
@interface IGPollStickerView : UIView
@end
@interface IGPollStickerV2View : UIView
@end
@interface IGSliderStickerView : UIView
@end
// Composer sticker tray data source — hooked to inject the quiz model.
@interface IGStoryStickerDataSourceImpl : NSObject
- (NSArray *)items;
@end
@interface IGQuizStickerTrayModel : NSObject
@property (nonatomic) BOOL isBoostEligible;
@property (nonatomic, copy) id stickerSection;
@property (nonatomic, copy) NSArray *prompts;
@end
@@ -57,6 +57,7 @@
* Translation made by @bruuhim (التعريب بواسطة @bruuhim)
*/
//////////////////////////////////////////////////////////////////////////////
// CHROME — TOP BAR, LANGUAGE PICKER, FIRST-RUN //
// Shown on the root Settings screen: title, search bar, the globe language //
@@ -67,11 +68,11 @@
"settings.firstrun.message" = "‏مستقبلاً: اضغط مطولاً على الخطوط الثلاثة أعلى صفحة ملفك الشخصي لفتح إعدادات ريوك غرام.‏";
"settings.firstrun.ok" = "‏مفهوم!";
"settings.firstrun.title" = "‏معلومات إعدادات ريوك غرام‏";
"settings.language.english_only" = "‏يتوفر ريوك غرام حاليًا باللغة الإنجليزية فقط. اللغات الأخرى جاهزة بانتظار الترجمة — ساهم في الترجمة إلى لغتك باتباع الدليل القصير في ملف ريدمي (README).";
"settings.language.help_translate" = "‏المساعدة في الترجمة‏";
"settings.language.ok" = "‏موافق‏";
"settings.language.system" = "‏الافتراضي للنظام‏";
"settings.language.title" = "‏اللغة‏";
"settings.language.english_only" = "‏يتوفر ريوك غرام حاليًا باللغة الإنجليزية فقط. اللغات الأخرى جاهزة بانتظار الترجمة — ساهم في الترجمة إلى لغتك باتباع الدليل القصير في ملف ريدمي (README).";
"settings.language.ok" = "‏موافق‏";
"settings.language.help_translate" = "‏المساعدة في الترجمة‏";
"settings.results.many" = "%lu نتائج‏";
"settings.results.none" = "‏لا توجد نتائج‏";
"settings.results.one" = "%lu نتيجة‏";
@@ -85,6 +86,8 @@
"Adds a copy option to the comment long-press menu" = "‏إضافة خيار النسخ إلى قائمة الضغط المطول للتعليقات‏";
"Adds a download option for GIF comments" = "‏إضافة خيار تنزيل لتعليقات صور جيف (GIF)‏";
"Anonymous live viewing" = "‏مشاهدة البث المباشر مجهول الهوية‏";
"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "‏يمنع نبض عدّاد المشاهدين فلا يراك المذيع — ولن ترى عدّاد المشاهدين أيضاً‏";
"Browser" = "‏المتصفح‏";
"Comments" = "‏التعليقات‏";
"Copy comment text" = "‏نسخ نص التعليق‏";
@@ -105,13 +108,14 @@
"Experimental features" = "‏ميزات تجريبية‏";
"Focus/distractions" = "‏التركيز والمشتتات‏";
"General" = "‏عام‏";
"Hide Meta AI" = "‏إخفاء ذكاء ميتا الاصطناعي‏";
"Hide ads" = "‏إخفاء الإعلانات‏";
"Hide explore posts grid" = "‏إخفاء شبكة منشورات الإكسبلور‏";
"Hide friends map" = "‏إخفاء خريطة الأصدقاء‏";
"Hide Meta AI" = "‏إخفاء ذكاء ميتا الاصطناعي‏";
"Hide metrics" = "‏إخفاء الإحصائيات‏";
"Hide notes tray" = "‏إخفاء شريط الملاحظات‏";
"Hide trending searches" = "‏إخفاء عمليات البحث الرائجة‏";
"Hide UI on capture" = "‏إخفاء الواجهة عند الالتقاط‏";
"Hides all suggested users for you to follow, outside your feed" = "‏إخفاء جميع المستخدمين المقترحين لمتابعتهم، خارج يومياتك‏";
"Hides like/comment/share counts on posts and reels" = "‏إخفاء عدد الإعجابات والتعليقات والمشاركات على المنشورات ومقاطع ريلز‏";
"Hides the friends map icon in the notes tray" = "‏إخفاء أيقونة خريطة الأصدقاء في شريط الملاحظات‏";
@@ -121,23 +125,30 @@
"Hides the suggested broadcast channels in direct messages" = "‏إخفاء قنوات البث المقترحة في الرسائل الخاصة‏";
"Hides the trending searches under the explore search bar" = "‏إخفاء عمليات البحث الرائجة أسفل شريط البحث في الإكسبلور‏";
"Hold down on the Instagram logo to change the app icon" = "‏اضغط مطولاً على شعار إنستغرام لتغيير أيقونة التطبيق‏";
"Live" = "‏البث المباشر‏";
"Long press on the eyedropper tool in stories to customize the text color more precisely" = "‏اضغط مطولاً على أداة القطارة في القصص لتخصيص لون النص بدقة أكبر‏";
"Long-press the heart button in a live to hide or show the comments" = "‏اضغط مطولاً على زر القلب في البث المباشر لإخفاء التعليقات أو إظهارها‏";
"Long-press the search tab to open a copied Instagram link" = "اضغط مطولاً على تبويب البحث لفتح رابط إنستغرام المنسوخ";
"No suggested chats" = "‏لا توجد دردشات مقترحة‏";
"No suggested users" = "‏لا يوجد مستخدمون مقترحون‏";
"Notes" = "‏الملاحظات‏";
"Open app icon picker" = "‏فتح منتقي أيقونات التطبيق‏";
"Open link from clipboard" = "فتح الرابط من الحافظة";
"Open links in external 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" = "‏إزالة روابط تتبع إنستغرام ومعلمات التتبع من الروابط";
"Privacy" = "‏الخصوصية";
"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "‏يُخفي أزرار RyukGram من لقطات الشاشة وتسجيل الشاشة والعرض المنعكس‏";
"Removes all ads from the Instagram app" = "‏إزالة جميع الإعلانات من تطبيق إنستغرام‏";
"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." = "‏استبدال الطوابع الزمنية النسبية لإنستغرام (\"منذ 3 أيام\") بتنسيق مخصص. قم بتبديل الأماكن التي يتم تطبيقها عليها من الداخل.";
"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "‏إزالة روابط تتبع إنستغرام ومعلمات التتبع من الروابط";
"Replace domain in shared links" = "‏استبدال النطاق في الروابط المشتركة‏";
"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "‏استبدال الطوابع الزمنية النسبية لإنستغرام (\"منذ 3 أيام\") بتنسيق مخصص. قم بتبديل الأماكن التي يتم تطبيقها عليها من الداخل.‏";
"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" = "‏لن تقوم أشرطة البحث بحفظ عمليات بحثك الأخيرة بعد الآن‏";
"Sharing" = "‏المشاركة‏";
"Strip tracking from links" = "‏تجريد الروابط من التتبع‏";
"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)." = "‏تعتمد هذه الميزات على إعدادات إنستغرام المخفية وقد لا تعمل على جميع الحسابات أو الإصدارات.\nأبحاث الميزات التجريبية بواسطة @euoradan (رادان).";
"Toggle live comments" = "‏تبديل التعليقات المباشرة";
"Use detailed color picker" = "‏استخدام منتقي الألوان التفصيلي‏";
//////////////////////////////////////////////////////////////////////////////
@@ -232,9 +243,6 @@
"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" = "‏إظهار شريط التقدم دائمًا‏";
"Auto-scroll reels" = "‏التمرير التلقائي لمقاطع ريلز‏";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "‏الافتراضي لإنستغرام: السلوك الأصلي. ريوك غرام: يعيد التقدم بعد التمرير للخلف.‏";
"IG default" = "‏الافتراضي لإنستغرام‏";
"RyukGram" = "‏ريوك غرام‏";
"Change what happens when you tap on a reel" = "‏تغيير ما يحدث عند النقر على مقطع ريلز‏";
"Confirm reel refresh" = "‏تأكيد تحديث ريلز‏";
"Disable auto-unmuting reels" = "‏تعطيل إلغاء الكتم التلقائي لمقاطع ريلز‏";
@@ -246,6 +254,8 @@
"Hides the repost button on the reels sidebar" = "‏إخفاء زر إعادة النشر على الشريط الجانبي لمقاطع ريلز‏";
"Hides the top navigation bar when watching reels" = "‏إخفاء شريط التنقل العلوي عند مشاهدة ريلز‏";
"Hiding" = "‏إخفاء‏";
"IG default" = "‏الافتراضي لإنستغرام‏";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "‏الافتراضي لإنستغرام: السلوك الأصلي. ريوك غرام: يعيد التقدم بعد التمرير للخلف.‏";
"Limits" = "‏الحدود‏";
"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "‏يحد من عدد مقاطع ريلز المتاحة للتمرير في أي وقت، ويمنع التحديث‏";
"Only loads %@ %@" = "‏تحميل %@ %@ فقط‏";
@@ -253,11 +263,14 @@
"Prevent doom scrolling" = "‏منع التمرير المفرط‏";
"Prevents reels from being scrolled to the next video" = "‏يمنع التمرير إلى الفيديو التالي في ريلز‏";
"Prevents reels from unmuting when the volume/silent button is pressed" = "‏يمنع إلغاء كتم صوت ريلز عند الضغط على أزرار الصوت‏";
"RyukGram" = "‏ريوك غرام‏";
"Shows an alert when you trigger a reels refresh" = "‏يعرض تنبيهًا عند بدء تحديث ريلز‏";
"Shows buttons to reveal and auto-fill the password on locked reels" = "‏يظهر أزرارًا للكشف عن كلمة المرور وتعبئتها تلقائيًا في مقاطع ريلز المقفلة‏";
"Tap Controls" = "‏عناصر تحكم النقر‏";
"Tap to mute on photo reels" = "‏اضغط للكتم في ريلز الصور‏";
"Tapping the Reels tab while on reels does nothing" = "‏النقر على تبويب ريلز أثناء التواجد فيه لا يفعل شيئًا‏";
"Unlock password-locked reels" = "‏فتح مقاطع ريلز المقفلة بكلمة مرور‏";
"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "‏عند تفعيل وضع الإيقاف المؤقت، يؤدي النقر على ريلز الصور إلى تبديل الصوت بدلاً من إيماءة الإيقاف الأصلية‏";
//////////////////////////////////////////////////////////////////////////////
// PROFILE //
@@ -267,14 +280,25 @@
"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" = "‏إضافة خيار عرض إلى قائمة الضغط المطول على الهايلايت لفتح الغلاف بملء الشاشة‏";
"Copy note on long press" = "‏نسخ الملاحظة عند الضغط المطول‏";
"Fake follower count" = "‏عدد متابعين وهمي‏";
"Fake following count" = "‏عدد المتابَعين وهمي‏";
"Fake post count" = "‏عدد منشورات وهمي‏";
"Fake profile stats" = "‏إحصائيات ملف شخصي وهمية‏";
"Fake verified badge" = "‏شارة توثيق وهمية‏";
"Follow indicator" = "‏مؤشر المتابعة‏";
"Follower count" = "‏عدد المتابعين‏";
"Following count" = "‏عدد المتابَعين‏";
"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 to download directly (ignored when zoom is on)" = "‏اضغط مطولاً للتنزيل مباشرة (يتم تجاهله عند تشغيل التكبير)‏";
"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "‏إيماءات الضغط المطول على عناصر الملف الشخصي — منفصلة عن أزرار الإجراءات لكل ميزة.‏";
"Only affects your own profile header. Other users see the real numbers." = "‏يؤثر فقط على رأس ملفك الشخصي. يرى المستخدمون الآخرون الأرقام الحقيقية.‏";
"Post count" = "‏عدد المنشورات‏";
"Profile copy button" = "‏زر نسخ الملف الشخصي‏";
"Save profile picture" = "‏حفظ صورة الملف الشخصي‏";
"Show a checkmark next to your name on your own profile" = "‏إظهار علامة توثيق بجوار اسمك في ملفك الشخصي‏";
"Shows whether the profile user follows you" = "‏يُظهر ما إذا كان صاحب الملف الشخصي يتابعك‏";
"Tap to set" = "‏اضغط للتعيين‏";
"View highlight cover" = "‏عرض غلاف الهايلايت‏";
"Zoom profile photo" = "‏تكبير صورة الملف الشخصي‏";
@@ -330,16 +354,25 @@
"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 when you send a reply or emoji reaction, even with seen blocking on" = "‏يحدد القصة كمقروءة عند إرسال رد أو تفاعل، حتى مع تفعيل حظر المشاهدة‏";
"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "‏يحدد القصص كمقروءة محليًا (حلقة رمادية) بينما لا يزال يحظر إرسال مؤشر المشاهدة للخادم‏";
"Master toggle. When off, the list is ignored" = "‏المفتاح الرئيسي. عند إيقاف التشغيل، يتم تجاهل القائمة‏";
"Other" = "‏أخرى‏";
"Playback" = "‏التشغيل‏";
"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "‏يحدد القصص كمقروءة محليًا (حلقة رمادية) بينما لا يزال يحظر إرسال مؤشر المشاهدة للخادم‏";
"Quick list button in stories" = "‏زر القائمة السريعة في القصص‏";
"Search, sort, swipe to remove" = "‏بحث، فرز، التمرير للإزالة‏";
"Seen receipts" = "‏مؤشرات القراءة‏";
"Sending a reply or emoji reaction automatically advances to the next story" = "‏إرسال رد أو تفاعل ينقلك تلقائيًا للقصة التالية‏";
"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" = "‏يعرض زر عين في القصص لإضافة وإزالة المستخدمين من القائمة. إيقاف = استخدم قائمة النقاط الثلاث أو الضغط المطول فقط‏";
"Stickers" = "‏ملصقات‏";
"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "‏اطلع على نتائج استطلاعات الرأي/الاختبارات/شريط التمرير قبل التفاعل — لا يزال بإمكانك النقر للتصويت بشكل طبيعي. 'إجبار ظهور ملصق الاختبار' يعيد ملصق الاختبار القديم إلى لوحة إنشاء القصة.‏";
"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "‏اطلع على نتائج استطلاعات الرأي/الاختبارات/شريط التمرير في الريلز قبل التفاعل — لا يزال بإمكانك النقر للتصويت بشكل طبيعي.‏";
"Force Quiz sticker in tray" = "‏إجبار ظهور ملصق الاختبار‏";
"Adds Quiz back to the story sticker picker" = "‏إعادة ملصق الاختبار إلى منتقي ملصقات القصة‏";
"Show quiz answer" = "‏إظهار إجابة الاختبار‏";
"Circle the correct option on quiz stickers, or the leading option on polls" = "‏تمييز الخيار الصحيح في ملصقات الاختبار أو الخيار الأكثر تصويتًا في الاستطلاعات‏";
"Show poll vote counts" = "‏إظهار عدد أصوات الاستطلاع‏";
"Show vote tallies on poll options and slider count/average before you vote" = "‏إظهار عدد الأصوات على خيارات الاستطلاع ومتوسط/عدد شريط التمرير قبل التصويت‏";
"Stop story auto-advance" = "‏إيقاف التقدم التلقائي للقصص‏";
"Stories" = "‏القصص‏";
"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "‏لن تنتقل القصص تلقائيًا للتالية عند انتهاء المؤقت. انقر للتقدم يدويًا‏";
@@ -384,7 +417,7 @@
"Copy text on hold" = "‏نسخ النص عند التوقف‏";
"Custom emojis and background/text colors" = "‏إيموجي مخصصة وألوان للخلفية والنص‏";
"Custom note themes" = "‏سمات ملاحظات مخصصة‏";
"Disable disappearing mode swipe" = "‏تعطيل سحب وضع الاختفاء‏";
"Disable vanish mode swipe" = "‏تعطيل سحب وضع الاختفاء‏";
"Disable screenshot detection" = "‏تعطيل الكشف عن لقطات الشاشة‏";
"Disable typing status" = "‏تعطيل حالة الكتابة (يكتب...)‏";
"Disable view-once limitations" = "‏تعطيل قيود العرض لمرة واحدة‏";
@@ -405,7 +438,7 @@
"Note actions" = "‏إجراءات الملاحظات‏";
"Preserve messages that others unsend" = "‏الاحتفاظ بالرسائل التي يلغي الآخرون إرسالها‏";
"Preserves messages that others unsend" = "‏يحتفظ بالرسائل التي يلغي الآخرون إرسالها‏";
"Prevents accidental swipe-up activation of disappearing mode" = "‏يمنع التنشيط العشوائي لوضع الاختفاء عند السحب لأعلى‏";
"Prevents accidental swipe-up activation of vanish mode" = "‏يمنع التنشيط العشوائي لوضع الاختفاء عند السحب لأعلى‏";
"Quick list button in chats" = "‏زر القائمة السريعة في الدردشات‏";
"Removes the audio call button from DM thread header" = "‏يزيل زر المكالمة الصوتية من أعلى المحادثة‏";
"Removes the screenshot-prevention features for visual messages in DMs" = "‏يزيل ميزات منع لقطات الشاشة للرسائل المرئية في الرسائل الخاصة‏";
@@ -420,7 +453,6 @@
"Shows an \"Unsent\" label on preserved messages" = "‏يعرض علامة \"أُلغي الإرسال\" على الرسائل المحفوظة‏";
"Unlimited replay of visual messages" = "‏إعادة تشغيل غير محدودة للرسائل المرئية‏";
"Unsent message notification" = "‏إشعار الرسائل الملغاة‏";
"Visual messages" = "‏الرسائل المرئية‏";
"Voice messages" = "‏الرسائل الصوتية‏";
"Warn before clearing on refresh" = "‏تحذير قبل المسح عند التحديث‏";
"Which chats get read-receipt blocking" = "‏الدردشات التي يُحظر فيها مؤشر القراءة‏";
@@ -439,11 +471,13 @@
// Settings → Navigation tab //
//////////////////////////////////////////////////////////////////////////////
"Also hide the bottom tab bar — only the inbox is visible" = "‏إخفاء شريط التبويبات السفلي أيضًا — يظهر صندوق الوارد فقط‏";
"Hide create tab" = "‏إخفاء تبويب الإنشاء‏";
"Hide explore tab" = "‏إخفاء تبويب الإكسبلور‏";
"Hide feed tab" = "‏إخفاء تبويب اليوميات‏";
"Hide messages tab" = "‏إخفاء تبويب الرسائل‏";
"Hide reels tab" = "‏إخفاء تبويب ريلز‏";
"Hide tab bar" = "‏إخفاء شريط التبويبات‏";
"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 direct messages tab on the bottom navigation bar" = "‏يخفي تبويب الرسائل المباشرة من شريط التنقل السفلي‏";
@@ -468,7 +502,8 @@
//////////////////////////////////////////////////////////////////////////////
"Confirm actions" = "‏تأكيد الإجراءات‏";
"Confirm call" = "‏تأكيد المكالمة‏";
"Confirm video call" = "‏تأكيد مكالمة الفيديو";
"Confirm voice call" = "‏تأكيد المكالمة الصوتية‏";
"Confirm changing theme" = "‏تأكيد تغيير السمة‏";
"Confirm follow" = "‏تأكيد المتابعة‏";
"Confirm follow requests" = "‏تأكيد طلبات المتابعة‏";
@@ -476,24 +511,26 @@
"Confirm like: Reels" = "‏تأكيد الإعجاب: ريلز‏";
"Confirm posting comment" = "‏تأكيد نشر التعليق‏";
"Confirm repost" = "‏تأكيد إعادة النشر‏";
"Confirm shh mode" = "‏تأكيد وضع الصمت (Shh)";
"Confirm sticker interaction" = "‏تأكيد التفاعل مع الملصقات‏";
"Confirm vanish mode" = "‏تأكيد وضع الاختفاء";
"Confirm sticker interaction (stories)" = "‏تأكيد التفاعل مع الملصقات (القصص)";
"Confirm sticker interaction (highlights)" = "‏تأكيد التفاعل مع الملصقات (الهايلايت)‏";
"Confirm story emoji reaction" = "‏تأكيد تفاعل الإيموجي في القصة‏";
"Confirm story like" = "‏تأكيد الإعجاب بالقصة‏";
"Confirm unfollow" = "‏تأكيد إلغاء المتابعة‏";
"Shows an alert before sending an emoji reaction on a story" = "‏يُظهر تنبيهًا قبل إرسال تفاعل إيموجي على قصة‏";
"Shows an alert when you click the like button on posts to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في المنشورات للتأكيد‏";
"Shows an alert when you click the like button on stories to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في القصص للتأكيد‏";
"Confirm voice messages" = "‏تأكيد الرسائل الصوتية‏";
"Shows an alert before sending an emoji reaction on a story" = "‏يُظهر تنبيهًا قبل إرسال تفاعل إيموجي على قصة‏";
"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 vanish mode" = "‏يُظهر تنبيهًا للتأكيد قبل تفعيل وضع الاختفاء‏";
"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 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 tap a sticker on someone's story" = "‏يُظهر تنبيهًا عند النقر على ملصق في قصة شخص ما‏";
"Shows an alert when you tap a sticker inside a highlight" = "‏يُظهر تنبيهًا عند النقر على ملصق داخل هايلايت";
"Shows an alert when you click the video call button to confirm before calling" = "‏يُظهر تنبيهًا عند النقر على زر مكالمة الفيديو للتأكيد قبل الاتصال‏";
"Shows an alert when you click the voice 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 like button on posts or stories to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في المنشورات أو القصص للتأكيد‏";
"Shows an alert when you click the like button on posts 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 stories to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في القصص للتأكيد‏";
"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 unfollow button to confirm" = "‏يُظهر تنبيهًا عند النقر على زر إلغاء المتابعة للتأكيد‏";
@@ -504,22 +541,6 @@
//////////////////////////////////////////////////////////////////////////////
"Backup & Restore" = "‏النسخ الاحتياطي والاستعادة‏";
"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." = "‏قم بتصدير إعدادات ريوك غرام الخاصة بك إلى ملف جيسون (JSON) واستيرادها لاحقًا. الاستيراد يعيد تعيين جميع الإعدادات للافتراضي قبل التطبيق، ويظهر معاينة مسبقة.‏";
"Import settings" = "‏استيراد الإعدادات‏";
"Load settings from a JSON file" = "‏تحميل الإعدادات من ملف جيسون (JSON)";
"Reset to defaults" = "‏إعادة التعيين للافتراضي‏";
"Revert every RyukGram preference" = "‏إرجاع كل تفضيلات ريوك غرام‏";
"Save settings as a JSON file" = "‏حفظ الإعدادات كملف جيسون (JSON)";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL //
// Settings → Experimental tab //
//////////////////////////////////////////////////////////////////////////////
"Experimental" = "‏تجريبي‏";
"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "‏هذه الميزات غير مستقرة وقد تسبب انهيار تطبيق إنستغرام بشكل مفاجئ.\n\nاستخدمها على مسؤوليتك الخاصة!‏";
"Warning" = "‏تحذير‏";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED //
@@ -527,17 +548,76 @@
//////////////////////////////////////////////////////////////////////////////
"Advanced" = "‏متقدم‏";
"Auto-clear cache" = "‏مسح تلقائي للذاكرة المؤقتة‏";
"Automatically opens settings when the app launches" = "‏يفتح الإعدادات تلقائيًا عند تشغيل التطبيق‏";
"Cache" = "‏ذاكرة التخزين المؤقت‏";
"Cache cleared" = "‏تم مسح ذاكرة التخزين المؤقت‏";
"Calculating cache size…" = "‏جاري حساب حجم ذاكرة التخزين المؤقت…‏";
"Clear" = "‏مسح‏";
"Clear cache" = "‏مسح ذاكرة التخزين المؤقت‏";
"Clear cache (%@)" = "‏مسح ذاكرة التخزين المؤقت (%@)‏";
"Clear cache?" = "‏مسح ذاكرة التخزين المؤقت؟‏";
"Clearing cache…" = "‏جاري المسح…‏";
"Clearing still scans on demand." = "‏سيظل المسح يُجري الفحص عند الطلب.‏";
"Daily" = "‏يومياً‏";
"Disable safe mode" = "‏تعطيل الوضع الآمن‏";
"Enable tweak settings quick-access" = "‏تفعيل الوصول السريع لإعدادات الأداة‏";
"Free %@ of Instagram cache. A restart is recommended." = "‏تحرير %@ من ذاكرة إنستغرام. يُنصح بإعادة التشغيل.‏";
"Freed %@. Restart to apply." = "‏تم تحرير %@. أعد التشغيل للتطبيق.‏";
"Hold on the home tab to open RyukGram settings" = "‏اضغط مطولاً على تبويب الصفحة الرئيسية لفتح إعدادات ريوك غرام‏";
"Instagram" = "‏إنستغرام‏";
"Monthly" = "‏شهرياً‏";
"Nothing to clear" = "‏لا شيء للمسح‏";
"Off skips the size scan when Advanced opens." = "‏عند الإيقاف يتم تخطي فحص الحجم عند فتح «متقدم».‏";
"Pause playback when opening settings" = "‏إيقاف التشغيل مؤقتًا عند فتح الإعدادات‏";
"Pauses any playing video/audio when settings opens" = "‏يوقف أي فيديو أو صوت قيد التشغيل مؤقتًا عند فتح الإعدادات‏";
"Prevents Instagram from resetting settings after crashes (at your own risk)" = "‏يمنع إنستغرام من إعادة ضبط الإعدادات بعد الانهيارات (على مسؤوليتك)‏";
"Remove Instagram's cached images, videos, and temporary files." = "‏يُزيل صور وفيديوهات وملفات إنستغرام المؤقتة.‏";
"Reset onboarding state" = "‏إعادة ضبط حالة التهيئة التمهيدية‏";
"Settings" = "‏الإعدادات";
"Run a silent cache clear on launch when the interval has elapsed." = "‏يُنفّذ مسحاً صامتاً للذاكرة المؤقتة عند التشغيل إذا انقضت المدة المحددة.";
"Show cache size" = "‏إظهار حجم ذاكرة التخزين المؤقت‏";
"Show tweak settings on app launch" = "‏إظهار إعدادات الأداة عند تشغيل التطبيق‏";
"Weekly" = "‏أسبوعياً‏";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED EXPERIMENTAL //
// Settings → Advanced → Advanced experimental features //
//////////////////////////////////////////////////////////////////////////////
"Actions" = "‏الإجراءات‏";
"Advanced experimental features" = "‏الميزات التجريبية المتقدمة‏";
"All experimental toggles will be turned off. Instagram will restart." = "‏سيتم إيقاف جميع المفاتيح التجريبية. سيُعاد تشغيل Instagram.";
"Direct Notes — Audio reply" = "‏ملاحظات Direct — الرد الصوتي‏";
"Direct Notes — Avatar reply" = "‏ملاحظات Direct — الرد بالصورة الرمزية‏";
"Direct Notes — Friend Map" = "‏ملاحظات Direct — خريطة الأصدقاء‏";
"Direct Notes — GIFs & stickers reply" = "‏ملاحظات Direct — الرد بصور GIF والملصقات‏";
"Direct Notes — Photo reply" = "‏ملاحظات Direct — الرد بصورة‏";
"Disabled after repeated crashes." = "‏تم الإيقاف بعد تعطل متكرر.‏";
"Enables GIF/sticker replies" = "‏يُفعّل الرد بصور GIF والملصقات‏";
"Enables photo replies" = "‏يُفعّل الرد بالصور‏";
"Enables the audio-note reply type" = "‏يُفعّل الرد بملاحظة صوتية‏";
"Enables the avatar reply type" = "‏يُفعّل الرد بالصورة الرمزية‏";
"Experimental flags reset" = "‏تمت إعادة تعيين الميزات التجريبية‏";
"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "‏فعّل ما تريد ثم اضغط «تطبيق» لإعادة التشغيل. قد لا تعمل بعض الميزات على جميع الحسابات أو إصدارات IG. تُعاد تعيينها تلقائيًا إذا تعطل IG عند بدء التشغيل 3 مرات.‏";
"Forces Prism-gated experiments on" = "‏يُجبر تفعيل التجارب المُقيّدة بواسطة Prism‏";
"Forces the Homecoming home surface / nav on" = "‏يُجبر تفعيل واجهة وتنقل Homecoming";
"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "‏يُجبر ظهور QuickSnap / Instants في الخلاصة والرسائل والقصص وشريط الملاحظات‏";
"Got it" = "‏فهمت‏";
"Heads up" = "‏تنبيه‏";
"Hidden Instagram experiments" = "‏تجارب Instagram المخفية‏";
"Hidden Instagram experiments (in Advanced)" = "‏تجارب Instagram المخفية (في الإعدادات المتقدمة)‏";
"Homecoming" = "Homecoming";
"Notes & QuickSnap" = "‏الملاحظات وQuickSnap";
"Prism design system" = "‏نظام تصميم Prism";
"QuickSnap (Instants)" = "QuickSnap (Instants)";
"Reset all experimental flags" = "‏إعادة تعيين جميع المفاتيح التجريبية‏";
"Reset experimental flags?" = "‏إعادة تعيين المفاتيح التجريبية؟‏";
"Restart Instagram to apply changes" = "‏أعِد تشغيل Instagram لتطبيق التغييرات‏";
"Shows the friend map entry in Direct Notes" = "‏يُظهر خريطة الأصدقاء في ملاحظات Direct";
"Surfaces" = "‏الواجهات‏";
"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "‏تُفعّل هذه المفاتيح تجارب Instagram المخفية. قد لا تعمل بعض الميزات على جميع الحسابات أو إصدارات IG. إذا استمر IG بالتعطل عند التشغيل، تُعاد تعيين المفاتيح بعد 3 محاولات فاشلة.‏";
"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "‏فعّل تجارب Instagram المخفية. قد لا يعمل بعضها على جميع الحسابات أو إصدارات IG.‏";
"Turn every experimental toggle off" = "‏إيقاف جميع المفاتيح التجريبية‏";
//////////////////////////////////////////////////////////////////////////////
// DEBUG //
@@ -546,24 +626,38 @@
"Button Cell" = "‏خلية زر‏";
"Change the value on the right" = "‏غيّر القيمة الموجودة على اليسار‏";
"Could not delete: %@" = "‏تعذّر الحذف: %@";
"Debug" = "‏تصحيح الأخطاء‏";
"Delete an imported override and fall back to the shipped strings" = "‏احذف تجاوزاً مستورداً وارجع إلى النصوص الأصلية‏";
"Deleted %@ override. Restart to apply." = "‏تم حذف تجاوز %@. أعد التشغيل للتطبيق.‏";
"Enable FLEX gesture" = "‏تفعيل إيماءة أداة فليكس (FLEX)";
"Export English strings" = "‏تصدير نصوص الإنجليزية‏";
"Hold 5 fingers on the screen to open FLEX" = "‏ضع 5 أصابع على الشاشة لفتح فليكس (FLEX)";
"I have %@%@" = "‏لدي %@%@";
"Import a .strings file for a language" = "‏استيراد ملف .strings للغة‏";
"Import a .strings file to update a translation. Pick a language, select the file, restart." = "‏استورد ملف .strings لتحديث الترجمة. اختر لغة، حدد الملف، وأعد التشغيل.‏";
"Link Cell" = "‏خلية رابط‏";
"Localization" = "‏التعريب‏";
"Menu Cell" = "‏خلية قائمة‏";
"Navigation Cell" = "‏خلية التنقل‏";
"No imported localization files to reset." = "‏لا توجد ملفات ترجمة مستوردة للإعادة.‏";
"No overrides" = "‏لا توجد تجاوزات‏";
"Open FLEX on app focus" = "‏فتح فليكس عند التركيز على التطبيق‏";
"Open FLEX on app launch" = "‏فتح فليكس عند إطلاق التطبيق‏";
"Opens FLEX when the app is focused" = "‏يفتح فليكس عندما يكون التطبيق قيد التركيز‏";
"Opens FLEX when the app launches" = "‏يفتح فليكس عند إطلاق التطبيق‏";
"Pick a language to delete the imported file" = "‏اختر لغة لحذف الملف المستورد‏";
"Reset localization" = "‏إعادة تعيين الترجمة‏";
"Share the base English .strings file for translating" = "‏مشاركة ملف .strings الأساسي بالإنجليزية للترجمة‏";
"Static Cell" = "‏خلية ثابتة‏";
"Stepper cell" = "‏خلية متدرج‏";
"Switch Cell" = "‏خلية مفتاح تبديل‏";
"Switch Cell (Restart)" = "‏خلية مفتاح تبديل (إعادة تشغيل)‏";
"Tap the switch" = "‏انقر فوق مفتاح التبديل‏";
"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "‏تعتمد هذه الميزات على علامات Instagram مخفية وقد لا تعمل على جميع الحسابات أو الإصدارات.‏";
"Update localization file" = "‏تحديث ملف التعريب‏";
"Using icon" = "‏استخدام أيقونة‏";
"Using image" = "‏استخدام صورة‏";
"_ Example" = "_ مثال‏";
//////////////////////////////////////////////////////////////////////////////
// DOWNLOADS & MEDIA ACTIONS //
@@ -595,16 +689,16 @@
"Failed to save" = "‏فشل الحفظ‏";
"HD download complete" = "‏اكتمل التنزيل بدقة عالية (HD)‏";
"Mute audio" = "‏كتم الصوت‏";
"No URLs" = "‏لا توجد روابط‏";
"No URLs found" = "‏لم يتم العثور على روابط‏";
"No caption on this post" = "‏لا يوجد وصف في هذا المنشور‏";
"No carousel children" = "‏لا توجد وسائط متعددة في هذا المنشور‏";
"No cover image" = "‏لا توجد صورة غلاف‏";
"No files downloaded" = "‏لم يتم تنزيل أي ملفات‏";
"No media" = "‏لا توجد وسائط‏";
"No media URL" = "‏لا يوجد رابط للوسائط‏";
"No media to expand" = "‏لا توجد وسائط لتوسيعها‏";
"No media to show" = "‏لا توجد وسائط لعرضها‏";
"No media URL" = "‏لا يوجد رابط للوسائط‏";
"No URLs" = "‏لا توجد روابط‏";
"No URLs found" = "‏لم يتم العثور على روابط‏";
"No video URL" = "‏لا يوجد رابط للفيديو‏";
"Not a carousel" = "‏ليس منشورًا متعدد الوسائط‏";
"Nothing to save" = "‏لا يوجد شيء لحفظه‏";
@@ -637,6 +731,7 @@
"Add to block list" = "‏إضافة لقائمة الحظر‏";
"Add to block list?" = "‏إضافة لقائمة الحظر؟‏";
"Added to block list" = "‏تمت الإضافة لقائمة الحظر‏";
"Added to exclude list" = "‏أُضيف إلى قائمة الاستبعاد‏";
"Audio not loaded yet. Play the message first and try again." = "‏لم يتم تحميل الصوت بعد. قم بتشغيل الرسالة أولاً وحاول مجددًا.‏";
"Audio sent" = "‏تم إرسال الصوت‏";
"Audio/Video from Files" = "‏صوت أو فيديو من الملفات‏";
@@ -650,12 +745,14 @@
"Could not get audio data. Try again after refreshing the chat." = "‏تعذر الحصول على بيانات الصوت. حاول مجددًا بعد تحديث الدردشة.‏";
"Could not get video URL" = "‏تعذر الحصول على رابط الفيديو‏";
"Disable read receipts" = "‏تعطيل مؤشرات القراءة‏";
"Disappearing media" = "‏وسائط مختفية‏";
"Done!" = "‏تم!";
"Download audio" = "‏تنزيل الصوت‏";
"Downloading audio..." = "‏جارِ تنزيل الصوت...‏";
"Enable read receipts" = "‏تفعيل مؤشرات القراءة‏";
"Error: %@" = "‏خطأ: %@";
"Exclude chat" = "‏استبعاد الدردشة‏";
"Exclude from seen" = "‏استبعاد من المُشاهد‏";
"Exclude story seen" = "‏استبعاد مشاهدة القصة‏";
"Excluded" = "‏مستبعد‏";
"Extracting audio..." = "‏جارِ استخراج الصوت...‏";
@@ -663,6 +760,10 @@
"File sending not supported" = "‏إرسال الملفات غير مدعوم‏";
"Follow" = "‏متابعة‏";
"Following" = "‏تُتابع‏";
"Inserts a button on disappearing media overlays" = "‏يُضيف زراً على طبقة الوسائط المختفية‏";
"Inserts a speaker button to mute/unmute disappearing media" = "‏يُضيف زر مكبر صوت لكتم/إلغاء كتم الوسائط المختفية‏";
"Inserts an eye button to mark the current disappearing media as viewed" = "‏يُضيف زر عين لتعليم الوسائط المختفية الحالية كمُشاهدة‏";
"Mark as viewed" = "‏تعليم كمُشاهدة‏";
"Mark messages as seen" = "‏تحديد الرسائل كمقروءة‏";
"Mark seen" = "‏تحديد كمقروءة‏";
"Marked as seen" = "‏تم التحديد كمقروءة‏";
@@ -671,6 +772,7 @@
"Mentions" = "‏الإشارات‏";
"Message sender not found" = "‏لم يتم العثور على مُرسل الرسالة‏";
"Messages settings" = "‏إعدادات الرسائل‏";
"Audio URL not available" = "‏رابط الصوت غير متاح‏";
"Mute story audio" = "‏كتم صوت القصة‏";
"No audio URL found. Try again after refreshing the chat." = "‏لم يتم العثور على رابط للصوت. حاول مجددًا بعد تحديث الدردشة.‏";
"No mentions in this story" = "‏لا توجد إشارات في هذه القصة‏";
@@ -686,26 +788,25 @@
"Remove" = "‏إزالة‏";
"Remove from block list" = "‏إزالة من قائمة الحظر‏";
"Remove from block list?" = "‏إزالة من قائمة الحظر؟‏";
"Remove from exclude list" = "‏إزالة من قائمة الاستبعاد‏";
"Removed" = "‏تمت الإزالة‏";
"Removed from list" = "‏تمت الإزالة من القائمة‏";
"Save GIF" = "‏حفظ صورة جيف (GIF)";
"Selection too short (min 0.5s)" = "‏التحديد قصير جدًا (الحد الأدنى 0.5 ثانية)‏";
"Send Audio" = "‏إرسال صوت‏";
"Send anyway" = "‏إرسال على أي حال‏";
"Send Audio" = "‏إرسال صوت‏";
"Send failed: %@" = "‏فشل الإرسال: %@";
"Send service not found" = "‏لم يتم العثور على خدمة الإرسال‏";
"Share" = "‏مشاركة";
"Show audio toggle" = "‏إظهار زر الصوت";
"Show mark-as-viewed button" = "‏إظهار زر علامة كمُشاهدة‏";
"Story read receipts disabled" = "‏تم تعطيل مؤشرات قراءة القصص‏";
"Story read receipts enabled" = "‏تم تفعيل مؤشرات قراءة القصص‏";
"Story seen receipts will be blocked for @%@." = "‏سيتم حظر مؤشرات مشاهدة القصة لـ @%@.‏";
"This chat will resume normal read-receipt behavior." = "‏ستستأنف هذه الدردشة السلوك الطبيعي لمؤشر القراءة.‏";
"Total: %@" = "‏الإجمالي: %@";
"Un-exclude" = "‏إلغاء الاستبعاد‏";
"Un-exclude chat" = "‏إلغاء استبعاد الدردشة‏";
"Un-exclude chat?" = "‏إلغاء استبعاد الدردشة؟‏";
"Un-exclude story seen" = "‏إلغاء استبعاد مشاهدة القصة‏";
"Un-exclude story seen?" = "‏إلغاء استبعاد مشاهدة القصة؟‏";
"Un-excluded" = "‏تم إلغاء الاستبعاد‏";
"Unblock" = "‏إلغاء الحظر‏";
"Unblocked" = "‏تم إلغاء الحظر‏";
"Unlimited replay enabled" = "‏تم تفعيل الإعادة غير المحدودة‏";
"Unmute story audio" = "‏إلغاء كتم صوت القصة‏";
@@ -728,6 +829,9 @@
"Add preset" = "‏إضافة إعداد مسبق‏";
"Change location" = "‏تغيير الموقع‏";
"Click the Apply button after this to see the emoji" = "‏انقر على زر تطبيق بعد هذا لرؤية الإيموجي‏";
"Clipboard is not an Instagram URL" = "الحافظة لا تحتوي على رابط إنستغرام";
"Comments hidden" = "‏تم إخفاء التعليقات‏";
"Comments shown" = "‏تم إظهار التعليقات‏";
"Copied text to clipboard" = "‏تم نسخ النص إلى الحافظة‏";
"Copy" = "‏نسخ‏";
"Copy all" = "‏نسخ الكل‏";
@@ -738,19 +842,168 @@
"Current: %@" = "‏الحالي: %@";
"Disable" = "‏تعطيل‏";
"Download GIF" = "‏تنزيل صورة جيف (GIF)‏";
"Dropped pin" = "‏دبوس الموقع‏";
"Enable" = "‏تفعيل‏";
"Enable Location Services for Instagram in Settings to use your current location." = "‏فعّل خدمات الموقع لإنستغرام من الإعدادات لاستخدام موقعك الحالي.‏";
"Enter Emoji Text" = "‏أدخل نص الإيموجي‏";
"Fake location" = "‏موقع وهمي‏";
"Location access denied" = "‏تم رفض الوصول إلى الموقع‏";
"Location Services off" = "‏خدمات الموقع متوقفة‏";
"Name" = "‏الاسم‏";
"Nothing to copy" = "‏لا يوجد شيء لنسخه‏";
"Open Settings" = "‏فتح الإعدادات‏";
"Pick location" = "‏اختر الموقع‏";
"Save" = "‏حفظ‏";
"Save preset" = "‏حفظ الإعداد المسبق‏";
"Saved locations" = "‏المواقع المحفوظة‏";
"Select color" = "‏اختيار اللون‏";
"Set location" = "‏تعيين الموقع‏";
"Settings…" = "‏الإعدادات…‏";
"Turn Location Services on in Settings → Privacy to use your current location." = "‏فعّل خدمات الموقع من الإعدادات ← الخصوصية لاستخدام موقعك الحالي.‏";
"Type emoji..." = "‏اكتب إيموجي...";
"Theme" = "‏السمة‏";
"Appearance" = "‏المظهر‏";
"Keyboard" = "‏لوحة المفاتيح‏";
"Force dark mode" = "‏فرض الوضع الداكن‏";
"Keep Instagram in dark appearance regardless of iOS system setting" = "‏إبقاء انستغرام في الوضع الداكن بغض النظر عن إعدادات النظام‏";
"Full OLED" = "OLED كامل‏";
"Replace Instagram's dark grays with pure black across the entire app" = "‏استبدال الرمادي الداكن في انستغرام بالأسود النقي في جميع أنحاء التطبيق‏";
"OLED chat theme" = "‏سمة OLED للمحادثات‏";
"Pure black DM thread background and incoming message bubbles" = "‏خلفية سوداء نقية لمحادثات الرسائل وفقاعات الرسائل الواردة‏";
"Keyboard theme" = "‏سمة لوحة المفاتيح‏";
"Override the keyboard appearance when typing inside Instagram" = "‏تجاوز مظهر لوحة المفاتيح عند الكتابة داخل انستغرام‏";
"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "‏الداكن يستخدم لوحة المفاتيح الداكنة للنظام. OLED يفرض خلفية لوحة المفاتيح إلى الأسود النقي.‏";
"Dark" = "‏داكن‏";
"OLED" = "OLED";
"Apply & restart" = "‏تطبيق وإعادة التشغيل‏";
"Restart Instagram to apply your theme changes" = "‏أعد تشغيل انستغرام لتطبيق تغييرات السمة‏";
"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "‏لا تُطبَّق تغييرات السمة إلا بعد إعادة تشغيل التطبيق. اضغط تطبيق بالأسفل عند الانتهاء من الاختيار.‏";
//////////////////////////////////////////////////////////////////////////////
// PROFILE ANALYZER //
// Settings → General → Profile Analyzer //
//////////////////////////////////////////////////////////////////////////////
"%lu followers · %lu following" = "%lu متابع · %lu يتابع‏";
"%lu of %lu" = "%lu من %lu";
"Analysis complete" = "‏اكتمل التحليل‏";
"Analysis failed" = "‏فشل التحليل‏";
"Another analysis is already running" = "‏هناك تحليل آخر قيد التشغيل بالفعل‏";
"Available after your next scan" = "‏متاحة بعد التحليل التالي‏";
"Cancelled" = "‏تم الإلغاء‏";
"Couldn't fetch profile information" = "‏تعذّر جلب معلومات الملف الشخصي‏";
"Fetching followers (%lu/%ld)…" = "‏جاري جلب المتابعين (%lu/%ld)…‏";
"Fetching following (%lu/%ld)…" = "‏جاري جلب المتابَعين (%lu/%ld)…‏";
"Fetching profile info…" = "‏جاري جلب معلومات الملف الشخصي…‏";
"Categories" = "‏الفئات‏";
"First scan: %@" = "‏أول تحليل: %@";
"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "‏عدد المتابعين يتجاوز %ld — التحليل معطّل لتجنب حدود المعدل.‏";
"Gained since last scan" = "‏اكتسبتهم منذ آخر تحليل‏";
"Last scan: %@" = "‏آخر تحليل: %@";
"Lost followers" = "‏متابعون مفقودون‏";
"Mutual followers" = "‏متابعون متبادلون‏";
"Name: %@ → %@" = "‏الاسم: %@ ← %@";
"New followers" = "‏متابعون جدد‏";
"No results" = "‏لا توجد نتائج‏";
"No active Instagram session found" = "‏لا توجد جلسة Instagram نشطة‏";
"No scan yet" = "‏لا يوجد تحليل بعد‏";
"Not following you back" = "‏لا يتابعونك بالمقابل‏";
"OK" = "‏حسنًا‏";
"Private account" = "‏حساب خاص‏";
"Profile Analyzer" = "‏محلل الملف الشخصي‏";
"Profile picture changed" = "‏تم تغيير صورة الملف الشخصي‏";
"Profile updates" = "‏تحديثات الملف الشخصي‏";
"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "‏يزيل اللقطات المحفوظة لهذا الحساب. ستفقد الفروق منذ آخر تحليل.‏";
"Request failed" = "‏فشل الطلب‏";
"Reset analyzer data?" = "‏إعادة تعيين بيانات المحلل؟‏";
"Run analysis" = "‏تشغيل التحليل‏";
"Run your first analysis" = "‏شغّل أول تحليل لك‏";
"Search username or name" = "‏ابحث باسم المستخدم أو الاسم‏";
"Since last scan" = "‏منذ آخر تحليل‏";
"Starting…" = "‏يبدأ…‏";
"They follow you, you don't follow back" = "‏يتابعونك، لكنك لا تتابعهم‏";
"Too many followers" = "‏عدد متابعين كبير جدًا‏";
"Too many followers to analyze" = "‏عدد المتابعين أكبر من أن يُحلَّل‏";
"Unfollow" = "‏إلغاء المتابعة‏";
"Unfollow @%@?" = "‏إلغاء متابعة @%@؟‏";
"Unfollowed you since last scan" = "‏ألغوا متابعتك منذ آخر تحليل‏";
"Username, name or picture changes" = "‏تغييرات اسم المستخدم أو الاسم أو الصورة‏";
"Username: @%@ → @%@" = "‏اسم المستخدم: @%@ ← @%@";
"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "‏لا نشغّل التحليل عندما يتجاوز عدد المتابعين %ld لتجنب حدود Instagram.";
"You both follow each other" = "‏تتابعان بعضكما البعض‏";
"You don't follow back" = "‏لا تتابعهم بالمقابل‏";
"You follow them, they don't follow back" = "‏تتابعهم، لكنهم لا يتابعونك‏";
"You started following" = "‏بدأت تتابعهم‏";
"You unfollowed" = "‏ألغيت متابعتهم‏";
"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "%@ %lu حسابًا؟ ستتم معالجة أول %ld لتجنّب حدود المعدل.‏";
"%@ %lu accounts? This runs sequentially with a short pause between each." = "%@ %lu حسابًا؟ ستتم المعالجة بالتتابع مع وقفة قصيرة بين كل طلب.‏";
"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu حساب · %lu لقطة · انقر للفحص‏";
"%lu accounts followed" = "‏تمت متابعة %lu حسابًا‏";
"%lu accounts unfollowed" = "‏تم إلغاء متابعة %lu حسابًا‏";
"%lu entries across %lu lists · tap to inspect" = "%lu إدخال عبر %lu قائمة · انقر للفحص‏";
"%lu preferences · tap to inspect" = "%lu تفضيلات · انقر للفحص‏";
"(empty)" = "(فارغ)";
"(no analyzer data)" = "(لا توجد بيانات محلل)‏";
"(no lists)" = "(لا توجد قوائم)";
"About Profile Analyzer" = "‏عن محلل الملف الشخصي‏";
"All preferences (%lu)" = "‏جميع التفضيلات (%lu)‏";
"Apply imported data?" = "‏تطبيق البيانات المستوردة؟‏";
"Batch follow" = "‏متابعة جماعية‏";
"Batch follow finished" = "‏اكتملت المتابعة الجماعية‏";
"Batch unfollow" = "‏إلغاء متابعة جماعي‏";
"Batch unfollow finished" = "‏اكتمل إلغاء المتابعة الجماعي‏";
"Continue" = "‏متابعة‏";
"Current snapshot" = "‏اللقطة الحالية‏";
"Embed domains" = "‏نطاقات التضمين‏";
"Excluded lists" = "‏القوائم المستبعدة‏";
"Excluded story users" = "‏مستخدمو القصص المستبعدون‏";
"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "‏سيتم استبدال القيم الحالية للنطاق المحدد. قد يحتاج التطبيق إلى إعادة التشغيل ليسري مفعول بعض التغييرات.‏";
"Export" = "‏تصدير‏";
"File has no importable sections." = "‏لا يحتوي الملف على أقسام قابلة للاستيراد.‏";
"File is not a valid RyukGram export." = "‏الملف ليس تصديرًا صالحًا من RyukGram.";
"Filter" = "‏تصفية‏";
"First scan: we collect your followers and following lists and save them locally." = "‏أول تحليل: نجمع قوائم المتابعين والمتابَعين ونحفظها محليًا.‏";
"Follow %lu" = "‏متابعة %lu";
"Followers" = "‏المتابعون‏";
"Following… %lu / %lu" = "‏جارٍ المتابعة… %lu / %lu";
"Full name" = "‏الاسم الكامل‏";
"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "‏تنبيه: هذه الميزة تجريبية وتستخدم واجهة Instagram الخاصة. تشغيلها بشكل متتالٍ أو بعد نشاط متابعة/إلغاء متابعة كثيف قد يسبب حدًا مؤقتًا. استخدمها باعتدال وعلى مسؤوليتك.‏";
"Import complete" = "‏اكتمل الاستيراد‏";
"Include" = "‏تضمين‏";
"Included story users" = "‏مستخدمو القصص المدرجون‏";
"Inspect the full payload" = "‏افحص البيانات الكاملة‏";
"Keep scan history" = "‏الاحتفاظ بسجل التحليلات‏";
"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "‏الحسابات الكبيرة محظورة: يتم تعطيل التحليل فوق 13,000 متابع لتجنّب قيام Instagram بتحديد معدل التطبيق بأكمله.‏";
"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "‏لا يتم رفع أي شيء — كل البيانات تبقى على هذا الجهاز ويمكن مسحها من أيقونة سلة المهملات.‏";
"Not verified only" = "‏غير الموثقة فقط‏";
"Nothing was applied." = "‏لم يتم تطبيق شيء.‏";
"Posts" = "‏المنشورات‏";
"Preferences" = "‏التفضيلات‏";
"Previous snapshot" = "‏اللقطة السابقة‏";
"Private only" = "‏الخاصة فقط‏";
"Profile Analyzer data" = "‏بيانات محلل الملف الشخصي‏";
"Raw" = "‏خام‏";
"Raw JSON" = "JSON الخام‏";
"Reset analyzer data" = "‏إعادة تعيين بيانات المحلل‏";
"Reset complete" = "‏اكتملت إعادة التعيين‏";
"Reset selected data?" = "‏إعادة تعيين البيانات المحددة؟‏";
"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "‏من التحليل الثاني فصاعدًا: يقارن كل تحليل بما قبله، لتظهر المتابعين الجدد والمفقودين وإجراءاتك من متابعة/إلغاء متابعة وتحديثات الملف الشخصي.‏";
"Select all" = "‏تحديد الكل‏";
"Selected data will be cleared. Tap any row to see what's stored." = "‏سيتم مسح البيانات المحددة. انقر على أي صف لرؤية ما هو مخزّن.‏";
"Settings" = "‏الإعدادات‏";
"Sort" = "‏ترتيب‏";
"This can't be undone." = "‏لا يمكن التراجع عن هذا الإجراء.‏";
"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "‏حدّد ما تريد تطبيقه. انقر على أي صف للفحص. الأقسام غير الموجودة في الملف معطّلة.‏";
"Tick what to include. Tap any row to inspect its contents." = "‏حدّد ما تريد تضمينه. انقر على أي صف لفحص محتوياته.‏";
"Unfollow %lu" = "‏إلغاء متابعة %lu";
"Unfollowing… %lu / %lu" = "‏جارٍ إلغاء المتابعة… %lu / %lu";
"Username A → Z" = "‏اسم المستخدم أ ← ي‏";
"Username Z → A" = "‏اسم المستخدم ي ← أ‏";
"Verified only" = "‏الموثقة فقط‏";
"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "‏عند التفعيل، تُقارن التحليلات بتحليلك الأول فلا يختفي المتابعون الجدد/المفقودون وتحديثات الملفات الشخصية بين التحليلات.‏";
//////////////////////////////////////////////////////////////////////////////
// SETTINGS VIEWS & DIALOGS //
// Excluded-lists managers, backup/restore flows, in-picker labels. //
@@ -758,72 +1011,62 @@
"Add chat" = "‏إضافة دردشة‏";
"Add custom domain" = "‏إضافة نطاق مخصص‏";
"Add preset…" = "‏إضافة إعداد مسبق…‏";
"Add to list?" = "‏إضافة للقائمة؟‏";
"Add user" = "‏إضافة مستخدم‏";
"Could not resolve user ID" = "‏تعذر تحليل معرف المستخدم‏";
"Enter username" = "‏أدخل اسم المستخدم‏";
"Enter username of the DM thread" = "‏أدخل اسم المستخدم لمحادثة الرسائل الخاصة‏";
"No DM thread found with @%@" = "‏لم يتم العثور على محادثة رسائل مع @%@‏";
"User '%@' not found" = "‏لم يتم العثور على المستخدم '%@'";
"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." = "‏سيتم إعادة تعيين جميع إعدادات ريوك غرام للافتراضي وتطبيق القيم المستوردة. سيحتاج التطبيق لإعادة التشغيل لتفعيل التغييرات.‏";
"Apply" = "‏تطبيق‏";
"Apply imported settings?" = "‏تطبيق الإعدادات المستوردة؟‏";
"Apply to" = "‏تطبيق على‏";
"Chats" = "‏الدردشات‏";
"Could not read file." = "‏تعذرت قراءة الملف.‏";
"Could not resolve user ID" = "‏تعذر تحليل معرف المستخدم‏";
"Could not write temporary file." = "‏تعذرت كتابة الملف المؤقت.‏";
"Current location" = "‏الموقع الحالي‏";
"Custom" = "‏مخصص‏";
"Date Format" = "‏تنسيق التاريخ‏";
"Delete" = "‏حذف‏";
"Done editing" = "‏تم التعديل‏";
"Edit values" = "‏تعديل القيم‏";
"Enable fake location" = "‏تفعيل الموقع الوهمي‏";
"Every RyukGram preference will revert to its built-in default. This can't be undone." = "‏ستعود كل تفضيلات ريوك غرام إلى قيمها الافتراضية المدمجة. لا يمكن التراجع عن هذا.";
"Enter username" = "‏أدخل اسم المستخدم";
"Enter username of the DM thread" = "‏أدخل اسم المستخدم لمحادثة الرسائل الخاصة‏";
"Excluded chats" = "‏الدردشات المستبعدة‏";
"Excluded users" = "‏المستخدمون المستبعدون‏";
"File is not a valid RyukGram settings export." = "‏الملف ليس ملف تصدير إعدادات ريوك غرام صالح.‏";
"Follow default" = "‏اتباع الافتراضي‏";
"Force OFF (allow unsends)" = "‏إيقاف إجباري (السماح بإلغاء الإرسال)‏";
"Force ON (preserve unsends)" = "‏تشغيل إجباري (الاحتفاظ بالرسائل الملغاة)‏";
"Form view" = "‏عرض النموذج‏";
"Format" = "‏التنسيق‏";
"Import failed" = "‏فشل الاستيراد‏";
"Import preview" = "‏معاينة الاستيراد‏";
"Included chats" = "‏الدردشات المشمولة‏";
"Included users" = "‏المستخدمون المشمولون‏";
"KD: ON" = "‏الاحتفاظ: تشغيل‏";
"KD: default" = "‏الاحتفاظ: الافتراضي‏";
"KD: ON" = "‏الاحتفاظ: تشغيل‏";
"Keep-deleted" = "‏الاحتفاظ بالمحذوف‏";
"Keep-deleted override" = "‏تجاوز الاحتفاظ بالمحذوف‏";
"Name (AZ)" = "‏الاسم (أ–ي)";
"No DM thread found with @%@" = "‏لم يتم العثور على محادثة رسائل مع @%@‏";
"Off" = "‏إيقاف‏";
"On" = "‏تشغيل‏";
"Presets" = "‏الإعدادات المسبقة‏";
"Raw JSON view" = "‏عرض جيسون (JSON) الخام";
"Remove Selected" = "‏إزالة المحدد‏";
"Recently added" = "‏المُضاف مؤخراً";
"Remove from list" = "‏إزالة من القائمة‏";
"Remove Selected" = "‏إزالة المحدد‏";
"Reset" = "‏إعادة تعيين‏";
"Reset all settings?" = "‏إعادة تعيين كافة الإعدادات؟‏";
"Saved presets are reusable. Tap a preset to make it the active location." = "‏الإعدادات المسبقة المحفوظة قابلة لإعادة الاستخدام. انقر على الإعداد لجعله الموقع النشط.‏";
"Search" = "‏بحث‏";
"Search address or place" = "‏بحث عن عنوان أو مكان‏";
"Search by name or username" = "‏بحث بالاسم أو اسم المستخدم‏";
"Search by username or name" = "‏البحث باسم المستخدم أو الاسم‏";
"Search settings" = "‏إعدادات البحث‏";
"Select" = "‏تحديد‏";
"Select location on map" = "‏تحديد الموقع على الخريطة‏";
"Set current location" = "‏تعيين الموقع الحالي‏";
"Set keep-deleted override" = "‏تعيين تجاوز الاحتفاظ بالمحذوف‏";
"Settings exported" = "‏تم تصدير الإعدادات‏";
"Settings imported" = "‏تم استيراد الإعدادات";
"Show map button" = "‏إظهار زر الخريطة";
"Show seconds" = "‏إظهار الثواني‏";
"Sort by" = "‏فرز حسب‏";
"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." = "‏بدّل كل أداة تنسيق NSDate يستخدمها إنستغرام. الأقسام المختلفة (اليوميات، التعليقات، القصص، الرسائل الخاصة) لها طرق مختلفة — فعّل ما تريد تطبيق التنسيق المخصص عليه.‏";
"Use this location" = "‏استخدام هذا الموقع‏";
"When on, all CoreLocation requests inside Instagram return the location below." = "‏عند التشغيل، ستعيد كل طلبات الموقع (CoreLocation) داخل إنستغرام الموقع أدناه.";
"User '%@' not found" = "‏لم يتم العثور على المستخدم '%@'";
"Username (AZ)" = "‏اسم المستخدم (أ–ي)‏";
"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" = "‏إظهار زر الخريطة‏";
//////////////////////////////////////////////////////////////////////////////
// REELS (FEATURES) //
@@ -859,6 +1102,7 @@
"720p • progressive • fastest" = "720p • تدريجي • الأسرع‏";
"Are you sure?" = "‏هل أنت متأكد؟‏";
"Bundle" = "‏الحزمة‏";
"Copy audio URL" = "‏نسخ رابط الصوت‏";
"Copy quality info" = "‏نسخ معلومات الجودة‏";
"Copy video URL" = "‏نسخ رابط الفيديو‏";
@@ -871,11 +1115,14 @@
"Could not extract video url from reel" = "‏تعذر استخراج رابط الفيديو من مقطع ريلز‏";
"Could not extract video url from story" = "‏تعذر استخراج رابط الفيديو من القصة‏";
"Download Quality" = "‏جودة التنزيل‏";
"Extras" = "Extras";
"FFmpegKit Debug" = "‏تصحيح أخطاء FFmpegKit";
"Later" = "‏لاحقًا‏";
"No!" = "‏لا!";
"OK" = "‏حسناً‏";
"Restart" = "‏إعادة تشغيل‏";
"Restart required" = "‏إعادة التشغيل مطلوبة‏";
"username" = "‏اسم المستخدم‏";
"Yes" = "‏نعم‏";
"You must restart the app to apply this change" = "‏يجب عليك إعادة تشغيل التطبيق لتطبيق هذا التغيير‏";
@@ -884,44 +1131,58 @@
// Strings from the About / Credits footer of Settings. //
//////////////////////////////////////////////////////////////////////////////
"%@ — view source, report issues, see releases" = "%@ — عرض المصدر، الإبلاغ عن مشاكل، رؤية الإصدارات";
"%@ — GitHub & Telegram" = "%@ — جيت هاب وتيليجرام";
"About" = "‏حول‏";
"Arabic translation" = "‏الترجمة العربية‏";
"Chinese (Traditional) translation" = "الترجمة الصينية (التقليدية)";
"Credits" = "‏شكر وتقدير‏";
"Developer" = "‏المطور";
"Developers" = "‏المطوّرون";
"Donate to SoCuul" = "‏تبرع إلى SoCuul";
"installed" = "‏مُثبّت‏";
"Korean translation" = "‏الترجمة الكورية‏";
"latest" = "‏الأحدث‏";
"Links" = "‏الروابط‏";
"No releases" = "‏لا توجد إصدارات‏";
"Original SCInsta developer" = "‏مطور SCInsta الأصلي‏";
"Ryuk" = "‏ريوك (Ryuk)";
"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "‏ريوك غرام %@\n\nإنستغرام إصدار %@\n\nمبني على SCInsta بواسطة SoCuul\n\nالتعريب بواسطة @bruuhim";
"RyukGram on GitHub" = "‏ريوك غرام على غيت هاب (GitHub)";
"SoCuul" = "SoCuul";
"Release notes" = "‏ملاحظات الإصدار";
"Releases" = "‏الإصدارات";
"Report an issue" = "‏الإبلاغ عن مشكلة";
"Russian translation" = "‏الترجمة الروسية";
"RyukGram developer" = "‏مطوّر RyukGram";
"Join Telegram channel" = "‏انضم إلى قناة تيليجرام‏";
"Source code" = "‏الكود المصدري‏";
"View on GitHub" = "‏عرض على جيت هاب‏";
"Spanish translation" = "‏الترجمة الإسبانية‏";
"Support the original developer" = "‏ادعم المطور الأصلي‏";
"View Repo" = "‏عرض المستودع";
"View the source code on GitHub" = "‏عرض الكود المصدري على غيت هاب (GitHub)";
"Telegram channel" = "‏قناة تيليجرام";
"Testing and feature suggestions" = "‏الاختبار واقتراحات الميزات";
"Tweak settings" = "‏إعدادات التعديل‏";
"Version" = "‏الإصدار‏";
"Version, credits, and links" = "‏الإصدار والاعتمادات والروابط‏";
"What's new in RyukGram" = "‏جديد RyukGram";
//////////////////////////////////////////////////////////////////////////////
// HD DOWNLOADS //
// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). //
//////////////////////////////////////////////////////////////////////////////
"720p • progressive • silent" = "720p • تدريجي • صامت‏";
"Audio extract failed" = "‏فشل استخراج الصوت‏";
"Audio only" = "‏الصوت فقط‏";
"Audio ready" = "‏الصوت جاهز‏";
"Download video at the highest available quality" = "‏تنزيل الفيديو بأعلى جودة متاحة‏";
"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "‏تنزيل الفيديو عالي الدقة (HD) عبر بث داش (DASH) وترميزه إلى H.264. يتطلب حزمة FFmpegKit.";
"Encoding speed" = "‏سرعة الترميز‏";
"Enhanced downloads" = "‏تنزيلات محسّنة‏";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "‏حزمة FFmpegKit غير متوفرة. قم بتثبيت تطبيق IPA المُحمّل جانبياً أو نسخة _ffmpeg .deb للتفعيل.";
"Faster = lower quality" = "‏أسرع = جودة أقل‏";
"FFmpeg not available" = "FFmpeg غير متاح‏";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "‏حزمة FFmpegKit غير متوفرة. قم بتثبيت تطبيق IPA المُحمّل جانبياً أو نسخة _ffmpeg .deb للتفعيل.";
"No audio stream available" = "‏لا يتوفر مسار صوتي‏";
"No audio track found" = "‏لم يتم العثور على مسار صوتي‏";
"Photo" = "‏صورة‏";
"Photo quality" = "‏جودة الصورة‏";
"Raw image (no audio, no video)" = "‏صورة خام (بدون صوت أو فيديو)‏";
"silent" = "‏صامت‏";
"Use highest resolution available" = "‏استخدام أعلى دقة متاحة‏";
"Video quality" = "‏جودة الفيديو‏";
"Which quality to download" = "‏الجودة المراد تنزيلها‏";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL / DEBUG //
// Placeholder rows only shown in the experimental settings sandbox. //
//////////////////////////////////////////////////////////////////////////////
"Navigation Cell" = "‏خلية التنقل‏";
"Localization" = "‏التعريب‏";
"Update localization file" = "‏تحديث ملف التعريب‏";
"Import a .strings file for a language" = "‏استيراد ملف .strings للغة‏";
"Import a .strings file to update a translation. Pick a language, select the file, restart." = "‏استورد ملف .strings لتحديث الترجمة. اختر لغة، حدد الملف، وأعد التشغيل.‏";
"Export English strings" = "‏تصدير نصوص الإنجليزية‏";
"Share the base English .strings file for translating" = "‏مشاركة ملف .strings الأساسي بالإنجليزية للترجمة‏";
@@ -55,6 +55,7 @@
* - 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 //
@@ -65,11 +66,11 @@
"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.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.help_translate" = "Help translate";
"settings.language.ok" = "OK";
"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";
@@ -83,6 +84,8 @@
"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";
"Anonymous live viewing" = "Anonymous live viewing";
"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count";
"Browser" = "Browser";
"Comments" = "Comments";
"Copy comment text" = "Copy comment text";
@@ -103,13 +106,14 @@
"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 Meta AI" = "Hide Meta AI";
"Hide metrics" = "Hide metrics";
"Hide notes tray" = "Hide notes tray";
"Hide trending searches" = "Hide trending searches";
"Hide UI on capture" = "Hide UI on capture";
"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";
@@ -119,23 +123,30 @@
"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";
"Live" = "Live";
"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";
"Long-press the heart button in a live to hide or show the comments" = "Long-press the heart button in a live to hide or show the comments";
"Long-press the search tab to open a copied Instagram link" = "Long-press the search tab to open a copied Instagram link";
"No suggested chats" = "No suggested chats";
"No suggested users" = "No suggested users";
"Notes" = "Notes";
"Open app icon picker" = "Open app icon picker";
"Open link from clipboard" = "Open link from clipboard";
"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";
"Privacy" = "Privacy";
"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "Redacts RyukGram buttons from screenshots, screen recordings, and mirroring";
"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.";
"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";
"Replace domain in shared links" = "Replace domain in 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.";
"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).";
"Toggle live comments" = "Toggle live comments";
"Use detailed color picker" = "Use detailed color picker";
//////////////////////////////////////////////////////////////////////////////
@@ -230,9 +241,6 @@
"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";
"Auto-scroll reels" = "Auto-scroll reels";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG default: native behavior. RyukGram: re-advances after swiping back.";
"IG default" = "IG default";
"RyukGram" = "RyukGram";
"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";
@@ -244,6 +252,8 @@
"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";
"IG default" = "IG default";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG default: native behavior. RyukGram: re-advances after swiping back.";
"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 %@ %@";
@@ -251,11 +261,14 @@
"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";
"RyukGram" = "RyukGram";
"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";
"Tap to mute on photo reels" = "Tap to mute on photo reels";
"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";
"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture";
//////////////////////////////////////////////////////////////////////////////
// PROFILE //
@@ -265,14 +278,25 @@
"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";
"Fake follower count" = "Fake follower count";
"Fake following count" = "Fake following count";
"Fake post count" = "Fake post count";
"Fake profile stats" = "Fake profile stats";
"Fake verified badge" = "Fake verified badge";
"Follow indicator" = "Follow indicator";
"Follower count" = "Follower count";
"Following count" = "Following count";
"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.";
"Only affects your own profile header. Other users see the real numbers." = "Only affects your own profile header. Other users see the real numbers.";
"Post count" = "Post count";
"Profile copy button" = "Profile copy button";
"Save profile picture" = "Save profile picture";
"Show a checkmark next to your name on your own profile" = "Show a checkmark next to your name on your own profile";
"Shows whether the profile user follows you" = "Shows whether the profile user follows you";
"Tap to set" = "Tap to set";
"View highlight cover" = "View highlight cover";
"Zoom profile photo" = "Zoom profile photo";
@@ -328,16 +352,25 @@
"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";
"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server";
"Master toggle. When off, the list is ignored" = "Master toggle. When off, the list is ignored";
"Other" = "Other";
"Playback" = "Playback";
"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server";
"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";
"Stickers" = "Stickers";
"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray.";
"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally.";
"Force Quiz sticker in tray" = "Force Quiz sticker in tray";
"Adds Quiz back to the story sticker picker" = "Adds Quiz back to the story sticker picker";
"Show quiz answer" = "Show quiz answer";
"Circle the correct option on quiz stickers, or the leading option on polls" = "Circle the correct option on quiz stickers, or the leading option on polls";
"Show poll vote counts" = "Show poll vote counts";
"Show vote tallies on poll options and slider count/average before you vote" = "Show vote tallies on poll options and slider count/average before you vote";
"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";
@@ -382,7 +415,7 @@
"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 vanish mode swipe" = "Disable vanish mode swipe";
"Disable screenshot detection" = "Disable screenshot detection";
"Disable typing status" = "Disable typing status";
"Disable view-once limitations" = "Disable view-once limitations";
@@ -403,7 +436,7 @@
"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";
"Prevents accidental swipe-up activation of vanish mode" = "Prevents accidental swipe-up activation of vanish 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";
@@ -418,7 +451,6 @@
"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";
@@ -437,11 +469,13 @@
// Settings → Navigation tab //
//////////////////////////////////////////////////////////////////////////////
"Also hide the bottom tab bar — only the inbox is visible" = "Also hide the bottom tab bar — only the inbox is visible";
"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";
"Hide tab bar" = "Hide tab bar";
"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";
@@ -466,7 +500,8 @@
//////////////////////////////////////////////////////////////////////////////
"Confirm actions" = "Confirm actions";
"Confirm call" = "Confirm call";
"Confirm video call" = "Confirm video call";
"Confirm voice call" = "Confirm voice call";
"Confirm changing theme" = "Confirm changing theme";
"Confirm follow" = "Confirm follow";
"Confirm follow requests" = "Confirm follow requests";
@@ -474,24 +509,26 @@
"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 vanish mode" = "Confirm vanish mode";
"Confirm sticker interaction (stories)" = "Confirm sticker interaction (stories)";
"Confirm sticker interaction (highlights)" = "Confirm sticker interaction (highlights)";
"Confirm story emoji reaction" = "Confirm story emoji reaction";
"Confirm story like" = "Confirm story like";
"Confirm unfollow" = "Confirm unfollow";
"Shows an alert before sending an emoji reaction on a story" = "Shows an alert before sending an emoji reaction on a story";
"Shows an alert when you click the like button on posts to confirm the like" = "Shows an alert when you click the like button on posts to confirm the like";
"Shows an alert when you click the like button on stories to confirm the like" = "Shows an alert when you click the like button on stories to confirm the like";
"Confirm voice messages" = "Confirm voice messages";
"Shows an alert before sending an emoji reaction on a story" = "Shows an alert before sending an emoji reaction on a story";
"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 to confirm before toggling vanish mode" = "Shows an alert to confirm before toggling vanish mode";
"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 tap a sticker on someone's story" = "Shows an alert when you tap a sticker on someone's story";
"Shows an alert when you tap a sticker inside a highlight" = "Shows an alert when you tap a sticker inside a highlight";
"Shows an alert when you click the video call button to confirm before calling" = "Shows an alert when you click the video call button to confirm before calling";
"Shows an alert when you click the voice call button to confirm before calling" = "Shows an alert when you click the voice 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 posts to confirm the like" = "Shows an alert when you click the like button on posts 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 like button on stories to confirm the like" = "Shows an alert when you click the like button on stories 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";
@@ -502,22 +539,6 @@
//////////////////////////////////////////////////////////////////////////////
"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 //
@@ -525,17 +546,76 @@
//////////////////////////////////////////////////////////////////////////////
"Advanced" = "Advanced";
"Auto-clear cache" = "Auto-clear cache";
"Automatically opens settings when the app launches" = "Automatically opens settings when the app launches";
"Cache" = "Cache";
"Cache cleared" = "Cache cleared";
"Calculating cache size…" = "Calculating cache size…";
"Clear" = "Clear";
"Clear cache" = "Clear cache";
"Clear cache (%@)" = "Clear cache (%@)";
"Clear cache?" = "Clear cache?";
"Clearing cache…" = "Clearing cache…";
"Clearing still scans on demand." = "Clearing still scans on demand.";
"Daily" = "Daily";
"Disable safe mode" = "Disable safe mode";
"Enable tweak settings quick-access" = "Enable tweak settings quick-access";
"Free %@ of Instagram cache. A restart is recommended." = "Free %@ of Instagram cache. A restart is recommended.";
"Freed %@. Restart to apply." = "Freed %@. Restart to apply.";
"Hold on the home tab to open RyukGram settings" = "Hold on the home tab to open RyukGram settings";
"Instagram" = "Instagram";
"Monthly" = "Monthly";
"Nothing to clear" = "Nothing to clear";
"Off skips the size scan when Advanced opens." = "Off skips the size scan when Advanced opens.";
"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)";
"Remove Instagram's cached images, videos, and temporary files." = "Remove Instagram's cached images, videos, and temporary files.";
"Reset onboarding state" = "Reset onboarding state";
"Settings" = "Settings";
"Run a silent cache clear on launch when the interval has elapsed." = "Run a silent cache clear on launch when the interval has elapsed.";
"Show cache size" = "Show cache size";
"Show tweak settings on app launch" = "Show tweak settings on app launch";
"Weekly" = "Weekly";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED EXPERIMENTAL //
// Settings → Advanced → Advanced experimental features //
//////////////////////////////////////////////////////////////////////////////
"Actions" = "Actions";
"Advanced experimental features" = "Advanced experimental features";
"All experimental toggles will be turned off. Instagram will restart." = "All experimental toggles will be turned off. Instagram will restart.";
"Direct Notes — Audio reply" = "Direct Notes — Audio reply";
"Direct Notes — Avatar reply" = "Direct Notes — Avatar reply";
"Direct Notes — Friend Map" = "Direct Notes — Friend Map";
"Direct Notes — GIFs & stickers reply" = "Direct Notes — GIFs & stickers reply";
"Direct Notes — Photo reply" = "Direct Notes — Photo reply";
"Disabled after repeated crashes." = "Disabled after repeated crashes.";
"Enables GIF/sticker replies" = "Enables GIF/sticker replies";
"Enables photo replies" = "Enables photo replies";
"Enables the audio-note reply type" = "Enables the audio-note reply type";
"Enables the avatar reply type" = "Enables the avatar reply type";
"Experimental flags reset" = "Experimental flags reset";
"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times.";
"Forces Prism-gated experiments on" = "Forces Prism-gated experiments on";
"Forces the Homecoming home surface / nav on" = "Forces the Homecoming home surface / nav on";
"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray";
"Got it" = "Got it";
"Heads up" = "Heads up";
"Hidden Instagram experiments" = "Hidden Instagram experiments";
"Hidden Instagram experiments (in Advanced)" = "Hidden Instagram experiments (in Advanced)";
"Homecoming" = "Homecoming";
"Notes & QuickSnap" = "Notes & QuickSnap";
"Prism design system" = "Prism design system";
"QuickSnap (Instants)" = "QuickSnap (Instants)";
"Reset all experimental flags" = "Reset all experimental flags";
"Reset experimental flags?" = "Reset experimental flags?";
"Restart Instagram to apply changes" = "Restart Instagram to apply changes";
"Shows the friend map entry in Direct Notes" = "Shows the friend map entry in Direct Notes";
"Surfaces" = "Surfaces";
"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts.";
"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "Toggle hidden Instagram experiments. Some may not work on every account or IG version.";
"Turn every experimental toggle off" = "Turn every experimental toggle off";
//////////////////////////////////////////////////////////////////////////////
// DEBUG //
@@ -544,24 +624,38 @@
"Button Cell" = "Button Cell";
"Change the value on the right" = "Change the value on the right";
"Could not delete: %@" = "Could not delete: %@";
"Debug" = "Debug";
"Delete an imported override and fall back to the shipped strings" = "Delete an imported override and fall back to the shipped strings";
"Deleted %@ override. Restart to apply." = "Deleted %@ override. Restart to apply.";
"Enable FLEX gesture" = "Enable FLEX gesture";
"Export English strings" = "Export English strings";
"Hold 5 fingers on the screen to open FLEX" = "Hold 5 fingers on the screen to open FLEX";
"I have %@%@" = "I have %@%@";
"Import a .strings file for a language" = "Import a .strings file for a language";
"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Import a .strings file to update a translation. Pick a language, select the file, restart.";
"Link Cell" = "Link Cell";
"Localization" = "Localization";
"Menu Cell" = "Menu Cell";
"Navigation Cell" = "Navigation Cell";
"No imported localization files to reset." = "No imported localization files to reset.";
"No overrides" = "No overrides";
"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";
"Pick a language to delete the imported file" = "Pick a language to delete the imported file";
"Reset localization" = "Reset localization";
"Share the base English .strings file for translating" = "Share the base English .strings file for translating";
"Static Cell" = "Static Cell";
"Stepper cell" = "Stepper cell";
"Switch Cell" = "Switch Cell";
"Switch Cell (Restart)" = "Switch Cell (Restart)";
"Tap the switch" = "Tap the switch";
"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "These features rely on hidden Instagram flags and may not work on all accounts or versions.";
"Update localization file" = "Update localization file";
"Using icon" = "Using icon";
"Using image" = "Using image";
"_ Example" = "_ Example";
//////////////////////////////////////////////////////////////////////////////
// DOWNLOADS & MEDIA ACTIONS //
@@ -593,16 +687,16 @@
"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 media URL" = "No media URL";
"No URLs" = "No URLs";
"No URLs found" = "No URLs found";
"No video URL" = "No video URL";
"Not a carousel" = "Not a carousel";
"Nothing to save" = "Nothing to save";
@@ -635,6 +729,7 @@
"Add to block list" = "Add to block list";
"Add to block list?" = "Add to block list?";
"Added to block list" = "Added to block list";
"Added to exclude list" = "Added to exclude 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";
@@ -648,12 +743,14 @@
"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";
"Disappearing media" = "Disappearing media";
"Done!" = "Done!";
"Download audio" = "Download audio";
"Downloading audio..." = "Downloading audio...";
"Enable read receipts" = "Enable read receipts";
"Error: %@" = "Error: %@";
"Exclude chat" = "Exclude chat";
"Exclude from seen" = "Exclude from seen";
"Exclude story seen" = "Exclude story seen";
"Excluded" = "Excluded";
"Extracting audio..." = "Extracting audio...";
@@ -661,6 +758,10 @@
"File sending not supported" = "File sending not supported";
"Follow" = "Follow";
"Following" = "Following";
"Inserts a button on disappearing media overlays" = "Inserts a button on disappearing media overlays";
"Inserts a speaker button to mute/unmute disappearing media" = "Inserts a speaker button to mute/unmute disappearing media";
"Inserts an eye button to mark the current disappearing media as viewed" = "Inserts an eye button to mark the current disappearing media as viewed";
"Mark as viewed" = "Mark as viewed";
"Mark messages as seen" = "Mark messages as seen";
"Mark seen" = "Mark seen";
"Marked as seen" = "Marked as seen";
@@ -669,6 +770,7 @@
"Mentions" = "Mentions";
"Message sender not found" = "Message sender not found";
"Messages settings" = "Messages settings";
"Audio URL not available" = "Audio URL not available";
"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";
@@ -684,26 +786,25 @@
"Remove" = "Remove";
"Remove from block list" = "Remove from block list";
"Remove from block list?" = "Remove from block list?";
"Remove from exclude list" = "Remove from exclude list";
"Removed" = "Removed";
"Removed from list" = "Removed from list";
"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 Audio" = "Send Audio";
"Send failed: %@" = "Send failed: %@";
"Send service not found" = "Send service not found";
"Share" = "Share";
"Show audio toggle" = "Show audio toggle";
"Show mark-as-viewed button" = "Show mark-as-viewed button";
"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";
@@ -726,6 +827,9 @@
"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";
"Clipboard is not an Instagram URL" = "Clipboard is not an Instagram URL";
"Comments hidden" = "Comments hidden";
"Comments shown" = "Comments shown";
"Copied text to clipboard" = "Copied text to clipboard";
"Copy" = "Copy";
"Copy all" = "Copy all";
@@ -736,19 +840,168 @@
"Current: %@" = "Current: %@";
"Disable" = "Disable";
"Download GIF" = "Download GIF";
"Dropped pin" = "Dropped pin";
"Enable" = "Enable";
"Enable Location Services for Instagram in Settings to use your current location." = "Enable Location Services for Instagram in Settings to use your current location.";
"Enter Emoji Text" = "Enter Emoji Text";
"Fake location" = "Fake location";
"Location access denied" = "Location access denied";
"Location Services off" = "Location Services off";
"Name" = "Name";
"Nothing to copy" = "Nothing to copy";
"Open Settings" = "Open Settings";
"Pick location" = "Pick location";
"Save" = "Save";
"Save preset" = "Save preset";
"Saved locations" = "Saved locations";
"Select color" = "Select color";
"Set location" = "Set location";
"Settings…" = "Settings…";
"Turn Location Services on in Settings → Privacy to use your current location." = "Turn Location Services on in Settings → Privacy to use your current location.";
"Type emoji..." = "Type emoji...";
"Theme" = "Theme";
"Appearance" = "Appearance";
"Keyboard" = "Keyboard";
"Force dark mode" = "Force dark mode";
"Keep Instagram in dark appearance regardless of iOS system setting" = "Keep Instagram in dark appearance regardless of iOS system setting";
"Full OLED" = "Full OLED";
"Replace Instagram's dark grays with pure black across the entire app" = "Replace Instagram's dark grays with pure black across the entire app";
"OLED chat theme" = "OLED chat theme";
"Pure black DM thread background and incoming message bubbles" = "Pure black DM thread background and incoming message bubbles";
"Keyboard theme" = "Keyboard theme";
"Override the keyboard appearance when typing inside Instagram" = "Override the keyboard appearance when typing inside Instagram";
"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black.";
"Dark" = "Dark";
"OLED" = "OLED";
"Apply & restart" = "Apply & restart";
"Restart Instagram to apply your theme changes" = "Restart Instagram to apply your theme changes";
"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "Theme changes only take effect after an app restart. Tap Apply below when you're done choosing.";
//////////////////////////////////////////////////////////////////////////////
// PROFILE ANALYZER //
// Settings → Profile Analyzer //
//////////////////////////////////////////////////////////////////////////////
"%lu followers · %lu following" = "%lu followers · %lu following";
"%lu of %lu" = "%lu of %lu";
"Analysis complete" = "Analysis complete";
"Analysis failed" = "Analysis failed";
"Another analysis is already running" = "Another analysis is already running";
"Available after your next scan" = "Available after your next scan";
"Cancelled" = "Cancelled";
"Categories" = "Categories";
"Couldn't fetch profile information" = "Couldn't fetch profile information";
"Fetching followers (%lu/%ld)…" = "Fetching followers (%lu/%ld)…";
"Fetching following (%lu/%ld)…" = "Fetching following (%lu/%ld)…";
"Fetching profile info…" = "Fetching profile info…";
"First scan: %@" = "First scan: %@";
"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "Follower count exceeds %ld — analysis disabled to avoid rate limits.";
"Gained since last scan" = "Gained since last scan";
"Last scan: %@" = "Last scan: %@";
"Lost followers" = "Lost followers";
"Mutual followers" = "Mutual followers";
"Name: %@ → %@" = "Name: %@ → %@";
"New followers" = "New followers";
"No results" = "No results";
"No active Instagram session found" = "No active Instagram session found";
"No scan yet" = "No scan yet";
"Not following you back" = "Not following you back";
"OK" = "OK";
"Private account" = "Private account";
"Profile Analyzer" = "Profile Analyzer";
"Profile picture changed" = "Profile picture changed";
"Profile updates" = "Profile updates";
"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "Removes cached snapshots for this account. You'll lose since-last-scan diffs.";
"Request failed" = "Request failed";
"Reset analyzer data?" = "Reset analyzer data?";
"Run analysis" = "Run analysis";
"Run your first analysis" = "Run your first analysis";
"Search username or name" = "Search username or name";
"Since last scan" = "Since last scan";
"Starting…" = "Starting…";
"They follow you, you don't follow back" = "They follow you, you don't follow back";
"Too many followers" = "Too many followers";
"Too many followers to analyze" = "Too many followers to analyze";
"Unfollow" = "Unfollow";
"Unfollow @%@?" = "Unfollow @%@?";
"Unfollowed you since last scan" = "Unfollowed you since last scan";
"Username, name or picture changes" = "Username, name or picture changes";
"Username: @%@ → @%@" = "Username: @%@ → @%@";
"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits.";
"You both follow each other" = "You both follow each other";
"You don't follow back" = "You don't follow back";
"You follow them, they don't follow back" = "You follow them, they don't follow back";
"You started following" = "You started following";
"You unfollowed" = "You unfollowed";
"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "%@ %lu accounts? The first %ld will be processed to avoid rate limits.";
"%@ %lu accounts? This runs sequentially with a short pause between each." = "%@ %lu accounts? This runs sequentially with a short pause between each.";
"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu account(s) · %lu snapshot(s) · tap to inspect";
"%lu accounts followed" = "%lu accounts followed";
"%lu accounts unfollowed" = "%lu accounts unfollowed";
"%lu entries across %lu lists · tap to inspect" = "%lu entries across %lu lists · tap to inspect";
"%lu preferences · tap to inspect" = "%lu preferences · tap to inspect";
"(empty)" = "(empty)";
"(no analyzer data)" = "(no analyzer data)";
"(no lists)" = "(no lists)";
"About Profile Analyzer" = "About Profile Analyzer";
"All preferences (%lu)" = "All preferences (%lu)";
"Apply imported data?" = "Apply imported data?";
"Batch follow" = "Batch follow";
"Batch follow finished" = "Batch follow finished";
"Batch unfollow" = "Batch unfollow";
"Batch unfollow finished" = "Batch unfollow finished";
"Continue" = "Continue";
"Current snapshot" = "Current snapshot";
"Embed domains" = "Embed domains";
"Excluded lists" = "Excluded lists";
"Excluded story users" = "Excluded story users";
"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect.";
"Export" = "Export";
"File has no importable sections." = "File has no importable sections.";
"File is not a valid RyukGram export." = "File is not a valid RyukGram export.";
"Filter" = "Filter";
"First scan: we collect your followers and following lists and save them locally." = "First scan: we collect your followers and following lists and save them locally.";
"Follow %lu" = "Follow %lu";
"Followers" = "Followers";
"Following… %lu / %lu" = "Following… %lu / %lu";
"Full name" = "Full name";
"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk.";
"Import complete" = "Import complete";
"Include" = "Include";
"Included story users" = "Included story users";
"Inspect the full payload" = "Inspect the full payload";
"Keep scan history" = "Keep scan history";
"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app.";
"Not verified only" = "Not verified only";
"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "Nothing is uploaded — everything stays on this device and can be wiped from the trash icon.";
"Nothing was applied." = "Nothing was applied.";
"Posts" = "Posts";
"Preferences" = "Preferences";
"Previous snapshot" = "Previous snapshot";
"Private only" = "Private only";
"Profile Analyzer data" = "Profile Analyzer data";
"Raw" = "Raw";
"Raw JSON" = "Raw JSON";
"Reset analyzer data" = "Reset analyzer data";
"Reset complete" = "Reset complete";
"Reset selected data?" = "Reset selected data?";
"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates.";
"Select all" = "Select all";
"Selected data will be cleared. Tap any row to see what's stored." = "Selected data will be cleared. Tap any row to see what's stored.";
"Settings" = "Settings";
"Sort" = "Sort";
"This can't be undone." = "This can't be undone.";
"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "Tick what to apply. Tap any row to inspect. Sections not in the file are disabled.";
"Tick what to include. Tap any row to inspect its contents." = "Tick what to include. Tap any row to inspect its contents.";
"Unfollow %lu" = "Unfollow %lu";
"Unfollowing… %lu / %lu" = "Unfollowing… %lu / %lu";
"Username A → Z" = "Username A → Z";
"Username Z → A" = "Username Z → A";
"Verified only" = "Verified only";
"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans.";
//////////////////////////////////////////////////////////////////////////////
// SETTINGS VIEWS & DIALOGS //
// Excluded-lists managers, backup/restore flows, in-picker labels. //
@@ -756,72 +1009,62 @@
"Add chat" = "Add chat";
"Add custom domain" = "Add custom domain";
"Add preset…" = "Add preset…";
"Add to list?" = "Add to list?";
"Add user" = "Add user";
"Could not resolve user ID" = "Could not resolve user ID";
"Enter username" = "Enter username";
"Enter username of the DM thread" = "Enter username of the DM thread";
"No DM thread found with @%@" = "No DM thread found with @%@";
"User '%@' not found" = "User '%@' not found";
"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 resolve user ID" = "Could not resolve user ID";
"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.";
"Enter username" = "Enter username";
"Enter username of the DM thread" = "Enter username of the DM thread";
"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";
"KD: ON" = "KD: ON";
"Keep-deleted" = "Keep-deleted";
"Keep-deleted override" = "Keep-deleted override";
"Name (AZ)" = "Name (AZ)";
"No DM thread found with @%@" = "No DM thread found with @%@";
"Off" = "Off";
"On" = "On";
"Presets" = "Presets";
"Raw JSON view" = "Raw JSON view";
"Remove Selected" = "Remove Selected";
"Recently added" = "Recently added";
"Remove from list" = "Remove from list";
"Remove Selected" = "Remove Selected";
"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" = "Search";
"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 map button" = "Show map button";
"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.";
"User '%@' not found" = "User '%@' not found";
"Username (AZ)" = "Username (AZ)";
"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) //
@@ -857,6 +1100,7 @@
"720p • progressive • fastest" = "720p • progressive • fastest";
"Are you sure?" = "Are you sure?";
"Bundle" = "Bundle";
"Copy audio URL" = "Copy audio URL";
"Copy quality info" = "Copy quality info";
"Copy video URL" = "Copy video URL";
@@ -869,11 +1113,14 @@
"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";
"Extras" = "Extras";
"FFmpegKit Debug" = "FFmpegKit Debug";
"Later" = "Later";
"No!" = "No!";
"OK" = "OK";
"Restart" = "Restart";
"Restart required" = "Restart required";
"username" = "username";
"Yes" = "Yes";
"You must restart the app to apply this change" = "You must restart the app to apply this change";
@@ -882,45 +1129,58 @@
// Strings from the About / Credits footer of Settings. //
//////////////////////////////////////////////////////////////////////////////
"%@ — view source, report issues, see releases" = "%@ — view source, report issues, see releases";
"%@ — GitHub & Telegram" = "%@ — GitHub & Telegram";
"About" = "About";
"Arabic translation" = "Arabic translation";
"Chinese (Traditional) translation" = "Chinese (Traditional) translation";
"Credits" = "Credits";
"Developer" = "Developer";
"Developers" = "Developers";
"Donate to SoCuul" = "Donate to SoCuul";
"installed" = "installed";
"Korean translation" = "Korean translation";
"latest" = "latest";
"Links" = "Links";
"No releases" = "No releases";
"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";
"Release notes" = "Release notes";
"Releases" = "Releases";
"Report an issue" = "Report an issue";
"Russian translation" = "Russian translation";
"RyukGram developer" = "RyukGram developer";
"Join Telegram channel" = "Join Telegram channel";
"Source code" = "Source code";
"View on GitHub" = "View on GitHub";
"Spanish translation" = "Spanish translation";
"Support the original developer" = "Support the original developer";
"View Repo" = "View Repo";
"View the source code on GitHub" = "View the source code on GitHub";
"Telegram channel" = "Telegram channel";
"Testing and feature suggestions" = "Testing and feature suggestions";
"Tweak settings" = "Tweak settings";
"Version" = "Version";
"Version, credits, and links" = "Version, credits, and links";
"What's new in RyukGram" = "What's new in RyukGram";
//////////////////////////////////////////////////////////////////////////////
// HD DOWNLOADS //
// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). //
//////////////////////////////////////////////////////////////////////////////
"720p • progressive • silent" = "720p • progressive • silent";
"Audio extract failed" = "Audio extract failed";
"Audio only" = "Audio only";
"Audio ready" = "Audio ready";
"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";
"FFmpeg not available" = "FFmpeg not available";
"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.";
"No audio stream available" = "No audio stream available";
"No audio track found" = "No audio track found";
"Photo" = "Photo";
"Photo quality" = "Photo quality";
"Raw image (no audio, no video)" = "Raw image (no audio, no video)";
"silent" = "silent";
"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";
"Localization" = "Localization";
"Update localization file" = "Update localization file";
"Import a .strings file for a language" = "Import a .strings file for a language";
"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Import a .strings file to update a translation. Pick a language, select the file, restart.";
"Export English strings" = "Export English strings";
"Share the base English .strings file for translating" = "Share the base English .strings file for translating";
@@ -57,6 +57,7 @@
* - 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 //
@@ -67,11 +68,11 @@
"settings.firstrun.message" = "Para el futuro: Mantener pulsadas las tres líneas en la parte superior derecha en la página de perfil, para volver a abrir la configuración de RyukGram";
"settings.firstrun.ok" = "Entiendo!";
"settings.firstrun.title" = "Información de configuración de RyukGram";
"settings.language.english_only" = "Por el momento, RyukGram solo está disponible en Inglés. ¡Las traducciones son bienvenidas!";
"settings.language.help_translate" = "Ayudar a traducir";
"settings.language.ok" = "OK";
"settings.language.system" = "Por defecto del sistema";
"settings.language.title" = "Idioma";
/* [ADDED_BY_DEV] */ "settings.language.english_only" = "Por el momento, RyukGram solo está disponible en Inglés. ¡Las traducciones son bienvenidas!";
/* [ADDED_BY_DEV] */ "settings.language.help_translate" = "Ayudar a traducir";
/* [ADDED_BY_DEV] */ "settings.language.ok" = "OK";
"settings.results.many" = "%lu resultados";
"settings.results.none" = "Sin resultados";
"settings.results.one" = "%lu resultado";
@@ -85,6 +86,8 @@
"Adds a copy option to the comment long-press menu" = "Añade la opción de copiar en el menú que aparece al mantener pulsado un comentario";
"Adds a download option for GIF comments" = "Añade la opción de descargar los GIF en comentarios";
"Anonymous live viewing" = "Ver directos de forma anónima";
"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "Bloquea el latido del contador de espectadores para que el transmisor no te vea — tampoco verás el contador de espectadores";
"Browser" = "Navegador";
"Comments" = "Comentarios";
"Copy comment text" = "Copiar texto del comentario";
@@ -94,7 +97,6 @@
"Disable app haptics" = "Deshabilitar respuesta háptica de la aplicación";
"Disables haptics/vibrations within the app" = "Deshabilita la respuesta háptica y vibraciones dentro de la aplicación";
"Do not save recent searches" = "No guardar búsquedas recientes";
/* [ADDED_BY_DEV] */ "Search bars will no longer save your recent searches" = "Las barras de búsqueda ya no guardarán tus búsquedas recientes";
"Download GIF comments" = "Descargar GIF en comentarios";
"Embed domain" = "Dominio embebido";
"Embed domain: %@" = "Dominio embebido: %@";
@@ -106,13 +108,14 @@
"Experimental features" = "Funciones experimentales";
"Focus/distractions" = "Concentración/Distracciones";
"General" = "General";
"Hide Meta AI" = "Ocultar Meta AI";
"Hide ads" = "Ocultar anuncios";
"Hide explore posts grid" = "Ocultar la cuadrícula de publicaciones";
"Hide friends map" = "Ocultar el mapa de amigos";
"Hide Meta AI" = "Ocultar Meta AI";
"Hide metrics" = "Ocultar métricas";
"Hide notes tray" = "Ocultar bandeja de notas";
"Hide trending searches" = "Ocultar búsquedas en tendencia";
"Hide UI on capture" = "Ocultar UI al capturar";
"Hides all suggested users for you to follow, outside your feed" = "Oculta 'Sugerencias para ti' en tu Feed (Inicio)";
"Hides like/comment/share counts on posts and reels" = "Oculta el contador de me gusta, comentarios y compartidos en publicaciones y reels";
"Hides the friends map icon in the notes tray" = "Oculta el ícono de mapa de amigos en la bandeja de notas";
@@ -122,22 +125,30 @@
"Hides the suggested broadcast channels in direct messages" = "Oculta los canales sugeridos en mensajes";
"Hides the trending searches under the explore search bar" = "Oculta las búsquedas en tendencia debajo de la barra de búsqueda";
"Hold down on the Instagram logo to change the app icon" = "Mantén pulsado el logo de Instagram para cambiar el ícono de la aplicación";
"Live" = "En vivo";
"Long press on the eyedropper tool in stories to customize the text color more precisely" = "Mantener pulsada la herramienta de selección de color en historias para seleccionar el color del texto de manera más precisa";
"Long-press the heart button in a live to hide or show the comments" = "Mantén presionado el botón de corazón en un directo para ocultar o mostrar los comentarios";
"Long-press the search tab to open a copied Instagram link" = "Mantén presionado el botón de búsqueda para abrir un enlace de Instagram copiado";
"No suggested chats" = "Ocultar conversaciones sugeridas";
"No suggested users" = "Ocultar usuarios sugeridos";
"Notes" = "Notas";
"Open app icon picker" = "Abrir selector de ícono de app";
"Open link from clipboard" = "Abrir enlace desde el portapapeles";
"Open links in external browser" = "Abrir enlaces en navegador externo";
"Opens links in Safari instead of Instagram's in-app browser" = "Abrir enlaces en Safari en vez del navegador interno de Instagram";
"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Elimina intermediarios de rastreo de Instagram (l.instagram.com) y los parámetros UTM/fbclid de los enlaces";
"Privacy" = "Privacidad";
"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "Oculta los botones de RyukGram en capturas, grabaciones y duplicado de pantalla";
"Removes all ads from the Instagram app" = "Elimina todos los anuncios de la aplicación de Instagram";
"Removes igsh, utm_source, and other tracking parameters from shared links" = "Elimina igsh, utm_source, y otros parámetros de rastreo de los enlaces compartidos";
"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Reemplaza las marcas de tiempo relativas de Instagram (\"Hace 3d\") con un formato personalizado. Escoge sobre cuales superficies se aplica dentro del selector.";
"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Elimina intermediarios de rastreo de Instagram (l.instagram.com) y los parámetros UTM/fbclid de los enlaces";
"Replace domain in shared links" = "Reemplazar dominio en enlaces compartidos";
"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Reemplaza las marcas de tiempo relativas de Instagram (\"Hace 3d\") con un formato personalizado. Escoge sobre cuales superficies se aplica dentro del selector.";
"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "Reescribe enlaces copiados/compartidos para utilizar un dominio compatible con vistas previas embebidas en Discord, Telegram, etc.";
"Search bars will no longer save your recent searches" = "Las barras de búsqueda ya no guardarán tus búsquedas recientes";
"Sharing" = "Compartir";
"Strip tracking from links" = "Eliminar rastreo de los enlaces";
"Strip tracking params" = "Eliminar parámetros de rastreo";
"These features rely on hidden Instagram flags and may not work on all accounts or versions.\nExperimental flags research by @euoradan (Radan)." = "Estas funciones se basan en opciones ocultas de Instagram y es posible que no funcionen en todas las cuentas o versiones.\nInvestigación sobre opciones experimentales por @euoradan (Radan).";
"Toggle live comments" = "Alternar comentarios en vivo";
"Use detailed color picker" = "Usar selector de color detallado";
//////////////////////////////////////////////////////////////////////////////
@@ -232,9 +243,6 @@
"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." = "Añade un botón de acción de RyukGram sobre la barra lateral del reel con las opciones ver portada, descargar, compartir, copiar, ampliar y repost. Tocar abre el menú de forma predeterminada.\nArriba puedes cambiar el comportamiento al tocar.";
"Always show progress scrubber" = "Siempre mostrar el indicador de progreso";
"Auto-scroll reels" = "Desplazamiento automático de reels";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG por defecto: comportamiento nativo. RyukGram: vuelve a avanzar después de deslizar hacia atrás.";
"IG default" = "IG por defecto";
"RyukGram" = "RyukGram";
"Change what happens when you tap on a reel" = "Cambia lo que ocurre cuando tocas en un reel";
"Confirm reel refresh" = "Confirmar actualización de reels";
"Disable auto-unmuting reels" = "Deshabilitar el reactivado automático del sonido en los reels";
@@ -246,6 +254,8 @@
"Hides the repost button on the reels sidebar" = "Oculta el botón repost en la barra lateral de los reels";
"Hides the top navigation bar when watching reels" = "Oculta la barra de navegación superior al ver reels";
"Hiding" = "Ocultar";
"IG default" = "IG por defecto";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG por defecto: comportamiento nativo. RyukGram: vuelve a avanzar después de deslizar hacia atrás.";
"Limits" = "Límites";
"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "Limita la cantidad de reels disponibles para desplazar en cualquier momento, y evita que se actualice";
"Only loads %@ %@" = "Solo cargar %@ %@";
@@ -253,11 +263,14 @@
"Prevent doom scrolling" = "Evitar doom scrolling";
"Prevents reels from being scrolled to the next video" = "Evita que los reels se desplacen al siguiente video";
"Prevents reels from unmuting when the volume/silent button is pressed" = "Evita que los reels dejen de estar silenciados cuando se presionan los botones de volumen o silencio";
"RyukGram" = "RyukGram";
"Shows an alert when you trigger a reels refresh" = "Muestra una alerta al solicitar una actualización de reels";
"Shows buttons to reveal and auto-fill the password on locked reels" = "Muestra botones para revelar y auto-completar la contraseña en reels bloqueados";
"Tap Controls" = "Controles táctiles";
"Tap to mute on photo reels" = "Tocar para silenciar en reels de fotos";
"Tapping the Reels tab while on reels does nothing" = "Pulsar el botón de reels no hace nada cuando te encuentres en la pestaña de Reels";
"Unlock password-locked reels" = "Desbloquea reels bloqueados por contraseña";
"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "Cuando el modo pausa está activo, al tocar reels de fotos se alterna el audio en lugar del gesto nativo";
//////////////////////////////////////////////////////////////////////////////
// PROFILE //
@@ -267,14 +280,25 @@
"Adds a button next to the burger menu on profiles to copy username, name or bio" = "Añade un botón junto al menú de hamburguesa (☰) en los perfiles para copiar nombre de usuario, nombre o presentación";
"Adds a view option to the highlight long-press menu to open the cover in full-screen" = "Añade una opción de visualización en el menú que aparece al mantener pulsado sobre la historia destacada para abrir la portada en pantalla completa";
"Copy note on long press" = "Copia la nota al mantener pulsado";
"Fake follower count" = "Seguidores falsos";
"Fake following count" = "Seguidos falsos";
"Fake post count" = "Publicaciones falsas";
"Fake profile stats" = "Estadísticas de perfil falsas";
"Fake verified badge" = "Insignia verificada falsa";
"Follow indicator" = "Indicador de seguido";
"Follower count" = "Número de seguidores";
"Following count" = "Número de seguidos";
"Long press a profile picture to open it in full-screen with zoom, share, and save" = "Mantener pulsado en una foto de perfil para abrirla en pantalla completa para ampliar, compartir y guardar";
"Long press the note bubble on a profile to copy the text" = "Mantén pulsado la burbuja de una nota en un perfil para copiar el texto";
"Long press to download directly (ignored when zoom is on)" = "Mantén pulsado para descargar directamente (Se ignora cuando la foto está ampliada)";
"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "Los gestos al mantener pulsado en los elementos del perfil, se mantienen separados de los botones de acción específicos de cada función.";
"Only affects your own profile header. Other users see the real numbers." = "Solo afecta al encabezado de tu propio perfil. Los demás usuarios ven los números reales.";
"Post count" = "Número de publicaciones";
"Profile copy button" = "Botón de copiar perfil";
"Save profile picture" = "Guardar foto de perfil";
"Show a checkmark next to your name on your own profile" = "Muestra una marca de verificación junto a tu nombre en tu propio perfil";
"Shows whether the profile user follows you" = "Muestra si el usuario del perfil te sigue";
"Tap to set" = "Tocar para establecer";
"View highlight cover" = "Ver portada de la historia destacada";
"Zoom profile photo" = "Ampliar foto de perfil";
@@ -330,16 +354,25 @@
"Mark seen on story reply" = "Marcar visualización de historia al responder";
"Marks a story as seen the moment you tap the heart, even with seen blocking on" = "Marca una historia como vista en el momento en que tocas el corazón, incluso con el bloqueo de aviso de visualización activado";
"Marks a story as seen when you send a reply or emoji reaction, even with seen blocking on" = "Marca una historia como vista cuando envías una respuesta o una reacción con emoji, incluso con el bloqueo de aviso de visualización activado";
"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marca las historias como vistas localmente (círculo gris) mientras sigue bloqueando el recibo de visto en el servidor";
"Master toggle. When off, the list is ignored" = "Control general. Cuando está desactivado, la lista es ignorada";
"Other" = "Otros";
"Playback" = "Reproducción";
"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marca las historias como vistas localmente (círculo gris) mientras sigue bloqueando el recibo de visto en el servidor";
"Quick list button in stories" = "Botón de lista rápida en historias";
"Search, sort, swipe to remove" = "Buscar y ordenar. Desliza para eliminar";
"Seen receipts" = "Confirmación de visualización";
"Sending a reply or emoji reaction automatically advances to the next story" = "Enviar una respuesta o una reacción con emoji automáticamente avanza a la siguiente historia";
"Show mentioned users in eye button and story menu" = "Mostrar usuarios mencionados en el botón con forma de ojo (👁) y el menú de la historia";
"Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only" = "Muestra un botón con forma de ojo (👁) en las historias para añadir o eliminar usuarios de la lista. Desactivado = Usar el menú de los 3 puntos o solo mantener pulsado";
"Stickers" = "Stickers";
"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "Mira los resultados de encuestas/cuestionarios/deslizador antes de interactuar — aún puedes tocar para votar con normalidad. 'Forzar cuestionario' devuelve el sticker clásico de cuestionario a la bandeja del editor de historias.";
"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "Mira los resultados de encuestas/cuestionarios/deslizador en reels antes de interactuar — aún puedes tocar para votar con normalidad.";
"Force Quiz sticker in tray" = "Forzar sticker de cuestionario";
"Adds Quiz back to the story sticker picker" = "Devuelve el cuestionario al selector de stickers de historia";
"Show quiz answer" = "Mostrar respuesta del cuestionario";
"Circle the correct option on quiz stickers, or the leading option on polls" = "Resalta la opción correcta en cuestionarios, o la más votada en encuestas";
"Show poll vote counts" = "Mostrar votos de encuestas";
"Show vote tallies on poll options and slider count/average before you vote" = "Muestra los votos en opciones de encuesta y la media/conteo del deslizador antes de votar";
"Stop story auto-advance" = "Detener avance automático de las historias";
"Stories" = "Historias";
"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "Historias no avanzan automáticamente a la siguiente cuando el temporizador termina. Toca para avanzar manualmente";
@@ -384,7 +417,7 @@
"Copy text on hold" = "Copiar texto al mantener pulsado";
"Custom emojis and background/text colors" = "Emojis, color de fondo y texto personalizado";
"Custom note themes" = "Tema de notas personalizado";
"Disable disappearing mode swipe" = "Deshabilitar deslizamiento para mensajes temporales";
"Disable vanish mode swipe" = "Deshabilitar deslizamiento al modo vanish";
"Disable screenshot detection" = "Deshabilitar detección de capturas de pantalla";
"Disable typing status" = "Deshabilitar estado de escritura";
"Disable view-once limitations" = "Deshabilitar limitaciones de ver una vez";
@@ -405,7 +438,7 @@
"Note actions" = "Acciones en notas";
"Preserve messages that others unsend" = "Guardar los mensajes que los demás eliminen";
"Preserves messages that others unsend" = "Guarda los mensajes que los demás eliminen";
"Prevents accidental swipe-up activation of disappearing mode" = "Evita la activación accidental de los mensajes temporales al deslizar hacia arriba";
"Prevents accidental swipe-up activation of vanish mode" = "Evita la activación accidental del modo vanish al deslizar hacia arriba";
"Quick list button in chats" = "Botón de lista rápida en conversaciones";
"Removes the audio call button from DM thread header" = "Elimina el botón de llamada en las conversaciones";
"Removes the screenshot-prevention features for visual messages in DMs" = "Elimina las funciones que impiden hacer capturas de pantalla para mensajes visuales en las conversaciones";
@@ -420,7 +453,6 @@
"Shows an \"Unsent\" label on preserved messages" = "Muestra una etiqueta \"Mensaje eliminado\" en mensajes guardados";
"Unlimited replay of visual messages" = "Reproducción ilimitada de mensajes visuales";
"Unsent message notification" = "Notificación de eliminación de mensaje";
"Visual messages" = "Mensajes visuales";
"Voice messages" = "Mensajes de voz";
"Warn before clearing on refresh" = "Mostrar un aviso antes de actualizar";
"Which chats get read-receipt blocking" = "Cuales conversaciones tienen bloqueada la confirmación de lectura";
@@ -439,11 +471,13 @@
// Settings → Navigation tab //
//////////////////////////////////////////////////////////////////////////////
"Also hide the bottom tab bar — only the inbox is visible" = "Oculta también la barra de pestañas inferior — solo se ve la bandeja de entrada";
"Hide create tab" = "Ocultar pestaña Crear";
"Hide explore tab" = "Ocultar pestaña Explorar";
"Hide feed tab" = "Ocultar pestaña Feed (Inicio)";
"Hide messages tab" = "Ocultar pestaña Mensajes";
"Hide reels tab" = "Ocultar pestaña Reels";
"Hide tab bar" = "Ocultar barra de pestañas";
"Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab." = "Oculta todas las pestañas excepto Mensajes y Perfil. Inicia la aplicación en la pestaña Mensajes. El acceso rápido a la configuración se cambia a mantener pulsada la pestaña Mensajes.";
"Hides the create tab on the bottom navigation bar" = "Oculta la pestaña Crear en la barra de navegación inferior";
"Hides the direct messages tab on the bottom navigation bar" = "Oculta la pestaña Mensajes en la barra de navegación inferior";
@@ -468,35 +502,38 @@
//////////////////////////////////////////////////////////////////////////////
"Confirm actions" = "Confirmar acciones";
"Confirm call" = "Confirmar llamada";
"Confirm video call" = "Confirmar videollamada";
"Confirm voice call" = "Confirmar llamada de voz";
"Confirm changing theme" = "Confirmar cambiar el tema";
"Confirm follow" = "Confirmar seguir";
"Confirm follow requests" = "Confirmar solicitud de seguimiento";
/* [ADDED_BY_DEV] */ "Confirm like: Posts" = "Confirmar me gusta en publicaciones";
/* [ADDED_BY_DEV] */ "Confirm story like" = "Confirmar me gusta en historias";
/* [ADDED_BY_DEV] */ "Confirm story emoji reaction" = "Confirmar reacción con emojis en historias";
"Confirm like: Posts" = "Confirmar me gusta en publicaciones";
"Confirm like: Reels" = "Confirmar me gusta en reels";
"Confirm posting comment" = "Confirmar publicar comentario";
"Confirm repost" = "Confirmar repost";
"Confirm shh mode" = "Confirmar mensajes temporales";
"Confirm sticker interaction" = "Confirma interacción con stickers";
"Confirm vanish mode" = "Confirmar modo Vanish";
"Confirm sticker interaction (stories)" = "Confirma interacción con stickers (historias)";
"Confirm sticker interaction (highlights)" = "Confirma interacción con stickers (destacadas)";
"Confirm story emoji reaction" = "Confirmar reacción con emojis en historias";
"Confirm story like" = "Confirmar me gusta en historias";
"Confirm unfollow" = "Confirmar dejar de seguir";
"Confirm voice messages" = "Confirmar mensaje de voz";
"Shows an alert before sending an emoji reaction on a story" = "Muestra una alerta antes de enviar una reacción con emojis en una historia";
"Shows an alert to confirm before sending a voice message" = "Muestra una alerta para confirmar antes de enviar un mensaje de voz";
"Shows an alert to confirm before toggling disappearing messages" = "Muestra una alerta para confirmar antes de activar los mensajes temporales";
"Shows an alert to confirm before toggling vanish mode" = "Muestra una alerta para confirmar antes de activar el modo Vanish";
"Shows an alert when you accept/decline a follow request" = "Muestra una alerta cuando aceptas o rechazas una solicitud de seguimiento";
"Shows an alert when you change a chat theme to confirm" = "Muestra una alerta para confirmar cuando cambias el tema en una conversación";
"Shows an alert when you click a sticker on someone's story to confirm the action" = "Muestra una alerta para confirmar la acción cuando tocas un sticker en la historia de alguien";
"Shows an alert when you click the audio/video call button to confirm before calling" = "Muestra una alerta cuando tocas los botones de llamada y video llamada, antes de llamar";
"Shows an alert when you tap a sticker on someone's story" = "Muestra una alerta cuando tocas un sticker en la historia de alguien";
"Shows an alert when you tap a sticker inside a highlight" = "Muestra una alerta cuando tocas un sticker dentro de una historia destacada";
"Shows an alert when you click the video call button to confirm before calling" = "Muestra una alerta cuando tocas el botón de videollamada, antes de llamar";
"Shows an alert when you click the voice call button to confirm before calling" = "Muestra una alerta cuando tocas el botón de llamada de voz, antes de llamar";
"Shows an alert when you click the follow button to confirm the follow" = "Muestra una alerta para confirmar cuando tocas el botón de seguir";
/* [ADDED_BY_DEV] */ "Shows an alert when you click the like button on posts to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en publicaciones";
"Shows an alert when you click the like button on posts to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en publicaciones";
"Shows an alert when you click the like button on reels to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en reels";
/* [ADDED_BY_DEV] */ "Shows an alert when you click the like button on stories to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en historias";
/* [ADDED_BY_DEV] */ "Shows an alert before sending an emoji reaction on a story" = "Muestra una alerta antes de enviar una reacción con emojis en una historia";
"Shows an alert when you click the like button on stories to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en historias";
"Shows an alert when you click the post comment button to confirm" = "Muestra una alerta para confirmar cuando tocas el botón de publicar comentario";
"Shows an alert when you click the repost button to confirm before resposting" = "Muestra una alerta para confirmar cuando tocas el botón de repost";
"Shows an alert when you click the unfollow button to confirm" = "Muestra una alerta para confirmar cuando tocas el botón de dejar de seguir";
"Shows an alert when you click the like button on posts or stories to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en publicaciones o historias";
//////////////////////////////////////////////////////////////////////////////
// BACKUP & RESTORE //
@@ -504,22 +541,6 @@
//////////////////////////////////////////////////////////////////////////////
"Backup & Restore" = "Copia de seguridad & restauración";
"Export settings" = "Exportar configuración";
"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." = "Exporta tu configuración de RyukGram a un archivo JSON para importarlos mas tarde. Al importar, se restaurarán los valores predeterminados antes de aplicar la nueva configuración. Podrás ver una vista previa antes de confirmar los cambios.";
"Import settings" = "Importar configuración";
"Load settings from a JSON file" = "Cargar configuración desde un archivo JSON";
"Reset to defaults" = "Restablecer los valores predeterminados";
"Revert every RyukGram preference" = "Restablecer todas las preferencias de RyukGram";
"Save settings as a JSON file" = "Guarda configuración como un archivo JSON";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL //
// Settings → Experimental tab //
//////////////////////////////////////////////////////////////////////////////
"Experimental" = "Experimental";
"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "Estas funciones son inestables y provocan que la aplicación de Instagram se cierre inesperadamente.\n\n¡Úsalas bajo tu propia responsabilidad!";
"Warning" = "Advertencia";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED //
@@ -527,17 +548,76 @@
//////////////////////////////////////////////////////////////////////////////
"Advanced" = "Avanzado";
"Auto-clear cache" = "Limpieza automática de caché";
"Automatically opens settings when the app launches" = "Abre la configuración automáticamente cuando se inicia la aplicación";
"Cache" = "Caché";
"Cache cleared" = "Caché limpiada";
"Calculating cache size…" = "Calculando tamaño de caché…";
"Clear" = "Limpiar";
"Clear cache" = "Limpiar caché";
"Clear cache (%@)" = "Limpiar caché (%@)";
"Clear cache?" = "¿Limpiar caché?";
"Clearing cache…" = "Limpiando caché…";
"Clearing still scans on demand." = "La limpieza sigue escaneando bajo demanda.";
"Daily" = "Diario";
"Disable safe mode" = "Deshabilitar modo seguro";
"Enable tweak settings quick-access" = "Habilitar acceso rápido a la configuración del Tweak";
"Free %@ of Instagram cache. A restart is recommended." = "Liberar %@ de la caché de Instagram. Se recomienda reiniciar.";
"Freed %@. Restart to apply." = "Se liberaron %@. Reinicia para aplicar.";
"Hold on the home tab to open RyukGram settings" = "Mantén pulsada la pestaña Feed (Inicio) para abrir la configuración de RyukGram";
"Instagram" = "Instagram";
"Monthly" = "Mensual";
"Nothing to clear" = "Nada que limpiar";
"Off skips the size scan when Advanced opens." = "Desactivado omite el escaneo de tamaño al abrir Avanzado.";
"Pause playback when opening settings" = "Pausa la reproducción al abrir la configuración";
"Pauses any playing video/audio when settings opens" = "Pausa cualquier reproducción de video o audio cuando se abre la configuración";
"Prevents Instagram from resetting settings after crashes (at your own risk)" = "Evita que Instagram restablezca la configuración después de un cierre inesperado\n(¡Bajo tu propia responsabilidad!)";
"Reset onboarding state" = "Restablecer estado onboarding"; // Verify onboarding - Verificar onboarding
"Settings" = "Configuración";
"Remove Instagram's cached images, videos, and temporary files." = "Elimina imágenes, videos y archivos temporales en caché de Instagram.";
"Reset onboarding state" = "Restablecer estado onboarding";
"Run a silent cache clear on launch when the interval has elapsed." = "Ejecuta una limpieza silenciosa al iniciar cuando ha pasado el intervalo.";
"Show cache size" = "Mostrar tamaño de caché";
"Show tweak settings on app launch" = "Muestra la configuración del Tweak cuando se inicia la aplicación";
"Weekly" = "Semanal";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED EXPERIMENTAL //
// Settings → Advanced → Advanced experimental features //
//////////////////////////////////////////////////////////////////////////////
"Actions" = "Acciones";
"Advanced experimental features" = "Funciones experimentales avanzadas";
"All experimental toggles will be turned off. Instagram will restart." = "Se desactivarán todas las funciones experimentales. Instagram se reiniciará.";
"Direct Notes — Audio reply" = "Notas directas — Respuesta de audio";
"Direct Notes — Avatar reply" = "Notas directas — Respuesta con avatar";
"Direct Notes — Friend Map" = "Notas directas — Mapa de amigos";
"Direct Notes — GIFs & stickers reply" = "Notas directas — Respuesta con GIF y stickers";
"Direct Notes — Photo reply" = "Notas directas — Respuesta con foto";
"Disabled after repeated crashes." = "Desactivado tras varios fallos.";
"Enables GIF/sticker replies" = "Activa las respuestas con GIF y stickers";
"Enables photo replies" = "Activa las respuestas con foto";
"Enables the audio-note reply type" = "Activa el tipo de respuesta con nota de audio";
"Enables the avatar reply type" = "Activa el tipo de respuesta con avatar";
"Experimental flags reset" = "Funciones experimentales restablecidas";
"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "Activa lo que quieras y pulsa Aplicar para reiniciar. Algunas pueden no funcionar en todas las cuentas o versiones de IG. Se restablecen solas si IG falla al iniciar 3 veces.";
"Forces Prism-gated experiments on" = "Fuerza la activación de los experimentos basados en Prism";
"Forces the Homecoming home surface / nav on" = "Fuerza la activación del inicio y la navegación de Homecoming";
"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "Fuerza la aparición de QuickSnap / Instants en el feed, bandeja, historias y notas";
"Got it" = "Entendido";
"Heads up" = "Atención";
"Hidden Instagram experiments" = "Experimentos ocultos de Instagram";
"Hidden Instagram experiments (in Advanced)" = "Experimentos ocultos de Instagram (en Avanzado)";
"Homecoming" = "Homecoming";
"Notes & QuickSnap" = "Notas y QuickSnap";
"Prism design system" = "Sistema de diseño Prism";
"QuickSnap (Instants)" = "QuickSnap (Instants)";
"Reset all experimental flags" = "Restablecer todas las funciones experimentales";
"Reset experimental flags?" = "¿Restablecer las funciones experimentales?";
"Restart Instagram to apply changes" = "Reinicia Instagram para aplicar los cambios";
"Shows the friend map entry in Direct Notes" = "Muestra el acceso al mapa de amigos en Notas directas";
"Surfaces" = "Superficies";
"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "Estos interruptores activan experimentos ocultos de Instagram. Algunas funciones pueden no funcionar en todas las cuentas o versiones de IG. Si IG sigue fallando al iniciar, se restablecen solas tras 3 inicios fallidos.";
"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "Activa experimentos ocultos de Instagram. Algunos pueden no funcionar en todas las cuentas o versiones de IG.";
"Turn every experimental toggle off" = "Desactivar todas las funciones experimentales";
//////////////////////////////////////////////////////////////////////////////
// DEBUG //
@@ -546,24 +626,38 @@
"Button Cell" = "Celda de botón";
"Change the value on the right" = "Cambia el valor a la derecha";
"Could not delete: %@" = "No se pudo eliminar: %@";
"Debug" = "Debug";
"Delete an imported override and fall back to the shipped strings" = "Elimina un archivo importado y vuelve a las cadenas integradas";
"Deleted %@ override. Restart to apply." = "Se eliminó el archivo %@. Reinicia para aplicar.";
"Enable FLEX gesture" = "Habilitar gesto FLEX";
"Export English strings" = "Exportar archivo .strings en Inglés";
"Hold 5 fingers on the screen to open FLEX" = "Mantén pulsados 5 dedos en la pantalla para abrir FLEX";
"I have %@%@" = "Tengo %@%@";
"Import a .strings file for a language" = "Importa un archivo .strings para añadir un idioma";
"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Importa un archivo .strings para actualizar una traducción. Escoge un idioma, selecciona el archivo y reinicia";
"Link Cell" = "Celda de enlace";
"Localization" = "Traducción";
"Menu Cell" = "Celda de menú";
"Navigation Cell" = "Celda de navegación";
"No imported localization files to reset." = "No hay archivos de localización importados para restablecer.";
"No overrides" = "Sin anulaciones";
"Open FLEX on app focus" = "Abrir FLEX al enfocar la aplicación";
"Open FLEX on app launch" = "Abrir FLEX al iniciar la aplicación";
"Opens FLEX when the app is focused" = "Abre FLEX cuando la aplicación es enfocada";
"Opens FLEX when the app launches" = "Abre FLEX cuando la aplicación se inicia";
"Pick a language to delete the imported file" = "Elige un idioma para borrar el archivo importado";
"Reset localization" = "Restablecer localización";
"Share the base English .strings file for translating" = "Comparte el archivo .strings en Inglés para traducir";
"Static Cell" = "Celda estática";
"Stepper cell" = "Celda de paso";
"Switch Cell" = "Celda interruptor";
"Switch Cell (Restart)" = "Cambiar celda (Reinicio)";
"Tap the switch" = "Toca el interruptor";
"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "Estas funciones dependen de marcadores ocultos de Instagram y pueden no funcionar en todas las cuentas o versiones.";
"Update localization file" = "Actualizar traducción";
"Using icon" = "Usar ícono";
"Using image" = "Usar imagen";
"_ Example" = "_ Ejemplo";
//////////////////////////////////////////////////////////////////////////////
// DOWNLOADS & MEDIA ACTIONS //
@@ -595,16 +689,16 @@
"Failed to save" = "Error al guardar";
"HD download complete" = "Descarga HD completada";
"Mute audio" = "Silenciar sonido";
"No URLs" = "Sin enlaces";
"No URLs found" = "Sin enlaces encontrados";
"No caption on this post" = "No hay descripción en esta publicación";
"No carousel children" = "Sin carrusel hijo";
"No cover image" = "Sin imagen de portada";
"No files downloaded" = "No se descargaron archivos";
"No media" = "Sin medios";
"No media URL" = "Sin enlace de medios";
"No media to expand" = "Sin medios para ampliar";
"No media to show" = "Sin medios para mostrar";
"No media URL" = "Sin enlace de medios";
"No URLs" = "Sin enlaces";
"No URLs found" = "Sin enlaces encontrados";
"No video URL" = "Sin enlace de video";
"Not a carousel" = "No es un carrusel";
"Nothing to save" = "Nada para guardar";
@@ -637,6 +731,7 @@
"Add to block list" = "Añadir a la lista de bloqueo";
"Add to block list?" = "¿Añadir a la lista de bloqueo?";
"Added to block list" = "Añadido a la lista de bloqueo";
"Added to exclude list" = "Añadido a la lista de excluidos";
"Audio not loaded yet. Play the message first and try again." = "Audio aún no cargado. Reproduce el mensaje primero y vuelve a intentar.";
"Audio sent" = "Audio enviado";
"Audio/Video from Files" = "Audio/Video desde Archivos";
@@ -650,12 +745,14 @@
"Could not get audio data. Try again after refreshing the chat." = "No se encontró datos de audio. Intenta nuevamente luego de actualizar la conversación.";
"Could not get video URL" = "No se encontró enlace al video";
"Disable read receipts" = "Deshabilitar confirmación de lectura";
"Disappearing media" = "Medios efímeros";
"Done!" = "¡Finalizado!";
"Download audio" = "Descargar audio";
"Downloading audio..." = "Descargando audio...";
"Enable read receipts" = "Habilitar confirmación de lectura";
"Error: %@" = "Error: %@";
"Exclude chat" = "Excluir chat";
"Exclude from seen" = "Excluir de vistos";
"Exclude story seen" = "Excluir de visualización de historia";
"Excluded" = "Excluído";
"Extracting audio..." = "Extrayendo audio...";
@@ -663,6 +760,10 @@
"File sending not supported" = "Enviar archivos no soportado";
"Follow" = "Seguir";
"Following" = "Siguiendo";
"Inserts a button on disappearing media overlays" = "Añade un botón sobre los medios efímeros";
"Inserts a speaker button to mute/unmute disappearing media" = "Añade un botón de altavoz para silenciar/activar medios efímeros";
"Inserts an eye button to mark the current disappearing media as viewed" = "Añade un botón de ojo para marcar los medios efímeros como vistos";
"Mark as viewed" = "Marcar como visto";
"Mark messages as seen" = "Marcar mensajes como vistos";
"Mark seen" = "Marcar como vista";
"Marked as seen" = "Marcado como visto";
@@ -671,6 +772,7 @@
"Mentions" = "Menciones";
"Message sender not found" = "No se encontró remitente del mensaje";
"Messages settings" = "Configuración de Mensajes";
"Audio URL not available" = "URL de audio no disponible";
"Mute story audio" = "Silenciar sonido de historia";
"No audio URL found. Try again after refreshing the chat." = "No se encontró enlace de audio. Intenta nuevamente luego de actualizar la conversación.";
"No mentions in this story" = "Sin menciones en esta historia";
@@ -686,32 +788,31 @@
"Remove" = "Eliminar";
"Remove from block list" = "Eliminar de la lista de bloqueo";
"Remove from block list?" = "¿Eliminar de la lista de bloqueo?";
"Remove from exclude list" = "Quitar de la lista de excluidos";
"Removed" = "Eliminado";
"Removed from list" = "Eliminado de la lista";
"Save GIF" = "Guardar GIF";
"Selection too short (min 0.5s)" = "Selección demasiado corta (min 0.5s)";
"Send Audio" = "Enviar audio";
"Send anyway" = "Enviar de todos modos";
"Send Audio" = "Enviar audio";
"Send failed: %@" = "Envío fallido: %@";
"Send service not found" = "Servicio de envío no encontrado";
"Share" = "Compartir";
"Show audio toggle" = "Mostrar interruptor de audio";
"Show mark-as-viewed button" = "Mostrar botón de marcar como visto";
"Story read receipts disabled" = "Aviso de visualización de historia DESACTIVADO";
"Story read receipts enabled" = "Aviso de visualización de historia ACTIVADO";
"Story seen receipts will be blocked for @%@." = "Aviso de visualización de historia será bloqueado para @%@.";
"This chat will resume normal read-receipt behavior." = "Este chat volverá a funcionar con el sistema habitual de confirmaciones de lectura.";
"Total: %@" = "Total: %@";
"Un-exclude" = "No excluir";
"Un-exclude chat" = "No excluir conversación";
"Un-exclude chat?" = "¿No excluir conversación?";
"Un-exclude story seen" = "No excluir de visualización de la historia";
"Un-exclude story seen?" = "¿No excluir de visualización de la historia?";
"Un-excluded" = "No excluído";
"Unblock" = "Desbloquear";
"Unblocked" = "Desbloqueado";
"Unlimited replay enabled" = "Reproducción ilimitada ACTIVADA";
"Unmute story audio" = "Activar sonido de la historia";
"Unsent" = "Mensaje eliminado";
"Upload Audio" = "Subir Audio";
"VC not found" = "VC no encontrado"; // Verify - Verificar
"VC not found" = "VC no encontrado";
"Video from Library" = "Video desde Fototeca";
"Visual messages will expire" = "Mensajes visuales expirarán";
"Visual messages: expiring" = "Mensajes visuales: Expirando";
@@ -728,6 +829,9 @@
"Add preset" = "Añadir ajuste preestablecido";
"Change location" = "Cambiar ubicación";
"Click the Apply button after this to see the emoji" = "Toca el botón Aplicar después de esto para ver el emoji";
"Clipboard is not an Instagram URL" = "El portapapeles no contiene un enlace de Instagram";
"Comments hidden" = "Comentarios ocultos";
"Comments shown" = "Comentarios mostrados";
"Copied text to clipboard" = "Texto copiado al portapapeles";
"Copy" = "Copiar";
"Copy all" = "Copiar todo";
@@ -738,19 +842,168 @@
"Current: %@" = "Actual: %@";
"Disable" = "Desactivado";
"Download GIF" = "Descargar GIF";
"Dropped pin" = "Marcador";
"Enable" = "Activado";
"Enable Location Services for Instagram in Settings to use your current location." = "Activa los servicios de ubicación para Instagram en Ajustes para usar tu ubicación actual.";
"Enter Emoji Text" = "Introduce Texto con Emoji";
"Fake location" = "Ubicación falsa";
"Location access denied" = "Acceso a ubicación denegado";
"Location Services off" = "Servicios de ubicación desactivados";
"Name" = "Nombre";
"Nothing to copy" = "Nada para copiar";
"Open Settings" = "Abrir Ajustes";
"Pick location" = "Elegir ubicación";
"Save" = "Guardar";
"Save preset" = "Guardar ajuste preestablecido";
"Saved locations" = "Ubicaciones guardadas";
"Select color" = "Escoger color";
"Set location" = "Establecer ubicación";
"Settings…" = "Configuración…";
"Turn Location Services on in Settings → Privacy to use your current location." = "Activa los servicios de ubicación en Ajustes → Privacidad para usar tu ubicación actual.";
"Type emoji..." = "Introduce emoji...";
"Theme" = "Tema";
"Appearance" = "Apariencia";
"Keyboard" = "Teclado";
"Force dark mode" = "Forzar modo oscuro";
"Keep Instagram in dark appearance regardless of iOS system setting" = "Mantén Instagram en apariencia oscura sin importar el ajuste de iOS";
"Full OLED" = "OLED completo";
"Replace Instagram's dark grays with pure black across the entire app" = "Sustituye los grises oscuros de Instagram por negro puro en toda la app";
"OLED chat theme" = "Tema OLED en chats";
"Pure black DM thread background and incoming message bubbles" = "Fondo negro puro en los mensajes directos y en las burbujas entrantes";
"Keyboard theme" = "Tema del teclado";
"Override the keyboard appearance when typing inside Instagram" = "Cambia la apariencia del teclado al escribir dentro de Instagram";
"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "Oscuro usa el teclado oscuro del sistema. OLED fuerza el fondo del teclado a negro puro.";
"Dark" = "Oscuro";
"OLED" = "OLED";
"Apply & restart" = "Aplicar y reiniciar";
"Restart Instagram to apply your theme changes" = "Reinicia Instagram para aplicar los cambios de tema";
"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "Los cambios de tema solo se aplican tras reiniciar la app. Toca Aplicar cuando termines de elegir.";
//////////////////////////////////////////////////////////////////////////////
// PROFILE ANALYZER //
// Settings → General → Profile Analyzer //
//////////////////////////////////////////////////////////////////////////////
"%lu followers · %lu following" = "%lu seguidores · %lu seguidos";
"%lu of %lu" = "%lu de %lu";
"Analysis complete" = "Análisis completado";
"Analysis failed" = "Error en el análisis";
"Another analysis is already running" = "Ya hay otro análisis en curso";
"Available after your next scan" = "Disponible tras tu próximo análisis";
"Cancelled" = "Cancelado";
"Couldn't fetch profile information" = "No se pudo obtener la información del perfil";
"Fetching followers (%lu/%ld)…" = "Obteniendo seguidores (%lu/%ld)…";
"Fetching following (%lu/%ld)…" = "Obteniendo seguidos (%lu/%ld)…";
"Fetching profile info…" = "Obteniendo información del perfil…";
"Categories" = "Categorías";
"First scan: %@" = "Primer análisis: %@";
"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "Número de seguidores supera %ld — análisis desactivado para evitar límites de la API.";
"Gained since last scan" = "Ganados desde el último análisis";
"Last scan: %@" = "Último análisis: %@";
"Lost followers" = "Seguidores perdidos";
"Mutual followers" = "Seguidores mutuos";
"Name: %@ → %@" = "Nombre: %@ → %@";
"New followers" = "Nuevos seguidores";
"No results" = "Sin resultados";
"No active Instagram session found" = "No se encontró una sesión de Instagram activa";
"No scan yet" = "Aún sin análisis";
"Not following you back" = "No te siguen de vuelta";
"OK" = "OK";
"Private account" = "Cuenta privada";
"Profile Analyzer" = "Analizador de perfil";
"Profile picture changed" = "Foto de perfil cambiada";
"Profile updates" = "Cambios de perfil";
"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "Borra las instantáneas guardadas de esta cuenta. Perderás los cambios desde el último análisis.";
"Request failed" = "Solicitud fallida";
"Reset analyzer data?" = "¿Restablecer datos del analizador?";
"Run analysis" = "Ejecutar análisis";
"Run your first analysis" = "Ejecuta tu primer análisis";
"Search username or name" = "Buscar usuario o nombre";
"Since last scan" = "Desde el último análisis";
"Starting…" = "Iniciando…";
"They follow you, you don't follow back" = "Te siguen, no los sigues de vuelta";
"Too many followers" = "Demasiados seguidores";
"Too many followers to analyze" = "Demasiados seguidores para analizar";
"Unfollow" = "Dejar de seguir";
"Unfollow @%@?" = "¿Dejar de seguir a @%@?";
"Unfollowed you since last scan" = "Te dejaron de seguir desde el último análisis";
"Username, name or picture changes" = "Cambios de usuario, nombre o foto";
"Username: @%@ → @%@" = "Usuario: @%@ → @%@";
"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "No se ejecuta cuando los seguidores superan %ld, para evitar límites de Instagram.";
"You both follow each other" = "Os seguís mutuamente";
"You don't follow back" = "No los sigues de vuelta";
"You follow them, they don't follow back" = "Los sigues, no te siguen de vuelta";
"You started following" = "Empezaste a seguir";
"You unfollowed" = "Dejaste de seguir";
"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "¿%@ %lu cuentas? Se procesarán las primeras %ld para evitar límites de la API.";
"%@ %lu accounts? This runs sequentially with a short pause between each." = "¿%@ %lu cuentas? Se ejecuta en orden con una pausa breve entre cada una.";
"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu cuenta(s) · %lu captura(s) · toca para ver";
"%lu accounts followed" = "%lu cuentas seguidas";
"%lu accounts unfollowed" = "%lu cuentas sin seguir";
"%lu entries across %lu lists · tap to inspect" = "%lu entradas en %lu listas · toca para ver";
"%lu preferences · tap to inspect" = "%lu preferencias · toca para ver";
"(empty)" = "(vacío)";
"(no analyzer data)" = "(sin datos del analizador)";
"(no lists)" = "(sin listas)";
"About Profile Analyzer" = "Acerca del analizador de perfil";
"All preferences (%lu)" = "Todas las preferencias (%lu)";
"Apply imported data?" = "¿Aplicar datos importados?";
"Batch follow" = "Seguir en lote";
"Batch follow finished" = "Seguir en lote finalizado";
"Batch unfollow" = "Dejar de seguir en lote";
"Batch unfollow finished" = "Dejar de seguir en lote finalizado";
"Continue" = "Continuar";
"Current snapshot" = "Captura actual";
"Embed domains" = "Dominios de embed";
"Excluded lists" = "Listas excluidas";
"Excluded story users" = "Usuarios de historias excluidos";
"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "Los valores actuales del ámbito seleccionado serán reemplazados. Puede ser necesario reiniciar la app para que algunos cambios surtan efecto.";
"Export" = "Exportar";
"File has no importable sections." = "El archivo no contiene secciones importables.";
"File is not a valid RyukGram export." = "El archivo no es una exportación válida de RyukGram.";
"Filter" = "Filtrar";
"First scan: we collect your followers and following lists and save them locally." = "Primer análisis: recopilamos tus listas de seguidores y seguidos y las guardamos localmente.";
"Follow %lu" = "Seguir %lu";
"Followers" = "Seguidores";
"Following… %lu / %lu" = "Siguiendo… %lu / %lu";
"Full name" = "Nombre completo";
"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "Aviso: esta función está en beta y utiliza la API privada de Instagram. Ejecutarla de forma consecutiva o justo tras mucha actividad de seguir/dejar de seguir puede provocar un límite temporal. Úsala con moderación y bajo tu propio riesgo.";
"Import complete" = "Importación completada";
"Include" = "Incluir";
"Included story users" = "Usuarios de historias incluidos";
"Inspect the full payload" = "Inspeccionar la carga completa";
"Keep scan history" = "Conservar historial de análisis";
"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "Las cuentas grandes están bloqueadas: el análisis se desactiva por encima de 13.000 seguidores para evitar que Instagram aplique un límite a toda la app.";
"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "No se sube nada — todo queda en este dispositivo y puede borrarse desde el icono de la papelera.";
"Not verified only" = "Solo no verificadas";
"Nothing was applied." = "No se aplicó nada.";
"Posts" = "Publicaciones";
"Preferences" = "Preferencias";
"Previous snapshot" = "Captura anterior";
"Private only" = "Solo privadas";
"Profile Analyzer data" = "Datos del analizador de perfil";
"Raw" = "Bruto";
"Raw JSON" = "JSON bruto";
"Reset analyzer data" = "Restablecer datos del analizador";
"Reset complete" = "Restablecimiento completado";
"Reset selected data?" = "¿Restablecer los datos seleccionados?";
"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "A partir del segundo análisis: cada análisis se compara con el anterior, mostrando seguidores nuevos/perdidos, tus propios movimientos de seguir/dejar de seguir y cambios de perfil.";
"Select all" = "Seleccionar todo";
"Selected data will be cleared. Tap any row to see what's stored." = "Los datos seleccionados se borrarán. Toca cualquier fila para ver qué hay guardado.";
"Settings" = "Ajustes";
"Sort" = "Ordenar";
"This can't be undone." = "No se puede deshacer.";
"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "Marca lo que quieras aplicar. Toca cualquier fila para inspeccionar. Las secciones ausentes del archivo están deshabilitadas.";
"Tick what to include. Tap any row to inspect its contents." = "Marca lo que quieras incluir. Toca cualquier fila para ver su contenido.";
"Unfollow %lu" = "Dejar de seguir %lu";
"Unfollowing… %lu / %lu" = "Dejando de seguir… %lu / %lu";
"Username A → Z" = "Usuario A → Z";
"Username Z → A" = "Usuario Z → A";
"Verified only" = "Solo verificadas";
"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "Cuando está activado, cada análisis se compara con el primero, así los seguidores nuevos/perdidos y los cambios de perfil no desaparecen entre análisis.";
//////////////////////////////////////////////////////////////////////////////
// SETTINGS VIEWS & DIALOGS //
// Excluded-lists managers, backup/restore flows, in-picker labels. //
@@ -761,68 +1014,58 @@
"Add preset…" = "Añadir ajuste preestablecido";
"Add to list?" = "¿Añadir a la lista?";
"Add user" = "Añadir usuario";
"Could not resolve user ID" = "No se pudo resolver el ID del usuario";
"Enter username" = "Introducir nombre de usuario";
"Enter username of the DM thread" = "Introducir nombre de usuario de la conversación";
"No DM thread found with @%@" = "No se encontró conversación con @%@";
"User '%@' not found" = "Usuario '%@' no encontrado";
"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." = "Todas las configuraciones de RyukGram se restablecerán a los valores predeterminados y se aplicarán los valores importados. Será necesario reiniciar la aplicación para que algunos cambios surtan efecto.";
"Apply" = "Aplicar";
"Apply imported settings?" = "¿Aplicar configuración importada?";
"Apply to" = "Aplicar a";
"Chats" = "Conversaciones";
"Could not read file." = "No se logró leer el archivo.";
"Could not resolve user ID" = "No se pudo resolver el ID del usuario";
"Could not write temporary file." = "No se logró escribir el archivo temporal.";
"Current location" = "Ubicación actual";
"Custom" = "Personalizada";
"Date Format" = "Formato de Fecha";
"Delete" = "Eliminar";
"Done editing" = "Finalizar edición";
"Edit values" = "Editar valores";
"Enable fake location" = "Habilitar ubicación falsa";
"Every RyukGram preference will revert to its built-in default. This can't be undone." = "Todas las preferencias de RyukGram volverán a los valores predeterminados. Esto no se puede deshacer.";
"Enter username" = "Introducir nombre de usuario";
"Enter username of the DM thread" = "Introducir nombre de usuario de la conversación";
"Excluded chats" = "Conversaciones excluídas";
"Excluded users" = "Usuarios excluídos";
"File is not a valid RyukGram settings export." = "Archivo no es una exportación válida de la configuración de RyukGram.";
"Follow default" = "Seguir predeterminada";
"Force OFF (allow unsends)" = "Forzar DESACTIVADO (Permite anular envío)";
"Force ON (preserve unsends)" = "Forzar ACTIVADO (Mantiene eliminados)";
"Form view" = "Vista de forma";
"Format" = "Formato";
"Import failed" = "Importación fallida";
"Import preview" = "Previsualizar importación";
"Included chats" = "Conversaciones incluidas";
"Included users" = "Usuarios incluidos";
"KD: ON" = "KD: ACTIVADO";
"KD: default" = "ME: Predeterminado";
"KD: ON" = "KD: ACTIVADO";
"Keep-deleted" = "Mantener eliminados";
"Keep-deleted override" = "Anular mantener eliminados";
"Name (AZ)" = "Nombre (AZ)";
"No DM thread found with @%@" = "No se encontró conversación con @%@";
"Off" = "DESACTIVADO";
"On" = "ACTIVADO";
"Presets" = "Preajustes";
"Raw JSON view" = "Ver JSON sin formato";
"Remove Selected" = "Eliminar Seleccionados";
"Recently added" = "Añadidos recientemente";
"Remove from list" = "Eliminar de la lista";
"Remove Selected" = "Eliminar Seleccionados";
"Reset" = "Restablecer";
"Reset all settings?" = "¿Restablecer todas las configuraciones?";
"Saved presets are reusable. Tap a preset to make it the active location." = "Los ajustes preestablecidos guardados se pueden reutilizar. Toca un ajuste preestablecido para convertirlo en la ubicación activa.";
"Search" = "Buscar";
"Search address or place" = "Buscar dirección o lugar";
"Search by name or username" = "Buscar por nombre o nombre de usuario";
"Search by username or name" = "Buscar por nombre de usuario o nombre";
"Search settings" = "Buscar en configuración";
"Select" = "Seleccionar";
"Select location on map" = "Seleccionar ubicación en el mapa";
"Set current location" = "Establecer ubicación actual";
"Set keep-deleted override" = "Establecer anulación de mantener eliminados";
"Settings exported" = "Configuración exportada";
"Settings imported" = "Configuración importada";
"Show map button" = "Botón de mostrar mapa";
"Show seconds" = "Mostrar segundos";
"Sort by" = "Ordenar por";
"Story users" = "Usuarios de historias";
"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." = "Alterna cada formato NSDate que utiliza Instagram. Las distintas secciones (Feed (Inicio), comentarios, historias, mensajes) utilizan métodos diferentes: activa aquellos a los que quieras aplicar el formato personalizado.";
"Use this location" = "Usar esta ubicación";
"When on, all CoreLocation requests inside Instagram return the location below." = "Cuando está activada, todas las solicitudes de CoreLocation dentro de Instagram devuelven la ubicación que se indica a continuación.";
"Show map button" = "Botón de mostrar mapa";
"User '%@' not found" = "Usuario '%@' no encontrado";
"Username (AZ)" = "Usuario (AZ)";
"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." = "Cuando está activado, todas las solicitudes de CoreLocation dentro de Instagram devuelven la ubicación que se muestra a continuación. Pulsa el botón del mapa para mostrar u ocultar el control rápido en la vista mapa de amigos.";
//////////////////////////////////////////////////////////////////////////////
@@ -859,6 +1102,7 @@
"720p • progressive • fastest" = "720p • Progresivo • Más rápido";
"Are you sure?" = "¿Estás seguro?";
"Bundle" = "Paquete";
"Copy audio URL" = "Copiar enlace de audio";
"Copy quality info" = "Copiar información sobre la calidad";
"Copy video URL" = "Copiar enlace de video";
@@ -871,60 +1115,74 @@
"Could not extract video url from reel" = "No se logró extraer enlace del video del reel";
"Could not extract video url from story" = "No se logró extraer enlace del video de la historia";
"Download Quality" = "Calidad de descarga";
"Extras" = "Extras";
"FFmpegKit Debug" = "FFmpegKit Debug";
"Later" = "Mas tarde";
"No!" = "¡No!";
"OK" = "OK";
"Restart" = "Reiniciar";
"Restart required" = "Reinicio requerido";
"username" = "nombre de usuario";
"Yes" = "Si";
"You must restart the app to apply this change" = "Debes reiniciar la aplicación para aplicar este cambio";
//////////////////////////////////////////////////////////////////////////////
// ABOUT / CREDITS //
// Settings → Credits footer. //
// Strings from the About / Credits footer of Settings. //
//////////////////////////////////////////////////////////////////////////////
"%@ — view source, report issues, see releases" = "%@ — ver código fuente, reportar problemas y ver lanzamientos";
"%@ — GitHub & Telegram" = "%@ — GitHub y Telegram";
"About" = "Acerca de";
"Arabic translation" = "Traducción al árabe";
"Chinese (Traditional) translation" = "Traducción al chino (tradicional)";
"Credits" = "Créditos";
"Developer" = "Desarrollador";
"Developers" = "Desarrolladores";
"Donate to SoCuul" = "Donar a SoCuul";
"installed" = "instalado";
"Korean translation" = "Traducción al coreano";
"latest" = "última";
"Links" = "Enlaces";
"No releases" = "Sin versiones";
"Original SCInsta developer" = "Desarrollador Original SCInsta";
"Ryuk" = "Ryuk";
"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "RyukGram %@\n\nInstagram v%@\n\nBasado en SCInsta por SoCuul";
"RyukGram on GitHub" = "RyukGram en GitHub";
"SoCuul" = "SoCuul";
"Release notes" = "Notas de la versión";
"Releases" = "Versiones";
"Report an issue" = "Informar de un problema";
"Russian translation" = "Traducción al ruso";
"RyukGram developer" = "Desarrollador de RyukGram";
"Join Telegram channel" = "Unirse al canal de Telegram";
"View on GitHub" = "Ver en GitHub";
"Source code" = "Código fuente";
"Spanish translation" = "Traducción al español";
"Support the original developer" = "Apoyar al desarrollador original";
"View Repo" = "Ver Repo";
"View the source code on GitHub" = "Ver el código fuente en GitHub";
/* [ADDED_BY_TRANSLATOR] */ "Translator" = "Traductor";
/* [ADDED_BY_TRANSLATOR] */ "Flamako" = "Flamako";
"Telegram channel" = "Canal de Telegram";
"Testing and feature suggestions" = "Pruebas y sugerencias de funciones";
"Tweak settings" = "Ajustes del tweak";
"Version" = "Versión";
"Version, credits, and links" = "Versión, créditos y enlaces";
"What's new in RyukGram" = "Novedades de RyukGram";
//////////////////////////////////////////////////////////////////////////////
// HD DOWNLOADS //
// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). //
//////////////////////////////////////////////////////////////////////////////
"720p • progressive • silent" = "720p • progresivo • silencioso";
"Audio extract failed" = "Falló la extracción de audio";
"Audio only" = "Solo audio";
"Audio ready" = "Audio listo";
"Download video at the highest available quality" = "Descargar video en la mejor calidad disponible";
"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "Descarga el video en HD mediante transmisión DASH y lo codifica en H.264. Requiere FFmpegKit.";
"Encoding speed" = "Velocidad de codificación";
"Enhanced downloads" = "Mejorar descargas";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit no está disponible. Instala el archivo IPA descargado o la variante .deb de _ffmpeg para activarlo.";
"Faster = lower quality" = "Más rápido = Menor calidad";
"FFmpeg not available" = "FFmpeg no disponible";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit no está disponible. Instala el archivo IPA descargado o la variante .deb de _ffmpeg para activarlo.";
"No audio stream available" = "No hay pista de audio disponible";
"No audio track found" = "No se encontró pista de audio";
"Photo" = "Foto";
"Photo quality" = "Calidad de imagen";
"Raw image (no audio, no video)" = "Imagen sin procesar (sin audio, sin video)";
"silent" = "silencioso";
"Use highest resolution available" = "Usar la resolución mas alta disponible";
"Video quality" = "Calidad de video";
"Which quality to download" = "En qué calidad descargar";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL / DEBUG //
// Placeholder rows only shown in the experimental settings sandbox. //
//////////////////////////////////////////////////////////////////////////////
"Navigation Cell" = "Celda de navegación";
/* [ADDED_BY_DEV] */ "Localization" = "Traducción";
/* [ADDED_BY_DEV] */ "Update localization file" = "Actualizar traducción";
/* [ADDED_BY_DEV] */ "Import a .strings file for a language" = "Importa un archivo .strings para añadir un idioma";
/* [ADDED_BY_DEV] */ "Import a .strings file to update a translation. Pick a language, select the file, restart." = "Importa un archivo .strings para actualizar una traducción. Escoge un idioma, selecciona el archivo y reinicia";
/* [ADDED_BY_DEV] */ "Export English strings" = "Exportar archivo .strings en Inglés";
/* [ADDED_BY_DEV] */ "Share the base English .strings file for translating" = "Comparte el archivo .strings en Inglés para traducir";
File diff suppressed because it is too large Load Diff
@@ -55,6 +55,7 @@
* - 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 //
@@ -65,11 +66,11 @@
"settings.firstrun.message" = "На будущее: удерживайте кнопку с тремя линиями в правом верхнем углу страницы профиля, чтобы снова открыть настройки RyukGram.";
"settings.firstrun.ok" = "Понятно!";
"settings.firstrun.title" = "Информация о настройках RyukGram";
"settings.language.english_only" = "Сейчас RyukGram поставляется только с английским языком. Другие языки уже подключены и ждут перевода — помочь перевести приложение на свой язык можно по короткой инструкции в README.";
"settings.language.help_translate" = "Помочь с переводом";
"settings.language.ok" = "ОК";
"settings.language.system" = "Системный язык";
"settings.language.title" = "Язык";
"settings.language.english_only" = "Сейчас RyukGram поставляется только с английским языком. Другие языки уже подключены и ждут перевода — помочь перевести приложение на свой язык можно по короткой инструкции в README.";
"settings.language.ok" = "ОК";
"settings.language.help_translate" = "Помочь с переводом";
"settings.results.many" = "%lu результатов";
"settings.results.none" = "Нет результатов";
"settings.results.one" = "%lu результат";
@@ -83,6 +84,8 @@
"Adds a copy option to the comment long-press menu" = "Добавляет пункт копирования в меню удержания комментария";
"Adds a download option for GIF comments" = "Добавляет пункт скачивания для GIF-комментариев";
"Anonymous live viewing" = "Анонимный просмотр трансляций";
"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "Блокирует heartbeat счётчика зрителей, чтобы трансляция не видела вас — вы также не увидите счётчик зрителей";
"Browser" = "Браузер";
"Comments" = "Комментарии";
"Copy comment text" = "Копировать текст комментария";
@@ -103,13 +106,14 @@
"Experimental features" = "Экспериментальные функции";
"Focus/distractions" = "Фокус/отвлечения";
"General" = "Общие";
"Hide Meta AI" = "Скрыть Meta AI";
"Hide ads" = "Скрыть рекламу";
"Hide explore posts grid" = "Скрыть сетку постов в Explore";
"Hide friends map" = "Скрыть карту друзей";
"Hide Meta AI" = "Скрыть Meta AI";
"Hide metrics" = "Скрыть метрики";
"Hide notes tray" = "Скрыть панель заметок";
"Hide trending searches" = "Скрыть популярные запросы";
"Hide UI on capture" = "Скрыть интерфейс при захвате";
"Hides all suggested users for you to follow, outside your feed" = "Скрывает всех рекомендуемых пользователей вне вашей ленты";
"Hides like/comment/share counts on posts and reels" = "Скрывает количество лайков, комментариев и репостов у постов и рилсов";
"Hides the friends map icon in the notes tray" = "Скрывает значок карты друзей на панели заметок";
@@ -119,23 +123,30 @@
"Hides the suggested broadcast channels in direct messages" = "Скрывает рекомендуемые каналы вещания в личных сообщениях";
"Hides the trending searches under the explore search bar" = "Скрывает популярные запросы под строкой поиска Explore";
"Hold down on the Instagram logo to change the app icon" = "Удерживайте логотип Instagram, чтобы сменить иконку приложения";
"Live" = "Прямые трансляции";
"Long press on the eyedropper tool in stories to customize the text color more precisely" = "Удерживайте пипетку в историях, чтобы точнее настроить цвет текста";
"Long-press the heart button in a live to hide or show the comments" = "Удерживайте кнопку сердца в прямой трансляции, чтобы скрыть или показать комментарии";
"Long-press the search tab to open a copied Instagram link" = "Удерживайте вкладку поиска, чтобы открыть скопированную ссылку Instagram";
"No suggested chats" = "Без рекомендуемых чатов";
"No suggested users" = "Без рекомендуемых пользователей";
"Notes" = "Заметки";
"Open app icon picker" = "Открыть выбор иконки приложения";
"Open link from clipboard" = "Открыть ссылку из буфера обмена";
"Open links in external browser" = "Открывать ссылки во внешнем браузере";
"Opens links in Safari instead of Instagram's in-app browser" = "Открывает ссылки в Safari вместо встроенного браузера Instagram";
"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Удаляет обёртки отслеживания Instagram (l.instagram.com) и параметры UTM/fbclid из URL";
"Privacy" = "Конфиденциальность";
"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "Скрывает кнопки RyukGram на скриншотах, записи экрана и зеркалировании";
"Removes all ads from the Instagram app" = "Удаляет всю рекламу из приложения Instagram";
"Removes igsh, utm_source, and other tracking parameters from shared links" = "Удаляет igsh, utm_source и другие параметры отслеживания из общих ссылок";
"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Заменяет относительные метки времени IG (\"3d ago\") на пользовательский формат. Внутри выбора можно указать, к каким разделам это применять.";
"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Удаляет обёртки отслеживания Instagram (l.instagram.com) и параметры UTM/fbclid из URL";
"Replace domain in shared links" = "Заменять домен в общих ссылках";
"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Заменяет относительные метки времени IG (\"3d ago\") на пользовательский формат. Внутри выбора можно указать, к каким разделам это применять.";
"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "Переписывает скопированные и отправляемые ссылки на домен, удобный для предпросмотра в Discord, Telegram и т.д.";
"Search bars will no longer save your recent searches" = "Строки поиска больше не будут сохранять недавние запросы";
"Sharing" = "Поделиться";
"Strip tracking from links" = "Удалять трекинг из ссылок";
"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)." = "Эти функции зависят от скрытых флагов Instagram и могут работать не на всех аккаунтах или версиях.\nИсследование экспериментальных флагов: @euoradan (Radan).";
"Toggle live comments" = "Переключить комментарии прямой трансляции";
"Use detailed color picker" = "Использовать подробный выбор цвета";
//////////////////////////////////////////////////////////////////////////////
@@ -241,8 +252,8 @@
"Hides the repost button on the reels sidebar" = "Скрывает кнопку репоста на боковой панели рилсов";
"Hides the top navigation bar when watching reels" = "Скрывает верхнюю панель навигации при просмотре рилсов";
"Hiding" = "Скрытие";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG по умолчанию: стандартное поведение. RyukGram: снова продвигает вперёд после свайпа назад.";
"IG default" = "IG по умолчанию";
"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG по умолчанию: стандартное поведение. RyukGram: снова продвигает вперёд после свайпа назад.";
"Limits" = "Ограничения";
"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "Ограничивает количество рилсов, доступных для прокрутки в любой момент, и запрещает обновление";
"Only loads %@ %@" = "Загружает только %@ %@";
@@ -254,8 +265,10 @@
"Shows an alert when you trigger a reels refresh" = "Показывает предупреждение при попытке обновить рилсы";
"Shows buttons to reveal and auto-fill the password on locked reels" = "Показывает кнопки для отображения и автозаполнения пароля на защищённых рилсах";
"Tap Controls" = "Управление нажатием";
"Tap to mute on photo reels" = "Нажмите для отключения звука в фото-Reels";
"Tapping the Reels tab while on reels does nothing" = "Нажатие вкладки Reels ничего не делает, если вы уже в рилсах";
"Unlock password-locked reels" = "Разблокировать рилсы с паролем";
"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "Когда режим паузы включён, нажатие на фото-Reels переключает звук вместо стандартной паузы";
//////////////////////////////////////////////////////////////////////////////
// PROFILE //
@@ -265,14 +278,25 @@
"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" = "Добавляет пункт просмотра в меню удержания хайлайта, чтобы открыть обложку во весь экран";
"Copy note on long press" = "Копировать заметку по удержанию";
"Fake follower count" = "Поддельное число подписчиков";
"Fake following count" = "Поддельное число подписок";
"Fake post count" = "Поддельное число публикаций";
"Fake profile stats" = "Поддельная статистика профиля";
"Fake verified badge" = "Поддельная галочка";
"Follow indicator" = "Индикатор подписки";
"Follower count" = "Число подписчиков";
"Following count" = "Число подписок";
"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 to download directly (ignored when zoom is on)" = "Удерживайте для прямого скачивания (игнорируется, если включено увеличение)";
"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "Жесты долгого нажатия на элементы профиля — отдельно от кнопок действий конкретных функций.";
"Only affects your own profile header. Other users see the real numbers." = "Влияет только на заголовок вашего собственного профиля. Другие пользователи видят реальные числа.";
"Post count" = "Число публикаций";
"Profile copy button" = "Кнопка копирования в профиле";
"Save profile picture" = "Сохранить фото профиля";
"Show a checkmark next to your name on your own profile" = "Показывать галочку рядом с вашим именем в вашем профиле";
"Shows whether the profile user follows you" = "Показывает, подписан ли пользователь профиля на вас";
"Tap to set" = "Нажмите, чтобы задать";
"View highlight cover" = "Посмотреть обложку хайлайта";
"Zoom profile photo" = "Увеличение фото профиля";
@@ -320,7 +344,6 @@
"Hides the notification for others when you view their story" = "Скрывает уведомление для других пользователей, когда вы смотрите их историю";
"Inserts a button next to the seen/eye button on story overlays" = "Добавляет кнопку рядом с кнопкой просмотра/глаза в интерфейсе историй";
"Keep stories visually seen locally" = "Помечать истории просмотренными только локально";
"Keep stories visually unseen" = "Оставлять истории визуально непросмотренными";
"Liking a story automatically advances to the next one after a short delay" = "После лайка истории автоматически переходит к следующей через короткую задержку";
"Manage list" = "Управлять списком";
"Manage list (%lu)" = "Управлять списком (%lu)";
@@ -329,17 +352,25 @@
"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 when you send a reply or emoji reaction, even with seen blocking on" = "Отмечает историю как просмотренную при отправке ответа или реакции эмодзи, даже если блокировка просмотров включена";
"Master toggle. When off, the list is ignored" = "Главный переключатель. Когда выключен, список игнорируется";
"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Помечает истории как просмотренные локально (серое кольцо), при этом блокируя уведомление о просмотре на сервере";
"Master toggle. When off, the list is ignored" = "Главный переключатель. Когда выключен, список игнорируется";
"Other" = "Другое";
"Playback" = "Воспроизведение";
"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" = "Не даёт историям визуально отмечаться как просмотренные в панели (цветное кольцо остаётся)";
"Quick list button in stories" = "Кнопка быстрого списка в историях";
"Search, sort, swipe to remove" = "Поиск, сортировка, свайп для удаления";
"Seen receipts" = "Уведомления о просмотре";
"Sending a reply or emoji reaction automatically advances to the next story" = "Отправка ответа или реакции эмодзи автоматически переключает на следующую историю";
"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" = "Показывает кнопку глаза в историях для добавления и удаления пользователей из списка. Выкл. = только меню с тремя точками или долгое нажатие";
"Stickers" = "Стикеры";
"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "Смотрите результаты опросов/викторин/слайдера до взаимодействия — вы по-прежнему можете нажать для голосования. «Принудительно добавить викторину» возвращает устаревший стикер викторины в редактор истории.";
"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "Смотрите результаты опросов/викторин/слайдера в reels до взаимодействия — вы по-прежнему можете нажать для голосования.";
"Force Quiz sticker in tray" = "Принудительно добавить стикер викторины";
"Adds Quiz back to the story sticker picker" = "Возвращает викторину в выбор стикеров истории";
"Show quiz answer" = "Показывать ответ викторины";
"Circle the correct option on quiz stickers, or the leading option on polls" = "Обводит правильный вариант в викторине или лидирующий в опросе";
"Show poll vote counts" = "Показывать количество голосов опроса";
"Show vote tallies on poll options and slider count/average before you vote" = "Показывает число голосов по вариантам опроса и среднее/количество слайдера до голосования";
"Stop story auto-advance" = "Остановить авто-переход историй";
"Stories" = "Истории";
"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "Истории не будут автоматически переключаться на следующую после окончания таймера. Нажимайте для ручного перехода";
@@ -384,7 +415,7 @@
"Copy text on hold" = "Копировать текст по удержанию";
"Custom emojis and background/text colors" = "Пользовательские эмодзи и цвета фона/текста";
"Custom note themes" = "Пользовательские темы заметок";
"Disable disappearing mode swipe" = "Отключить свайп для исчезающего режима";
"Disable vanish mode swipe" = "Отключить свайп для режима Vanish";
"Disable screenshot detection" = "Отключить обнаружение скриншотов";
"Disable typing status" = "Отключить статус набора";
"Disable view-once limitations" = "Отключить ограничения view-once";
@@ -405,7 +436,7 @@
"Note actions" = "Действия с заметками";
"Preserve messages that others unsend" = "Сохранять сообщения, которые другие отзывают";
"Preserves messages that others unsend" = "Сохраняет сообщения, которые другие отзывают";
"Prevents accidental swipe-up activation of disappearing mode" = "Предотвращает случайное включение исчезающего режима свайпом вверх";
"Prevents accidental swipe-up activation of vanish mode" = "Предотвращает случайное включение режима Vanish свайпом вверх";
"Quick list button in chats" = "Кнопка быстрого списка в чатах";
"Removes the audio call button from DM thread header" = "Убирает кнопку аудиозвонка из заголовка диалога DM";
"Removes the screenshot-prevention features for visual messages in DMs" = "Убирает защиту от скриншотов для визуальных сообщений в DM";
@@ -420,7 +451,6 @@
"Shows an \"Unsent\" label on preserved messages" = "Показывает метку \"Отозвано\" на сохранённых сообщениях";
"Unlimited replay of visual messages" = "Неограниченный повтор визуальных сообщений";
"Unsent message notification" = "Уведомление об отозванном сообщении";
"Visual messages" = "Визуальные сообщения";
"Voice messages" = "Голосовые сообщения";
"Warn before clearing on refresh" = "Предупреждать перед очисткой при обновлении";
"Which chats get read-receipt blocking" = "Для каких чатов блокируются уведомления о прочтении";
@@ -439,11 +469,13 @@
// Settings → Navigation tab //
//////////////////////////////////////////////////////////////////////////////
"Also hide the bottom tab bar — only the inbox is visible" = "Также скрыть нижнюю панель вкладок — видна только папка входящих";
"Hide create tab" = "Скрыть вкладку создания";
"Hide explore tab" = "Скрыть вкладку Explore";
"Hide feed tab" = "Скрыть вкладку ленты";
"Hide messages tab" = "Скрыть вкладку сообщений";
"Hide reels tab" = "Скрыть вкладку рилсов";
"Hide tab bar" = "Скрыть панель вкладок";
"Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab." = "Скрывает все вкладки, кроме входящих DM и профиля, и принудительно запускает приложение во входящих. Быстрый доступ к настройкам переносится на долгое нажатие по вкладке входящих.";
"Hides the create tab on the bottom navigation bar" = "Скрывает вкладку создания на нижней панели навигации";
"Hides the direct messages tab on the bottom navigation bar" = "Скрывает вкладку личных сообщений на нижней панели навигации";
@@ -468,31 +500,33 @@
//////////////////////////////////////////////////////////////////////////////
"Confirm actions" = "Подтверждение действий";
"Confirm call" = "Подтверждать звонок";
"Confirm video call" = "Подтверждать видеозвонок";
"Confirm voice call" = "Подтверждать аудиозвонок";
"Confirm changing theme" = "Подтверждать смену темы";
"Confirm follow" = "Подтверждать подписку";
"Confirm follow requests" = "Подтверждать запросы на подписку";
"Confirm like: Posts" = "Подтверждать лайк: посты";
"Confirm like: Posts/Stories" = "Подтверждать лайк: посты/истории";
"Confirm like: Reels" = "Подтверждать лайк: рилсы";
"Confirm posting comment" = "Подтверждать отправку комментария";
"Confirm repost" = "Подтверждать репост";
"Confirm shh mode" = "Подтверждать shh-режим";
"Confirm sticker interaction" = "Подтверждать взаимодействие со стикером";
"Confirm vanish mode" = "Подтверждать режим исчезновения";
"Confirm sticker interaction (stories)" = "Подтверждать взаимодействие со стикером (истории)";
"Confirm sticker interaction (highlights)" = "Подтверждать взаимодействие со стикером (актуальное)";
"Confirm story emoji reaction" = "Подтверждать эмодзи-реакцию на историю";
"Confirm story like" = "Подтверждать лайк истории";
"Confirm unfollow" = "Подтверждать отписку";
"Confirm voice messages" = "Подтверждать голосовые сообщения";
"Shows an alert before sending an emoji reaction on a story" = "Показывает подтверждение перед отправкой эмодзи-реакции на историю";
"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 vanish mode" = "Показывает подтверждение перед включением режима исчезновения";
"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 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 tap a sticker on someone's story" = "Показывает подтверждение при нажатии на стикер в чьей-то истории";
"Shows an alert when you tap a sticker inside a highlight" = "Показывает подтверждение при нажатии на стикер в актуальном";
"Shows an alert when you click the video call button to confirm before calling" = "Показывает подтверждение при нажатии кнопки видеозвонка";
"Shows an alert when you click the voice 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 like button on posts 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 stories to confirm the like" = "Показывает подтверждение при нажатии кнопки лайка на историях";
"Shows an alert when you click the post comment button to confirm" = "Показывает подтверждение при нажатии кнопки отправки комментария";
@@ -505,22 +539,6 @@
//////////////////////////////////////////////////////////////////////////////
"Backup & Restore" = "Резервная копия и восстановление";
"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." = "Экспортируйте настройки RyukGram в JSON-файл и импортируйте их позже. При импорте все настройки сначала сбрасываются к значениям по умолчанию, затем применяются импортированные значения, а перед этим показывается предварительный просмотр.";
"Import settings" = "Импорт настроек";
"Load settings from a JSON file" = "Загрузить настройки из JSON-файла";
"Reset to defaults" = "Сбросить по умолчанию";
"Revert every RyukGram preference" = "Сбросить все параметры RyukGram";
"Save settings as a JSON file" = "Сохранить настройки в JSON-файл";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL //
// Settings → Experimental tab //
//////////////////////////////////////////////////////////////////////////////
"Experimental" = "Экспериментальное";
"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "Эти функции нестабильны и могут неожиданно вызывать вылеты Instagram.\n\nИспользуйте на свой страх и риск!";
"Warning" = "Предупреждение";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED //
@@ -528,17 +546,76 @@
//////////////////////////////////////////////////////////////////////////////
"Advanced" = "Дополнительно";
"Auto-clear cache" = "Автоочистка кэша";
"Automatically opens settings when the app launches" = "Автоматически открывает настройки при запуске приложения";
"Cache" = "Кэш";
"Cache cleared" = "Кэш очищен";
"Calculating cache size…" = "Вычисление размера кэша…";
"Clear" = "Очистить";
"Clear cache" = "Очистить кэш";
"Clear cache (%@)" = "Очистить кэш (%@)";
"Clear cache?" = "Очистить кэш?";
"Clearing cache…" = "Очистка кэша…";
"Clearing still scans on demand." = "Очистка по-прежнему сканирует по запросу.";
"Daily" = "Ежедневно";
"Disable safe mode" = "Отключить безопасный режим";
"Enable tweak settings quick-access" = "Включить быстрый доступ к настройкам твика";
"Free %@ of Instagram cache. A restart is recommended." = "Освободить %@ кэша Instagram. Рекомендуется перезапуск.";
"Freed %@. Restart to apply." = "Освобождено %@. Перезапустите для применения.";
"Hold on the home tab to open RyukGram settings" = "Удерживайте вкладку Home, чтобы открыть настройки RyukGram";
"Instagram" = "Instagram";
"Monthly" = "Ежемесячно";
"Nothing to clear" = "Нечего очищать";
"Off skips the size scan when Advanced opens." = "В выключенном состоянии пропускает сканирование размера при открытии «Дополнительно».";
"Pause playback when opening settings" = "Ставить воспроизведение на паузу при открытии настроек";
"Pauses any playing video/audio when settings opens" = "Ставит на паузу любое воспроизводимое видео или аудио при открытии настроек";
"Prevents Instagram from resetting settings after crashes (at your own risk)" = "Не даёт Instagram сбрасывать настройки после сбоев (на ваш страх и риск)";
"Remove Instagram's cached images, videos, and temporary files." = "Удаляет кэшированные изображения, видео и временные файлы Instagram.";
"Reset onboarding state" = "Сбросить состояние онбординга";
"Settings" = "Настройки";
"Run a silent cache clear on launch when the interval has elapsed." = "Выполняет тихую очистку кэша при запуске, когда истёк интервал.";
"Show cache size" = "Показывать размер кэша";
"Show tweak settings on app launch" = "Показывать настройки твика при запуске приложения";
"Weekly" = "Еженедельно";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED EXPERIMENTAL //
// Settings → Advanced → Advanced experimental features //
//////////////////////////////////////////////////////////////////////////////
"Actions" = "Действия";
"Advanced experimental features" = "Расширенные экспериментальные функции";
"All experimental toggles will be turned off. Instagram will restart." = "Все экспериментальные переключатели будут отключены. Instagram перезапустится.";
"Direct Notes — Audio reply" = "Direct-заметки — ответ голосом";
"Direct Notes — Avatar reply" = "Direct-заметки — ответ аватаром";
"Direct Notes — Friend Map" = "Direct-заметки — карта друзей";
"Direct Notes — GIFs & stickers reply" = "Direct-заметки — ответ GIF и стикерами";
"Direct Notes — Photo reply" = "Direct-заметки — ответ фото";
"Disabled after repeated crashes." = "Отключено после повторных сбоев.";
"Enables GIF/sticker replies" = "Включает ответы GIF и стикерами";
"Enables photo replies" = "Включает ответы фото";
"Enables the audio-note reply type" = "Включает ответы голосовой заметкой";
"Enables the avatar reply type" = "Включает ответы аватаром";
"Experimental flags reset" = "Экспериментальные флаги сброшены";
"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "Включите нужное и нажмите «Применить», чтобы перезапустить. Некоторые флаги могут не работать на всех аккаунтах или версиях IG. Сбрасываются автоматически после 3 сбоев запуска.";
"Forces Prism-gated experiments on" = "Принудительно включает эксперименты под Prism";
"Forces the Homecoming home surface / nav on" = "Принудительно включает интерфейс и навигацию Homecoming";
"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "Принудительно показывает QuickSnap / Instants в ленте, чатах, историях и заметках";
"Got it" = "Понятно";
"Heads up" = "Внимание";
"Hidden Instagram experiments" = "Скрытые эксперименты Instagram";
"Hidden Instagram experiments (in Advanced)" = "Скрытые эксперименты Instagram (в разделе «Дополнительно»)";
"Homecoming" = "Homecoming";
"Notes & QuickSnap" = "Заметки и QuickSnap";
"Prism design system" = "Дизайн-система Prism";
"QuickSnap (Instants)" = "QuickSnap (Instants)";
"Reset all experimental flags" = "Сбросить все экспериментальные флаги";
"Reset experimental flags?" = "Сбросить экспериментальные флаги?";
"Restart Instagram to apply changes" = "Перезапустите Instagram, чтобы применить изменения";
"Shows the friend map entry in Direct Notes" = "Показывает вход на карту друзей в Direct-заметках";
"Surfaces" = "Интерфейсы";
"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "Эти переключатели включают скрытые эксперименты Instagram. Некоторые функции могут не работать на всех аккаунтах или версиях IG. Если IG падает при запуске, флаги сбрасываются после 3 неудачных стартов.";
"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "Переключайте скрытые эксперименты Instagram. Некоторые могут не работать на всех аккаунтах или версиях IG.";
"Turn every experimental toggle off" = "Отключить все экспериментальные переключатели";
//////////////////////////////////////////////////////////////////////////////
// DEBUG //
@@ -547,24 +624,38 @@
"Button Cell" = "Ячейка кнопки";
"Change the value on the right" = "Измените значение справа";
"Could not delete: %@" = "Не удалось удалить: %@";
"Debug" = "Отладка";
"Delete an imported override and fall back to the shipped strings" = "Удалить импортированный файл и вернуться к встроенным строкам";
"Deleted %@ override. Restart to apply." = "Файл %@ удалён. Перезапустите для применения.";
"Enable FLEX gesture" = "Включить жест FLEX";
"Export English strings" = "Экспортировать английские строки";
"Hold 5 fingers on the screen to open FLEX" = "Удерживайте 5 пальцев на экране, чтобы открыть FLEX";
"I have %@%@" = "У меня есть %@%@";
"Import a .strings file for a language" = "Импортировать файл .strings для языка";
"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Импортируйте файл .strings, чтобы обновить перевод. Выберите язык, укажите файл и перезапустите приложение.";
"Link Cell" = "Ячейка ссылки";
"Localization" = "Локализация";
"Menu Cell" = "Ячейка меню";
"Navigation Cell" = "Ячейка навигации";
"No imported localization files to reset." = "Нет импортированных файлов локализации для сброса.";
"No overrides" = "Нет переопределений";
"Open FLEX on app focus" = "Открывать FLEX при возврате в приложение";
"Open FLEX on app launch" = "Открывать FLEX при запуске приложения";
"Opens FLEX when the app is focused" = "Открывает FLEX, когда приложение становится активным";
"Opens FLEX when the app launches" = "Открывает FLEX при запуске приложения";
"Pick a language to delete the imported file" = "Выберите язык, чтобы удалить импортированный файл";
"Reset localization" = "Сбросить локализацию";
"Share the base English .strings file for translating" = "Поделиться базовым английским файлом .strings для перевода";
"Static Cell" = "Статическая ячейка";
"Stepper cell" = "Ячейка степпера";
"Switch Cell" = "Ячейка переключателя";
"Switch Cell (Restart)" = "Ячейка переключателя (перезапуск)";
"Tap the switch" = "Нажмите переключатель";
"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "Эти функции используют скрытые флаги Instagram и могут работать не на всех аккаунтах или версиях.";
"Update localization file" = "Обновить файл локализации";
"Using icon" = "Используя иконку";
"Using image" = "Используя изображение";
"_ Example" = "_ Пример";
//////////////////////////////////////////////////////////////////////////////
// DOWNLOADS & MEDIA ACTIONS //
@@ -596,16 +687,16 @@
"Failed to save" = "Не удалось сохранить";
"HD download complete" = "HD-загрузка завершена";
"Mute audio" = "Выключить звук";
"No URLs" = "Нет URL";
"No URLs found" = "URL не найдены";
"No caption on this post" = "У этого поста нет подписи";
"No carousel children" = "У карусели нет элементов";
"No cover image" = "Нет обложки";
"No files downloaded" = "Файлы не скачаны";
"No media" = "Нет медиа";
"No media URL" = "Нет URL медиа";
"No media to expand" = "Нет медиа для разворачивания";
"No media to show" = "Нет медиа для показа";
"No media URL" = "Нет URL медиа";
"No URLs" = "Нет URL";
"No URLs found" = "URL не найдены";
"No video URL" = "Нет URL видео";
"Not a carousel" = "Это не карусель";
"Nothing to save" = "Нечего сохранять";
@@ -638,6 +729,7 @@
"Add to block list" = "Добавить в список блокировки";
"Add to block list?" = "Добавить в список блокировки?";
"Added to block list" = "Добавлено в список блокировки";
"Added to exclude list" = "Добавлено в список исключений";
"Audio not loaded yet. Play the message first and try again." = "Аудио ещё не загружено. Сначала воспроизведите сообщение и попробуйте снова.";
"Audio sent" = "Аудио отправлено";
"Audio/Video from Files" = "Аудио/видео из Файлов";
@@ -651,12 +743,14 @@
"Could not get audio data. Try again after refreshing the chat." = "Не удалось получить аудиоданные. Попробуйте снова после обновления чата.";
"Could not get video URL" = "Не удалось получить URL видео";
"Disable read receipts" = "Отключить уведомления о прочтении";
"Disappearing media" = "Исчезающие медиа";
"Done!" = "Готово!";
"Download audio" = "Скачать аудио";
"Downloading audio..." = "Скачивание аудио...";
"Enable read receipts" = "Включить уведомления о прочтении";
"Error: %@" = "Ошибка: %@";
"Exclude chat" = "Исключить чат";
"Exclude from seen" = "Исключить из просмотренных";
"Exclude story seen" = "Исключить просмотр истории";
"Excluded" = "Исключено";
"Extracting audio..." = "Извлечение аудио...";
@@ -664,6 +758,10 @@
"File sending not supported" = "Отправка файлов не поддерживается";
"Follow" = "Подписаться";
"Following" = "Подписки";
"Inserts a button on disappearing media overlays" = "Добавляет кнопку на оверлей исчезающих медиа";
"Inserts a speaker button to mute/unmute disappearing media" = "Добавляет кнопку динамика для звука исчезающих медиа";
"Inserts an eye button to mark the current disappearing media as viewed" = "Добавляет кнопку-глаз для отметки текущего исчезающего медиа как просмотренного";
"Mark as viewed" = "Отметить как просмотренное";
"Mark messages as seen" = "Отметить сообщения как просмотренные";
"Mark seen" = "Отметить просмотр";
"Marked as seen" = "Отмечено как просмотренное";
@@ -672,6 +770,7 @@
"Mentions" = "Упоминания";
"Message sender not found" = "Отправитель сообщения не найден";
"Messages settings" = "Настройки сообщений";
"Audio URL not available" = "URL аудио недоступен";
"Mute story audio" = "Выключить звук истории";
"No audio URL found. Try again after refreshing the chat." = "URL аудио не найден. Попробуйте снова после обновления чата.";
"No mentions in this story" = "В этой истории нет упоминаний";
@@ -687,26 +786,25 @@
"Remove" = "Удалить";
"Remove from block list" = "Убрать из списка блокировки";
"Remove from block list?" = "Убрать из списка блокировки?";
"Remove from exclude list" = "Удалить из списка исключений";
"Removed" = "Удалено";
"Removed from list" = "Удалено из списка";
"Save GIF" = "Сохранить GIF";
"Selection too short (min 0.5s)" = "Слишком короткий фрагмент (минимум 0.5 с)";
"Send Audio" = "Отправить аудио";
"Send anyway" = "Всё равно отправить";
"Send Audio" = "Отправить аудио";
"Send failed: %@" = "Ошибка отправки: %@";
"Send service not found" = "Сервис отправки не найден";
"Share" = "Поделиться";
"Show audio toggle" = "Показывать переключатель звука";
"Show mark-as-viewed button" = "Показывать кнопку отметки о просмотре";
"Story read receipts disabled" = "Уведомления о просмотре историй отключены";
"Story read receipts enabled" = "Уведомления о просмотре историй включены";
"Story seen receipts will be blocked for @%@." = "Уведомления о просмотре историй будут заблокированы для @%@.";
"This chat will resume normal read-receipt behavior." = "Для этого чата будет восстановлено обычное поведение уведомлений о прочтении.";
"Total: %@" = "Всего: %@";
"Un-exclude" = "Убрать исключение";
"Un-exclude chat" = "Убрать чат из исключений";
"Un-exclude chat?" = "Убрать чат из исключений?";
"Un-exclude story seen" = "Убрать просмотр истории из исключений";
"Un-exclude story seen?" = "Убрать просмотр истории из исключений?";
"Un-excluded" = "Исключение убрано";
"Unblock" = "Разблокировать";
"Unblocked" = "Разблокировано";
"Unlimited replay enabled" = "Неограниченный повтор включён";
"Unmute story audio" = "Включить звук истории";
@@ -729,6 +827,9 @@
"Add preset" = "Добавить пресет";
"Change location" = "Изменить местоположение";
"Click the Apply button after this to see the emoji" = "После этого нажмите кнопку Apply, чтобы увидеть эмодзи";
"Clipboard is not an Instagram URL" = "В буфере обмена нет ссылки Instagram";
"Comments hidden" = "Комментарии скрыты";
"Comments shown" = "Комментарии показаны";
"Copied text to clipboard" = "Текст скопирован в буфер обмена";
"Copy" = "Копировать";
"Copy all" = "Копировать всё";
@@ -739,34 +840,179 @@
"Current: %@" = "Текущее: %@";
"Disable" = "Отключить";
"Download GIF" = "Скачать GIF";
"Dropped pin" = "Установленная метка";
"Enable" = "Включить";
"Enable Location Services for Instagram in Settings to use your current location." = "Включите службы геолокации для Instagram в настройках, чтобы использовать текущее местоположение.";
"Enter Emoji Text" = "Введите текст эмодзи";
"Fake location" = "Поддельное местоположение";
"Location access denied" = "Доступ к геолокации запрещён";
"Location Services off" = "Службы геолокации выключены";
"Name" = "Имя";
"Nothing to copy" = "Нечего копировать";
"Open Settings" = "Открыть настройки";
"Pick location" = "Выбрать местоположение";
"Save" = "Сохранить";
"Save preset" = "Сохранить пресет";
"Saved locations" = "Сохранённые местоположения";
"Select color" = "Выбрать цвет";
"Set location" = "Установить местоположение";
"Settings…" = "Настройки…";
"Turn Location Services on in Settings → Privacy to use your current location." = "Включите службы геолокации в Настройках → Конфиденциальность, чтобы использовать текущее местоположение.";
"Type emoji..." = "Введите эмодзи...";
"direct-inbox-tab" = "direct-inbox-tab";
"mainfeed-tab" = "mainfeed-tab";
"Theme" = "Тема";
"Appearance" = "Оформление";
"Keyboard" = "Клавиатура";
"Force dark mode" = "Принудительная тёмная тема";
"Keep Instagram in dark appearance regardless of iOS system setting" = "Оставлять Instagram в тёмном оформлении независимо от настроек iOS";
"Full OLED" = "Полный OLED";
"Replace Instagram's dark grays with pure black across the entire app" = "Заменить тёмно-серые тона Instagram на чистый чёрный по всему приложению";
"OLED chat theme" = "Тема OLED для чатов";
"Pure black DM thread background and incoming message bubbles" = "Чистый чёрный фон в переписках и входящих сообщениях";
"Keyboard theme" = "Тема клавиатуры";
"Override the keyboard appearance when typing inside Instagram" = "Переопределить внешний вид клавиатуры при вводе в Instagram";
"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "Тёмный использует системную тёмную клавиатуру. OLED принудительно делает фон клавиатуры чисто чёрным.";
"Dark" = "Тёмный";
"OLED" = "OLED";
"Apply & restart" = "Применить и перезапустить";
"Restart Instagram to apply your theme changes" = "Перезапустите Instagram, чтобы применить изменения темы";
"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "Изменения темы вступают в силу только после перезапуска приложения. Нажмите Применить ниже, когда закончите выбор.";
//////////////////////////////////////////////////////////////////////////////
// PROFILE ANALYZER //
// Settings → General → Profile Analyzer //
//////////////////////////////////////////////////////////////////////////////
"%lu followers · %lu following" = "%lu подписчиков · %lu подписок";
"%lu of %lu" = "%lu из %lu";
"Analysis complete" = "Анализ завершён";
"Analysis failed" = "Ошибка анализа";
"Another analysis is already running" = "Другой анализ уже выполняется";
"Available after your next scan" = "Доступно после следующего анализа";
"Cancelled" = "Отменено";
"Couldn't fetch profile information" = "Не удалось получить информацию о профиле";
"Fetching followers (%lu/%ld)…" = "Загрузка подписчиков (%lu/%ld)…";
"Fetching following (%lu/%ld)…" = "Загрузка подписок (%lu/%ld)…";
"Fetching profile info…" = "Загрузка информации профиля…";
"Categories" = "Категории";
"First scan: %@" = "Первый анализ: %@";
"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "Число подписчиков больше %ld — анализ отключён, чтобы не упереться в лимиты API.";
"Gained since last scan" = "Появились с последнего анализа";
"Last scan: %@" = "Последний анализ: %@";
"Lost followers" = "Потерянные подписчики";
"Mutual followers" = "Взаимные подписчики";
"Name: %@ → %@" = "Имя: %@ → %@";
"New followers" = "Новые подписчики";
"No results" = "Нет результатов";
"No active Instagram session found" = "Активная сессия Instagram не найдена";
"No scan yet" = "Анализа ещё нет";
"Not following you back" = "Не подписаны на вас в ответ";
"OK" = "OK";
"Private account" = "Закрытый аккаунт";
"Profile Analyzer" = "Анализ профиля";
"Profile picture changed" = "Фото профиля изменено";
"Profile updates" = "Изменения профиля";
"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "Удаляет сохранённые снимки для этого аккаунта. Вы потеряете изменения с последнего анализа.";
"Request failed" = "Запрос не выполнен";
"Reset analyzer data?" = "Сбросить данные анализа?";
"Run analysis" = "Запустить анализ";
"Run your first analysis" = "Запустите первый анализ";
"Search username or name" = "Поиск по логину или имени";
"Since last scan" = "С последнего анализа";
"Starting…" = "Начинаем…";
"They follow you, you don't follow back" = "Они подписаны на вас, вы — нет";
"Too many followers" = "Слишком много подписчиков";
"Too many followers to analyze" = "Слишком много подписчиков для анализа";
"Unfollow" = "Отписаться";
"Unfollow @%@?" = "Отписаться от @%@?";
"Unfollowed you since last scan" = "Отписались от вас с последнего анализа";
"Username, name or picture changes" = "Изменения логина, имени или фото";
"Username: @%@ → @%@" = "Логин: @%@ → @%@";
"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "Не запускается, когда подписчиков больше %ld — во избежание лимитов Instagram.";
"You both follow each other" = "Вы подписаны друг на друга";
"You don't follow back" = "Вы не подписаны в ответ";
"You follow them, they don't follow back" = "Вы подписаны на них, они — нет";
"You started following" = "Вы подписались";
"You unfollowed" = "Вы отписались";
"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "%@ %lu аккаунт(ов)? Обработаны будут первые %ld, чтобы не упереться в лимиты.";
"%@ %lu accounts? This runs sequentially with a short pause between each." = "%@ %lu аккаунт(ов)? Запускается последовательно с небольшой паузой между каждым.";
"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu аккаунт(ов) · %lu снимок(ов) · нажмите, чтобы просмотреть";
"%lu accounts followed" = "Подписались на %lu";
"%lu accounts unfollowed" = "Отписались от %lu";
"%lu entries across %lu lists · tap to inspect" = "%lu записей в %lu списках · нажмите для просмотра";
"%lu preferences · tap to inspect" = "%lu настроек · нажмите для просмотра";
"(empty)" = "(пусто)";
"(no analyzer data)" = "(нет данных анализатора)";
"(no lists)" = "(списков нет)";
"About Profile Analyzer" = "О разделе «Анализ профиля»";
"All preferences (%lu)" = "Все настройки (%lu)";
"Apply imported data?" = "Применить импортированные данные?";
"Batch follow" = "Массовая подписка";
"Batch follow finished" = "Массовая подписка завершена";
"Batch unfollow" = "Массовая отписка";
"Batch unfollow finished" = "Массовая отписка завершена";
"Continue" = "Продолжить";
"Current snapshot" = "Текущий снимок";
"Embed domains" = "Домены встраивания";
"Excluded lists" = "Списки исключений";
"Excluded story users" = "Исключённые пользователи историй";
"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "Текущие значения выбранной области будут заменены. Приложение, возможно, нужно будет перезапустить, чтобы изменения вступили в силу.";
"Export" = "Экспорт";
"File has no importable sections." = "В файле нет секций для импорта.";
"File is not a valid RyukGram export." = "Файл не является корректным экспортом RyukGram.";
"Filter" = "Фильтр";
"First scan: we collect your followers and following lists and save them locally." = "Первый анализ: собираем ваши списки подписчиков и подписок и сохраняем их локально.";
"Follow %lu" = "Подписаться на %lu";
"Followers" = "Подписчики";
"Following… %lu / %lu" = "Подписка… %lu / %lu";
"Full name" = "Имя";
"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "Внимание: функция в бете и использует приватный API Instagram. Запуск подряд или сразу после активных подписок/отписок может привести к временному лимиту. Используйте с умеренностью и на свой риск.";
"Import complete" = "Импорт завершён";
"Include" = "Включить";
"Included story users" = "Включённые пользователи историй";
"Inspect the full payload" = "Посмотреть полные данные";
"Keep scan history" = "Сохранять историю анализов";
"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "Большие аккаунты заблокированы: анализ отключён при количестве подписчиков больше 13 000, чтобы Instagram не ограничил всё приложение.";
"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "Ничего не загружается — все данные остаются на этом устройстве и могут быть удалены по иконке корзины.";
"Not verified only" = "Только неверифицированные";
"Nothing was applied." = "Ничего не применено.";
"Posts" = "Публикации";
"Preferences" = "Настройки";
"Previous snapshot" = "Предыдущий снимок";
"Private only" = "Только закрытые";
"Profile Analyzer data" = "Данные анализатора профиля";
"Raw" = "Сырое";
"Raw JSON" = "Сырой JSON";
"Reset analyzer data" = "Сбросить данные анализатора";
"Reset complete" = "Сброс завершён";
"Reset selected data?" = "Сбросить выбранные данные?";
"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "Со второго анализа: каждый сравнивается с предыдущим — видно новых/потерянных подписчиков, ваши собственные подписки/отписки и изменения профилей.";
"Select all" = "Выбрать всё";
"Selected data will be cleared. Tap any row to see what's stored." = "Выбранные данные будут удалены. Нажмите любую строку, чтобы увидеть, что сохранено.";
"Settings" = "Настройки";
"Sort" = "Сортировка";
"This can't be undone." = "Отменить это будет нельзя.";
"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "Отметьте, что применить. Нажмите строку, чтобы просмотреть. Секции, которых нет в файле, отключены.";
"Tick what to include. Tap any row to inspect its contents." = "Отметьте, что включить. Нажмите строку, чтобы увидеть её содержимое.";
"Unfollow %lu" = "Отписаться от %lu";
"Unfollowing… %lu / %lu" = "Отписка… %lu / %lu";
"Username A → Z" = "Логин А → Я";
"Username Z → A" = "Логин Я → А";
"Verified only" = "Только верифицированные";
"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "Когда включено, каждый анализ сравнивается с первым — новые/потерянные подписчики и изменения профилей не теряются между анализами.";
//////////////////////////////////////////////////////////////////////////////
// SETTINGS VIEWS & DIALOGS //
// Excluded-lists managers, backup/restore flows, in-picker labels. //
//////////////////////////////////////////////////////////////////////////////
"Add custom domain" = "Добавить свой домен";
"Add chat" = "Добавить чат";
"Add custom domain" = "Добавить свой домен";
"Add preset…" = "Добавить пресет…";
"Add to list?" = "Добавить в список?";
"Add user" = "Добавить пользователя";
"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." = "Все настройки RyukGram будут сброшены к значениям по умолчанию, после чего будут применены импортированные значения. Для применения некоторых изменений потребуется перезапуск приложения.";
"Apply" = "Применить";
"Apply imported settings?" = "Применить импортированные настройки?";
"Apply to" = "Применить к";
"Chats" = "Чаты";
"Could not read file." = "Не удалось прочитать файл.";
@@ -776,57 +1022,49 @@
"Custom" = "Свои";
"Date Format" = "Формат даты";
"Delete" = "Удалить";
"Done editing" = "Завершить редактирование";
"Edit values" = "Редактировать значения";
"Enable fake location" = "Включить поддельное местоположение";
"Enter username" = "Введите имя пользователя";
"Enter username of the DM thread" = "Введите имя пользователя диалога DM";
"Every RyukGram preference will revert to its built-in default. This can't be undone." = "Все параметры RyukGram будут сброшены к встроенным значениям по умолчанию. Это действие нельзя отменить.";
"Excluded chats" = "Исключённые чаты";
"Excluded users" = "Исключённые пользователи";
"File is not a valid RyukGram settings export." = "Файл не является корректным экспортом настроек RyukGram.";
"Follow default" = "Следовать значению по умолчанию";
"Force OFF (allow unsends)" = "Принудительно ВЫКЛ. (разрешить отзыв)";
"Force ON (preserve unsends)" = "Принудительно ВКЛ. (сохранять отозванные)";
"Form view" = "Форма";
"Format" = "Формат";
"Import failed" = "Ошибка импорта";
"Import preview" = "Предпросмотр импорта";
"Included chats" = "Включённые чаты";
"Included users" = "Включённые пользователи";
"KD: ON" = "KD: ВКЛ.";
"KD: default" = "KD: по умолчанию";
"Keep-deleted" = "Keep-deleted";
"KD: ON" = "KD: ВКЛ.";
"Keep-deleted" = "Хранить удалённые";
"Keep-deleted override" = "Переопределение keep-deleted";
"Name (AZ)" = "Имя (А–Я)";
"No DM thread found with @%@" = "Диалог DM с @%@ не найден";
"Off" = "Выкл.";
"On" = "Вкл.";
"Presets" = "Пресеты";
"Raw JSON view" = "Просмотр сырого JSON";
"Remove Selected" = "Удалить выбранное";
"Recently added" = "Недавно добавленные";
"Remove from list" = "Убрать из списка";
"Remove Selected" = "Удалить выбранное";
"Reset" = "Сбросить";
"Reset all settings?" = "Сбросить все настройки?";
"Saved presets are reusable. Tap a preset to make it the active location." = "Сохранённые пресеты можно использовать повторно. Нажмите на пресет, чтобы сделать его активным местоположением.";
"Search" = "Поиск";
"Search address or place" = "Искать адрес или место";
"Search by name or username" = "Поиск по имени или имени пользователя";
"Search by username or name" = "Поиск по имени пользователя или имени";
"Search settings" = "Поиск по настройкам";
"Select" = "Выбрать";
"Select location on map" = "Выбрать местоположение на карте";
"Set current location" = "Установить текущее местоположение";
"Set keep-deleted override" = "Задать переопределение keep-deleted";
"Settings exported" = "Настройки экспортированы";
"Settings imported" = "Настройки импортированы";
"Show map button" = "Показывать кнопку карты";
"Show seconds" = "Показывать секунды";
"Sort by" = "Сортировать по";
"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." = "Переключайте каждый форматтер NSDate, который использует IG. Разные разделы (лента, комментарии, истории, DM) проходят через разные методы — включите те, к которым хотите применить свой формат.";
"User '%@' not found" = "Пользователь '%@' не найден";
"Use this location" = "Использовать это местоположение";
"When on, all CoreLocation requests inside Instagram return the location below." = "Когда включено, все запросы CoreLocation внутри Instagram возвращают местоположение ниже.";
"User '%@' not found" = "Пользователь '%@' не найден";
"Username (AZ)" = "Пользователь (А–Я)";
"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." = "Когда включено, все запросы CoreLocation внутри Instagram возвращают местоположение ниже. Переключите кнопку карты, чтобы показать или скрыть быстрый переключатель на экране Friends Map.";
"Show map button" = "Показывать кнопку карты";
//////////////////////////////////////////////////////////////////////////////
// REELS (FEATURES) //
@@ -860,8 +1098,9 @@
// Anything that didn't fit a named section. Usually short labels. //
//////////////////////////////////////////////////////////////////////////////
"720p • progressive • fastest" = "720p • progressive • fastest";
"720p • progressive • fastest" = "720p • прогрессивный • самый быстрый";
"Are you sure?" = "Вы уверены?";
"Bundle" = "Пакет";
"Copy audio URL" = "Копировать URL аудио";
"Copy quality info" = "Копировать информацию о качестве";
"Copy video URL" = "Копировать URL видео";
@@ -874,17 +1113,14 @@
"Could not extract video url from reel" = "Не удалось извлечь URL видео из рилса";
"Could not extract video url from story" = "Не удалось извлечь URL видео из истории";
"Download Quality" = "Качество загрузки";
"Extras" = "Extras";
"FFmpegKit Debug" = "Отладка FFmpegKit";
"Later" = "Позже";
"No!" = "Нет!";
"OK" = "ОК";
"Restart" = "Перезапустить";
"Localization" = "Локализация";
"Update localization file" = "Обновить файл локализации";
"Import a .strings file for a language" = "Импортировать файл .strings для языка";
"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Импортируйте файл .strings, чтобы обновить перевод. Выберите язык, укажите файл и перезапустите приложение.";
"Export English strings" = "Экспортировать английские строки";
"Share the base English .strings file for translating" = "Поделиться базовым английским файлом .strings для перевода";
"Restart required" = "Требуется перезапуск";
"username" = "имя пользователя";
"Yes" = "Да";
"You must restart the app to apply this change" = "Чтобы применить это изменение, нужно перезапустить приложение";
@@ -893,38 +1129,58 @@
// Strings from the About / Credits footer of Settings. //
//////////////////////////////////////////////////////////////////////////////
"%@ — view source, report issues, see releases" = "%@ — посмотреть исходники, сообщить о проблемах, посмотреть релизы";
"%@ — GitHub & Telegram" = "%@ — GitHub и Telegram";
"About" = "О программе";
"Arabic translation" = "Арабский перевод";
"Chinese (Traditional) translation" = "Китайский (традиционный) перевод";
"Credits" = "Благодарности";
"Developer" = "Разработчик";
"Developers" = "Разработчики";
"Donate to SoCuul" = "Поддержать SoCuul";
"installed" = "установлено";
"Korean translation" = "Корейский перевод";
"latest" = "последняя";
"Links" = "Ссылки";
"No releases" = "Нет выпусков";
"Original SCInsta developer" = "Оригинальный разработчик SCInsta";
"Ryuk" = "Ryuk";
"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "RyukGram %@\n\nInstagram v%@\n\nОсновано на SCInsta от SoCuul";
"RyukGram on GitHub" = "RyukGram на GitHub";
"SoCuul" = "SoCuul";
"Release notes" = "Примечания к выпуску";
"Releases" = "Выпуски";
"Report an issue" = "Сообщить о проблеме";
"Russian translation" = "Русский перевод";
"RyukGram developer" = "Разработчик RyukGram";
"Join Telegram channel" = "Присоединиться к Telegram-каналу";
"View on GitHub" = "Открыть на GitHub";
"Source code" = "Исходный код";
"Spanish translation" = "Испанский перевод";
"Support the original developer" = "Поддержать оригинального разработчика";
"View Repo" = "Открыть репозиторий";
"View the source code on GitHub" = "Посмотреть исходный код на GitHub";
"Telegram channel" = "Telegram-канал";
"Testing and feature suggestions" = "Тестирование и предложения функций";
"Tweak settings" = "Настройки твика";
"Version" = "Версия";
"Version, credits, and links" = "Версия, благодарности и ссылки";
"What's new in RyukGram" = "Что нового в RyukGram";
//////////////////////////////////////////////////////////////////////////////
// HD DOWNLOADS //
// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). //
//////////////////////////////////////////////////////////////////////////////
"720p • progressive • silent" = "720p • прогрессивный • без звука";
"Audio extract failed" = "Не удалось извлечь аудио";
"Audio only" = "Только аудио";
"Audio ready" = "Аудио готово";
"Download video at the highest available quality" = "Скачивать видео в максимально доступном качестве";
"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "Скачивает HD-видео через DASH-потоки и кодирует в H.264. Требуется FFmpegKit.";
"Encoding speed" = "Скорость кодирования";
"Enhanced downloads" = "Расширенные загрузки";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit недоступен. Сайдлоуните IPA или установите файл .deb с _ffmpeg, чтобы включить эту функцию.";
"Faster = lower quality" = "Быстрее = ниже качество";
"FFmpeg not available" = "FFmpeg недоступен";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit недоступен. Сайдлоуните IPA или установите файл .deb с _ffmpeg, чтобы включить эту функцию.";
"No audio stream available" = "Аудиопоток недоступен";
"No audio track found" = "Аудиодорожка не найдена";
"Photo" = "Фото";
"Photo quality" = "Качество фото";
"Raw image (no audio, no video)" = "Исходное изображение (без аудио и видео)";
"silent" = "без звука";
"Use highest resolution available" = "Использовать максимально доступное разрешение";
"Video quality" = "Качество видео";
"Which quality to download" = "Какое качество скачивать";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL / DEBUG //
// Placeholder rows only shown in the experimental settings sandbox. //
//////////////////////////////////////////////////////////////////////////////
"Navigation Cell" = "Ячейка навигации";
File diff suppressed because it is too large Load Diff
+3 -37
View File
@@ -1,6 +1,7 @@
// Reusable IG private API helper. See SCIInstagramAPI.h.
#import "SCIInstagramAPI.h"
#import "../Utils.h"
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <objc/message.h>
@@ -32,43 +33,8 @@ static NSString *sciUserAgent(void) {
// ============ 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;
}
static id sciCurrentUserSession(void) { return [SCIUtils activeUserSession]; }
static NSString *sciCurrentUserPK(void) { return [SCIUtils currentUserPK]; }
// Bearer token for the active account, read fresh from
// -[IGUserSession authHeaderManager] -> -[IGUserAuthHeaderManager authHeader].
+66
View File
@@ -0,0 +1,66 @@
// Capture-aware chrome primitives. SCIChromeCanvas handles redaction via
// the UITextField secure-canvas technique; SCIChromeButton / SCIChromeLabel
// own the full visible hierarchy so IG's liquid glass can't wrap them.
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
// MARK: - SCIChromeCanvas
@interface SCIChromeCanvas : UIView
@property (nonatomic, readonly) UIView *contentContainer;
@end
// MARK: - SCIChromeButton
@interface SCIChromeButton : UIButton
- (instancetype)initWithSymbol:(NSString *)symbol
pointSize:(CGFloat)pointSize
diameter:(CGFloat)diameter NS_DESIGNATED_INITIALIZER;
@property (nonatomic, assign, readonly) CGFloat diameter;
@property (nonatomic, copy) NSString *symbolName;
@property (nonatomic, assign) CGFloat symbolPointSize;
@property (nonatomic, copy) UIColor *iconTint;
@property (nonatomic, copy) UIColor *bubbleColor;
// Set `.image` for custom/baked images that the symbol API can't produce.
@property (nonatomic, strong, readonly) UIImageView *iconView;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
@end
// MARK: - SCIChromeLabel
@interface SCIChromeLabel : UIView
- (instancetype)initWithText:(NSString *)text NS_DESIGNATED_INITIALIZER;
@property (nonatomic, copy) NSString *text;
@property (nonatomic, strong) UIFont *font;
@property (nonatomic, strong) UIColor *textColor;
@property (nonatomic, assign) NSTextAlignment textAlignment;
- (instancetype)init NS_UNAVAILABLE;
- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE;
- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE;
@end
#ifdef __cplusplus
extern "C" {
#endif
// Bar button item whose customView is an SCIChromeButton. `outButton` yields
// the inner button for menu/tint/etc.
UIBarButtonItem *SCIChromeBarButtonItem(NSString *symbol,
CGFloat pointSize,
id _Nullable target,
SEL _Nullable action,
SCIChromeButton * _Nullable * _Nullable outButton);
SCIChromeButton * _Nullable SCIChromeButtonForBarItem(UIBarButtonItem *item);
#ifdef __cplusplus
}
#endif
NS_ASSUME_NONNULL_END
+279
View File
@@ -0,0 +1,279 @@
#import "SCIChrome.h"
#import "Utils.h"
#import "SCIPrefObserver.h"
// MARK: - Canvas discovery
static UIView *sciFindCanvasDeep(UIView *root, int depth) {
if (depth > 4) return nil;
for (UIView *sub in root.subviews) {
if ([NSStringFromClass([sub class]) containsString:@"CanvasView"]) return sub;
UIView *found = sciFindCanvasDeep(sub, depth + 1);
if (found) return found;
}
return nil;
}
// MARK: - SCIChromeCanvas
@interface SCIChromeCanvas ()
@property (nonatomic, strong) UITextField *secureField;
@property (nonatomic, strong, nullable) UIView *canvas;
@end
@implementation SCIChromeCanvas
+ (NSHashTable<SCIChromeCanvas *> *)instances {
static NSHashTable *t;
static dispatch_once_t once;
dispatch_once(&once, ^{ t = [NSHashTable weakObjectsHashTable]; });
return t;
}
+ (void)ensureObserverInstalled {
static dispatch_once_t once;
dispatch_once(&once, ^{
[SCIPrefObserver observeKey:@"hide_ui_on_capture" handler:^{
for (SCIChromeCanvas *v in [SCIChromeCanvas instances]) [v applyPref];
}];
});
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[SCIChromeCanvas ensureObserverInstalled];
self.translatesAutoresizingMaskIntoConstraints = NO;
_secureField = [UITextField new];
_secureField.userInteractionEnabled = NO;
_secureField.autocorrectionType = UITextAutocorrectionTypeNo;
_secureField.spellCheckingType = UITextSpellCheckingTypeNo;
_secureField.smartDashesType = UITextSmartDashesTypeNo;
_secureField.smartQuotesType = UITextSmartQuotesTypeNo;
_secureField.smartInsertDeleteType = UITextSmartInsertDeleteTypeNo;
_secureField.autocapitalizationType = UITextAutocapitalizationTypeNone;
[self applyPref];
[[SCIChromeCanvas instances] addObject:self];
[self attachCanvasIfPossible];
}
return self;
}
- (UIView *)contentContainer { return self.canvas ?: self; }
- (void)applyPref {
BOOL on = [SCIUtils getBoolPref:@"hide_ui_on_capture"];
if (self.secureField.secureTextEntry != on) self.secureField.secureTextEntry = on;
}
- (void)didMoveToWindow { [super didMoveToWindow]; [self attachCanvasIfPossible]; }
- (void)layoutSubviews { [super layoutSubviews]; [self attachCanvasIfPossible]; }
- (void)attachCanvasIfPossible {
if (self.canvas && self.canvas.superview == self) return;
[self.secureField layoutIfNeeded];
UIView *c = sciFindCanvasDeep(self.secureField, 0);
if (!c) return;
// Migrate anything that landed on self (contentContainer fallback) into
// the canvas so redaction covers it.
NSMutableArray<UIView *> *stashed = [NSMutableArray array];
for (UIView *sub in self.subviews) {
if (sub != c) [stashed addObject:sub];
}
[c removeFromSuperview];
[self insertSubview:c atIndex:0];
c.translatesAutoresizingMaskIntoConstraints = NO;
[NSLayoutConstraint activateConstraints:@[
[c.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[c.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[c.topAnchor constraintEqualToAnchor:self.topAnchor],
[c.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
]];
self.canvas = c;
for (UIView *v in stashed) {
[v removeFromSuperview];
[c addSubview:v];
}
}
@end
// MARK: - SCIChromeButton
@interface SCIChromeButton ()
@property (nonatomic, strong) SCIChromeCanvas *chromeCanvas;
@property (nonatomic, strong) UIView *bubbleView;
@property (nonatomic, strong, readwrite) UIImageView *iconView;
@end
@implementation SCIChromeButton
- (instancetype)initWithSymbol:(NSString *)symbol
pointSize:(CGFloat)pointSize
diameter:(CGFloat)diameter {
self = [super initWithFrame:CGRectMake(0, 0, diameter, diameter)];
if (self) {
_diameter = diameter;
_symbolName = [symbol copy];
_symbolPointSize = pointSize;
_iconTint = [UIColor whiteColor];
_bubbleColor = [UIColor colorWithWhite:0.0 alpha:0.4];
[self buildChrome];
}
return self;
}
- (void)buildChrome {
self.adjustsImageWhenHighlighted = NO;
self.translatesAutoresizingMaskIntoConstraints = NO;
_chromeCanvas = [SCIChromeCanvas new];
_chromeCanvas.userInteractionEnabled = NO;
[self addSubview:_chromeCanvas];
[NSLayoutConstraint activateConstraints:@[
[_chromeCanvas.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[_chromeCanvas.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[_chromeCanvas.topAnchor constraintEqualToAnchor:self.topAnchor],
[_chromeCanvas.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
]];
_bubbleView = [UIView new];
_bubbleView.userInteractionEnabled = NO;
_bubbleView.translatesAutoresizingMaskIntoConstraints = NO;
_bubbleView.backgroundColor = _bubbleColor;
_bubbleView.layer.cornerRadius = _diameter / 2;
_bubbleView.clipsToBounds = YES;
_iconView = [UIImageView new];
_iconView.userInteractionEnabled = NO;
_iconView.contentMode = UIViewContentModeCenter;
_iconView.translatesAutoresizingMaskIntoConstraints = NO;
_iconView.tintColor = _iconTint;
[self reloadIcon];
UIView *host = _chromeCanvas.contentContainer;
[host addSubview:_bubbleView];
[host addSubview:_iconView];
[NSLayoutConstraint activateConstraints:@[
[_bubbleView.leadingAnchor constraintEqualToAnchor:host.leadingAnchor],
[_bubbleView.trailingAnchor constraintEqualToAnchor:host.trailingAnchor],
[_bubbleView.topAnchor constraintEqualToAnchor:host.topAnchor],
[_bubbleView.bottomAnchor constraintEqualToAnchor:host.bottomAnchor],
[_iconView.centerXAnchor constraintEqualToAnchor:host.centerXAnchor],
[_iconView.centerYAnchor constraintEqualToAnchor:host.centerYAnchor],
]];
}
- (CGSize)intrinsicContentSize { return CGSizeMake(_diameter, _diameter); }
- (void)setSymbolName:(NSString *)symbolName {
_symbolName = [symbolName copy];
[self reloadIcon];
}
- (void)setSymbolPointSize:(CGFloat)symbolPointSize {
_symbolPointSize = symbolPointSize;
[self reloadIcon];
}
- (void)setIconTint:(UIColor *)iconTint {
_iconTint = [iconTint copy];
_iconView.tintColor = iconTint;
}
- (void)setBubbleColor:(UIColor *)bubbleColor {
_bubbleColor = [bubbleColor copy];
_bubbleView.backgroundColor = bubbleColor;
}
- (void)reloadIcon {
if (!_symbolName.length) { _iconView.image = nil; return; }
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:_symbolPointSize
weight:UIImageSymbolWeightSemibold];
_iconView.image = [UIImage systemImageNamed:_symbolName withConfiguration:cfg];
}
- (void)layoutSubviews {
[super layoutSubviews];
// Keep the bubble circular when the caller resizes via constraints.
CGFloat r = MIN(self.bounds.size.width, self.bounds.size.height) / 2;
_bubbleView.layer.cornerRadius = r;
}
@end
// MARK: - SCIChromeLabel
@interface SCIChromeLabel ()
@property (nonatomic, strong) SCIChromeCanvas *chromeCanvas;
@property (nonatomic, strong) UILabel *label;
@end
@implementation SCIChromeLabel
- (instancetype)initWithText:(NSString *)text {
self = [super initWithFrame:CGRectZero];
if (self) {
self.translatesAutoresizingMaskIntoConstraints = NO;
_chromeCanvas = [SCIChromeCanvas new];
_chromeCanvas.userInteractionEnabled = NO;
[self addSubview:_chromeCanvas];
[NSLayoutConstraint activateConstraints:@[
[_chromeCanvas.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
[_chromeCanvas.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
[_chromeCanvas.topAnchor constraintEqualToAnchor:self.topAnchor],
[_chromeCanvas.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
]];
_label = [UILabel new];
_label.translatesAutoresizingMaskIntoConstraints = NO;
_label.text = text;
UIView *host = _chromeCanvas.contentContainer;
[host addSubview:_label];
[NSLayoutConstraint activateConstraints:@[
[_label.leadingAnchor constraintEqualToAnchor:host.leadingAnchor],
[_label.trailingAnchor constraintEqualToAnchor:host.trailingAnchor],
[_label.topAnchor constraintEqualToAnchor:host.topAnchor],
[_label.bottomAnchor constraintEqualToAnchor:host.bottomAnchor],
]];
}
return self;
}
- (NSString *)text { return _label.text; }
- (void)setText:(NSString *)t { _label.text = t; }
- (UIFont *)font { return _label.font; }
- (void)setFont:(UIFont *)f { _label.font = f; }
- (UIColor *)textColor { return _label.textColor; }
- (void)setTextColor:(UIColor *)c { _label.textColor = c; }
- (NSTextAlignment)textAlignment { return _label.textAlignment; }
- (void)setTextAlignment:(NSTextAlignment)a { _label.textAlignment = a; }
@end
// MARK: - Bar button helpers
UIBarButtonItem *SCIChromeBarButtonItem(NSString *symbol,
CGFloat pointSize,
id target,
SEL action,
SCIChromeButton **outButton) {
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:symbol
pointSize:pointSize
diameter:28];
btn.bubbleColor = [UIColor clearColor];
if (target && action) [btn addTarget:target action:action forControlEvents:UIControlEventTouchUpInside];
if (outButton) *outButton = btn;
return [[UIBarButtonItem alloc] initWithCustomView:btn];
}
SCIChromeButton *SCIChromeButtonForBarItem(UIBarButtonItem *item) {
UIView *v = item.customView;
return [v isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)v : nil;
}
+159 -16
View File
@@ -5,14 +5,17 @@
@implementation SCIDashRepresentation
@end
// Resolve _fieldCache per class (walking the hierarchy). Caching the ivar
// against IGAPIStorableObject and then reading that offset from an unrelated
// class like IGVideo segfaults — ivar offsets aren't shared.
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 (!obj || !key.length) return nil;
Ivar fcIvar = NULL;
@try {
for (Class c = [obj class]; c && !fcIvar; c = class_getSuperclass(c)) {
fcIvar = class_getInstanceVariable(c, "_fieldCache");
}
} @catch (__unused id e) { return nil; }
if (!fcIvar) return nil;
id fc = nil;
@try { fc = object_getIvar(obj, fcIvar); } @catch (__unused id e) { return nil; }
@@ -24,30 +27,170 @@ static id sciDashFieldCache(id obj, NSString *key) {
@implementation SCIDashParser
// Looks like XML DASH manifest or a URL to one.
static BOOL sciLooksLikeManifest(id val) {
if (![val isKindOfClass:[NSString class]]) return NO;
NSString *s = (NSString *)val;
if (s.length < 10) return NO;
NSString *head = [s substringToIndex:MIN((NSUInteger)16, s.length)];
return [head containsString:@"<MPD"] || [head containsString:@"<?xml"]
|| [head hasPrefix:@"http"];
}
// Walk a fieldCache dict looking for any key containing "dash" or "manifest".
static NSString *sciScanDictForManifest(NSDictionary *dict, NSString *path, int depth) {
if (depth > 3 || ![dict isKindOfClass:[NSDictionary class]]) return nil;
for (NSString *k in dict) {
id v = dict[k];
NSString *lk = k.lowercaseString;
if (([lk containsString:@"dash"] || [lk containsString:@"manifest"]) && sciLooksLikeManifest(v)) {
NSLog(@"[SCInsta][Dash] hit %@/%@ (len=%lu)", path, k, (unsigned long)[(NSString *)v length]);
return v;
}
if ([v isKindOfClass:[NSDictionary class]]) {
NSString *found = sciScanDictForManifest(v, [NSString stringWithFormat:@"%@/%@", path, k], depth + 1);
if (found) return found;
} else if ([v isKindOfClass:[NSArray class]]) {
for (id item in (NSArray *)v) {
if ([item isKindOfClass:[NSDictionary class]]) {
NSString *found = sciScanDictForManifest(item, [NSString stringWithFormat:@"%@/%@[]", path, k], depth + 1);
if (found) return found;
}
}
}
}
return nil;
}
static NSDictionary *sciFieldCacheDict(id obj) {
if (!obj) return nil;
Ivar fcIvar = NULL;
@try {
for (Class c = [obj class]; c && !fcIvar; c = class_getSuperclass(c)) {
fcIvar = class_getInstanceVariable(c, "_fieldCache");
}
} @catch (__unused id e) { return nil; }
if (!fcIvar) return nil;
id fc = nil;
@try { fc = object_getIvar(obj, fcIvar); } @catch (__unused id e) { return nil; }
return [fc isKindOfClass:[NSDictionary class]] ? fc : nil;
}
// Coerce an arbitrary object (NSString or NSData) into a manifest string.
static NSString *sciToManifestString(id val) {
if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10) return val;
if ([val isKindOfClass:[NSData class]] && [(NSData *)val length] > 10) {
NSString *s = [[NSString alloc] initWithData:(NSData *)val encoding:NSUTF8StringEncoding];
if (s.length > 10) return s;
}
return nil;
}
+ (NSString *)dashManifestForMedia:(id)media {
if (!media) return nil;
NSArray *keys = @[@"video_dash_manifest", @"dash_manifest",
@"video_dash_manifest_url", @"dash_manifest_url"];
// Direct hits on the media's fieldCache (older builds).
for (NSString *key in keys) {
id val = sciDashFieldCache(media, key);
if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10)
return val;
if (sciLooksLikeManifest(val)) return val;
}
// IGBaseMedia -videoDashManifest (used through IG v440ish).
@try {
if ([media respondsToSelector:@selector(videoDashManifest)]) {
id val = ((id(*)(id, SEL))objc_msgSend)(media, @selector(videoDashManifest));
NSString *str = sciToManifestString(val);
if (sciLooksLikeManifest(str)) return str;
}
} @catch (__unused id e) {}
// Nested IGVideo — both fieldCache + the new -dashManifestData NSData getter.
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;
}
@try {
if ([media respondsToSelector:@selector(video)]) {
video = ((id(*)(id, SEL))objc_msgSend)(media, @selector(video));
}
} @catch (__unused id e) { 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;
if (sciLooksLikeManifest(val)) return val;
}
@try {
if ([video respondsToSelector:@selector(dashManifestData)]) {
id val = ((id(*)(id, SEL))objc_msgSend)(video, @selector(dashManifestData));
NSString *str = sciToManifestString(val);
if (sciLooksLikeManifest(str)) return str;
}
} @catch (__unused id e) {}
// Direct ivar read as last resort (handles future property removals).
@try {
Ivar iv = NULL;
for (Class c = [video class]; c && !iv; c = class_getSuperclass(c))
iv = class_getInstanceVariable(c, "_dashManifestData");
if (iv) {
id val = object_getIvar(video, iv);
NSString *str = sciToManifestString(val);
if (sciLooksLikeManifest(str)) return str;
}
} @catch (__unused id e) {}
}
// Wider scan: walk the fieldCache dict recursively for any key containing
// "dash" or "manifest".
NSDictionary *fc = sciFieldCacheDict(media);
if (fc) {
NSString *found = sciScanDictForManifest(fc, @"fieldCache", 0);
if (found) return found;
// Last-ditch manifest hunt + dump via iterative stack (no recursion,
// no block self-capture).
NSMutableArray *stack = [NSMutableArray arrayWithObject:@[fc, @"fieldCache", @(0)]];
NSString *bigManifest = nil;
NSString *bigManifestPath = nil;
NSMutableArray *longStrings = [NSMutableArray array];
while (stack.count) {
NSArray *frame = stack.lastObject; [stack removeLastObject];
id obj = frame[0];
NSString *path = frame[1];
int depth = [frame[2] intValue];
if (depth > 4) continue;
if ([obj isKindOfClass:[NSDictionary class]]) {
for (NSString *k in obj) {
[stack addObject:@[obj[k], [NSString stringWithFormat:@"%@/%@", path, k], @(depth + 1)]];
}
} else if ([obj isKindOfClass:[NSArray class]]) {
NSUInteger i = 0;
for (id item in obj) {
[stack addObject:@[item, [NSString stringWithFormat:@"%@[%lu]", path, (unsigned long)i++], @(depth + 1)]];
}
} else if ([obj isKindOfClass:[NSString class]]) {
NSString *s = obj;
if (s.length > 300) {
NSString *head = [s substringToIndex:MIN((NSUInteger)32, s.length)];
if (!bigManifest && ([head containsString:@"<MPD"] || [head containsString:@"<?xml"])) {
bigManifest = s;
bigManifestPath = path;
}
if (s.length > 200) [longStrings addObject:@[path, @(s.length), [s substringToIndex:MIN((NSUInteger)120, s.length)]]];
}
}
}
if (bigManifest) {
NSLog(@"[SCInsta][Dash] found manifest at %@ (len=%lu)", bigManifestPath, (unsigned long)bigManifest.length);
return bigManifest;
}
static dispatch_once_t once;
dispatch_once(&once, ^{
NSLog(@"[SCInsta][Dash] no manifest found; top-level keys=%@", [[fc allKeys] componentsJoinedByString:@","]);
for (NSArray *row in longStrings) {
NSLog(@"[SCInsta][Dash] long-str %@ (len=%@) head=%@", row[0], row[1], row[2]);
}
});
}
return nil;
+5 -1
View File
@@ -1,4 +1,5 @@
#import "SCIFFmpeg.h"
#import "ActionButton/SCIMediaActions.h"
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <objc/message.h>
@@ -396,12 +397,15 @@ static void sciLoadFFmpegKit(void) {
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSString *tmpDir = NSTemporaryDirectory();
// Intermediates stay UUID-named; the muxed output uses the stem.
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 *outStem = [SCIMediaActions currentFilenameStem]
?: [NSString stringWithFormat:@"sci_muxed_%@", [[NSUUID UUID] UUIDString]];
NSString *outputPath = [tmpDir stringByAppendingPathComponent:
[NSString stringWithFormat:@"sci_muxed_%@.mp4", [[NSUUID UUID] UUIDString]]];
[NSString stringWithFormat:@"%@.mp4", outStem]];
NSError *(^cancelledError)(void) = ^NSError *{
return [NSError errorWithDomain:@"SCIFFmpeg" code:NSUserCancelledError
+10
View File
@@ -0,0 +1,10 @@
#import <UIKit/UIKit.h>
// Memory + disk image cache for remote URLs. Completion runs on main queue.
// Disk cache lives under Library/Caches/RyukGramImages and survives reinstall
// so long as Caches isn't wiped.
@interface SCIImageCache : NSObject
+ (void)loadImageFromURL:(NSURL *)url completion:(void (^)(UIImage *_Nullable image))completion;
@end
+70
View File
@@ -0,0 +1,70 @@
#import "SCIImageCache.h"
#import <CommonCrypto/CommonDigest.h>
static NSCache *memCache(void) {
static NSCache *c;
static dispatch_once_t once;
dispatch_once(&once, ^{
c = [NSCache new];
c.countLimit = 64;
});
return c;
}
static NSString *diskDir(void) {
static NSString *dir;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSString *base = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
dir = [base stringByAppendingPathComponent:@"RyukGramImages"];
[[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
});
return dir;
}
static NSString *hashKey(NSString *urlString) {
const char *cstr = urlString.UTF8String;
unsigned char hash[CC_SHA1_DIGEST_LENGTH];
CC_SHA1(cstr, (CC_LONG)strlen(cstr), hash);
NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 2];
for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", hash[i]];
return hex;
}
@implementation SCIImageCache
+ (void)loadImageFromURL:(NSURL *)url completion:(void (^)(UIImage *))completion {
if (!url || !completion) return;
NSString *key = url.absoluteString;
void (^deliver)(UIImage *) = ^(UIImage *image) {
dispatch_async(dispatch_get_main_queue(), ^{ completion(image); });
};
UIImage *hit = [memCache() objectForKey:key];
if (hit) { deliver(hit); return; }
NSString *path = [diskDir() stringByAppendingPathComponent:hashKey(key)];
NSFileManager *fm = [NSFileManager defaultManager];
if ([fm fileExistsAtPath:path]) {
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
NSData *data = [NSData dataWithContentsOfFile:path];
UIImage *image = data ? [UIImage imageWithData:data] : nil;
if (image) [memCache() setObject:image forKey:key];
deliver(image);
});
return;
}
[[[NSURLSession sharedSession] dataTaskWithURL:url
completionHandler:^(NSData *data, NSURLResponse *_r, NSError *_e) {
UIImage *image = data ? [UIImage imageWithData:data] : nil;
if (image) {
[memCache() setObject:image forKey:key];
[data writeToFile:path atomically:YES];
}
deliver(image);
}] resume];
}
@end
+14
View File
@@ -0,0 +1,14 @@
#import <Foundation/Foundation.h>
// KVO on a single NSUserDefaults key. Handler runs on main queue.
// App-lifetime observer — no teardown.
//
// Usage:
// [SCIPrefObserver observeKey:@"my_pref_key" handler:^{
// // main queue — do the reflect work here
// }];
@interface SCIPrefObserver : NSObject
+ (void)observeKey:(NSString *)key handler:(void (^)(void))handler;
@end
+50
View File
@@ -0,0 +1,50 @@
#import "SCIPrefObserver.h"
@interface SCIPrefObserver ()
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSMutableArray *> *handlers;
@end
@implementation SCIPrefObserver
+ (instancetype)shared {
static SCIPrefObserver *s;
static dispatch_once_t once;
dispatch_once(&once, ^{
s = [SCIPrefObserver new];
s.handlers = [NSMutableDictionary dictionary];
});
return s;
}
+ (void)observeKey:(NSString *)key handler:(void (^)(void))handler {
if (!key.length || !handler) return;
SCIPrefObserver *s = [self shared];
@synchronized (s) {
NSMutableArray *arr = s.handlers[key];
if (!arr) {
arr = [NSMutableArray array];
s.handlers[key] = arr;
[[NSUserDefaults standardUserDefaults] addObserver:s
forKeyPath:key
options:0
context:NULL];
}
[arr addObject:[handler copy]];
}
}
- (void)observeValueForKeyPath:(NSString *)keyPath
ofObject:(id)object
change:(NSDictionary *)change
context:(void *)context {
NSArray *snapshot;
@synchronized (self) { snapshot = [self.handlers[keyPath] copy]; }
if (!snapshot.count) return;
dispatch_block_t run = ^{
for (void (^h)(void) in snapshot) h();
};
if ([NSThread isMainThread]) run();
else dispatch_async(dispatch_get_main_queue(), run);
}
@end
+5 -2
View File
@@ -2,13 +2,16 @@
#import <UIKit/UIKit.h>
#import "SCIDashParser.h"
#import "Downloader/Download.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).
/// Show quality picker or auto-pick based on prefs. Returns NO if enhanced
/// downloads are off or no DASH manifest is found (calls fallback).
/// `action` is passed through to the Audio / Photo rows inside the sheet.
+ (BOOL)pickQualityForMedia:(id)media
fromView:(UIView *)sourceView
action:(DownloadAction)action
picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked
fallback:(void(^)(void))fallback;
+86 -7
View File
@@ -2,6 +2,7 @@
#import "SCIFFmpeg.h"
#import "Utils.h"
#import "InstagramHeaders.h"
#import "ActionButton/SCIMediaActions.h"
#import <AVFoundation/AVFoundation.h>
#import <AVKit/AVKit.h>
#import <objc/message.h>
@@ -105,7 +106,11 @@
@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, strong) NSURL *standardURL;
@property (nonatomic, strong) id mediaRef;
@property (nonatomic, assign) DownloadAction saveAction;
@property (nonatomic, assign) BOOL hasAudio;
@property (nonatomic, strong) NSURL *photoURL;
@property (nonatomic, copy) void (^onPickStandard)(void);
@property (nonatomic, copy) void (^onPickHD)(SCIDashRepresentation *video, SCIDashRepresentation *audio);
@end
@@ -168,26 +173,52 @@
- (void)dismiss { [self dismissViewControllerAnimated:YES completion:nil]; }
// MARK: - Table
// Sections: Standard, HD, optional Audio, optional Extras (photo). Audio
// appears before Extras when both are present.
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; }
- (BOOL)_hasExtrasSection { return self.photoURL != nil; }
- (BOOL)_hasAudioSection { return self.audioRep.url != nil; }
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv {
NSInteger n = 2;
if ([self _hasExtrasSection]) n++;
if ([self _hasAudioSection]) n++;
return n;
}
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return section == 0 ? 1 : (NSInteger)self.videoReps.count;
if (section == 0) return 1;
if (section == 1) return (NSInteger)self.videoReps.count;
return 1;
}
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
return section == 0 ? @"Standard" : @"HD";
if (section == 0) return @"Standard";
if (section == 1) return @"HD";
if (section == 2 && [self _hasAudioSection]) return SCILocalized(@"Audio");
return SCILocalized(@"Extras");
}
- (UIImage *)_playIconSilent:(BOOL)silent {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
NSString *name = silent ? @"play.slash.fill" : @"play.fill";
return [UIImage systemImageNamed:name withConfiguration:cfg];
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
_SCIQualityCell *cell = [tv dequeueReusableCellWithIdentifier:@"q" forIndexPath:ip];
[cell setLoading:NO];
BOOL silent = !self.hasAudio;
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.subtitleLabel.text = silent
? SCILocalized(@"720p • progressive • silent")
: SCILocalized(@"720p • progressive • fastest");
cell.playButton.hidden = (self.standardURL == nil);
cell.menuButton.hidden = (self.standardURL == nil);
[cell.playButton setImage:[self _playIconSilent:silent] forState:UIControlStateNormal];
cell.accessoryType = UITableViewCellAccessoryNone;
cell.playButton.tag = -1;
@@ -195,11 +226,12 @@
[cell.playButton addTarget:self action:@selector(playStandardPreview:) forControlEvents:UIControlEventTouchUpInside];
cell.menuButton.menu = [self menuForStandard];
} else {
} else if (ip.section == 1) {
SCIDashRepresentation *rep = self.videoReps[ip.row];
cell.accessoryType = UITableViewCellAccessoryNone;
cell.playButton.hidden = NO;
cell.menuButton.hidden = NO;
[cell.playButton setImage:[self _playIconSilent:silent] forState:UIControlStateNormal];
NSString *label = rep.qualityLabel ?: @"";
if (rep.height > 0) {
@@ -222,6 +254,7 @@
NSString *codec = [[rep.codecs componentsSeparatedByString:@"."] firstObject] ?: rep.codecs;
[parts addObject:codec];
}
if (silent) [parts addObject:SCILocalized(@"silent")];
cell.subtitleLabel.text = [parts componentsJoinedByString:@""];
cell.playButton.tag = ip.row;
@@ -229,6 +262,35 @@
[cell.playButton addTarget:self action:@selector(playPreview:) forControlEvents:UIControlEventTouchUpInside];
cell.menuButton.menu = [self menuForRow:ip.row videoRep:rep];
} else {
BOOL isAudio = (ip.section == 2 && [self _hasAudioSection]);
BOOL isPhoto = !isAudio;
cell.accessoryType = UITableViewCellAccessoryNone;
cell.playButton.hidden = NO;
cell.menuButton.hidden = YES;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
if (isPhoto) {
cell.titleLabel.text = SCILocalized(@"Photo");
cell.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
cell.subtitleLabel.text = SCILocalized(@"Raw image (no audio, no video)");
[cell.playButton setImage:[UIImage systemImageNamed:@"photo" withConfiguration:cfg] forState:UIControlStateNormal];
} else if (isAudio) {
cell.titleLabel.text = SCILocalized(@"Audio only");
cell.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
NSString *codec = self.audioRep.codecs.length
? [[self.audioRep.codecs componentsSeparatedByString:@"."] firstObject]
: @"m4a";
NSString *bw = self.audioRep.bandwidth > 0
? [NSString stringWithFormat:@"%ld Kbps", (long)(self.audioRep.bandwidth / 1000)]
: @"";
cell.subtitleLabel.text = [@[codec, bw] componentsJoinedByString:@""];
[cell.playButton setImage:[UIImage systemImageNamed:@"music.note" withConfiguration:cfg] forState:UIControlStateNormal];
}
cell.playButton.tag = -2;
[cell.playButton removeTarget:self action:NULL forControlEvents:UIControlEventTouchUpInside];
}
return cell;
}
@@ -298,9 +360,16 @@
[self dismissViewControllerAnimated:YES completion:^{
if (ip.section == 0) {
if (self.onPickStandard) self.onPickStandard();
} else {
} else if (ip.section == 1) {
SCIDashRepresentation *rep = self.videoReps[ip.row];
if (self.onPickHD) self.onPickHD(rep, self.audioRep);
} else {
BOOL isAudio = (ip.section == 2 && [self _hasAudioSection]);
if (isAudio) {
[SCIMediaActions downloadAudioOnlyForMedia:self.mediaRef action:self.saveAction];
} else if (self.photoURL) {
[SCIMediaActions downloadPhotoOnlyForMedia:self.mediaRef action:self.saveAction];
}
}
}];
}
@@ -400,6 +469,7 @@
+ (BOOL)pickQualityForMedia:(id)media
fromView:(UIView *)sourceView
action:(DownloadAction)action
picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked
fallback:(void(^)(void))fallback {
if (!media) { if (fallback) fallback(); return NO; }
@@ -427,6 +497,8 @@
[self showSheetWithVideoReps:videoReps
audioRep:audioRep
standardURL:standardURL
media:media
action:action
picked:picked
fallback:fallback];
} else {
@@ -443,6 +515,8 @@
+ (void)showSheetWithVideoReps:(NSArray<SCIDashRepresentation *> *)videoReps
audioRep:(SCIDashRepresentation *)audioRep
standardURL:(NSURL *)standardURL
media:(id)media
action:(DownloadAction)action
picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked
fallback:(void(^)(void))fallback {
dispatch_async(dispatch_get_main_queue(), ^{
@@ -450,6 +524,11 @@
vc.videoReps = videoReps;
vc.audioRep = audioRep;
vc.standardURL = standardURL;
vc.mediaRef = media;
vc.saveAction = action;
// DASH truth: audio exists iff the manifest parsed an audio rep.
vc.hasAudio = (audioRep.url != nil);
vc.photoURL = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
vc.onPickStandard = fallback;
vc.onPickHD = picked;
+11
View File
@@ -0,0 +1,11 @@
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
// Read-only searchable key/value list.
// Sections: [ { "title": ..., "rows": [ { "title": ..., "value": ... }, ... ] } ]
@interface SCIBackupDetailVC : UIViewController
- (instancetype)initWithTitle:(NSString *)title sections:(NSArray<NSDictionary *> *)sections;
@end
NS_ASSUME_NONNULL_END
+109
View File
@@ -0,0 +1,109 @@
#import "SCIBackupDetailVC.h"
#import "SCISearchBarStyler.h"
#import "../Utils.h"
#import "../Localization/SCILocalization.h"
@interface SCIBackupDetailVC () <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating, UISearchControllerDelegate>
@property (nonatomic, copy) NSArray<NSDictionary *> *allSections;
@property (nonatomic, copy) NSArray<NSDictionary *> *visibleSections;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UISearchController *searchController;
@end
@implementation SCIBackupDetailVC
- (instancetype)initWithTitle:(NSString *)title sections:(NSArray<NSDictionary *> *)sections {
self = [super init];
if (!self) return self;
self.title = title;
self.allSections = sections ?: @[];
self.visibleSections = self.allSections;
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
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.tableView.estimatedRowHeight = 44;
self.tableView.rowHeight = UITableViewAutomaticDimension;
[self.view addSubview:self.tableView];
self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil];
self.searchController.searchResultsUpdater = self;
self.searchController.delegate = self;
self.searchController.obscuresBackgroundDuringPresentation = NO;
self.searchController.searchBar.placeholder = SCILocalized(@"Search");
self.navigationItem.searchController = self.searchController;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.definesPresentationContext = YES;
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self sciStyleSearchBar];
}
- (void)sciStyleSearchBar { [SCISearchBarStyler styleSearchBar:self.searchController.searchBar]; }
- (void)willPresentSearchController:(UISearchController *)sc { [self sciStyleSearchBar]; }
- (void)didPresentSearchController:(UISearchController *)sc {
[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 *)sc {
NSString *q = [sc.searchBar.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (!q.length) { self.visibleSections = self.allSections; [self.tableView reloadData]; return; }
NSMutableArray *out = [NSMutableArray array];
for (NSDictionary *section in self.allSections) {
NSMutableArray *matched = [NSMutableArray array];
for (NSDictionary *r in section[@"rows"]) {
NSString *t = r[@"title"] ?: @"";
NSString *v = r[@"value"] ?: @"";
if ([t localizedCaseInsensitiveContainsString:q] || [v localizedCaseInsensitiveContainsString:q]) {
[matched addObject:r];
}
}
if (matched.count) [out addObject:@{ @"title": section[@"title"] ?: @"", @"rows": matched }];
}
self.visibleSections = out;
[self.tableView reloadData];
}
#pragma mark - Table
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return self.visibleSections.count; }
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return [self.visibleSections[section][@"rows"] count];
}
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
NSString *t = self.visibleSections[section][@"title"];
return t.length ? t : nil;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *rid = @"row";
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:rid];
NSDictionary *r = self.visibleSections[indexPath.section][@"rows"][indexPath.row];
cell.textLabel.text = r[@"title"];
cell.detailTextLabel.text = r[@"value"];
cell.textLabel.numberOfLines = 0;
cell.selectionStyle = UITableViewCellSelectionStyleNone;
// Color on/off for quick visual scan
NSString *v = r[@"value"] ?: @"";
if ([v isEqualToString:@"on"]) cell.detailTextLabel.textColor = [UIColor systemGreenColor];
else if ([v isEqualToString:@"off"]) cell.detailTextLabel.textColor = [UIColor tertiaryLabelColor];
else cell.detailTextLabel.textColor = [UIColor secondaryLabelColor];
return cell;
}
@end
+29
View File
@@ -0,0 +1,29 @@
#import <UIKit/UIKit.h>
NS_ASSUME_NONNULL_BEGIN
// Bitmask must match the one in SCISettingsBackup.m — kept as plain
// NSInteger here so consumers don't have to drag the enum around.
typedef NS_OPTIONS(NSInteger, SCIBackupScopePickerMask) {
SCIBackupScopePickerSettings = 1 << 0,
SCIBackupScopePickerLists = 1 << 1,
SCIBackupScopePickerAnalyzer = 1 << 2,
};
// Scope picker + live preview. Rows combine a leading checkbox toggle with a
// tappable body that pushes a read-only drill-down; a "Raw JSON" row pushes
// the full payload viewer; a CTA commits.
@interface SCIBackupScopePickerVC : UIViewController
@property (nonatomic, copy) NSString *continueTitle;
@property (nonatomic, copy, nullable) NSString *headerMessage;
// Scopes present in the payload. Rows outside the mask are disabled.
@property (nonatomic, assign) SCIBackupScopePickerMask availableScopes;
@property (nonatomic, assign) SCIBackupScopePickerMask initialSelection;
// v2 envelope: {"settings": {...}, "lists": {...}, "analyzer": {...}}.
@property (nonatomic, copy, nullable) NSDictionary *payload;
@property (nonatomic, copy) void (^onContinue)(SCIBackupScopePickerMask chosen);
@end
NS_ASSUME_NONNULL_END
+496
View File
@@ -0,0 +1,496 @@
#import "SCIBackupScopePickerVC.h"
#import "SCIBackupDetailVC.h"
#import "../Utils.h"
#import "../Localization/SCILocalization.h"
#pragma mark - Row model
typedef NS_ENUM(NSInteger, SCIPickerRowKind) {
SCIPickerRowKindScope,
SCIPickerRowKindJSON,
};
@interface SCIPickerRow : NSObject
@property (nonatomic, assign) SCIPickerRowKind kind;
@property (nonatomic, assign) SCIBackupScopePickerMask scope; // only for Scope
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *subtitle;
@property (nonatomic, copy) NSString *symbol;
@property (nonatomic, strong) UIColor *iconColor;
@end
@implementation SCIPickerRow @end
#pragma mark - Cell
@interface SCIPickerCell : UITableViewCell
@property (nonatomic, strong) UIButton *checkboxButton;
@property (nonatomic, strong) UIImageView *iconView;
@property (nonatomic, strong) UILabel *titleLabel;
@property (nonatomic, strong) UILabel *subtitleLabel;
@property (nonatomic, copy) void(^onToggle)(void);
@end
@implementation SCIPickerCell
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)rid {
self = [super initWithStyle:style reuseIdentifier:rid];
if (!self) return self;
self.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
_checkboxButton = [UIButton buttonWithType:UIButtonTypeSystem];
_checkboxButton.translatesAutoresizingMaskIntoConstraints = NO;
[_checkboxButton addTarget:self action:@selector(toggleTapped) forControlEvents:UIControlEventTouchUpInside];
[self.contentView addSubview:_checkboxButton];
_iconView = [UIImageView new];
_iconView.translatesAutoresizingMaskIntoConstraints = NO;
_iconView.contentMode = UIViewContentModeScaleAspectFit;
[self.contentView addSubview:_iconView];
_titleLabel = [UILabel new];
_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
_titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
_titleLabel.textColor = [UIColor labelColor];
[self.contentView addSubview:_titleLabel];
_subtitleLabel = [UILabel new];
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
_subtitleLabel.font = [UIFont systemFontOfSize:12];
_subtitleLabel.textColor = [UIColor secondaryLabelColor];
_subtitleLabel.numberOfLines = 2;
[self.contentView addSubview:_subtitleLabel];
[NSLayoutConstraint activateConstraints:@[
[_checkboxButton.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor],
[_checkboxButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_checkboxButton.widthAnchor constraintEqualToConstant:30],
[_checkboxButton.heightAnchor constraintEqualToConstant:30],
[_iconView.leadingAnchor constraintEqualToAnchor:_checkboxButton.trailingAnchor constant:12],
[_iconView.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
[_iconView.widthAnchor constraintEqualToConstant:22],
[_iconView.heightAnchor constraintEqualToConstant:22],
[_titleLabel.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:10],
[_titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10],
[_titleLabel.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor constant:-4],
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_titleLabel.leadingAnchor],
[_subtitleLabel.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:2],
[_subtitleLabel.trailingAnchor constraintEqualToAnchor:_titleLabel.trailingAnchor],
[_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-10],
]];
return self;
}
- (void)toggleTapped { if (self.onToggle) self.onToggle(); }
- (void)setChecked:(BOOL)checked enabled:(BOOL)enabled {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightRegular];
NSString *name = checked ? @"checkmark.circle.fill" : @"circle";
UIImage *img = [[UIImage systemImageNamed:name] imageByApplyingSymbolConfiguration:cfg];
[self.checkboxButton setImage:img forState:UIControlStateNormal];
self.checkboxButton.tintColor = checked ? ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor])
: [UIColor systemGray3Color];
self.checkboxButton.enabled = enabled;
self.contentView.alpha = enabled ? 1.0 : 0.45;
self.selectionStyle = enabled ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone;
self.userInteractionEnabled = enabled;
}
@end
#pragma mark - VC
@interface SCIBackupScopePickerVC () <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UIButton *continueButton;
@property (nonatomic, assign) SCIBackupScopePickerMask selection;
@property (nonatomic, copy) NSArray<SCIPickerRow *> *rows; // section 1
@end
@implementation SCIBackupScopePickerVC
- (instancetype)init {
self = [super init];
if (!self) return self;
_availableScopes = SCIBackupScopePickerSettings | SCIBackupScopePickerLists | SCIBackupScopePickerAnalyzer;
_continueTitle = SCILocalized(@"Continue");
return self;
}
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
self.selection = self.initialSelection & self.availableScopes;
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemCancel
target:self action:@selector(cancelTapped)];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
initWithTitle:SCILocalized(@"Select all") style:UIBarButtonItemStylePlain
target:self action:@selector(selectAllTapped)];
[self buildRows];
[self buildTable];
[self buildCommitBar];
[self refreshContinue];
}
- (void)buildRows {
NSMutableArray *rows = [NSMutableArray array];
if (self.availableScopes & SCIBackupScopePickerSettings) {
SCIPickerRow *r = [SCIPickerRow new];
r.kind = SCIPickerRowKindScope;
r.scope = SCIBackupScopePickerSettings;
r.title = SCILocalized(@"Settings");
r.subtitle = [self summaryForSettings];
r.symbol = @"slider.horizontal.3";
r.iconColor = [UIColor systemBlueColor];
[rows addObject:r];
}
if (self.availableScopes & SCIBackupScopePickerLists) {
SCIPickerRow *r = [SCIPickerRow new];
r.kind = SCIPickerRowKindScope;
r.scope = SCIBackupScopePickerLists;
r.title = SCILocalized(@"Excluded lists");
r.subtitle = [self summaryForLists];
r.symbol = @"person.crop.circle.badge.xmark";
r.iconColor = [UIColor systemOrangeColor];
[rows addObject:r];
}
if (self.availableScopes & SCIBackupScopePickerAnalyzer) {
SCIPickerRow *r = [SCIPickerRow new];
r.kind = SCIPickerRowKindScope;
r.scope = SCIBackupScopePickerAnalyzer;
r.title = SCILocalized(@"Profile Analyzer data");
r.subtitle = [self summaryForAnalyzer];
r.symbol = @"person.fill.viewfinder";
r.iconColor = [UIColor systemPurpleColor];
[rows addObject:r];
}
self.rows = rows;
}
#pragma mark - Summaries
- (NSDictionary *)settingsPayload {
NSDictionary *p = self.payload;
id s = p[@"settings"];
if ([s isKindOfClass:[NSDictionary class]]) return s;
if (p && !p[@"ryukgram_export"] && !p[@"settings"] && !p[@"lists"] && !p[@"analyzer"]) return p;
return @{};
}
- (NSDictionary *)listsPayload { id v = self.payload[@"lists"]; return [v isKindOfClass:[NSDictionary class]] ? v : @{}; }
- (NSDictionary *)analyzerPayload { id v = self.payload[@"analyzer"]; return [v isKindOfClass:[NSDictionary class]] ? v : @{}; }
- (NSString *)summaryForSettings {
NSUInteger n = [self settingsPayload].count;
return [NSString stringWithFormat:SCILocalized(@"%lu preferences · tap to inspect"), (unsigned long)n];
}
- (NSString *)summaryForLists {
NSDictionary *lists = [self listsPayload];
NSUInteger total = 0;
for (NSString *k in lists) {
id v = lists[k];
if ([v isKindOfClass:[NSArray class]]) total += [(NSArray *)v count];
}
return [NSString stringWithFormat:SCILocalized(@"%lu entries across %lu lists · tap to inspect"),
(unsigned long)total, (unsigned long)lists.count];
}
- (NSString *)summaryForAnalyzer {
NSDictionary *a = [self analyzerPayload];
NSMutableSet *pks = [NSMutableSet set];
NSUInteger snaps = 0;
for (NSString *f in a) {
NSArray *parts = [f componentsSeparatedByString:@"."];
if (parts.count >= 2) [pks addObject:parts[0]];
if ([f hasSuffix:@".current.json"] || [f hasSuffix:@".previous.json"]) snaps++;
}
return [NSString stringWithFormat:SCILocalized(@"%lu account(s) · %lu snapshot(s) · tap to inspect"),
(unsigned long)pks.count, (unsigned long)snaps];
}
#pragma mark - UI
- (void)buildTable {
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.estimatedRowHeight = 64;
self.tableView.rowHeight = UITableViewAutomaticDimension;
[self.tableView registerClass:[SCIPickerCell class] forCellReuseIdentifier:@"scope"];
[self.view addSubview:self.tableView];
[NSLayoutConstraint activateConstraints:@[
[self.tableView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
]];
}
- (void)buildCommitBar {
UIView *bar = [UIView new];
bar.translatesAutoresizingMaskIntoConstraints = NO;
bar.backgroundColor = [UIColor systemGroupedBackgroundColor];
[self.view addSubview:bar];
self.continueButton = [UIButton buttonWithType:UIButtonTypeSystem];
self.continueButton.translatesAutoresizingMaskIntoConstraints = NO;
self.continueButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
self.continueButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
[self.continueButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
self.continueButton.layer.cornerRadius = 14;
[self.continueButton addTarget:self action:@selector(continueTapped) forControlEvents:UIControlEventTouchUpInside];
[bar addSubview:self.continueButton];
[NSLayoutConstraint activateConstraints:@[
[bar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[bar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[bar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
[bar.topAnchor constraintEqualToAnchor:self.tableView.bottomAnchor],
[self.continueButton.leadingAnchor constraintEqualToAnchor:bar.leadingAnchor constant:16],
[self.continueButton.trailingAnchor constraintEqualToAnchor:bar.trailingAnchor constant:-16],
[self.continueButton.topAnchor constraintEqualToAnchor:bar.topAnchor constant:10],
[self.continueButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-10],
[self.continueButton.heightAnchor constraintEqualToConstant:48],
]];
}
#pragma mark - Actions
- (void)cancelTapped { [self dismissOrPopWithCompletion:nil]; }
- (void)selectAllTapped {
BOOL all = (self.selection & self.availableScopes) == self.availableScopes && self.availableScopes != 0;
self.selection = all ? 0 : self.availableScopes;
[self.tableView reloadData];
[self refreshContinue];
}
- (void)continueTapped {
SCIBackupScopePickerMask chosen = self.selection;
void (^block)(SCIBackupScopePickerMask) = self.onContinue;
[self dismissOrPopWithCompletion:^{
if (block && chosen) block(chosen);
}];
}
- (void)dismissOrPopWithCompletion:(void(^)(void))completion {
if (self.navigationController.viewControllers.firstObject == self) {
[self dismissViewControllerAnimated:YES completion:completion];
} else {
[self.navigationController popViewControllerAnimated:YES];
if (completion) dispatch_async(dispatch_get_main_queue(), completion);
}
}
- (void)refreshContinue {
BOOL any = self.selection != 0;
self.continueButton.enabled = any;
self.continueButton.alpha = any ? 1.0 : 0.4;
NSInteger n = __builtin_popcountll((unsigned long long)self.selection);
[self.continueButton setTitle:any
? [NSString stringWithFormat:@"%@ (%ld)", self.continueTitle, (long)n]
: self.continueTitle
forState:UIControlStateNormal];
}
- (void)toggleScope:(SCIBackupScopePickerMask)scope {
if (!(self.availableScopes & scope)) return;
if (self.selection & scope) self.selection &= ~scope;
else self.selection |= scope;
[self.tableView reloadData];
[self refreshContinue];
}
#pragma mark - Table
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; }
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return section == 0 ? (NSInteger)self.rows.count : 1;
}
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
return section == 0 ? SCILocalized(@"Include") : SCILocalized(@"Raw");
}
- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)section {
return section == 0 ? self.headerMessage : nil;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.section == 1) {
static NSString *rid = @"json";
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:rid];
cell.textLabel.text = SCILocalized(@"Raw JSON");
cell.detailTextLabel.text = SCILocalized(@"Inspect the full payload");
cell.imageView.image = [UIImage systemImageNamed:@"curlybraces"];
cell.imageView.tintColor = [UIColor systemGrayColor];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
return cell;
}
SCIPickerCell *cell = [tv dequeueReusableCellWithIdentifier:@"scope" forIndexPath:indexPath];
SCIPickerRow *r = self.rows[indexPath.row];
cell.titleLabel.text = r.title;
cell.subtitleLabel.text = r.subtitle;
cell.iconView.image = [UIImage systemImageNamed:r.symbol];
cell.iconView.tintColor = r.iconColor;
BOOL enabled = (self.availableScopes & r.scope) != 0;
BOOL checked = (self.selection & r.scope) != 0;
[cell setChecked:checked enabled:enabled];
__weak typeof(self) weakSelf = self;
cell.onToggle = ^{ [weakSelf toggleScope:r.scope]; };
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tv deselectRowAtIndexPath:indexPath animated:YES];
if (indexPath.section == 1) {
[self pushRawJSON];
return;
}
SCIPickerRow *r = self.rows[indexPath.row];
[self pushDetailForScope:r.scope];
}
#pragma mark - Detail pushes
- (void)pushRawJSON {
NSData *data = [NSJSONSerialization dataWithJSONObject:(self.payload ?: @{})
options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys
error:nil];
NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}";
UIViewController *vc = [UIViewController new];
vc.title = SCILocalized(@"Raw JSON");
UITextView *tv = [UITextView new];
tv.translatesAutoresizingMaskIntoConstraints = NO;
tv.editable = NO;
tv.font = [UIFont monospacedSystemFontOfSize:11 weight:UIFontWeightRegular];
tv.text = json;
tv.textContainerInset = UIEdgeInsetsMake(12, 12, 12, 12);
tv.backgroundColor = [UIColor secondarySystemBackgroundColor];
[vc.view addSubview:tv];
[NSLayoutConstraint activateConstraints:@[
[tv.topAnchor constraintEqualToAnchor:vc.view.topAnchor],
[tv.leadingAnchor constraintEqualToAnchor:vc.view.leadingAnchor],
[tv.trailingAnchor constraintEqualToAnchor:vc.view.trailingAnchor],
[tv.bottomAnchor constraintEqualToAnchor:vc.view.bottomAnchor],
]];
[self.navigationController pushViewController:vc animated:YES];
}
- (void)pushDetailForScope:(SCIBackupScopePickerMask)scope {
NSArray<NSDictionary *> *sections = nil;
NSString *title = nil;
if (scope == SCIBackupScopePickerSettings) {
title = SCILocalized(@"Settings");
sections = [self detailSectionsForSettings:[self settingsPayload]];
} else if (scope == SCIBackupScopePickerLists) {
title = SCILocalized(@"Excluded lists");
sections = [self detailSectionsForLists:[self listsPayload]];
} else if (scope == SCIBackupScopePickerAnalyzer) {
title = SCILocalized(@"Profile Analyzer data");
sections = [self detailSectionsForAnalyzer:[self analyzerPayload]];
} else return;
SCIBackupDetailVC *vc = [[SCIBackupDetailVC alloc] initWithTitle:title sections:sections];
[self.navigationController pushViewController:vc animated:YES];
}
- (NSString *)displayValue:(id)v {
if ([v isKindOfClass:[NSNumber class]]) {
NSNumber *n = v;
const char *t = n.objCType;
if (t && strcmp(t, "c") == 0) return n.boolValue ? @"on" : @"off";
return n.stringValue;
}
if ([v isKindOfClass:[NSString class]]) return v;
if ([v isKindOfClass:[NSArray class]]) return [NSString stringWithFormat:@"[%lu]", (unsigned long)[(NSArray *)v count]];
if ([v isKindOfClass:[NSDictionary class]]) return [NSString stringWithFormat:@"{%lu}", (unsigned long)[(NSDictionary *)v count]];
return @"";
}
- (NSString *)prettyKeyForList:(NSString *)k {
if ([k isEqualToString:@"excluded_threads"]) return SCILocalized(@"Excluded chats");
if ([k isEqualToString:@"included_threads"]) return SCILocalized(@"Included chats");
if ([k isEqualToString:@"excluded_story_users"]) return SCILocalized(@"Excluded story users");
if ([k isEqualToString:@"included_story_users"]) return SCILocalized(@"Included story users");
if ([k isEqualToString:@"embed_custom_domains"]) return SCILocalized(@"Embed domains");
return k;
}
- (NSArray<NSDictionary *> *)detailSectionsForSettings:(NSDictionary *)settings {
NSArray *keys = [[settings allKeys] sortedArrayUsingSelector:@selector(compare:)];
NSMutableArray *rows = [NSMutableArray array];
for (NSString *k in keys) {
[rows addObject:@{ @"title": k, @"value": [self displayValue:settings[k]] }];
}
return @[@{ @"title": [NSString stringWithFormat:SCILocalized(@"All preferences (%lu)"), (unsigned long)rows.count],
@"rows": rows }];
}
- (NSArray<NSDictionary *> *)detailSectionsForLists:(NSDictionary *)lists {
NSMutableArray *sections = [NSMutableArray array];
NSArray *keys = [[lists allKeys] sortedArrayUsingSelector:@selector(compare:)];
for (NSString *k in keys) {
id v = lists[k];
NSArray *items = [v isKindOfClass:[NSArray class]] ? v : @[];
NSMutableArray *rows = [NSMutableArray array];
for (id item in items) {
NSString *display = [item isKindOfClass:[NSString class]] ? item : [NSString stringWithFormat:@"%@", item];
[rows addObject:@{ @"title": display, @"value": @"" }];
}
if (!rows.count) [rows addObject:@{ @"title": SCILocalized(@"(empty)"), @"value": @"" }];
[sections addObject:@{ @"title": [self prettyKeyForList:k], @"rows": rows }];
}
if (!sections.count) sections = [@[@{ @"title": @"", @"rows": @[@{@"title": SCILocalized(@"(no lists)"), @"value": @""}] }] mutableCopy];
return sections;
}
- (NSArray<NSDictionary *> *)detailSectionsForAnalyzer:(NSDictionary *)analyzer {
NSMutableDictionary<NSString *, NSMutableDictionary *> *byPK = [NSMutableDictionary dictionary];
for (NSString *file in analyzer) {
NSArray *parts = [file componentsSeparatedByString:@"."];
if (parts.count < 2) continue;
NSMutableDictionary *slot = byPK[parts[0]] ?: [NSMutableDictionary dictionary];
slot[parts[1]] = analyzer[file];
byPK[parts[0]] = slot;
}
NSMutableArray *sections = [NSMutableArray array];
for (NSString *pk in [[byPK allKeys] sortedArrayUsingSelector:@selector(compare:)]) {
NSDictionary *slot = byPK[pk];
NSDictionary *hdr = slot[@"header"];
NSString *username = [hdr[@"username"] isKindOfClass:[NSString class]] ? hdr[@"username"] : nil;
NSString *header = username.length ? [NSString stringWithFormat:@"@%@", username] : [NSString stringWithFormat:@"PK %@", pk];
NSMutableArray *rows = [NSMutableArray array];
if (hdr) {
[rows addObject:@{ @"title": SCILocalized(@"Full name"), @"value": hdr[@"full_name"] ?: @"" }];
[rows addObject:@{ @"title": SCILocalized(@"Followers"), @"value": [NSString stringWithFormat:@"%ld", (long)[hdr[@"follower_count"] integerValue]] }];
[rows addObject:@{ @"title": SCILocalized(@"Following"), @"value": [NSString stringWithFormat:@"%ld", (long)[hdr[@"following_count"] integerValue]] }];
[rows addObject:@{ @"title": SCILocalized(@"Posts"), @"value": [NSString stringWithFormat:@"%ld", (long)[hdr[@"media_count"] integerValue]] }];
}
[rows addObject:@{ @"title": SCILocalized(@"Current snapshot"), @"value": [self snapshotSummary:slot[@"current"]] }];
[rows addObject:@{ @"title": SCILocalized(@"Previous snapshot"), @"value": [self snapshotSummary:slot[@"previous"]] }];
[sections addObject:@{ @"title": header, @"rows": rows }];
}
if (!sections.count) sections = [@[@{ @"title": @"", @"rows": @[@{@"title": SCILocalized(@"(no analyzer data)"), @"value": @""}] }] mutableCopy];
return sections;
}
- (NSString *)snapshotSummary:(NSDictionary *)snap {
if (![snap isKindOfClass:[NSDictionary class]]) return @"";
NSArray *followers = snap[@"followers"];
NSArray *following = snap[@"following"];
NSTimeInterval ts = [snap[@"scan_date"] doubleValue];
NSString *when = ts > 0 ? [NSDateFormatter localizedStringFromDate:[NSDate dateWithTimeIntervalSince1970:ts]
dateStyle:NSDateFormatterShortStyle
timeStyle:NSDateFormatterShortStyle]
: @"";
return [NSString stringWithFormat:@"%lu / %lu — %@",
(unsigned long)([followers isKindOfClass:[NSArray class]] ? followers.count : 0),
(unsigned long)([following isKindOfClass:[NSArray class]] ? following.count : 0),
when];
}
@end
+1 -1
View File
@@ -124,7 +124,7 @@ static NSString *sciExampleForKey(NSString *key) {
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.text = SCILocalized(entry[1]);
cell.textLabel.numberOfLines = 0;
cell.textLabel.font = [UIFont systemFontOfSize:15];
UISwitch *sw = [UISwitch new];
+1 -1
View File
@@ -4,7 +4,7 @@
#define SCI_CUSTOM_DOMAINS_KEY @"embed_custom_domains"
static NSArray *sciPresetDomains(void) {
return @[@"kkinstagram.com", @"ddinstagram.com", @"d.ddinstagram.com", @"g.ddinstagram.com"];
return @[@"eeinstagram.com", @"vxinstagram.com", @"kkinstagram.com", @"ddinstagram.com", @"d.ddinstagram.com", @"g.ddinstagram.com"];
}
@interface SCIEmbedDomainViewController ()
@@ -119,7 +119,7 @@
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Add chat")
message:SCILocalized(@"Enter username of the DM thread")
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = @"username"; tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"username"); tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Search") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
NSString *q = [alert.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
@@ -178,7 +178,7 @@
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *titles = @[@"Recently added", @"Name (AZ)"];
NSArray *titles = @[SCILocalized(@"Recently added"), SCILocalized(@"Name (AZ)")];
for (NSInteger i = 0; i < (NSInteger)titles.count; i++) {
UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i]
style:UIAlertActionStyleDefault
@@ -90,7 +90,7 @@
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Add user")
message:SCILocalized(@"Enter username")
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = @"username"; tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"username"); tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Search") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
NSString *q = [alert.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
@@ -131,7 +131,7 @@
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *titles = @[@"Recently added", @"Username (AZ)"];
NSArray *titles = @[SCILocalized(@"Recently added"), SCILocalized(@"Username (AZ)")];
for (NSInteger i = 0; i < (NSInteger)titles.count; i++) {
UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i]
style:UIAlertActionStyleDefault
+4
View File
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIExpFlagsViewController : UIViewController
@end
+420
View File
@@ -0,0 +1,420 @@
// Exp flag browser + override editor.
// Tabs: Browser(native) | Meta(override) | MC(view) | Scanned(view) | Overrides
#import "SCIExpFlagsViewController.h"
#import "../Features/ExpFlags/SCIExpFlags.h"
#import "../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
typedef NS_ENUM(NSInteger, SCIExpTab) {
SCIExpTabBrowser = 0,
SCIExpTabMeta,
SCIExpTabMC,
SCIExpTabScanned,
SCIExpTabOverrides,
};
@interface SCIExpFlagsViewController () <UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate>
@property (nonatomic, strong) UISegmentedControl *seg;
@property (nonatomic, strong) UISearchBar *searchBar;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
@property (nonatomic, strong) UILabel *empty;
@property (nonatomic, assign) SCIExpTab tab;
@property (nonatomic, copy) NSString *query;
// Tab data.
@property (nonatomic, strong) NSArray<SCIExpObservation *> *metaObs;
@property (nonatomic, strong) NSArray<SCIExpMCObservation *> *mcObs;
@property (nonatomic, strong) NSArray<NSString *> *scannedNames; // lazy-loaded
@property (nonatomic, assign) BOOL scannedLoading;
@property (nonatomic, strong) NSArray<NSString *> *overriddenNames;
@end
@implementation SCIExpFlagsViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Experimental flags";
self.view.backgroundColor = UIColor.systemBackgroundColor;
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:@"xmark.circle"]
style:UIBarButtonItemStylePlain target:self action:@selector(confirmResetAll)];
self.seg = [[UISegmentedControl alloc] initWithItems:@[@"Browser", @"Meta", @"MC IDs", @"Scanned", @"Overrides"]];
self.seg.selectedSegmentIndex = SCIExpTabMeta;
self.tab = SCIExpTabMeta;
[self.seg addTarget:self action:@selector(segChanged) forControlEvents:UIControlEventValueChanged];
self.seg.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.seg];
self.searchBar = [UISearchBar new];
self.searchBar.searchBarStyle = UISearchBarStyleMinimal;
self.searchBar.placeholder = @"Search";
self.searchBar.autocapitalizationType = UITextAutocapitalizationTypeNone;
self.searchBar.autocorrectionType = UITextAutocorrectionTypeNo;
self.searchBar.delegate = self;
self.searchBar.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.searchBar];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag;
[self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"cell"];
[self.view addSubview:self.tableView];
self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge];
self.spinner.translatesAutoresizingMaskIntoConstraints = NO;
self.spinner.hidesWhenStopped = YES;
[self.view addSubview:self.spinner];
self.empty = [UILabel new];
self.empty.translatesAutoresizingMaskIntoConstraints = NO;
self.empty.textColor = UIColor.secondaryLabelColor;
self.empty.textAlignment = NSTextAlignmentCenter;
self.empty.numberOfLines = 0;
self.empty.font = [UIFont systemFontOfSize:14];
[self.view addSubview:self.empty];
UILayoutGuide *g = self.view.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[self.seg.topAnchor constraintEqualToAnchor:g.topAnchor constant:8],
[self.seg.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:12],
[self.seg.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-12],
[self.searchBar.topAnchor constraintEqualToAnchor:self.seg.bottomAnchor constant:4],
[self.searchBar.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:8],
[self.searchBar.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-8],
[self.tableView.topAnchor constraintEqualToAnchor:self.searchBar.bottomAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:g.bottomAnchor],
[self.spinner.centerXAnchor constraintEqualToAnchor:self.tableView.centerXAnchor],
[self.spinner.centerYAnchor constraintEqualToAnchor:self.tableView.centerYAnchor],
[self.empty.centerXAnchor constraintEqualToAnchor:self.tableView.centerXAnchor],
[self.empty.centerYAnchor constraintEqualToAnchor:self.tableView.centerYAnchor],
[self.empty.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:24],
[self.empty.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-24],
]];
}
- (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self refresh]; }
// tab state
- (void)segChanged {
self.tab = (SCIExpTab)self.seg.selectedSegmentIndex;
if (self.tab == SCIExpTabScanned && !self.scannedNames && !self.scannedLoading) [self loadScanned];
[self refresh];
}
- (void)loadScanned {
self.scannedLoading = YES;
[self.spinner startAnimating];
[self updateEmpty];
[SCIExpFlags scanExecutableNamesWithCompletion:^(NSArray<NSString *> *names) {
self.scannedNames = names;
self.scannedLoading = NO;
[self.spinner stopAnimating];
[self refresh];
}];
}
- (void)refresh {
self.metaObs = [SCIExpFlags allObservations];
self.mcObs = [SCIExpFlags allMCObservations];
self.overriddenNames = [[SCIExpFlags allOverriddenNames] sortedArrayUsingSelector:@selector(compare:)];
[self.tableView reloadData];
[self updateEmpty];
}
- (void)updateEmpty {
NSInteger rows = [self tableView:self.tableView numberOfRowsInSection:0];
if (self.tab == SCIExpTabScanned && self.scannedLoading) {
self.empty.text = @"Scanning…";
self.empty.hidden = NO;
return;
}
if (rows == 0) {
switch (self.tab) {
case SCIExpTabBrowser: self.empty.text = @""; break;
case SCIExpTabMeta: self.empty.text = @"Browse IG to populate."; break;
case SCIExpTabMC: self.empty.text = @"Browse IG to populate."; break;
case SCIExpTabScanned: self.empty.text = self.query.length ? @"No match" : @"Empty."; break;
case SCIExpTabOverrides: self.empty.text = @"None."; break;
}
self.empty.hidden = NO;
return;
}
self.empty.hidden = YES;
}
// filter
- (NSArray *)filteredRows {
switch (self.tab) {
case SCIExpTabBrowser: return @[@"Open native list", @"Add override"];
case SCIExpTabMeta: return [self filtered:self.metaObs keyPath:@"experimentName"];
case SCIExpTabMC: return [self filterMC:self.mcObs];
case SCIExpTabScanned: return [self filterStrings:self.scannedNames];
case SCIExpTabOverrides: return [self filterStrings:self.overriddenNames];
}
}
- (NSArray *)filtered:(NSArray *)items keyPath:(NSString *)kp {
if (!self.query.length) return items ?: @[];
NSString *q = self.query.lowercaseString;
NSMutableArray *out = [NSMutableArray array];
for (id o in items) {
NSString *s = [[o valueForKey:kp] lowercaseString];
if ([s containsString:q]) [out addObject:o];
}
return out;
}
- (NSArray *)filterMC:(NSArray<SCIExpMCObservation *> *)items {
if (!self.query.length) return items ?: @[];
NSString *q = self.query.lowercaseString;
NSMutableArray *out = [NSMutableArray array];
for (SCIExpMCObservation *o in items) {
NSString *s = [NSString stringWithFormat:@"%llu", o.paramID];
if ([s containsString:q]) [out addObject:o];
}
return out;
}
- (NSArray *)filterStrings:(NSArray<NSString *> *)items {
if (!self.query.length) return items ?: @[];
NSString *q = self.query.lowercaseString;
NSMutableArray *out = [NSMutableArray array];
for (NSString *s in items) if ([s.lowercaseString containsString:q]) [out addObject:s];
return out;
}
// table
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { return [self filteredRows].count; }
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"cell" forIndexPath:ip];
cell.accessoryType = UITableViewCellAccessoryNone;
cell.selectionStyle = UITableViewCellSelectionStyleDefault;
cell.textLabel.textColor = UIColor.labelColor;
cell.textLabel.font = [UIFont systemFontOfSize:15];
cell.textLabel.numberOfLines = 0;
cell.detailTextLabel.text = nil;
id row = [self filteredRows][ip.row];
switch (self.tab) {
case SCIExpTabBrowser: {
cell.textLabel.text = (NSString *)row;
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
break;
}
case SCIExpTabMeta: {
SCIExpObservation *o = row;
[self fillCell:cell withName:o.experimentName subtitle:[NSString stringWithFormat:@"group=%@ · ×%lu", o.lastGroup ?: @"nil", (unsigned long)o.hitCount]];
break;
}
case SCIExpTabMC: {
SCIExpMCObservation *o = row;
NSString *tname = @"?";
switch (o.type) {
case SCIExpMCTypeBool: tname = @"bool"; break;
case SCIExpMCTypeInt: tname = @"int64"; break;
case SCIExpMCTypeDouble: tname = @"double"; break;
case SCIExpMCTypeString: tname = @"string"; break;
}
cell.textLabel.text = [NSString stringWithFormat:@"%llu", o.paramID];
cell.textLabel.font = [UIFont monospacedSystemFontOfSize:13 weight:UIFontWeightRegular];
cell.detailTextLabel.text = [NSString stringWithFormat:@"%@ · default=%@ · ×%lu", tname, o.lastDefault ?: @"?", (unsigned long)o.hitCount];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
break;
}
case SCIExpTabScanned: {
cell.textLabel.text = (NSString *)row;
cell.textLabel.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
break;
}
case SCIExpTabOverrides: {
NSString *name = (NSString *)row;
[self fillCell:cell withName:name subtitle:nil];
break;
}
}
return cell;
}
- (void)fillCell:(UITableViewCell *)cell withName:(NSString *)name subtitle:(NSString *)sub {
SCIExpFlagOverride o = [SCIExpFlags overrideForName:name];
NSString *prefix = o == SCIExpFlagOverrideTrue ? @"● " : o == SCIExpFlagOverrideFalse ? @"○ " : @"";
cell.textLabel.text = [prefix stringByAppendingString:name];
cell.textLabel.font = [UIFont monospacedSystemFontOfSize:13 weight:UIFontWeightRegular];
cell.textLabel.textColor = o == SCIExpFlagOverrideOff ? UIColor.labelColor : UIColor.systemOrangeColor;
NSMutableArray *parts = [NSMutableArray array];
if (sub.length) [parts addObject:sub];
if (o == SCIExpFlagOverrideTrue) [parts addObject:@"FORCED ON"];
if (o == SCIExpFlagOverrideFalse) [parts addObject:@"FORCED OFF"];
cell.detailTextLabel.text = [parts componentsJoinedByString:@" · "];
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip {
[tv deselectRowAtIndexPath:ip animated:YES];
UITableViewCell *cell = [tv cellForRowAtIndexPath:ip];
id row = [self filteredRows][ip.row];
switch (self.tab) {
case SCIExpTabBrowser:
if (ip.row == 0) [self openNativeBrowser];
else [self promptAddByName];
break;
case SCIExpTabMeta:
[self presentOverrideSheetForName:((SCIExpObservation *)row).experimentName fromCell:cell];
break;
case SCIExpTabMC: {
// View-only; offer Copy ID for user convenience.
SCIExpMCObservation *o = row;
[self presentCopySheetWithText:[NSString stringWithFormat:@"%llu", o.paramID] title:@"MobileConfig param" fromCell:cell];
break;
}
case SCIExpTabScanned:
[self presentCopySheetWithText:(NSString *)row title:@"Scanned name" fromCell:cell];
break;
case SCIExpTabOverrides:
[self presentOverrideSheetForName:(NSString *)row fromCell:cell];
break;
}
}
// actions
- (void)openNativeBrowser {
Class cls = NSClassFromString(@"MetaLocalExperimentListViewController");
if (!cls) { [SCIUtils showErrorHUDWithDescription:@"Native browser missing"]; return; }
SEL initSel = NSSelectorFromString(@"initWithExperimentConfigs:experimentGenerator:");
UIViewController *vc = nil;
@try {
if ([cls instancesRespondToSelector:initSel]) {
id (*send)(id, SEL, id, id) = (id (*)(id, SEL, id, id))objc_msgSend;
vc = send([cls alloc], initSel, [self nativeBrowserConfigs], [self nativeBrowserGenerator]);
} else {
vc = [[cls alloc] init];
}
} @catch (__unused id e) {}
if (!vc) { [SCIUtils showErrorHUDWithDescription:@"Init failed"]; return; }
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFullScreen;
[self presentViewController:nav animated:YES completion:nil];
}
- (NSArray *)nativeBrowserConfigs {
Protocol *p = objc_getProtocol("MetaLocalExperimentConfigProtocol");
if (!p) return @[];
unsigned int n = 0;
Class *all = objc_copyClassList(&n);
NSMutableArray *out = [NSMutableArray array];
for (unsigned int i = 0; i < n; i++) {
if (class_conformsToProtocol(all[i], p)) {
@try { id x = [[all[i] alloc] init]; if (x) [out addObject:x]; } @catch (__unused id e) {}
}
}
if (all) free(all);
return out;
}
- (id)nativeBrowserGenerator {
Class c = NSClassFromString(@"LIDExperimentGenerator");
if (!c) return nil;
SEL s = NSSelectorFromString(@"initWithDeviceID:logger:");
if (![c instancesRespondToSelector:s]) return nil;
id (*send)(id, SEL, id, id) = (id (*)(id, SEL, id, id))objc_msgSend;
return send([c alloc], s, nil, nil);
}
- (void)promptAddByName {
UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Add override" message:@"Substring match, case-insensitive." preferredStyle:UIAlertControllerStyleAlert];
[a addTextFieldWithConfigurationHandler:^(UITextField *tf) {
tf.placeholder = @"name (e.g. liquidglass)";
tf.autocapitalizationType = UITextAutocapitalizationTypeNone;
tf.autocorrectionType = UITextAutocorrectionTypeNo;
}];
[a addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[a addAction:[UIAlertAction actionWithTitle:@"Force ON" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
NSString *n = [a.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (n.length) { [SCIExpFlags setOverride:SCIExpFlagOverrideTrue forName:n]; [self refresh]; }
}]];
[a addAction:[UIAlertAction actionWithTitle:@"Force OFF" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
NSString *n = [a.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
if (n.length) { [SCIExpFlags setOverride:SCIExpFlagOverrideFalse forName:n]; [self refresh]; }
}]];
[self presentViewController:a animated:YES completion:nil];
}
- (void)presentOverrideSheetForName:(NSString *)name fromCell:(UITableViewCell *)cell {
SCIExpFlagOverride cur = [SCIExpFlags overrideForName:name];
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:name message:nil preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *opts = @[@{@"t": @"No override", @"v": @(SCIExpFlagOverrideOff)},
@{@"t": @"Force ON", @"v": @(SCIExpFlagOverrideTrue)},
@{@"t": @"Force OFF", @"v": @(SCIExpFlagOverrideFalse)}];
for (NSDictionary *o in opts) {
NSString *t = o[@"t"];
if (((NSNumber *)o[@"v"]).integerValue == cur) t = [t stringByAppendingString:@" ✓"];
[sheet addAction:[UIAlertAction actionWithTitle:t style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExpFlags setOverride:((NSNumber *)o[@"v"]).integerValue forName:name];
[self refresh];
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Copy name" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[UIPasteboard generalPasteboard].string = name;
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
if (sheet.popoverPresentationController) {
sheet.popoverPresentationController.sourceView = cell;
sheet.popoverPresentationController.sourceRect = cell.bounds;
}
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)presentCopySheetWithText:(NSString *)text title:(NSString *)title fromCell:(UITableViewCell *)cell {
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:title message:text preferredStyle:UIAlertControllerStyleActionSheet];
[sheet addAction:[UIAlertAction actionWithTitle:@"Copy" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[UIPasteboard generalPasteboard].string = text;
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
if (sheet.popoverPresentationController) {
sheet.popoverPresentationController.sourceView = cell;
sheet.popoverPresentationController.sourceRect = cell.bounds;
}
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)confirmResetAll {
UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Reset all?" message:nil preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[a addAction:[UIAlertAction actionWithTitle:@"Reset" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExpFlags resetAllOverrides];
[self refresh];
}]];
[self presentViewController:a animated:YES completion:nil];
}
// search
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)text {
self.query = text;
[self.tableView reloadData];
[self updateEmpty];
}
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { [searchBar resignFirstResponder]; }
@end
+7
View File
@@ -0,0 +1,7 @@
#import <UIKit/UIKit.h>
@interface SCILinksSheet : UIViewController
+ (void)presentFrom:(UIViewController *)source;
@end
+123
View File
@@ -0,0 +1,123 @@
#import "SCILinksSheet.h"
#import "../Localization/SCILocalization.h"
#import "../Utils.h"
@implementation SCILinksSheet
+ (void)presentFrom:(UIViewController *)source {
SCILinksSheet *vc = [[SCILinksSheet alloc] init];
vc.modalPresentationStyle = UIModalPresentationPageSheet;
UISheetPresentationController *sheet = vc.sheetPresentationController;
if (sheet) {
sheet.detents = @[[UISheetPresentationControllerDetent mediumDetent]];
sheet.prefersGrabberVisible = YES;
sheet.preferredCornerRadius = 28;
}
[source presentViewController:vc animated:YES completion:nil];
}
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) {
return tc.userInterfaceStyle == UIUserInterfaceStyleDark
? [UIColor colorWithWhite:0.11 alpha:1.0]
: [UIColor systemBackgroundColor];
}];
UIImageView *logo = [[UIImageView alloc] initWithImage:
[UIImage imageNamed:@"ryukgram"
inBundle:SCILocalizationBundle()
compatibleWithTraitCollection:nil]];
logo.contentMode = UIViewContentModeScaleAspectFill;
logo.clipsToBounds = YES;
logo.layer.cornerRadius = 18;
logo.layer.cornerCurve = kCACornerCurveContinuous;
[logo.widthAnchor constraintEqualToConstant:78].active = YES;
[logo.heightAnchor constraintEqualToConstant:78].active = YES;
UILabel *title = [[UILabel alloc] init];
title.text = @"RyukGram";
title.font = [UIFont systemFontOfSize:22 weight:UIFontWeightBold];
title.textAlignment = NSTextAlignmentCenter;
UILabel *version = [[UILabel alloc] init];
version.text = SCIVersionString;
version.font = [UIFont systemFontOfSize:14 weight:UIFontWeightRegular];
version.textColor = [UIColor secondaryLabelColor];
version.textAlignment = NSTextAlignmentCenter;
UIButton *github = [self makeButtonWithTitle:SCILocalized(@"View on GitHub")
sfSymbol:@"chevron.left.forwardslash.chevron.right"
tint:[UIColor labelColor]
background:[UIColor tertiarySystemFillColor]];
[github addTarget:self action:@selector(openGitHub) forControlEvents:UIControlEventTouchUpInside];
UIButton *telegram = [self makeButtonWithTitle:SCILocalized(@"Join Telegram channel")
sfSymbol:@"paperplane.fill"
tint:[UIColor whiteColor]
background:[UIColor colorWithRed:0.15 green:0.56 blue:0.93 alpha:1.0]];
[telegram addTarget:self action:@selector(openTelegram) forControlEvents:UIControlEventTouchUpInside];
UIStackView *buttons = [[UIStackView alloc] initWithArrangedSubviews:@[github, telegram]];
buttons.axis = UILayoutConstraintAxisVertical;
buttons.spacing = 10;
buttons.distribution = UIStackViewDistributionFillEqually;
UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[logo, title, version, buttons]];
stack.axis = UILayoutConstraintAxisVertical;
stack.alignment = UIStackViewAlignmentCenter;
stack.spacing = 14;
[stack setCustomSpacing:2 afterView:title];
[stack setCustomSpacing:22 afterView:version];
stack.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:stack];
UILayoutGuide *g = self.view.safeAreaLayoutGuide;
[NSLayoutConstraint activateConstraints:@[
[stack.centerYAnchor constraintEqualToAnchor:g.centerYAnchor],
[stack.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20],
[stack.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20],
[buttons.widthAnchor constraintEqualToAnchor:stack.widthAnchor],
]];
}
- (UIButton *)makeButtonWithTitle:(NSString *)title
sfSymbol:(NSString *)symbol
tint:(UIColor *)tint
background:(UIColor *)bg {
UIButtonConfiguration *cfg = [UIButtonConfiguration filledButtonConfiguration];
cfg.title = title;
cfg.image = [UIImage systemImageNamed:symbol];
cfg.imagePadding = 10;
cfg.imagePlacement = NSDirectionalRectEdgeLeading;
cfg.baseForegroundColor = tint;
cfg.baseBackgroundColor = bg;
cfg.cornerStyle = UIButtonConfigurationCornerStyleLarge;
cfg.contentInsets = NSDirectionalEdgeInsetsMake(14, 16, 14, 16);
UIButton *b = [UIButton buttonWithConfiguration:cfg primaryAction:nil];
b.translatesAutoresizingMaskIntoConstraints = NO;
return b;
}
- (void)openGitHub {
NSURL *url = [NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram"];
[self dismissViewControllerAnimated:YES completion:^{
if (url) [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}];
}
- (void)openTelegram {
UIApplication *app = [UIApplication sharedApplication];
NSURL *scheme = [NSURL URLWithString:@"tg://resolve?domain=ryukgram"];
NSURL *web = [NSURL URLWithString:@"https://t.me/ryukgram"];
// IG's Info.plist doesn't whitelist `tg` for canOpenURL skip the check
// and fall through to the web link if the scheme isn't handled.
[self dismissViewControllerAnimated:YES completion:^{
[app openURL:scheme options:@{} completionHandler:^(BOOL ok) {
if (!ok && web) [app openURL:web options:@{} completionHandler:nil];
}];
}];
}
@end
+9 -2
View File
@@ -14,8 +14,6 @@ typedef NS_ENUM(NSInteger, SCITableCell) {
SCITableCellNavigation,
};
///
@interface SCISetting : NSObject
@property (nonatomic, readonly) SCITableCell type;
@@ -28,6 +26,7 @@ typedef NS_ENUM(NSInteger, SCITableCell) {
@property (nonatomic, strong) NSURL *url;
@property (nonatomic, strong) NSURL *imageUrl;
@property (nonatomic, copy, nullable) NSString *bundleImageName;
@property (nonatomic) BOOL requiresRestart;
@property (nonatomic) BOOL disabled;
@@ -44,6 +43,14 @@ typedef NS_ENUM(NSInteger, SCITableCell) {
@property (nonatomic, copy, nullable) NSString *(^dynamicTitle)(void);
/// Optional trailing label for a static cell. Rendered right-aligned; pairs
/// with `subtitle` (which still renders beneath the title) when both are set.
@property (nonatomic, copy, nullable) NSString *valueText;
/// Optional override for the title text color. Primarily useful for giving
/// action-style button cells the same tint as link cells.
@property (nonatomic, strong, nullable) UIColor *titleColor;
@property (nonatomic, strong) NSArray *navSections;
@property (nonatomic, strong) UIViewController *navViewController;
+196 -601
View File
@@ -3,382 +3,31 @@
#import "SCISetting.h"
#import "../Utils.h"
#import "../Tweak.h"
#import "../Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.h"
#import "SCIBackupScopePickerVC.h"
#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.
#pragma mark - Preview view controller
typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
SCIBackupPreviewRowKindReadOnly,
SCIBackupPreviewRowKindSwitch,
SCIBackupPreviewRowKindMenu,
typedef NS_OPTIONS(NSInteger, SCIBackupScope) {
SCIBackupScopeSettings = 1 << 0, // preferences only (no lists, no analyzer)
SCIBackupScopeLists = 1 << 1, // excluded chats / story users / embed domains
SCIBackupScopeAnalyzer = 1 << 2, // Profile Analyzer snapshots + header cache
};
static const SCIBackupScope SCIBackupScopeAll =
SCIBackupScopeSettings | SCIBackupScopeLists | SCIBackupScopeAnalyzer;
@interface SCIBackupPreviewRow : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic, copy) NSString *value;
@property (nonatomic, copy, nullable) NSString *defaultsKey;
@property (nonatomic) SCIBackupPreviewRowKind kind;
@property (nonatomic, strong, nullable) NSArray<NSDictionary *> *menuOptions;
@end
@implementation SCIBackupPreviewRow
@end
// Export / import / reset for Settings, excluded lists, and analyzer data
// scoped via SCIBackupScopePickerVC, written as v2 JSON with a v1 flat-file
// import path for back-compat.
@interface SCIBackupPreviewGroup : NSObject
@property (nonatomic, copy) NSString *title;
@property (nonatomic, strong) NSMutableArray<SCIBackupPreviewRow *> *rows;
@property (nonatomic) BOOL collapsed;
@end
@implementation SCIBackupPreviewGroup
@end
@class SCIBackupPreviewVC, SCIBackupPreviewGroup;
@interface SCISettingsBackup (PreviewBuilder)
+ (NSArray<SCIBackupPreviewGroup *> *)buildPreviewGroupsForSettings:(NSDictionary *)values;
+ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out;
+ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw;
@end
@interface SCIBackupPreviewVC : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating, UISearchControllerDelegate>
@property (nonatomic, strong) NSMutableDictionary *mutableSettings;
@property (nonatomic, copy) NSString *primaryActionTitle;
@property (nonatomic, copy) void (^primaryAction)(SCIBackupPreviewVC *vc);
@property (nonatomic, strong) NSArray<SCIBackupPreviewGroup *> *allGroups;
@property (nonatomic, strong) NSArray<SCIBackupPreviewGroup *> *visibleGroups;
@property (nonatomic, copy) NSString *searchText;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UITextView *jsonTextView;
@property (nonatomic, strong) UISearchController *searchController;
@property (nonatomic, strong) UIBarButtonItem *moreItem;
@property (nonatomic) BOOL editMode;
@property (nonatomic) BOOL jsonMode;
@end
@implementation SCIBackupPreviewVC
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
self.navigationItem.leftBarButtonItem =
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel
target:self
action:@selector(cancel)];
NSMutableArray *rightItems = [NSMutableArray array];
if (self.primaryActionTitle.length && self.primaryAction) {
[rightItems addObject:[[UIBarButtonItem alloc] initWithTitle:self.primaryActionTitle
style:UIBarButtonItemStyleDone
target:self
action:@selector(runPrimary)]];
}
// Edit and JSON view live inside a single "More" menu so the title has room.
self.moreItem = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"]
style:UIBarButtonItemStylePlain
target:nil action:nil];
self.moreItem.menu = [self buildMoreMenu];
[rightItems addObject:self.moreItem];
self.navigationItem.rightBarButtonItems = rightItems;
UITableView *table = [[UITableView alloc] initWithFrame:self.view.bounds
style:UITableViewStyleInsetGrouped];
table.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
table.dataSource = self;
table.delegate = self;
table.rowHeight = UITableViewAutomaticDimension;
table.estimatedRowHeight = 50;
table.sectionHeaderHeight = UITableViewAutomaticDimension;
table.estimatedSectionHeaderHeight = 44;
[self.view addSubview:table];
self.tableView = table;
UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil];
sc.searchResultsUpdater = self;
sc.delegate = self;
sc.obscuresBackgroundDuringPresentation = NO;
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 {
NSString *q = searchController.searchBar.text ?: @"";
self.searchText = q;
if (q.length == 0) {
self.visibleGroups = self.allGroups;
} else {
NSMutableArray *out = [NSMutableArray array];
for (SCIBackupPreviewGroup *g in self.allGroups) {
NSMutableArray *matches = [NSMutableArray array];
for (SCIBackupPreviewRow *r in g.rows) {
if ([r.title rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound) {
[matches addObject:r];
}
}
if (matches.count) {
SCIBackupPreviewGroup *clone = [SCIBackupPreviewGroup new];
clone.title = g.title;
clone.rows = matches;
clone.collapsed = NO; // force-expand while searching
[out addObject:clone];
}
}
self.visibleGroups = out;
}
[self.tableView reloadData];
}
#pragma mark Table data source
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
return self.visibleGroups.count;
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
SCIBackupPreviewGroup *g = self.visibleGroups[section];
return g.collapsed ? 0 : g.rows.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section];
SCIBackupPreviewRow *row = g.rows[indexPath.row];
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"row"];
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"row"];
}
cell.textLabel.text = row.title;
cell.textLabel.numberOfLines = 0;
cell.textLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
if (row.kind == SCIBackupPreviewRowKindSwitch && row.defaultsKey.length) {
UISwitch *sw = [[UISwitch alloc] init];
id raw = self.mutableSettings[row.defaultsKey];
sw.on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO;
sw.enabled = self.editMode;
objc_setAssociatedObject(sw, "sci_key", row.defaultsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[sw addTarget:self action:@selector(switchToggled:) forControlEvents:UIControlEventValueChanged];
cell.accessoryView = sw;
cell.detailTextLabel.text = nil;
cell.accessoryType = UITableViewCellAccessoryNone;
} else if (row.kind == SCIBackupPreviewRowKindMenu && row.defaultsKey.length) {
cell.accessoryView = nil;
cell.detailTextLabel.text = row.value;
cell.accessoryType = self.editMode ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone;
cell.selectionStyle = self.editMode ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone;
} else {
cell.accessoryView = nil;
cell.accessoryType = UITableViewCellAccessoryNone;
cell.detailTextLabel.text = row.value;
}
return cell;
}
- (void)switchToggled:(UISwitch *)sender {
NSString *key = objc_getAssociatedObject(sender, "sci_key");
if (!key.length) return;
self.mutableSettings[key] = @(sender.isOn);
}
- (UIMenu *)buildMoreMenu {
__weak typeof(self) weakSelf = self;
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 ? SCILocalized(@"Form view") : SCILocalized(@"Raw JSON view"))
image:[UIImage systemImageNamed:(self.jsonMode ? @"list.bullet" : @"curlybraces")]
identifier:nil
handler:^(__kindof UIAction *_) {
[weakSelf toggleJsonMode];
}];
return [UIMenu menuWithChildren:@[editAction, jsonAction]];
}
- (void)refreshMoreMenu { self.moreItem.menu = [self buildMoreMenu]; }
- (void)toggleEditMode {
self.editMode = !self.editMode;
[self.tableView reloadData];
[self refreshMoreMenu];
}
- (void)toggleJsonMode {
self.jsonMode = !self.jsonMode;
if (self.jsonMode) {
if (!self.jsonTextView) {
self.jsonTextView = [[UITextView alloc] initWithFrame:self.view.bounds];
self.jsonTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.jsonTextView.editable = NO;
self.jsonTextView.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular];
self.jsonTextView.backgroundColor = [UIColor systemGroupedBackgroundColor];
self.jsonTextView.textContainerInset = UIEdgeInsetsMake(16, 12, 16, 12);
self.jsonTextView.alwaysBounceVertical = YES;
}
NSData *data = [NSJSONSerialization dataWithJSONObject:self.mutableSettings ?: @{}
options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys
error:nil];
self.jsonTextView.text = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}";
[self.view addSubview:self.jsonTextView];
self.tableView.hidden = YES;
self.navigationItem.searchController = nil;
} else {
[self.jsonTextView removeFromSuperview];
self.tableView.hidden = NO;
self.navigationItem.searchController = self.searchController;
}
[self refreshMoreMenu];
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tableView deselectRowAtIndexPath:indexPath animated:YES];
if (!self.editMode) return;
SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section];
SCIBackupPreviewRow *row = g.rows[indexPath.row];
if (row.kind != SCIBackupPreviewRowKindMenu || !row.menuOptions.count || !row.defaultsKey.length) return;
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:row.title
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSString *currentValue = [self.mutableSettings[row.defaultsKey] description];
for (NSDictionary *opt in row.menuOptions) {
NSString *optTitle = opt[@"title"];
NSString *optValue = opt[@"value"];
if (!optTitle.length || !optValue.length) continue;
NSString *display = [optValue isEqualToString:currentValue]
? [NSString stringWithFormat:@"%@ ✓", optTitle]
: optTitle;
[sheet addAction:[UIAlertAction actionWithTitle:display
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) {
self.mutableSettings[row.defaultsKey] = optValue;
row.value = optTitle;
[self.tableView reloadRowsAtIndexPaths:@[indexPath]
withRowAnimation:UITableViewRowAnimationFade];
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath];
sheet.popoverPresentationController.sourceView = cell;
sheet.popoverPresentationController.sourceRect = cell.bounds;
[self presentViewController:sheet animated:YES completion:nil];
}
#pragma mark Section headers (collapsible)
- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section {
SCIBackupPreviewGroup *g = self.visibleGroups[section];
UIView *header = [[UIView alloc] init];
header.backgroundColor = [UIColor clearColor];
UILabel *label = [[UILabel alloc] init];
label.text = g.title;
label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote];
label.textColor = [UIColor secondaryLabelColor];
label.translatesAutoresizingMaskIntoConstraints = NO;
UIImageView *chev = [[UIImageView alloc] init];
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
chev.image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")]
imageByApplyingSymbolConfiguration:cfg];
chev.tintColor = [UIColor secondaryLabelColor];
chev.translatesAutoresizingMaskIntoConstraints = NO;
[header addSubview:label];
[header addSubview:chev];
[NSLayoutConstraint activateConstraints:@[
[label.leadingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.leadingAnchor],
[label.centerYAnchor constraintEqualToAnchor:header.centerYAnchor],
[label.trailingAnchor constraintLessThanOrEqualToAnchor:chev.leadingAnchor constant:-8],
[chev.trailingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.trailingAnchor],
[chev.centerYAnchor constraintEqualToAnchor:header.centerYAnchor],
[header.heightAnchor constraintGreaterThanOrEqualToConstant:36],
]];
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sectionHeaderTapped:)];
header.tag = section;
[header addGestureRecognizer:tap];
return header;
}
- (void)sectionHeaderTapped:(UITapGestureRecognizer *)tap {
NSInteger section = tap.view.tag;
if (section < 0 || section >= (NSInteger)self.visibleGroups.count) return;
SCIBackupPreviewGroup *g = self.visibleGroups[section];
g.collapsed = !g.collapsed;
[self.tableView reloadSections:[NSIndexSet indexSetWithIndex:section]
withRowAnimation:UITableViewRowAnimationFade];
UIView *header = [self.tableView headerViewForSection:section] ?: [self tableView:self.tableView viewForHeaderInSection:section];
for (UIView *sub in header.subviews) {
if ([sub isKindOfClass:[UIImageView class]]) {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
((UIImageView *)sub).image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")]
imageByApplyingSymbolConfiguration:cfg];
}
}
}
- (void)cancel {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)runPrimary {
if (self.primaryAction) self.primaryAction(self);
}
@end
@class SCIBackupPreviewGroup;
@interface SCISettingsBackup ()
+ (void)showError:(NSString *)message;
+ (void)showSuccessHUD:(NSString *)message;
+ (void)presentApplyConfirmationForData:(NSData *)data;
+ (void)pickFromFiles;
+ (NSArray<SCIBackupPreviewGroup *> *)buildPreviewGroupsForSettings:(NSDictionary *)values;
@end
#pragma mark - Helper singleton (document picker delegate)
@@ -449,6 +98,14 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
return keys;
}
// Settings-scope keys = allPrefKeys minus the list keys. Used when the user
// picks "Settings only" in the scope sheet lists stay put on import/reset.
+ (NSSet<NSString *> *)settingsOnlyKeys {
NSMutableSet *keys = [[self allPrefKeys] mutableCopy];
[keys minusSet:[NSSet setWithArray:[self extraDataKeys]]];
return keys;
}
+ (void)collectKeysFromSections:(NSArray *)sections into:(NSMutableSet *)keys {
for (id section in sections) {
if (![section isKindOfClass:[NSDictionary class]]) continue;
@@ -536,186 +193,6 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
return [[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] ?: @"";
}
#pragma mark Human-readable preview groups
+ (NSArray<SCIBackupPreviewGroup *> *)buildPreviewGroupsForSettings:(NSDictionary *)values {
NSMutableArray<SCIBackupPreviewGroup *> *groups = [NSMutableArray array];
[self collectGroupsFromSections:[SCITweakSettings sections]
breadcrumb:@""
values:values
out:groups];
NSSet *known = [self allPrefKeys];
NSMutableArray *unknown = [NSMutableArray array];
for (NSString *k in values) {
if (![known containsObject:k]) [unknown addObject:k];
}
if (unknown.count) {
[unknown sortUsingSelector:@selector(compare:)];
SCIBackupPreviewGroup *g = [SCIBackupPreviewGroup new];
g.title = @"OTHER";
g.rows = [NSMutableArray array];
for (NSString *k in unknown) {
SCIBackupPreviewRow *r = [SCIBackupPreviewRow new];
r.title = k;
r.value = [self displayStringForValue:values[k]];
r.kind = SCIBackupPreviewRowKindReadOnly;
[g.rows addObject:r];
}
[groups addObject:g];
}
return groups;
}
+ (void)collectGroupsFromSections:(NSArray *)sections
breadcrumb:(NSString *)breadcrumb
values:(NSDictionary *)values
out:(NSMutableArray<SCIBackupPreviewGroup *> *)out {
for (id sectionObj in sections) {
if (![sectionObj isKindOfClass:[NSDictionary class]]) continue;
NSDictionary *section = sectionObj;
NSString *sectionHeader = section[@"header"] ?: @"";
NSArray *rows = section[@"rows"];
SCIBackupPreviewGroup *currentGroup = nil;
for (id rowObj in rows) {
if (![rowObj isKindOfClass:[SCISetting class]]) continue;
SCISetting *s = rowObj;
if (s.navSections) {
NSString *childBreadcrumb = breadcrumb.length
? [NSString stringWithFormat:@"%@ %@", breadcrumb, s.title]
: s.title;
[self collectGroupsFromSections:s.navSections
breadcrumb:childBreadcrumb
values:values
out:out];
continue;
}
BOOL isMenu = (s.type == SCITableCellMenu);
if (!s.defaultsKey.length && !isMenu) continue;
SCIBackupPreviewRow *r = [SCIBackupPreviewRow new];
r.title = s.title.length ? s.title : (s.defaultsKey ?: @"?");
r.defaultsKey = s.defaultsKey;
if (s.type == SCITableCellSwitch) {
r.kind = SCIBackupPreviewRowKindSwitch;
id raw = values[s.defaultsKey];
BOOL on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO;
r.value = on ? SCILocalized(@"On") : SCILocalized(@"Off");
} else if (s.type == SCITableCellStepper) {
r.kind = SCIBackupPreviewRowKindReadOnly;
id raw = values[s.defaultsKey];
NSString *display = @"";
if (raw) {
double d = [raw doubleValue];
if (fmod(d, 1.0) == 0.0) display = [NSString stringWithFormat:@"%lld", (long long)d];
else display = [NSString stringWithFormat:@"%g", d];
if (s.label.length) display = [display stringByAppendingFormat:@" %@", s.label];
}
r.value = display;
} else if (isMenu) {
r.kind = SCIBackupPreviewRowKindMenu;
NSMutableArray *opts = [NSMutableArray array];
NSString *defKey = nil;
[self collectOptionsFromMenu:s.baseMenu defaultsKeyOut:&defKey into:opts];
r.menuOptions = opts;
r.defaultsKey = defKey ?: s.defaultsKey;
NSString *menuTitle = [self menuTitleForBaseMenu:s.baseMenu values:values resolvedKey:NULL];
r.value = menuTitle ?: @"";
} else {
r.kind = SCIBackupPreviewRowKindReadOnly;
r.value = [self displayStringForValue:values[s.defaultsKey]];
}
if (!currentGroup) {
currentGroup = [SCIBackupPreviewGroup new];
NSMutableString *hdr = [NSMutableString string];
if (breadcrumb.length) [hdr appendString:breadcrumb];
if (sectionHeader.length) {
if (hdr.length) [hdr appendString:@""];
[hdr appendString:sectionHeader];
}
if (!hdr.length) hdr = [NSMutableString stringWithString:@"General"];
currentGroup.title = [hdr uppercaseString];
currentGroup.rows = [NSMutableArray array];
[out addObject:currentGroup];
}
[currentGroup.rows addObject:r];
}
}
}
+ (NSString *)displayStringForValue:(id)raw {
if (!raw || raw == [NSNull null]) return @"";
if ([raw isKindOfClass:[NSNumber class]]) {
NSNumber *n = raw;
const char *t = n.objCType;
if (t && strcmp(t, "c") == 0) return n.boolValue ? SCILocalized(@"On") : SCILocalized(@"Off");
return n.stringValue;
}
if ([raw isKindOfClass:[NSString class]]) return raw;
return [NSString stringWithFormat:@"%@", raw];
}
+ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw {
if (!menu) return nil;
NSString *defaultsKey = nil;
UICommand *match = [self findMatchingCommandInMenu:menu values:values defaultsKeyOut:&defaultsKey];
if (defaultsKey && outRaw) *outRaw = values[defaultsKey];
if (match) return match.title;
if (defaultsKey) return [self displayStringForValue:values[defaultsKey]];
return nil;
}
+ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out {
if (!menu) return;
for (id child in menu.children) {
if ([child isKindOfClass:[UIMenu class]]) {
[self collectOptionsFromMenu:child defaultsKeyOut:outKey into:out];
} else if ([child isKindOfClass:[UICommand class]]) {
UICommand *cmd = child;
id pl = cmd.propertyList;
if ([pl isKindOfClass:[NSDictionary class]]) {
NSString *k = ((NSDictionary *)pl)[@"defaultsKey"];
NSString *v = ((NSDictionary *)pl)[@"value"];
if ([k isKindOfClass:[NSString class]] && k.length &&
[v isKindOfClass:[NSString class]] && v.length) {
if (outKey && !*outKey) *outKey = k;
[out addObject:@{ @"value": v, @"title": cmd.title ?: v }];
}
}
}
}
}
+ (UICommand *)findMatchingCommandInMenu:(UIMenu *)menu values:(NSDictionary *)values defaultsKeyOut:(NSString **)outKey {
for (id child in menu.children) {
if ([child isKindOfClass:[UIMenu class]]) {
UICommand *m = [self findMatchingCommandInMenu:child values:values defaultsKeyOut:outKey];
if (m) return m;
} else if ([child isKindOfClass:[UICommand class]]) {
UICommand *cmd = child;
id pl = cmd.propertyList;
if ([pl isKindOfClass:[NSDictionary class]]) {
NSString *k = ((NSDictionary *)pl)[@"defaultsKey"];
NSString *v = ((NSDictionary *)pl)[@"value"];
if ([k isKindOfClass:[NSString class]] && k.length) {
if (outKey && !*outKey) *outKey = k;
id current = values[k];
if (current && v && [[NSString stringWithFormat:@"%@", current] isEqualToString:v]) {
return cmd;
}
}
}
}
}
return nil;
}
+ (void)showSuccessHUD:(NSString *)message {
UINotificationFeedbackGenerator *fb = [[UINotificationFeedbackGenerator alloc] init];
[fb prepare];
@@ -743,56 +220,164 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
[topMostController() presentViewController:a animated:YES completion:nil];
}
#pragma mark Export
#pragma mark Scope picker
+ (void)presentExport {
NSDictionary *snap = [self snapshotCurrentSettings];
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = SCILocalized(@"Export settings");
vc.mutableSettings = [snap mutableCopy];
vc.primaryActionTitle = @"Save";
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
NSData *data = [self serializeSettings:previewVC.mutableSettings];
NSString *fname = [NSString stringWithFormat:@"ryukgram-settings-%@.json", [self timestampString]];
NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname];
NSError *err = nil;
[data writeToURL:tmp options:NSDataWritingAtomic error:&err];
if (err) { [self showError:SCILocalized(@"Could not write temporary file.")]; return; }
UIDocumentPickerViewController *p =
[[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]];
SCIBackupHelper *helper = [SCIBackupHelper shared];
helper.expectingExportPick = YES;
p.delegate = helper;
[previewVC presentViewController:p animated:YES completion:nil];
};
// Scope enum bits match SCIBackupScopePickerMask one-to-one, so the mask can
// be cast back and forth.
+ (void)presentScopePickerWithContinueTitle:(NSString *)continueTitle
message:(NSString *)message
availableMask:(SCIBackupScope)available
initialSelection:(SCIBackupScope)initial
payload:(NSDictionary *)payload
handler:(void(^)(SCIBackupScope scope))handler {
SCIBackupScopePickerVC *vc = [SCIBackupScopePickerVC new];
vc.title = continueTitle;
vc.continueTitle = continueTitle;
vc.headerMessage = message;
vc.availableScopes = (SCIBackupScopePickerMask)available;
vc.initialSelection = (SCIBackupScopePickerMask)initial;
vc.payload = payload;
vc.onContinue = ^(SCIBackupScopePickerMask chosen) { handler((SCIBackupScope)chosen); };
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFormSheet;
[topMostController() presentViewController:nav animated:YES completion:nil];
}
#pragma mark Scoped payload build / apply
+ (NSDictionary *)snapshotForScope:(SCIBackupScope)scope {
NSMutableDictionary *root = [NSMutableDictionary dictionary];
root[@"ryukgram_export"] = @(YES);
root[@"version"] = @(2);
root[@"exported_at"] = @([[NSDate date] timeIntervalSince1970]);
NSDictionary *full = [self snapshotCurrentSettings];
if (scope & SCIBackupScopeSettings) {
NSMutableDictionary *s = [NSMutableDictionary dictionary];
NSSet *listKeys = [NSSet setWithArray:[self extraDataKeys]];
for (NSString *k in full) if (![listKeys containsObject:k]) s[k] = full[k];
root[@"settings"] = s;
}
if (scope & SCIBackupScopeLists) {
NSMutableDictionary *l = [NSMutableDictionary dictionary];
for (NSString *k in [self extraDataKeys]) if (full[k]) l[k] = full[k];
root[@"lists"] = l;
}
if (scope & SCIBackupScopeAnalyzer) {
root[@"analyzer"] = [SCIProfileAnalyzerStorage exportedDict] ?: @{};
}
return root;
}
// Applies the intersection of payload sections and the chosen scope.
+ (BOOL)applyImport:(NSDictionary *)root scope:(SCIBackupScope)scope {
if (![root isKindOfClass:[NSDictionary class]]) return NO;
BOOL anyApplied = NO;
NSDictionary *settings = [root[@"settings"] isKindOfClass:[NSDictionary class]] ? root[@"settings"] : nil;
// v1 back-compat: file is a flat map of pref keys value.
if (!settings && !root[@"ryukgram_export"]) settings = root;
if ((scope & SCIBackupScopeSettings) && settings) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
NSSet *keys = [self settingsOnlyKeys];
for (NSString *k in keys) [d removeObjectForKey:k];
for (NSString *k in settings) if ([keys containsObject:k]) [d setObject:settings[k] forKey:k];
[d synchronize];
anyApplied = YES;
}
if ((scope & SCIBackupScopeLists) && [root[@"lists"] isKindOfClass:[NSDictionary class]]) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
for (NSString *k in [self extraDataKeys]) [d removeObjectForKey:k];
NSDictionary *lists = root[@"lists"];
for (NSString *k in lists) if ([[self extraDataKeys] containsObject:k]) [d setObject:lists[k] forKey:k];
[d synchronize];
anyApplied = YES;
}
if ((scope & SCIBackupScopeAnalyzer) && [root[@"analyzer"] isKindOfClass:[NSDictionary class]]) {
[SCIProfileAnalyzerStorage importFromDict:root[@"analyzer"]];
anyApplied = YES;
}
return anyApplied;
}
+ (void)resetForScope:(SCIBackupScope)scope {
if (scope & SCIBackupScopeSettings) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
for (NSString *k in [self settingsOnlyKeys]) [d removeObjectForKey:k];
[d synchronize];
}
if (scope & SCIBackupScopeLists) {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
for (NSString *k in [self extraDataKeys]) [d removeObjectForKey:k];
[d synchronize];
}
if (scope & SCIBackupScopeAnalyzer) {
[SCIProfileAnalyzerStorage resetAll];
}
}
#pragma mark Export
+ (void)presentExport {
NSDictionary *preview = [self snapshotForScope:SCIBackupScopeAll];
[self presentScopePickerWithContinueTitle:SCILocalized(@"Export")
message:SCILocalized(@"Tick what to include. Tap any row to inspect its contents.")
availableMask:SCIBackupScopeAll
initialSelection:SCIBackupScopeAll
payload:preview
handler:^(SCIBackupScope scope) {
// Rebuild payload against the final selection.
NSDictionary *payload = [self snapshotForScope:scope];
[self writeExportToFilePicker:payload host:topMostController()];
}];
}
+ (void)writeExportToFilePicker:(NSDictionary *)payload host:(UIViewController *)host {
NSData *data = [self serializeSettings:payload];
NSString *fname = [NSString stringWithFormat:@"ryukgram-export-%@.json", [self timestampString]];
NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname];
NSError *err = nil;
[data writeToURL:tmp options:NSDataWritingAtomic error:&err];
if (err) { [self showError:SCILocalized(@"Could not write temporary file.")]; return; }
UIDocumentPickerViewController *p =
[[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]];
SCIBackupHelper *helper = [SCIBackupHelper shared];
helper.expectingExportPick = YES;
p.delegate = helper;
[host presentViewController:p animated:YES completion:nil];
}
#pragma mark Import
+ (void)presentImport {
// File first, then scope picker against its contents.
[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];
NSDictionary *preview = [self snapshotForScope:SCIBackupScopeAll];
[self presentScopePickerWithContinueTitle:SCILocalized(@"Reset")
message:SCILocalized(@"Selected data will be cleared. Tap any row to see what's stored.")
availableMask:SCIBackupScopeAll
initialSelection:0
payload:preview
handler:^(SCIBackupScope scope) {
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:SCILocalized(@"Reset selected data?")
message:SCILocalized(@"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) {
[self resetForScope:scope];
if (scope & SCIBackupScopeSettings) [SCIUtils showRestartConfirmation];
else [self showSuccessHUD:SCILocalized(@"Reset complete")];
}]];
[topMostController() presentViewController:alert animated:YES completion:nil];
}];
}
+ (void)pickFromFiles {
@@ -805,35 +390,45 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
}
+ (void)presentApplyConfirmationForData:(NSData *)data {
NSDictionary *settings = [self parseSettingsFromData:data];
if (!settings) {
[self showError:SCILocalized(@"File is not a valid RyukGram settings export.")];
NSError *parseErr = nil;
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseErr];
if (![parsed isKindOfClass:[NSDictionary class]]) {
[self showError:SCILocalized(@"File is not a valid RyukGram export.")];
return;
}
NSDictionary *root = parsed;
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = SCILocalized(@"Import preview");
vc.mutableSettings = [settings mutableCopy];
vc.primaryActionTitle = @"Apply";
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
UIAlertController *confirm =
[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];
// Offer only the sections actually present in the file.
SCIBackupScope available = 0;
if ([root[@"settings"] isKindOfClass:[NSDictionary class]]) available |= SCIBackupScopeSettings;
if ([root[@"lists"] isKindOfClass:[NSDictionary class]]) available |= SCIBackupScopeLists;
if ([root[@"analyzer"] isKindOfClass:[NSDictionary class]]) available |= SCIBackupScopeAnalyzer;
// v1 back-compat: flat pref map treat as settings-only.
if (!available && !root[@"ryukgram_export"]) available = SCIBackupScopeSettings;
if (!available) { [self showError:SCILocalized(@"File has no importable sections.")]; return; }
// Wrap v1 flat files into the v2 envelope for the picker.
NSDictionary *normalized = root[@"ryukgram_export"] ? root : @{ @"settings": root };
[self presentScopePickerWithContinueTitle:SCILocalized(@"Apply")
message:SCILocalized(@"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled.")
availableMask:available
initialSelection:available
payload:normalized
handler:^(SCIBackupScope scope) {
UIAlertController *confirm = [UIAlertController
alertControllerWithTitle:SCILocalized(@"Apply imported data?")
message:SCILocalized(@"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect.")
preferredStyle:UIAlertControllerStyleAlert];
[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:SCILocalized(@"Settings imported")];
[SCIUtils showRestartConfirmation];
}];
BOOL applied = [self applyImport:root scope:scope];
if (!applied) { [self showError:SCILocalized(@"Nothing was applied.")]; return; }
[self showSuccessHUD:SCILocalized(@"Import complete")];
if (scope & SCIBackupScopeSettings) [SCIUtils showRestartConfirmation];
}]];
[previewVC presentViewController:confirm animated:YES completion:nil];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFormSheet;
[topMostController() presentViewController:nav animated:YES completion:nil];
[topMostController() presentViewController:confirm animated:YES completion:nil];
}];
}
@end

Some files were not shown because too many files have changed in this diff Show More