mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-07-31 00:17:26 +02:00
feat: Mark seen on story like
feat: Added copy button in profile page to copy various profile information feat: Added export/import settings option - With Searchable, collapsible, editable preview before saving or applying imp: Search bar in tweak settings imp: Hide custom story buttons when zooming (follows ig buttons) bug: Fix a bug in keep deleted messages marking removed reactions as unset messages
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../../modules/JGProgressHUD/JGProgressHUD.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// Profile page copy button: hooks IG's native nav header builder to insert
|
||||
// a copy button alongside IG's own buttons, then opens a menu to copy
|
||||
// username/name/bio.
|
||||
|
||||
@interface IGProfileViewController : UIViewController
|
||||
@end
|
||||
|
||||
static id sci_safeValueForKey(id obj, NSString *key) {
|
||||
@try { return [obj valueForKey:key]; }
|
||||
@catch (__unused NSException *e) { return nil; }
|
||||
}
|
||||
|
||||
static id sci_valueForAnyKey(id obj, NSArray<NSString *> *keys) {
|
||||
for (NSString *k in keys) {
|
||||
id v = sci_safeValueForKey(obj, k);
|
||||
if (v && v != [NSNull null]) return v;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id sci_findUserOnVC(UIViewController *vc) {
|
||||
id user = sci_valueForAnyKey(vc, @[@"user", @"userGQL", @"profileUser", @"loggedInUser", @"currentUser"]);
|
||||
if (user) return user;
|
||||
|
||||
Class userCls = NSClassFromString(@"IGUser");
|
||||
Class c = [vc class];
|
||||
while (c && c != [NSObject class]) {
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList(c, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
id v = object_getIvar(vc, ivars[i]);
|
||||
if (userCls && [v isKindOfClass:userCls]) {
|
||||
free(ivars);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
c = class_getSuperclass(c);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static UIViewController *sci_findProfileVC(UIView *view) {
|
||||
Class profileCls = NSClassFromString(@"IGProfileViewController");
|
||||
UIResponder *r = view;
|
||||
while (r) {
|
||||
if (profileCls && [r isKindOfClass:profileCls]) return (UIViewController *)r;
|
||||
r = [r nextResponder];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sci_copyAndToast(NSString *value, NSString *label) {
|
||||
if (value.length == 0) return;
|
||||
[UIPasteboard generalPasteboard].string = value;
|
||||
|
||||
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
|
||||
HUD.textLabel.text = [NSString stringWithFormat:@"Copied %@", label];
|
||||
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
|
||||
UIView *host = nil;
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { host = w; break; }
|
||||
}
|
||||
if (host) {
|
||||
[HUD showInView:host];
|
||||
[HUD dismissAfterDelay:1.5];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton target for the copy button so we don't have to track lifetime.
|
||||
@interface SCIProfileCopyTarget : NSObject
|
||||
+ (instancetype)shared;
|
||||
- (void)handleTap:(UIButton *)sender;
|
||||
@end
|
||||
|
||||
@implementation SCIProfileCopyTarget
|
||||
+ (instancetype)shared {
|
||||
static SCIProfileCopyTarget *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [[SCIProfileCopyTarget alloc] init]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
- (void)handleTap:(UIButton *)sender {
|
||||
UIViewController *vc = sci_findProfileVC(sender);
|
||||
if (!vc) {
|
||||
NSLog(@"[SCInsta] copy button: no IGProfileViewController in responder chain");
|
||||
return;
|
||||
}
|
||||
|
||||
id user = sci_findUserOnVC(vc);
|
||||
if (!user) {
|
||||
NSLog(@"[SCInsta] copy button: no IGUser found on %@", vc.class);
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *username = [sci_valueForAnyKey(user, @[@"username"]) description];
|
||||
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:@"Copy from profile"
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
|
||||
if (username.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:[NSString stringWithFormat:@"Copy username (@%@)", username]
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]];
|
||||
}
|
||||
if (fullName.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Copy name"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]];
|
||||
}
|
||||
if (biography.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Copy bio"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]];
|
||||
}
|
||||
|
||||
NSMutableArray *parts = [NSMutableArray array];
|
||||
if (username.length) [parts addObject:[NSString stringWithFormat:@"Username: @%@", username]];
|
||||
if (fullName.length) [parts addObject:[NSString stringWithFormat:@"Name: %@", fullName]];
|
||||
if (biography.length) [parts addObject:[NSString stringWithFormat:@"Bio:\n%@", biography]];
|
||||
|
||||
if (parts.count >= 2) {
|
||||
NSString *combined = [parts componentsJoinedByString:@"\n\n"];
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Copy all"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]];
|
||||
}
|
||||
|
||||
if (menu.actions.count == 0) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Nothing to copy" style:UIAlertActionStyleDefault handler:nil]];
|
||||
}
|
||||
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
|
||||
if (sender) {
|
||||
menu.popoverPresentationController.sourceView = sender;
|
||||
menu.popoverPresentationController.sourceRect = sender.bounds;
|
||||
}
|
||||
|
||||
[vc presentViewController:menu animated:YES completion:nil];
|
||||
}
|
||||
@end
|
||||
|
||||
static UIView *sci_buildCopyButton(void) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
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.frame = CGRectMake(0, 0, 24, 44);
|
||||
[btn addTarget:[SCIProfileCopyTarget shared]
|
||||
action:@selector(handleTap:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
return btn;
|
||||
}
|
||||
|
||||
static void (*orig_configureHeaderView)(id, SEL, id, id, id, BOOL);
|
||||
|
||||
static void hooked_configureHeaderView(id self, SEL _cmd,
|
||||
id titleView,
|
||||
id leftButtons,
|
||||
id rightButtons,
|
||||
BOOL titleIsCentered) {
|
||||
if (![SCIUtils getBoolPref:@"profile_copy_button"]) {
|
||||
orig_configureHeaderView(self, _cmd, titleView, leftButtons, rightButtons, titleIsCentered);
|
||||
return;
|
||||
}
|
||||
|
||||
NSArray *patched = rightButtons;
|
||||
if ([rightButtons isKindOfClass:[NSArray class]] && [(NSArray *)rightButtons count] > 0) {
|
||||
NSArray *rb = (NSArray *)rightButtons;
|
||||
|
||||
BOOL alreadyHas = NO;
|
||||
for (id wrapper in rb) {
|
||||
UIView *v = sci_safeValueForKey(wrapper, @"view");
|
||||
if ([v isKindOfClass:[UIView class]] &&
|
||||
[v.accessibilityIdentifier isEqualToString:@"sci-profile-copy-button"]) {
|
||||
alreadyHas = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyHas) {
|
||||
Class wrapperCls = NSClassFromString(@"IGProfileNavigationHeaderViewButtonSwift.IGProfileNavigationHeaderViewButton");
|
||||
// Mirror an existing button's type so IG's layout treats ours the same.
|
||||
NSInteger type = 0;
|
||||
id sample = rb.firstObject;
|
||||
id typeVal = sci_safeValueForKey(sample, @"type");
|
||||
if ([typeVal respondsToSelector:@selector(integerValue)]) {
|
||||
type = [typeVal integerValue];
|
||||
}
|
||||
|
||||
UIView *btn = sci_buildCopyButton();
|
||||
id wrapper = nil;
|
||||
if (wrapperCls) {
|
||||
wrapper = [wrapperCls alloc];
|
||||
SEL initSel = @selector(initWithType:view:);
|
||||
if ([wrapper respondsToSelector:initSel]) {
|
||||
id (*ctor)(id, SEL, NSInteger, id) =
|
||||
(id (*)(id, SEL, NSInteger, id))objc_msgSend;
|
||||
wrapper = ctor(wrapper, initSel, type, btn);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapper) {
|
||||
NSMutableArray *m = [rb mutableCopy];
|
||||
[m insertObject:wrapper atIndex:0];
|
||||
patched = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
orig_configureHeaderView(self, _cmd, titleView, leftButtons, patched, titleIsCentered);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = objc_getClass("IGProfileNavigationSwift.IGProfileNavigationHeaderView");
|
||||
if (!cls) return;
|
||||
SEL sel = @selector(configureWithTitleView:leftButtons:rightButtons:titleIsCentered:);
|
||||
if (![cls instancesRespondToSelector:sel]) return;
|
||||
MSHookMessageEx(cls, sel,
|
||||
(IMP)hooked_configureHeaderView,
|
||||
(IMP *)&orig_configureHeaderView);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
// Story seen receipt blocking + visual seen state blocking
|
||||
#import "StoryHelpers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
BOOL sciSeenBypassActive = NO;
|
||||
NSMutableSet *sciAllowedSeenPKs = nil;
|
||||
@@ -92,3 +95,99 @@ static BOOL sciShouldBlockSeenVisual() {
|
||||
- (void)setSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
- (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
%end
|
||||
|
||||
// ============ MARK SEEN ON STORY LIKE ============
|
||||
// Story likes are dispatched through several classes — IGSundialViewerControlsOverlayController,
|
||||
// IGStoryLikesInteractionControllingImpl, and IGSundialLikeButton. Hook every
|
||||
// known entry so any UI path (heart button, future double-tap, etc.) is caught.
|
||||
// On a like, we defer to the manual seen-button handler in OverlayButtons.xm
|
||||
// (sciMarkSeenTapped:) which is the only flow IG actually treats as a real seen.
|
||||
|
||||
static __weak UIViewController *sciActiveStoryVC = nil;
|
||||
|
||||
%hook IGStoryViewerViewController
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciActiveStoryVC = self;
|
||||
}
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
if (sciActiveStoryVC == (UIViewController *)self) sciActiveStoryVC = nil;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
static UIView *sciFindStoryOverlayView(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
Class targetCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!targetCls) return nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:targetCls]) return v;
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciMarkActiveStorySeen(void) {
|
||||
if (![SCIUtils getBoolPref:@"seen_on_story_like"]) return;
|
||||
UIView *overlay = sciFindStoryOverlayView(sciActiveStoryVC);
|
||||
if (!overlay) return;
|
||||
SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:");
|
||||
if ([overlay respondsToSelector:sel]) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
|
||||
}
|
||||
}
|
||||
|
||||
static void (*orig_didLikeSundial)(id, SEL, id);
|
||||
static void new_didLikeSundial(id self, SEL _cmd, id pk) {
|
||||
orig_didLikeSundial(self, _cmd, pk);
|
||||
sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
static void (*orig_overlaySetIsLiked)(id, SEL, BOOL, BOOL);
|
||||
static void new_overlaySetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
|
||||
orig_overlaySetIsLiked(self, _cmd, isLiked, animated);
|
||||
if (isLiked) sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
static void (*orig_handleLikeTap)(id, SEL, id);
|
||||
static void new_handleLikeTap(id self, SEL _cmd, id button) {
|
||||
orig_handleLikeTap(self, _cmd, button);
|
||||
sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
static void (*orig_likeButtonSetIsLiked)(id, SEL, BOOL, BOOL);
|
||||
static void new_likeButtonSetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
|
||||
orig_likeButtonSetIsLiked(self, _cmd, isLiked, animated);
|
||||
if (isLiked) sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class overlayCtl = NSClassFromString(@"IGSundialViewerControlsOverlayController");
|
||||
if (overlayCtl) {
|
||||
SEL didLike = NSSelectorFromString(@"didLikeSundialWithMediaPK:");
|
||||
if (class_getInstanceMethod(overlayCtl, didLike))
|
||||
MSHookMessageEx(overlayCtl, didLike, (IMP)new_didLikeSundial, (IMP *)&orig_didLikeSundial);
|
||||
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(overlayCtl, setLiked))
|
||||
MSHookMessageEx(overlayCtl, setLiked, (IMP)new_overlaySetIsLiked, (IMP *)&orig_overlaySetIsLiked);
|
||||
}
|
||||
|
||||
Class likesImpl = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
|
||||
if (likesImpl) {
|
||||
SEL handleTap = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
|
||||
if (class_getInstanceMethod(likesImpl, handleTap))
|
||||
MSHookMessageEx(likesImpl, handleTap, (IMP)new_handleLikeTap, (IMP *)&orig_handleLikeTap);
|
||||
}
|
||||
|
||||
Class likeBtn = NSClassFromString(@"IGSundialViewerUFI.IGSundialLikeButton");
|
||||
if (likeBtn) {
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(likeBtn, setLiked))
|
||||
MSHookMessageEx(likeBtn, setLiked, (IMP)new_likeButtonSetIsLiked, (IMP *)&orig_likeButtonSetIsLiked);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ static NSMutableArray *sciPendingUpdates = nil;
|
||||
// Server message ID -> timestamp the reason=2 (delete-for-you) was observed.
|
||||
static NSMutableDictionary<NSString *, NSDate *> *sciDeleteForYouKeys = nil;
|
||||
static NSMutableSet *sciPreservedIds = nil;
|
||||
// Server message ID -> content class name for messages we recognize as
|
||||
// reaction/action-log bookkeeping (e.g. "X liked a message" thread entries).
|
||||
// Populated by hooking the data model class init. Used to skip preserving
|
||||
// these IDs when their remove arrives.
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciMessageContentClasses = nil;
|
||||
#define SCI_CONTENT_CLASSES_MAX 4000
|
||||
#define SCI_PENDING_MAX 50
|
||||
|
||||
#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds"
|
||||
#define SCI_PRESERVED_MAX 200
|
||||
@@ -53,6 +60,34 @@ void sciClearPreservedIds() {
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY];
|
||||
}
|
||||
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciGetContentClasses() {
|
||||
if (!sciMessageContentClasses) sciMessageContentClasses = [NSMutableDictionary dictionary];
|
||||
return sciMessageContentClasses;
|
||||
}
|
||||
|
||||
static void sciTrackInsertedMessage(NSString *sid, NSString *className) {
|
||||
if (!sid.length || !className.length) return;
|
||||
NSMutableDictionary *map = sciGetContentClasses();
|
||||
map[sid] = className;
|
||||
if (map.count > SCI_CONTENT_CLASSES_MAX) {
|
||||
// Drop ~10% oldest by simply removing arbitrary keys
|
||||
NSArray *keys = [map allKeys];
|
||||
for (NSUInteger i = 0; i < keys.count / 10; i++) [map removeObjectForKey:keys[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// Returns YES if the message at this server ID is known to be reaction-related
|
||||
// (action log entry, reaction record, etc.) — i.e. should never be preserved.
|
||||
static BOOL sciIsReactionRelatedMessage(NSString *sid) {
|
||||
if (!sid.length) return NO;
|
||||
NSString *className = sciGetContentClasses()[sid];
|
||||
if (!className.length) return NO;
|
||||
return [className containsString:@"Reaction"] ||
|
||||
[className containsString:@"ActionLog"] ||
|
||||
[className containsString:@"reaction"] ||
|
||||
[className containsString:@"actionLog"];
|
||||
}
|
||||
|
||||
// ============ ALLOC TRACKING ============
|
||||
|
||||
static id (*orig_msgUpdate_alloc)(id self, SEL _cmd);
|
||||
@@ -62,13 +97,14 @@ static id new_msgUpdate_alloc(id self, SEL _cmd) {
|
||||
if (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array];
|
||||
@synchronized(sciPendingUpdates) {
|
||||
[sciPendingUpdates addObject:instance];
|
||||
while (sciPendingUpdates.count > 10)
|
||||
while (sciPendingUpdates.count > SCI_PENDING_MAX)
|
||||
[sciPendingUpdates removeObjectAtIndex:0];
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
// ============ REMOTE UNSEND DETECTION ============
|
||||
|
||||
static NSString *sciExtractServerId(id key) {
|
||||
@@ -92,13 +128,17 @@ static void sciPruneStaleDeleteForYouKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
static BOOL sciConsumeRemoteUnsend() {
|
||||
if (!sciPendingUpdates) return NO;
|
||||
// Walks every pending IGDirectMessageUpdate, preserves the IDs of any reason=0
|
||||
// remove that isn't a delete-for-you follow-up, and returns the set of preserved
|
||||
// IDs. The caller decides whether to actually block + show a toast based on
|
||||
// whether those IDs match real (rendered) messages.
|
||||
static NSSet<NSString *> *sciConsumePendingPreserves() {
|
||||
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
|
||||
if (!sciPendingUpdates) return preserved;
|
||||
if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary];
|
||||
|
||||
sciPruneStaleDeleteForYouKeys();
|
||||
|
||||
BOOL shouldBlock = NO;
|
||||
@synchronized(sciPendingUpdates) {
|
||||
for (id update in [sciPendingUpdates copy]) {
|
||||
@try {
|
||||
@@ -114,8 +154,7 @@ static BOOL sciConsumeRemoteUnsend() {
|
||||
reason = *(long long *)((char *)(__bridge void *)update + off);
|
||||
}
|
||||
|
||||
// Delete-for-you initiator: remember the keys for the upcoming
|
||||
// reason=0 follow-up so we don't block it.
|
||||
// Delete-for-you initiator — remember keys for the follow-up.
|
||||
if (reason == 2) {
|
||||
NSDate *now = [NSDate date];
|
||||
for (id key in keys) {
|
||||
@@ -125,44 +164,53 @@ static BOOL sciConsumeRemoteUnsend() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reason == 0 && !sciLocalDeleteInProgress) {
|
||||
// If every key matches a recent delete-for-you, this is the
|
||||
// expected follow-up — let it through.
|
||||
BOOL allMatched = YES;
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
|
||||
}
|
||||
if (allMatched) {
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (reason != 0 || sciLocalDeleteInProgress) continue;
|
||||
|
||||
// Otherwise this is a genuine remote unsend — preserve the
|
||||
// affected message IDs and block the entire apply call.
|
||||
// If every key matches a recent delete-for-you, drop the
|
||||
// tracking entries and let it through (it's the follow-up).
|
||||
BOOL allMatched = YES;
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
|
||||
}
|
||||
if (allMatched) {
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid) [sciGetPreservedIds() addObject:sid];
|
||||
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
|
||||
}
|
||||
sciSavePreservedIds();
|
||||
shouldBlock = YES;
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Real remove — preserve only keys whose content class isn't a
|
||||
// known reaction / action-log entry. Reaction events also fire
|
||||
// reason=0 removes for the action-log record they create.
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (!sid) continue;
|
||||
if (sciIsReactionRelatedMessage(sid)) continue;
|
||||
[sciGetPreservedIds() addObject:sid];
|
||||
[preserved addObject:sid];
|
||||
}
|
||||
} @catch(id e) {}
|
||||
}
|
||||
[sciPendingUpdates removeAllObjects];
|
||||
}
|
||||
return shouldBlock;
|
||||
if (preserved.count > 0) sciSavePreservedIds();
|
||||
return preserved;
|
||||
}
|
||||
|
||||
// ============ CACHE UPDATE HOOK ============
|
||||
|
||||
static void (*orig_applyUpdates)(id self, SEL _cmd, id updates, id completion, id userAccess);
|
||||
static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id userAccess) {
|
||||
if (sciKeepDeletedEnabled() && sciConsumeRemoteUnsend()) {
|
||||
if (!sciKeepDeletedEnabled()) {
|
||||
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
|
||||
return;
|
||||
}
|
||||
|
||||
NSSet<NSString *> *preserved = sciConsumePendingPreserves();
|
||||
|
||||
if (preserved.count > 0) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// Refresh visible cells so newly preserved messages show the
|
||||
// "Unsent" indicator immediately without waiting for a scroll.
|
||||
@@ -371,15 +419,52 @@ static void new_cellLayoutSubviews(id self, SEL _cmd) {
|
||||
sciUpdateCellIndicator(self);
|
||||
}
|
||||
|
||||
// ============ ACTION LOG TRACKING ============
|
||||
//
|
||||
// IGDirectThreadActionLog is the local data-model class for "X liked a
|
||||
// message" thread entries. IG instantiates one whenever an action log row
|
||||
// is created — reaction add/remove, theme change, etc. We hook its full
|
||||
// init, grab the message ID via the messageId getter, and store the class
|
||||
// name in our content-class map. Later when a remove for that ID arrives,
|
||||
// the consume path recognizes it as bookkeeping and skips preserving it.
|
||||
static id (*orig_actionLogFullInit)(id, SEL, id, id, id, id, id, BOOL, BOOL, id);
|
||||
static id new_actionLogFullInit(id self, SEL _cmd,
|
||||
id message, id title, id textAttributes, id textParts,
|
||||
id actionLogType, BOOL collapsible, BOOL hidden, id genAIMetadata) {
|
||||
id result = orig_actionLogFullInit(self, _cmd, message, title, textAttributes, textParts,
|
||||
actionLogType, collapsible, hidden, genAIMetadata);
|
||||
@try {
|
||||
SEL midSel = @selector(messageId);
|
||||
if ([result respondsToSelector:midSel]) {
|
||||
id mid = ((id(*)(id, SEL))objc_msgSend)(result, midSel);
|
||||
if ([mid isKindOfClass:[NSString class]]) {
|
||||
sciTrackInsertedMessage(mid, @"IGDirectThreadActionLog");
|
||||
}
|
||||
}
|
||||
} @catch(id e) {}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ RUNTIME HOOKS ============
|
||||
|
||||
%ctor {
|
||||
// Action log entries (e.g. "X liked a message") — record their message IDs
|
||||
// when IG creates them so we can later recognize a remove for those IDs as
|
||||
// action-log bookkeeping rather than a real unsend.
|
||||
Class actionLogCls = NSClassFromString(@"IGDirectThreadActionLog");
|
||||
if (actionLogCls) {
|
||||
SEL fullInit = NSSelectorFromString(@"initWithMessage:title:textAttributes:textParts:actionLogType:collapsible:hidden:genAIMetadata:");
|
||||
if (class_getInstanceMethod(actionLogCls, fullInit))
|
||||
MSHookMessageEx(actionLogCls, fullInit, (IMP)new_actionLogFullInit, (IMP *)&orig_actionLogFullInit);
|
||||
}
|
||||
|
||||
Class msgUpdateClass = NSClassFromString(@"IGDirectMessageUpdate");
|
||||
if (msgUpdateClass) {
|
||||
MSHookMessageEx(object_getClass(msgUpdateClass), @selector(alloc),
|
||||
(IMP)new_msgUpdate_alloc, (IMP *)&orig_msgUpdate_alloc);
|
||||
}
|
||||
|
||||
|
||||
Class cacheClass = NSClassFromString(@"IGDirectCacheUpdatesApplicator");
|
||||
if (cacheClass) {
|
||||
SEL sel = NSSelectorFromString(@"_applyThreadUpdates:completion:userAccess:");
|
||||
|
||||
@@ -155,6 +155,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// download handler — works for both stories and DM visual messages
|
||||
%new - (void)sciDownloadTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
@@ -280,4 +281,32 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
|
||||
}
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Mirror IG's chrome alpha onto our injected seen + download buttons so they
|
||||
// fade in sync during hold/zoom. Walks up from a fading sibling to find the
|
||||
// IGStoryFullscreenOverlayView and updates its tagged subviews.
|
||||
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];
|
||||
if (seen) seen.alpha = alpha;
|
||||
if (dl) dl.alpha = alpha;
|
||||
return;
|
||||
}
|
||||
cur = cur.superview;
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGStoryFullscreenHeaderView
|
||||
- (void)setAlpha:(CGFloat)alpha {
|
||||
%orig;
|
||||
sciSyncStoryButtonsAlpha((UIView *)self, alpha);
|
||||
}
|
||||
%end
|
||||
|
||||
Reference in New Issue
Block a user