diff --git a/.gitignore b/.gitignore index 85bd685..2e0ce4c 100644 --- a/.gitignore +++ b/.gitignore @@ -38,3 +38,5 @@ CLAUDE.md upstream-scinsta *.ipa *.dylib +deploy.sh +PENDING_CHANGES.md diff --git a/README.md b/README.md index 5656588..ead07a2 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,8 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Manual mark story as seen — button on story overlay to selectively mark stories as seen **\*** - Stop story auto-advance — stories won't auto-skip when the timer ends **\*** - Story download button — download directly from the story overlay **\*** +- Download disappearing DM media (photos + videos) **\*** +- Mark disappearing messages as viewed button **\*** - Disable instants creation ### Navigation diff --git a/control b/control index c90a213..e995d75 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.faroukbmiled.ryukgram Name: RyukGram -Version: 1.1.4 +Version: 1.1.5 Architecture: iphoneos-arm Description: A feature-rich tweak for Instagram on iOS, based on SCInsta Homepage: https://github.com/faroukbmiled/RyukGram diff --git a/src/Features/StoriesAndMessages/DisableStorySeen.x b/src/Features/StoriesAndMessages/DisableStorySeen.x index 9cd2bb9..1168ed1 100644 --- a/src/Features/StoriesAndMessages/DisableStorySeen.x +++ b/src/Features/StoriesAndMessages/DisableStorySeen.x @@ -1,34 +1,15 @@ -#import "../../Utils.h" -#import "../../InstagramHeaders.h" -#import "../../Downloader/Download.h" -#import -#import +// Story seen receipt blocking + visual seen state blocking +#import "StoryHelpers.h" -// === State === -static BOOL sciSeenBypassActive = NO; -static NSMutableSet *sciAllowedSeenPKs = nil; +BOOL sciSeenBypassActive = NO; +NSMutableSet *sciAllowedSeenPKs = nil; -// === Helpers === -typedef id (*SCIMsgSend)(id, SEL); -typedef id (*SCIMsgSend1)(id, SEL, id); - -static id sciCall(id obj, SEL sel) { - if (!obj || ![obj respondsToSelector:sel]) return nil; - return ((SCIMsgSend)objc_msgSend)(obj, sel); -} -static id sciCall1(id obj, SEL sel, id arg1) { - if (!obj || ![obj respondsToSelector:sel]) return nil; - return ((SCIMsgSend1)objc_msgSend)(obj, sel, arg1); -} - -static void sciAllowSeenForPK(id media) { +void sciAllowSeenForPK(id media) { if (!media) return; id pk = sciCall(media, @selector(pk)); if (!pk) return; if (!sciAllowedSeenPKs) sciAllowedSeenPKs = [NSMutableSet set]; - NSString *pkStr = [NSString stringWithFormat:@"%@", pk]; - [sciAllowedSeenPKs addObject:pkStr]; - NSLog(@"[SCInsta] Allow-listed PK: %@", pkStr); + [sciAllowedSeenPKs addObject:[NSString stringWithFormat:@"%@", pk]]; } static BOOL sciIsPKAllowed(id media) { @@ -48,129 +29,13 @@ static BOOL sciShouldBlockSeenVisual() { return [SCIUtils getBoolPref:@"no_seen_receipt"] && [SCIUtils getBoolPref:@"no_seen_visual"]; } -static UIViewController * _Nullable sciFindVC(UIResponder *start, NSString *className) { - Class cls = NSClassFromString(className); - if (!cls) return nil; - UIResponder *r = start; - while (r) { - if ([r isKindOfClass:cls]) return (UIViewController *)r; - r = [r nextResponder]; - } - return nil; -} - -static IGMedia * _Nullable sciExtractMediaFromItem(id item) { - if (!item) return nil; - Class mediaClass = NSClassFromString(@"IGMedia"); - if (!mediaClass) return nil; - NSArray *trySelectors = @[@"media", @"mediaItem", @"storyItem", @"item", - @"feedItem", @"igMedia", @"model", @"backingModel", - @"storyMedia", @"mediaModel"]; - for (NSString *selName in trySelectors) { - id val = sciCall(item, NSSelectorFromString(selName)); - if (val && [val isKindOfClass:mediaClass]) return (IGMedia *)val; - } - unsigned int iCount = 0; - Ivar *ivars = class_copyIvarList([item class], &iCount); - for (unsigned int i = 0; i < iCount; i++) { - const char *type = ivar_getTypeEncoding(ivars[i]); - if (type && type[0] == '@') { - id val = object_getIvar(item, ivars[i]); - if (val && [val isKindOfClass:mediaClass]) { free(ivars); return (IGMedia *)val; } - } - } - if (ivars) free(ivars); - return nil; -} - -static id _Nullable sciGetCurrentStoryItem(UIResponder *start) { - UIViewController *storyVC = sciFindVC(start, @"IGStoryViewerViewController"); - if (!storyVC) return nil; - id vm = sciCall(storyVC, @selector(currentViewModel)); - if (!vm) return nil; - return sciCall1(storyVC, @selector(currentStoryItemForViewModel:), vm); -} - -// Find section controller: VC -> collectionView -> visibleCell -> containerView -> delegate -static id _Nullable sciFindSectionController(UIViewController *storyVC) { - Class sectionClass = NSClassFromString(@"IGStoryFullscreenSectionController"); - if (!sectionClass || !storyVC) return nil; - - // Find collection view in VC ivars - unsigned int count = 0; - Ivar *ivars = class_copyIvarList([storyVC class], &count); - UICollectionView *cv = nil; - for (unsigned int i = 0; i < count; i++) { - const char *type = ivar_getTypeEncoding(ivars[i]); - if (!type || type[0] != '@') continue; - id val = object_getIvar(storyVC, ivars[i]); - if (val && [val isKindOfClass:[UICollectionView class]]) { cv = val; break; } - } - if (ivars) free(ivars); - if (!cv) return nil; - - // Scan visible cells -> containerView -> delegate - for (UICollectionViewCell *cell in cv.visibleCells) { - unsigned int cCount = 0; - Ivar *cIvars = class_copyIvarList([cell class], &cCount); - for (unsigned int i = 0; i < cCount; i++) { - const char *type = ivar_getTypeEncoding(cIvars[i]); - if (!type || type[0] != '@') continue; - id val = object_getIvar(cell, cIvars[i]); - if (!val) continue; - // Check val's ivars for section controller (L4: cell.containerView.delegate) - unsigned int vCount = 0; - Ivar *vIvars = class_copyIvarList([val class], &vCount); - for (unsigned int j = 0; j < vCount; j++) { - const char *type2 = ivar_getTypeEncoding(vIvars[j]); - if (!type2 || type2[0] != '@') continue; - id val2 = object_getIvar(val, vIvars[j]); - if (val2 && [val2 isKindOfClass:sectionClass]) { free(vIvars); free(cIvars); return val2; } - } - if (vIvars) free(vIvars); - } - if (cIvars) free(cIvars); - } - return nil; -} - -// Story downloaders -static SCIDownloadDelegate *sciStoryVideoDl = nil; -static SCIDownloadDelegate *sciStoryImageDl = nil; - -static void sciInitStoryDownloaders() { - NSString *method = [SCIUtils getStringPref:@"dw_save_action"]; - DownloadAction action = [method isEqualToString:@"photos"] ? saveToPhotos : share; - DownloadAction imgAction = [method isEqualToString:@"photos"] ? saveToPhotos : quickLook; - sciStoryVideoDl = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES]; - sciStoryImageDl = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO]; -} - -static void sciDownloadMedia(IGMedia *media) { - sciInitStoryDownloaders(); - NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media]; - if (videoUrl) { - [sciStoryVideoDl downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil]; - return; - } - NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media]; - if (photoUrl) { - [sciStoryImageDl downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil]; - return; - } - [SCIUtils showErrorHUDWithDescription:@"Could not extract URL from story"]; -} - -// ============ BLOCK NETWORK SEEN ============ - +// network seen blocking %hook IGStorySeenStateUploader - (void)uploadSeenStateWithMedia:(id)arg1 { - // Allow if: bypass active, or this specific media was manually marked if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !sciIsPKAllowed(arg1)) return; %orig; } - (void)uploadSeenState { - // Batch upload — allow if bypass or any manual PKs are pending if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !(sciAllowedSeenPKs && sciAllowedSeenPKs.count > 0)) return; %orig; } @@ -182,21 +47,16 @@ static void sciDownloadMedia(IGMedia *media) { if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !sciIsPKAllowed(arg1)) return; %orig; } -// NEVER block networker — returning nil breaks the uploader permanently - (id)networker { return %orig; } %end -// ============ BLOCK VISUAL SEEN ============ - +// visual seen blocking + story auto-advance %hook IGStoryFullscreenSectionController -// Visual seen blocking - (void)markItemAsSeen:(id)arg1 { if (sciShouldBlockSeenVisual() && !sciIsPKAllowed(arg1)) return; %orig; } - (void)_markItemAsSeen:(id)arg1 { if (sciShouldBlockSeenVisual() && !sciIsPKAllowed(arg1)) return; %orig; } - (void)storySeenStateDidChange:(id)arg1 { if (sciShouldBlockSeenVisual()) return; %orig; } - (void)sendSeenRequestForCurrentItem { if (sciShouldBlockSeenVisual()) return; %orig; } - (void)markCurrentItemAsSeen { if (sciShouldBlockSeenVisual()) return; %orig; } - -// Stop auto-advance: block timer-triggered advances, allow manual taps - (void)storyPlayerMediaViewDidPlayToEnd:(id)arg1 { if ([SCIUtils getBoolPref:@"stop_story_auto_advance"]) return; %orig; @@ -232,153 +92,3 @@ static void sciDownloadMedia(IGMedia *media) { - (void)setSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; } - (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; } %end - -// ============ OVERLAY BUTTONS ============ - -%hook IGStoryFullscreenOverlayView -- (void)didMoveToSuperview { - %orig; - if (!self.superview) return; - - if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) { - UIButton *dlBtn = [UIButton buttonWithType:UIButtonTypeCustom]; - dlBtn.tag = 1340; - UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; - [dlBtn setImage:[UIImage systemImageNamed:@"arrow.down" withConfiguration:config] forState:UIControlStateNormal]; - dlBtn.tintColor = [UIColor whiteColor]; - dlBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4]; - dlBtn.layer.cornerRadius = 18; - dlBtn.clipsToBounds = YES; - dlBtn.translatesAutoresizingMaskIntoConstraints = NO; - [dlBtn addTarget:self action:@selector(sciStoryDownloadTapped:) forControlEvents:UIControlEventTouchUpInside]; - [self addSubview:dlBtn]; - [NSLayoutConstraint activateConstraints:@[ - [dlBtn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], - [dlBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], - [dlBtn.widthAnchor constraintEqualToConstant:36], - [dlBtn.heightAnchor constraintEqualToConstant:36] - ]]; - } - - if ([SCIUtils getBoolPref:@"no_seen_receipt"] && ![self viewWithTag:1339]) { - UIButton *seenBtn = [UIButton buttonWithType:UIButtonTypeCustom]; - seenBtn.tag = 1339; - UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; - [seenBtn setImage:[UIImage systemImageNamed:@"eye" withConfiguration:config] forState:UIControlStateNormal]; - seenBtn.tintColor = [UIColor whiteColor]; - seenBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4]; - seenBtn.layer.cornerRadius = 18; - seenBtn.clipsToBounds = YES; - seenBtn.translatesAutoresizingMaskIntoConstraints = NO; - [seenBtn addTarget:self action:@selector(sciMarkSeenTapped:) forControlEvents:UIControlEventTouchUpInside]; - [self addSubview:seenBtn]; - UIView *dlBtn = [self viewWithTag:1340]; - if (dlBtn) { - [NSLayoutConstraint activateConstraints:@[ - [seenBtn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor], - [seenBtn.trailingAnchor constraintEqualToAnchor:dlBtn.leadingAnchor constant:-10], - [seenBtn.widthAnchor constraintEqualToConstant:36], - [seenBtn.heightAnchor constraintEqualToConstant:36] - ]]; - } else { - [NSLayoutConstraint activateConstraints:@[ - [seenBtn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], - [seenBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], - [seenBtn.widthAnchor constraintEqualToConstant:36], - [seenBtn.heightAnchor constraintEqualToConstant:36] - ]]; - } - } -} - -// ============ STORY DOWNLOAD ============ - -%new - (void)sciStoryDownloadTapped:(UIButton *)sender { - UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; - [haptic impactOccurred]; - [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); } - completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }]; - @try { - id item = sciGetCurrentStoryItem(self); - IGMedia *media = sciExtractMediaFromItem(item); - if (media) { - if ([SCIUtils getBoolPref:@"dw_confirm"]) { - [SCIUtils showConfirmation:^{ sciDownloadMedia(media); } title:@"Download story?"]; - } else { - sciDownloadMedia(media); - } - return; - } - [SCIUtils showErrorHUDWithDescription:@"Could not find story media"]; - } @catch (NSException *e) { - [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]]; - } -} - -// ============ MARK SEEN ============ - -%new - (void)sciMarkSeenTapped:(UIButton *)sender { - 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; }]; }]; - - @try { - UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController"); - if (!storyVC) { [SCIUtils showErrorHUDWithDescription:@"Story VC not found"]; return; } - - // Get current story media - 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:@"Could not find story media"]; return; } - - // Add this media PK to the permanent allow list - // When Instagram's deferred upload eventually fires, our hooks will let this PK through - sciAllowSeenForPK(media); - - // Also set bypass for immediate calls - sciSeenBypassActive = YES; - - // Trigger the visual seen update via VC delegate - SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:); - if ([storyVC respondsToSelector:delegateSel]) { - typedef void (*Func)(id, SEL, id, id); - ((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media); - } - - // Trigger the section controller's mark flow - if (sectionCtrl) { - SEL markSel = NSSelectorFromString(@"markItemAsSeen:"); - if ([sectionCtrl respondsToSelector:markSel]) { - ((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media); - } - } - - // Update the session seen state manager - id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager)); - id vm = sciCall(storyVC, @selector(currentViewModel)); - if (seenManager && vm) { - SEL setSeenSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:"); - if ([seenManager respondsToSelector:setSeenSel]) { - 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, setSeenSel, mediaPK, reelPK); - } - } - } - - sciSeenBypassActive = NO; - - [SCIUtils showToastForDuration:2.0 title:@"Marked as seen" subtitle:@"Will sync when leaving stories"]; - } @catch (NSException *e) { - sciSeenBypassActive = NO; - [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]]; - } -} -%end diff --git a/src/Features/StoriesAndMessages/OverlayButtons.xm b/src/Features/StoriesAndMessages/OverlayButtons.xm new file mode 100644 index 0000000..ae38bc6 --- /dev/null +++ b/src/Features/StoriesAndMessages/OverlayButtons.xm @@ -0,0 +1,283 @@ +// Download + mark seen buttons on story/DM visual message overlay +#import "StoryHelpers.h" + +extern "C" BOOL sciSeenBypassActive; +extern "C" NSMutableSet *sciAllowedSeenPKs; +extern "C" void sciAllowSeenForPK(id); + +static SCIDownloadDelegate *sciStoryVideoDl = nil; +static SCIDownloadDelegate *sciStoryImageDl = nil; + +static void sciInitStoryDownloaders() { + NSString *method = [SCIUtils getStringPref:@"dw_save_action"]; + DownloadAction action = [method isEqualToString:@"photos"] ? saveToPhotos : share; + DownloadAction imgAction = [method isEqualToString:@"photos"] ? saveToPhotos : quickLook; + sciStoryVideoDl = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES]; + sciStoryImageDl = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO]; +} + +static void sciDownloadMedia(IGMedia *media) { + sciInitStoryDownloaders(); + NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media]; + if (videoUrl) { + [sciStoryVideoDl downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil]; + return; + } + NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media]; + if (photoUrl) { + [sciStoryImageDl downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil]; + return; + } + [SCIUtils showErrorHUDWithDescription:@"Could not extract URL"]; +} + +static void sciDownloadWithConfirm(void(^block)(void)) { + if ([SCIUtils getBoolPref:@"dw_confirm"]) { + [SCIUtils showConfirmation:block title:@"Download?"]; + } else { + block(); + } +} + +// get media from DM visual message VC +static void sciDownloadDMVisualMessage(UIViewController *dmVC) { + Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource"); + id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil; + if (!ds) return; + Ivar msgIvar = class_getInstanceVariable([ds class], "_currentMessage"); + id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil; + if (!msg) return; + + // video + id rawVideo = sciCall(msg, @selector(rawVideo)); + if (rawVideo) { + NSURL *url = [SCIUtils getVideoUrl:rawVideo]; + if (url) { + sciInitStoryDownloaders(); + sciDownloadWithConfirm(^{ [sciStoryVideoDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; }); + return; + } + } + + // photo via rawPhoto + id rawPhoto = sciCall(msg, @selector(rawPhoto)); + if (rawPhoto) { + NSURL *url = [SCIUtils getPhotoUrl:rawPhoto]; + if (url) { + sciInitStoryDownloaders(); + sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; }); + return; + } + } + + // photo via imageSpecifier + id imgSpec = sciCall(msg, NSSelectorFromString(@"imageSpecifier")); + if (imgSpec) { + NSURL *url = sciCall(imgSpec, @selector(url)); + if (url) { + sciInitStoryDownloaders(); + sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; }); + return; + } + } + + // photo via _visualMediaInfo._media + Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo"); + id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil; + if (vmi) { + Ivar mediaIvar = class_getInstanceVariable([vmi class], "_media"); + id mediaObj = mediaIvar ? object_getIvar(vmi, mediaIvar) : nil; + if (mediaObj) { + IGMedia *media = sciExtractMediaFromItem(mediaObj); + if (!media && [mediaObj isKindOfClass:NSClassFromString(@"IGMedia")]) media = (IGMedia *)mediaObj; + if (media) { sciDownloadWithConfirm(^{ sciDownloadMedia(media); }); return; } + } + } + + [SCIUtils showErrorHUDWithDescription:@"Could not find media"]; +} + +%hook IGStoryFullscreenOverlayView +- (void)didMoveToSuperview { + %orig; + if (!self.superview) return; + + // download button + if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) { + UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; + btn.tag = 1340; + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; + [btn setImage:[UIImage systemImageNamed:@"arrow.down" 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; + [btn addTarget:self action:@selector(sciDownloadTapped:) forControlEvents:UIControlEventTouchUpInside]; + [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] + ]]; + } + + // mark seen button (stories: mark as seen, DMs: mark as viewed + dismiss) + if ([SCIUtils getBoolPref:@"no_seen_receipt"] && ![self viewWithTag:1339]) { + UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; + btn.tag = 1339; + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; + [btn setImage:[UIImage systemImageNamed:@"eye" 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; + [btn addTarget:self action:@selector(sciMarkSeenTapped:) forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:btn]; + UIView *dlBtn = [self viewWithTag:1340]; + if (dlBtn) { + [NSLayoutConstraint activateConstraints:@[ + [btn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor], + [btn.trailingAnchor constraintEqualToAnchor:dlBtn.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] + ]]; + } + } +} + +// download handler — works for both stories and DM visual messages +%new - (void)sciDownloadTapped:(UIButton *)sender { + UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; + [haptic impactOccurred]; + [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); } + completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }]; + @try { + // story path + id item = sciGetCurrentStoryItem(self); + IGMedia *media = sciExtractMediaFromItem(item); + if (media) { + sciDownloadWithConfirm(^{ sciDownloadMedia(media); }); + return; + } + + // DM visual message path + UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController"); + if (dmVC) { + sciDownloadDMVisualMessage(dmVC); + return; + } + + [SCIUtils showErrorHUDWithDescription:@"Could not find media"]; + } @catch (NSException *e) { + [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]]; + } +} + +// mark seen handler — stories: allow-list approach, DMs: trigger viewed + dismiss +%new - (void)sciMarkSeenTapped:(UIButton *)sender { + 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; }]; }]; + + @try { + // story path + UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController"); + if (storyVC) { + // allow-list the current media PK for deferred upload + 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:@"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:@"Marked as seen" subtitle:@"Will sync when leaving stories"]; + 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:@"Marked as viewed"]; + return; + } + + [SCIUtils showErrorHUDWithDescription:@"VC not found"]; + } @catch (NSException *e) { + [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]]; + } +} +%end diff --git a/src/Features/StoriesAndMessages/SeenButtons.x b/src/Features/StoriesAndMessages/SeenButtons.x index 4a8ab14..ef3fd52 100644 --- a/src/Features/StoriesAndMessages/SeenButtons.x +++ b/src/Features/StoriesAndMessages/SeenButtons.x @@ -75,22 +75,14 @@ } %end -// DM stories viewed logic +// DM visual messages viewed logic %hook IGDirectVisualMessageViewerEventHandler - (void)visualMessageViewerController:(id)arg1 didBeginPlaybackForVisualMessage:(id)arg2 atIndex:(NSInteger)arg3 { - if ([SCIUtils getBoolPref:@"unlimited_replay"]) { - // Check if dm stories should be marked as viewed - if (dmVisualMsgsViewedButtonEnabled) { - %orig; - } - } + if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled) return; + %orig; } - (void)visualMessageViewerController:(id)arg1 didEndPlaybackForVisualMessage:(id)arg2 atIndex:(NSInteger)arg3 mediaCurrentTime:(CGFloat)arg4 forNavType:(NSInteger)arg5 { - if ([SCIUtils getBoolPref:@"unlimited_replay"]) { - // Check if dm stories should be marked as viewed - if (dmVisualMsgsViewedButtonEnabled) { - %orig; - } - } + if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled) return; + %orig; } %end \ No newline at end of file diff --git a/src/Features/StoriesAndMessages/StoryHelpers.h b/src/Features/StoriesAndMessages/StoryHelpers.h new file mode 100644 index 0000000..f11557b --- /dev/null +++ b/src/Features/StoriesAndMessages/StoryHelpers.h @@ -0,0 +1,98 @@ +// Shared helpers for story/DM visual message features +#import "../../Utils.h" +#import "../../InstagramHeaders.h" +#import "../../Downloader/Download.h" +#import +#import + +typedef id (*SCIMsgSend)(id, SEL); +typedef id (*SCIMsgSend1)(id, SEL, id); + +static inline id sciCall(id obj, SEL sel) { + if (!obj || ![obj respondsToSelector:sel]) return nil; + return ((SCIMsgSend)objc_msgSend)(obj, sel); +} +static inline id sciCall1(id obj, SEL sel, id arg1) { + if (!obj || ![obj respondsToSelector:sel]) return nil; + return ((SCIMsgSend1)objc_msgSend)(obj, sel, arg1); +} + +static inline UIViewController * _Nullable sciFindVC(UIResponder *start, NSString *className) { + Class cls = NSClassFromString(className); + if (!cls) return nil; + UIResponder *r = start; + while (r) { + if ([r isKindOfClass:cls]) return (UIViewController *)r; + r = [r nextResponder]; + } + return nil; +} + +static inline IGMedia * _Nullable sciExtractMediaFromItem(id item) { + if (!item) return nil; + Class mediaClass = NSClassFromString(@"IGMedia"); + if (!mediaClass) return nil; + NSArray *trySelectors = @[@"media", @"mediaItem", @"storyItem", @"item", + @"feedItem", @"igMedia", @"model", @"backingModel", + @"storyMedia", @"mediaModel"]; + for (NSString *selName in trySelectors) { + id val = sciCall(item, NSSelectorFromString(selName)); + if (val && [val isKindOfClass:mediaClass]) return (IGMedia *)val; + } + unsigned int iCount = 0; + Ivar *ivars = class_copyIvarList([item class], &iCount); + for (unsigned int i = 0; i < iCount; i++) { + const char *type = ivar_getTypeEncoding(ivars[i]); + if (type && type[0] == '@') { + id val = object_getIvar(item, ivars[i]); + if (val && [val isKindOfClass:mediaClass]) { free(ivars); return (IGMedia *)val; } + } + } + if (ivars) free(ivars); + return nil; +} + +static inline id _Nullable sciGetCurrentStoryItem(UIResponder *start) { + UIViewController *storyVC = sciFindVC(start, @"IGStoryViewerViewController"); + if (!storyVC) return nil; + id vm = sciCall(storyVC, @selector(currentViewModel)); + if (!vm) return nil; + return sciCall1(storyVC, @selector(currentStoryItemForViewModel:), vm); +} + +static inline id _Nullable sciFindSectionController(UIViewController *storyVC) { + Class sectionClass = NSClassFromString(@"IGStoryFullscreenSectionController"); + if (!sectionClass || !storyVC) return nil; + unsigned int count = 0; + Ivar *ivars = class_copyIvarList([storyVC class], &count); + UICollectionView *cv = nil; + for (unsigned int i = 0; i < count; i++) { + const char *type = ivar_getTypeEncoding(ivars[i]); + if (!type || type[0] != '@') continue; + id val = object_getIvar(storyVC, ivars[i]); + if (val && [val isKindOfClass:[UICollectionView class]]) { cv = val; break; } + } + if (ivars) free(ivars); + if (!cv) return nil; + for (UICollectionViewCell *cell in cv.visibleCells) { + unsigned int cCount = 0; + Ivar *cIvars = class_copyIvarList([cell class], &cCount); + for (unsigned int i = 0; i < cCount; i++) { + const char *type = ivar_getTypeEncoding(cIvars[i]); + if (!type || type[0] != '@') continue; + id val = object_getIvar(cell, cIvars[i]); + if (!val) continue; + unsigned int vCount = 0; + Ivar *vIvars = class_copyIvarList([val class], &vCount); + for (unsigned int j = 0; j < vCount; j++) { + const char *type2 = ivar_getTypeEncoding(vIvars[j]); + if (!type2 || type2[0] != '@') continue; + id val2 = object_getIvar(val, vIvars[j]); + if (val2 && [val2 isKindOfClass:sectionClass]) { free(vIvars); free(cIvars); return val2; } + } + if (vIvars) free(vIvars); + } + if (cIvars) free(cIvars); + } + return nil; +} diff --git a/src/Tweak.x b/src/Tweak.x index 4af1f51..2ee50d2 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -13,7 +13,7 @@ /////////////////////////////////////////////////////////// // * Tweak version * -NSString *SCIVersionString = @"v1.1.4"; +NSString *SCIVersionString = @"v1.1.5"; // Variables that work across features BOOL dmVisualMsgsViewedButtonEnabled = false; @@ -90,10 +90,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; - (void)applicationDidBecomeActive:(id)arg1 { %orig; - + if ([SCIUtils getBoolPref:@"flex_app_start"]) { [[objc_getClass("FLEXManager") sharedManager] showExplorer]; } + } %end @@ -101,7 +102,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; - (_Bool)isLiquidGlassInAppNotificationEnabled { return [SCIUtils liquidGlassEnabledBool:%orig]; } -- (_Bool)isLiquidGlassContextMenuEnabled{ +- (_Bool)isLiquidGlassContextMenuEnabled { return [SCIUtils liquidGlassEnabledBool:%orig]; } - (_Bool)isLiquidGlassToastEnabled { @@ -113,8 +114,12 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; - (_Bool)isLiquidGlassAlertDialogEnabled { return [SCIUtils liquidGlassEnabledBool:%orig]; } +- (_Bool)isLiquidGlassIconBarButtonEnabled { + return [SCIUtils liquidGlassEnabledBool:%orig]; +} %end + // Disable sending modded insta bug reports %hook IGWindow - (void)showDebugMenu { @@ -190,6 +195,7 @@ shouldPersistLastBugReportId:(id)arg6 %hook IGDirectVisualMessageViewerController - (void)screenshotObserverDidSeeScreenshotTaken:(id)arg1 { VOID_HANDLESCREENSHOT(%orig); } - (void)screenshotObserverDidSeeActiveScreenCapture:(id)arg1 event:(NSInteger)arg2 { VOID_HANDLESCREENSHOT(%orig); } + %end ///////////////////////////////////////////////////////////////////////////// @@ -659,24 +665,18 @@ shouldPersistLastBugReportId:(id)arg6 } } -- (void)_didTapRepostButton:(id)arg1 { +- (void)_didTapRepostButton { if ([SCIUtils getBoolPref:@"repost_confirm"]) { - NSLog(@"[SCInsta] Confirm repost triggered"); - [SCIUtils showConfirmation:^(void) { %orig; }]; } else { - return %orig; + %orig; } } - (void)_didLongPressRepostButton:(id)arg1 { - if ([SCIUtils getBoolPref:@"repost_confirm"]) { - NSLog(@"[SCInsta] Confirm repost triggered (long press ignored)"); - } - else { - return %orig; - } + if ([SCIUtils getBoolPref:@"repost_confirm"]) return; + %orig; } %end @@ -717,3 +717,38 @@ shouldPersistLastBugReportId:(id)arg6 return %orig; } %end + +// liquid glass Swift class hooks +static BOOL (*orig_swizzleToggle_isEnabled)(id, SEL) = NULL; +static BOOL new_swizzleToggle_isEnabled(id self, SEL _cmd) { + if ([SCIUtils getBoolPref:@"liquid_glass_buttons"]) return YES; + return orig_swizzleToggle_isEnabled(self, _cmd); +} + +static BOOL (*orig_expHelper_isEnabled)(id, SEL) = NULL; +static BOOL new_expHelper_isEnabled(id self, SEL _cmd) { + if ([SCIUtils getBoolPref:@"liquid_glass_buttons"]) return YES; + return orig_expHelper_isEnabled(self, _cmd); +} + +static BOOL (*orig_expHelper_isHomeFeed)(id, SEL) = NULL; +static BOOL new_expHelper_isHomeFeed(id self, SEL _cmd) { + if ([SCIUtils getBoolPref:@"liquid_glass_buttons"]) return YES; + return orig_expHelper_isHomeFeed(self, _cmd); +} + +%ctor { + Class swizzleToggle = objc_getClass("IGLiquidGlassSwizzle.IGLiquidGlassSwizzleToggle"); + if (swizzleToggle) { + MSHookMessageEx(swizzleToggle, @selector(isEnabled), + (IMP)new_swizzleToggle_isEnabled, (IMP *)&orig_swizzleToggle_isEnabled); + } + + Class expHelper = objc_getClass("IGLiquidGlassExperimentHelper.IGLiquidGlassNavigationExperimentHelper"); + if (expHelper) { + MSHookMessageEx(expHelper, @selector(isEnabled), + (IMP)new_expHelper_isEnabled, (IMP *)&orig_expHelper_isEnabled); + MSHookMessageEx(expHelper, @selector(isHomeFeedHeaderEnabled), + (IMP)new_expHelper_isHomeFeed, (IMP *)&orig_expHelper_isHomeFeed); + } +}