Files
RyukGram/src/Features/ProfileAnalyzer/SCIProfileAnalyzerModels.m
T
faroukbmiled 2977873932 [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
2026-04-24 02:50:30 +01:00

213 lines
8.2 KiB
Objective-C

#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