mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-05-31 20:51:35 +02:00
[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:
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(); });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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),
|
||||
|
||||
@@ -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 1342–1344).
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 1339–1341).
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (A–Z)" = "الاسم (أ–ي)";
|
||||
"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 (A–Z)" = "اسم المستخدم (أ–ي)";
|
||||
"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 (A–Z)" = "Name (A–Z)";
|
||||
"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 (A–Z)" = "Username (A–Z)";
|
||||
"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 (A–Z)" = "Nombre (A–Z)";
|
||||
"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 (A–Z)" = "Usuario (A–Z)";
|
||||
"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 (A–Z)" = "Имя (А–Я)";
|
||||
"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 (A–Z)" = "Пользователь (А–Я)";
|
||||
"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
@@ -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].
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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];
|
||||
|
||||
@@ -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 (A–Z)"];
|
||||
NSArray *titles = @[SCILocalized(@"Recently added"), SCILocalized(@"Name (A–Z)")];
|
||||
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 (A–Z)"];
|
||||
NSArray *titles = @[SCILocalized(@"Recently added"), SCILocalized(@"Username (A–Z)")];
|
||||
for (NSInteger i = 0; i < (NSInteger)titles.count; i++) {
|
||||
UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i]
|
||||
style:UIAlertActionStyleDefault
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCIExpFlagsViewController : UIViewController
|
||||
@end
|
||||
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCILinksSheet : UIViewController
|
||||
|
||||
+ (void)presentFrom:(UIViewController *)source;
|
||||
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user