feat: Mark as seen on typing

feat: Save to RyukGram album
feat: Hide trailing action buttons on preserved messages
feat: Confirmation when pulling to refresh the DMs tab if preserved messages would be cleared
This commit is contained in:
faroukbmiled
2026-04-07 03:04:50 +01:00
parent e634359acc
commit 6e96140895
12 changed files with 379 additions and 34 deletions
@@ -1,7 +1,20 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
// Defined in SeenButtons.x
extern __weak IGDirectThreadViewController *sciActiveThreadVC;
extern BOOL sciAutoTypingEnabled(void);
extern void sciDoAutoSeen(IGDirectThreadViewController *threadVC);
%hook IGDirectTypingStatusService
- (void)updateOutgoingStatusIsActive:(_Bool)active threadKey:(id)key threadMetadata:(id)metadata typingStatusType:(long long)type {
// Mark the visible thread as seen on the first typing event — runs even
// when typing-status broadcasting is blocked below.
if (active && sciAutoTypingEnabled()) {
IGDirectThreadViewController *vc = sciActiveThreadVC;
if (vc) sciDoAutoSeen(vc);
}
if ([SCIUtils getBoolPref:@"disable_typing_status"]) return;
return %orig(active, key, metadata, type);
@@ -140,13 +140,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
dispatch_async(dispatch_get_main_queue(), ^{
[pill setText:@"Done!"];
[pill dismissAfterDelay:0.5];
UIActivityViewController *shareVC = [[UIActivityViewController alloc]
initWithActivityItems:@[finalURL]
applicationActivities:nil];
UIViewController *top = [UIApplication sharedApplication].keyWindow.rootViewController;
while (top.presentedViewController) top = top.presentedViewController;
[top presentViewController:shareVC animated:YES completion:nil];
[SCIUtils showShareVC:finalURL];
});
}];
}];
@@ -0,0 +1,134 @@
// Pull-to-refresh in the DMs tab silently clears preserved (locally retained)
// unsent messages. This hook intercepts _pullToRefreshIfPossible to show a
// confirmation dialog when both keep_deleted_message and
// warn_refresh_clears_preserved are on.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
extern NSMutableSet *sciGetPreservedIds(void);
extern void sciClearPreservedIds(void);
static BOOL sciRefreshConfirmInFlight = NO;
static BOOL sciRefreshAlertVisible = NO;
static UIRefreshControl *sciFindRefreshControl(UIViewController *vc) {
Class igRC = NSClassFromString(@"IGRefreshControl");
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
while (stack.count > 0) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ((igRC && [v isKindOfClass:igRC]) || [v isKindOfClass:[UIRefreshControl class]]) {
return (UIRefreshControl *)v;
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
return nil;
}
// On cancel, the IGRefreshControl's state machine is already idle by the time
// our handler runs — but the scroll view's contentInset stays expanded, leaving
// the spinner area visually exposed. We grab the idle inset via the inbox VC's
// idleTopContentInsetForRefreshControl: helper and animate the inset back.
static void sciCancelRefresh(UIViewController *vc) {
UIRefreshControl *rc = sciFindRefreshControl(vc);
if (!rc) return;
Ivar stateIvar = class_getInstanceVariable([rc class], "_refreshState");
if (stateIvar) {
ptrdiff_t off = ivar_getOffset(stateIvar);
*(NSInteger *)((char *)(__bridge void *)rc + off) = 0;
}
Ivar animIvar = class_getInstanceVariable([rc class], "_swiftAnimationInfo");
if (animIvar) object_setIvar(rc, animIvar, nil);
if ([rc respondsToSelector:@selector(endRefreshing)]) [rc endRefreshing];
SEL didEnd = NSSelectorFromString(@"refreshControlDidEndFinishLoadingAnimation:");
if ([vc respondsToSelector:didEnd]) {
((void(*)(id, SEL, id))objc_msgSend)(vc, didEnd, rc);
}
UIScrollView *scroll = nil;
UIView *cur = rc.superview;
while (cur) {
if ([cur isKindOfClass:[UIScrollView class]]) { scroll = (UIScrollView *)cur; break; }
cur = cur.superview;
}
if (scroll) {
SEL idleSel = NSSelectorFromString(@"idleTopContentInsetForRefreshControl:");
CGFloat idleInset = scroll.contentInset.top;
if ([vc respondsToSelector:idleSel]) {
idleInset = ((CGFloat(*)(id, SEL, id))objc_msgSend)(vc, idleSel, rc);
}
UIEdgeInsets insets = scroll.contentInset;
insets.top = idleInset;
[UIView animateWithDuration:0.25 animations:^{
scroll.contentInset = insets;
CGPoint o = scroll.contentOffset;
if (o.y < -idleInset) o.y = -idleInset;
scroll.contentOffset = o;
}];
}
}
static void (*orig_pullToRefresh)(id self, SEL _cmd);
static void new_pullToRefresh(id self, SEL _cmd) {
if (sciRefreshConfirmInFlight ||
![SCIUtils getBoolPref:@"keep_deleted_message"] ||
![SCIUtils getBoolPref:@"warn_refresh_clears_preserved"]) {
orig_pullToRefresh(self, _cmd);
return;
}
// IG fires _pullToRefreshIfPossible repeatedly while the user holds the
// pull gesture — drop re-entrant calls until the alert is dismissed.
if (sciRefreshAlertVisible) return;
NSUInteger count = sciGetPreservedIds().count;
if (count == 0) {
orig_pullToRefresh(self, _cmd);
return;
}
UIViewController *vc = (UIViewController *)self;
NSString *msg = [NSString stringWithFormat:
@"Refreshing the DMs tab will clear %lu preserved unsent message%@. This cannot be undone.",
(unsigned long)count, count == 1 ? @"" : @"s"];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Clear preserved messages?"
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakSelf = vc;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel
handler:^(UIAlertAction *a) {
sciCancelRefresh(weakSelf);
sciRefreshAlertVisible = NO;
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *a) {
sciRefreshAlertVisible = NO;
id strongSelf = weakSelf;
if (!strongSelf) return;
sciClearPreservedIds();
sciRefreshConfirmInFlight = YES;
((void(*)(id, SEL))objc_msgSend)(strongSelf, _cmd);
sciRefreshConfirmInFlight = NO;
}]];
sciRefreshAlertVisible = YES;
UIViewController *top = [UIApplication sharedApplication].keyWindow.rootViewController;
while (top.presentedViewController) top = top.presentedViewController;
[top presentViewController:alert animated:YES completion:nil];
}
%ctor {
Class cls = NSClassFromString(@"IGDirectInboxViewController");
if (!cls) return;
SEL sel = NSSelectorFromString(@"_pullToRefreshIfPossible");
if (class_getInstanceMethod(cls, sel))
MSHookMessageEx(cls, sel, (IMP)new_pullToRefresh, (IMP *)&orig_pullToRefresh);
}
@@ -33,7 +33,7 @@ static NSMutableSet *sciPreservedIds = nil;
#define SCI_PRESERVED_MAX 200
#define SCI_PRESERVED_TAG 1399
static NSMutableSet *sciGetPreservedIds() {
NSMutableSet *sciGetPreservedIds() {
if (!sciPreservedIds) {
NSArray *saved = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_PRESERVED_IDS_KEY];
sciPreservedIds = saved ? [NSMutableSet setWithArray:saved] : [NSMutableSet set];
@@ -48,7 +48,7 @@ static void sciSavePreservedIds() {
[[NSUserDefaults standardUserDefaults] setObject:[ids allObjects] forKey:SCI_PRESERVED_IDS_KEY];
}
static void sciClearPreservedIds() {
void sciClearPreservedIds() {
[sciGetPreservedIds() removeAllObjects];
[[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY];
}
+21 -1
View File
@@ -12,6 +12,7 @@
BOOL dmSeenToggleEnabled = NO;
static BOOL sciSeenAutoBypass = NO;
__weak IGDirectThreadViewController *sciActiveThreadVC = nil;
static BOOL sciIsSeenToggleMode() {
return [[SCIUtils getStringPref:@"seen_mode"] isEqualToString:@"toggle"];
@@ -21,7 +22,11 @@ static BOOL sciAutoInteractEnabled() {
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_interact"];
}
static void sciDoAutoSeen(IGDirectThreadViewController *threadVC) {
BOOL sciAutoTypingEnabled() {
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_typing"];
}
void sciDoAutoSeen(IGDirectThreadViewController *threadVC) {
sciSeenAutoBypass = YES;
[threadVC markLastMessageAsSeen];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
@@ -40,6 +45,21 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
});
}
// ============ AUTO SEEN ON TYPING ============
// Tracks the visible thread VC so the typing-service hook (in
// DisableTypingStatus.x) can mark its messages as seen.
%hook IGDirectThreadViewController
- (void)viewDidAppear:(BOOL)animated {
%orig;
sciActiveThreadVC = self;
}
- (void)viewWillDisappear:(BOOL)animated {
if (sciActiveThreadVC == self) sciActiveThreadVC = nil;
%orig;
}
%end
// ============ NAV BAR BUTTONS ============
%hook IGTallNavigationBarView