26 Commits

Author SHA1 Message Date
faroukbmiled 9b2c7dc202 [release] RyukGram v1.1.5.1
### Fixes
- Fixed liquid glass buttons and surfaces
- Fixed repost confirmation in reels
- Fixed visual messages never being marked as viewed
- Fixed keep deleted messages on the Instamadillo backend
- Fixed Disable video autoplay
- Fixed play/pause force-audio-onfalsely triggering Instagram's "Reel has no sound" message
- Fixed confirm reel refresh triggering on app launch
- Fixed confirm reel refresh not working after first pull
- and more...

### Features
- Profile copy button — copy username/name/bio from any profile
- Backup & Restore — export/import RyukGram settings
- Download disappearing DM media (photos + videos)
- Mark disappearing messages as viewed
- Upload audio as voice message (with trim editor)
- Download voice messages from DMs
- Auto mark seen on typing
- Mark seen on story like
- Advance to next story when marking as seen
- Advance on story like — automatically skips to the next story after liking
- Per-chat and per-story blocking lists with two modes: "Block all" (exclude list) or "Block selected" (include list). Independent lists per mode, long-press to add/remove, per-entry keep-deleted override
- Save to RyukGram album (optional)
- Unlock password-locked reels
- Read receipt modes (button / toggle)
- Auto mark seen on interact
- Unsent message indicator
- Unsent message notification pill
- Hide trailing action buttons on preserved messages
- Warn before clearing preserved messages on DM refresh
- Long-press menu on the DM seen button for quick actions
- Quick list buttons in chats and stories (toggleable)
- Story seen button mode (button / toggle)
- Long-press menu on the story seen button for quick actions
- Story audio mute/unmute toggle — button on story overlay and 3-dot menu
- Hide repost button in reels and feed
- Copy comment text from long-press menu
- Download GIF comments
- Replace domain in shared links with preset or custom embed domains
- Strip tracking params from shared links (igsh, utm)
- Open links in external browser
- Strip tracking from browser links
- Download highlight cover from profile long-press menu

### Improvements
- Excluding a chat or story now immediately marks as seen
- Tweak settings quick-access (hold feed tab) now on by default
- Disable auto-unmuting reels now off by default
- Opening tweak settings pauses any playing video/audio (toggleable)
- Reorganized DM settings into sub-pages
- Reorganized tweak settings — split "Stories and messages" into separate Stories and Messages pages, regrouped related options
- Hide custom story buttons during pinch-to-zoom
- Search bar in tweak settings

### Known Issues
- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh in DMs clears them (warning available in settings).
- "Delete for you" detection uses a ~2 second window after the local action. If a real other-party unsend happens to land in the same window, it may not be preserved. Rare in practice and limited to that specific overlap.
2026-04-10 17:31:15 +01:00
faroukbmiled d17fba5778 feat: Add ability to download highlight cover images from the profile page via long press menu 2026-04-10 16:06:25 +01:00
faroukbmiled 06b2626714 feat: Per-chat and per-story blocking modes — "Block all" (exclude list) or "Block selected" (include list) with independent storage, per-entry
keep-deleted override, and adaptive UI
feat: Quick list buttons in chats and stories — add/remove directly from DM threads and story viewer
fix: KVO observer crash from multiple registrations in story audio toggle
fix: Seen auto-bypass race condition when overlapping events (boolean → counter)
fix: Confirm reel refresh not working after first pull
fix: Startup class scan replaced with direct class lookup
imp: All menu/button text adapts to active blocking mode
imp: Mark-seen triggers at the correct point per mode
imp: Migrated unexclude_inbox_button to chat_quick_list_button
imp: Menu changes in settings now reload table for dynamic titles
2026-04-10 13:41:58 +01:00
faroukbmiled 7952877545 feat: Auto-advance on story like 2026-04-10 11:12:14 +01:00
faroukbmiled 3693f3e93a feat: Replace Domain in Instagram urls
feat: Remove user param from copied links
feat: Open links in External Browser
feat: Strip Instagram tracking from browser links
fix: Confirm reel refresh confirmation appears on app launch
#2
2026-04-10 09:43:41 +01:00
faroukbmiled de3e13f60a feat: Hide repost button in feed
#2
2026-04-10 03:50:23 +01:00
faroukbmiled d8a8b6c0fe feat: Copy comment text from long-press menu
feat: Download GIF comments
2026-04-10 02:48:50 +01:00
faroukbmiled 89c5a25512 feat: Hide Repost Button in reels tab
Closes #2
2026-04-10 01:56:43 +01:00
faroukbmiled f2f310fce5 feat: Story audio mute/unmute toggle — button on story overlay and 3-dot menu
feat: Multi-select in excluded chats/story users lists with batch actions
feat: Dynamic count refresh on manage list buttons
imp: Opening tweak settings pauses any playing video/audio (toggleable in advanced settings)
imp: Tweak settings quick-access (hold feed tab) now on by default
imp: Disable auto-unmuting reels now off by default
imp: Excluding a chat or story now immediately marks as seen
2026-04-10 01:15:04 +01:00
faroukbmiled ceb89e65d2 feat: Per-user story seen-receipt exclusions
feat: Story seen button mode (button / toggle)
feat: Long-press menu on the story seen button (mark seen, exclude, settings)
feat: Auto mark-seen on exclude for both stories and DM chats
imp: Cleaner exclusion menu wording across stories and DMs
imp: Tweak settings now update in real time for exclude ui
imp: Ability to batch select in both stories and messages exclude UI
2026-04-09 18:46:21 +01:00
faroukbmiled d03da10941 fix: fixed a bug where manually deleted messages from chats are marked us unsend and are preserved when "keep deleted messages" is on 2026-04-09 04:57:05 +01:00
faroukbmiled ae6f70e47c feat: Long-press menu on the DM seen button for quick actions
Fix: Prevent play/pause patch from triggering Instagram’s “Reel has no sound” message when forcing audio on silent Reels
imp: Un-exclude button in excluded chats (toggleable)
imp: Show error for unsupported audio files that can't be processed  - we can add ffmpeg at some point
chore: Remove qr code text from import/export buttons
2026-04-09 00:32:09 +01:00
faroukbmiled fee6a026b4 imp: export/import — broaden coverage, drop QR, add JSON/form preview toggle 2026-04-08 11:48:41 +01:00
faroukbmiled 7300fe893e feat: Per-chat read-receipt exclusions - can exclude keep-delete messages as well
fix: Keep-deleted messages now reliable for cold-start backlogs
fix: Fix downloading some audio file formats (tries converting falls back to original file type)
2026-04-08 11:18:28 +01:00
faroukbmiled 84b4405b84 Imp: Moved copy button to the left in your profile page 2026-04-08 05:59:57 +01:00
faroukbmiled bdb0b5d2e3 feat: Added skip story on seen
Imp: Reorginzed some settings
2026-04-08 04:31:38 +01:00
faroukbmiled 0643f5e691 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
2026-04-08 03:19:05 +01:00
faroukbmiled 6e96140895 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
2026-04-07 03:04:50 +01:00
faroukbmiled e634359acc Improve unsent indicator 2026-04-07 00:53:36 +01:00
faroukbmiled a93929bbb2 - Fixed Disable video autoplay in feed 2026-04-06 23:31:09 +01:00
faroukbmiled 3490531941 - Addede Download voice messages from DMs
- Fixed Unset text position changing when i hold down on the unset message
- Fixed upload audio button not showing in some chats
2026-04-06 21:39:46 +01:00
faroukbmiled 7782ca34b3 - Fixed keep deleted messages
- Unsent message indicator: visual "Unsent" label on preserved messages
- Unsent message notification pill when a message is preserved
- Reorganized DM settings into sub-pages (keep deleted messages, read receipts)
2026-04-06 17:53:45 +01:00
faroukbmiled 2687f99cfb - Read receipt mode setting: button (one-shot) or toggle (blue/white)
- Auto mark seen on interact: locally marks messages as read when you send any message (text, photo, video, audio, sticker)
2026-04-05 11:01:58 +01:00
faroukbmiled 5282d67103 feat: Unlock password-locked reels (auto unlock and view passowrd) 2026-04-05 03:51:09 +01:00
faroukbmiled b99c20a254 Upload audio as voice message in DMs (audio/video from files or video from library, with trim editor) 2026-04-05 01:56:38 +01:00
faroukbmiled bf541bc483 - Fixed liquid glass buttons and surfaces
- Fixed repost confirmation not working in reels
- Fixed visual message seen bug (messages were never marked as viewed)
- Download button works in DM disappearing messages (photos + videos)
- Mark as viewed button for DM disappearing messages
2026-04-04 09:17:52 +01:00
50 changed files with 7025 additions and 502 deletions
+11 -6
View File
@@ -107,18 +107,23 @@ jobs:
if: steps.check_release.outputs.should_release == 'true'
id: notes
run: |
BODY=$(git log -1 --pretty=%b)
PENDING_FILE="PENDING_CHANGES.md"
PREV_TAG=$(git tag --sort=-creatordate | grep -v "v${VERSION}$" | head -n1 || true)
{
echo "## Changelog"
echo ""
if [ -n "$BODY" ]; then
echo '```'
echo "$BODY"
echo '```'
if [ -f "$PENDING_FILE" ]; then
# Drop the copy-as-commit header lines (HTML comment + [release] token)
# so they don't appear in the published release body.
sed -e '/^<!--/,/-->$/d' -e '/^\[release\]/d' "$PENDING_FILE"
fi
echo ""
echo "**[All commits](https://github.com/${{ github.repository }}/commits/main)**"
if [ -n "$PREV_TAG" ]; then
echo "**Full changelog:** [\`${PREV_TAG}\`...\`v${VERSION}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...v${VERSION})"
else
echo "**[All commits](https://github.com/${{ github.repository }}/commits/main)**"
fi
} > /tmp/release_notes.md
- name: Create Release
+4
View File
@@ -38,3 +38,7 @@ CLAUDE.md
upstream-scinsta
*.ipa
*.dylib
deploy.sh
PENDING_CHANGES.md
PENDING_CHANGES.md.bk
wrapper/
+1 -1
View File
@@ -7,7 +7,7 @@ include $(THEOS)/makefiles/common.mk
TWEAK_NAME = RyukGram
$(TWEAK_NAME)_FILES = $(shell find src -type f \( -iname \*.x -o -iname \*.xm -o -iname \*.m \)) $(wildcard modules/JGProgressHUD/*.m) modules/fishhook/fishhook.c
$(TWEAK_NAME)_FRAMEWORKS = UIKit Foundation CoreGraphics Photos CoreServices SystemConfiguration SafariServices Security QuartzCore
$(TWEAK_NAME)_FRAMEWORKS = UIKit Foundation CoreGraphics Photos CoreServices SystemConfiguration SafariServices Security QuartzCore AVFoundation UniformTypeIdentifiers
$(TWEAK_NAME)_PRIVATE_FRAMEWORKS = Preferences
$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Wno-unsupported-availability-guard -Wno-unused-value -Wno-deprecated-declarations -Wno-nullability-completeness -Wno-unused-function -Wno-incompatible-pointer-types
$(TWEAK_NAME)_LOGOSFLAGS = --c warnings=none
+47 -4
View File
@@ -1,6 +1,6 @@
# RyukGram
A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com/SoCuul/SCInsta) with additional features and fixes.\
`Version v1.1.4` | `Tested on Instagram 423.1.0`
`Version v1.1.4` | `Tested on Instagram 424.0.0`
---
@@ -22,6 +22,13 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Hide ads
- Hide Meta AI
- Copy description
- Copy comment text from long-press menu **\***
- Download GIF comments **\***
- Profile copy button **\***
- Replace domain in shared links — rewrite copied/shared links for embeds in Discord, Telegram, etc. with preset or custom domains **\***
- Strip tracking params from shared links (igsh, utm) **\***
- Open links in external browser **\***
- Strip tracking from browser links **\***
- Do not save recent searches
- Use detailed (native) color picker
- Enable liquid glass buttons
@@ -45,13 +52,16 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- No suggested reels
- No suggested threads posts
- Disable video autoplay
- Hide repost button in feed **\***
### Reels
- Modify tap controls
- Always show progress scrubber
- Disable auto-unmuting reels (properly blocks mute switch, volume buttons, and announcer broadcasts) **\***
- Confirm reel refresh
- Unlock password-locked reels **\***
- Hide reels header
- Hide repost button in reels **\***
- Hide reels blend button
- Disable scrolling reels
- Prevent doom scrolling (limit maximum viewable reels)
@@ -69,7 +79,9 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Save profile picture
- Download buttons on media — tap a button directly on feed posts, reels sidebar, and story overlay **\***
- Download method — choose between download button or long-press gesture **\***
- Download highlight cover from profile long-press menu **\***
- Save action — choose between share sheet or save directly to Photos **\***
- Save to RyukGram album — optional toggle that routes downloads (and share-sheet "Save to Photos" picks) into a dedicated "RyukGram" album in Photos **\***
- Download confirmation — optional confirmation dialog before downloading **\***
- Non-blocking download HUD — pill-style progress at the top, tap to cancel **\***
- Debug fallback — if IG updates break downloads, shows diagnostic info instead of crashing **\***
@@ -77,17 +89,34 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- *Customize hold time for long-press*
### Stories and messages
- Keep deleted messages
- Manually mark messages as seen
- Keep deleted messages (preserves unsent messages with visual indicator and notification pill) **\***
- Hide trailing action buttons on preserved messages
- Warn before clearing on refresh — optional confirmation when pulling to refresh the DMs tab if preserved messages would be cleared **\***
- Manually mark messages as seen (button or toggle mode) **\***
- Long-press the seen button for quick actions **\***
- Auto mark seen on send (marks messages as read when you send any message) **\***
- Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\***
- Mark seen on story like **\***
- Advance to next story when marking as seen — tapping the eye button auto-skips to the next story **\***
- Advance on story like — liking a story auto-skips to the next one **\***
- Per-chat read-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Long-press any DM chat to add/remove. Settings page with search, sort, multi-select, and per-entry keep-deleted override **\***
- Send audio as file — send audio files as voice messages from the DM plus menu **\***
- Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\***
- Disable typing status
- Unlimited replay of direct stories
- Disable view-once limitations
- Disable screenshot detection
- Disable story seen receipt (blocks network upload, toggleable at runtime without restart) **\***
- Keep stories visually unseen — keeps the colorful ring in the tray after viewing **\***
- Manual mark story as seen — button on story overlay to selectively mark stories as seen **\***
- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\***
- Long-press the story seen button for quick actions **\***
- Per-user story seen-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Manage via 3-dot menu, eye button long-press, or settings list **\***
- Story audio mute/unmute toggle — button on the story overlay and 3-dot menu to toggle audio **\***
- 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 **\***
- Upload audio as voice message — send audio files, extract audio from videos, with built-in trim editor **\***
- Disable instants creation
### Navigation
@@ -112,9 +141,23 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- Confirm changing direct message theme
- Confirm sticker interaction
### Tweak settings **\***
- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location
- Pause playback when opening settings (toggleable) **\***
- Quick-access via long-press on feed tab **\***
### Backup & Restore **\***
- Export RyukGram settings as a JSON file
- Import settings from a JSON file
- Searchable, collapsible, editable preview before saving or applying
### Optimization
- Automatically clears unneeded cache folders, reducing the size of your Instagram installation
## Known Issues
- Preserved unsent messages cannot be removed using "Delete for you". Pull to refresh in the DMs tab clears all preserved messages (with optional confirmation if "Warn before clearing on refresh" is enabled).
- "Delete for you" detection uses a ~2 second window after the local action. If a real other-party unsend happens to land in the same window, it may not be preserved. Rare in practice and limited to that specific overlap.
# Opening Tweak Settings
| | |
+1 -1
View File
@@ -1,6 +1,6 @@
Package: com.faroukbmiled.ryukgram
Name: RyukGram
Version: 1.1.4
Version: 1.1.5.1
Architecture: iphoneos-arm
Description: A feature-rich tweak for Instagram on iOS, based on SCInsta
Homepage: https://github.com/faroukbmiled/RyukGram
+27 -22
View File
@@ -1,4 +1,5 @@
#import "Download.h"
#import "../PhotoAlbum.h"
#import <Photos/Photos.h>
#pragma mark - SCIDownloadPillView
@@ -186,9 +187,14 @@
if (error && error.code != NSURLErrorCancelled) {
NSLog(@"[SCInsta] Download: Download failed with error: \"%@\"", error);
[self.pill setText:@"Download failed"];
self.pill.subtitleLabel.text = nil;
self.pill.subtitleLabel.text = error.localizedDescription;
self.pill.progressRing.hidden = YES;
[self.pill dismissAfterDelay:2.0];
[self.pill dismissAfterDelay:3.0];
} else if (!error) {
// nil error without fileURL callback — dismiss stale pill
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (self.pill.superview) [self.pill dismissAfterDelay:0];
});
}
});
}
@@ -217,30 +223,14 @@
return;
}
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
NSString *ext = [[fileURL pathExtension] lowercaseString];
BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext];
if (isVideo) {
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
opts.shouldMoveFile = YES;
[req addResourceWithType:PHAssetResourceTypeVideo fileURL:fileURL options:opts];
req.creationDate = [NSDate date];
} else {
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
opts.shouldMoveFile = YES;
[req addResourceWithType:PHAssetResourceTypePhoto fileURL:fileURL options:opts];
req.creationDate = [NSDate date];
}
} completionHandler:^(BOOL success, NSError *error) {
BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"];
void (^onDone)(BOOL, NSError *) = ^(BOOL success, NSError *error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (success) {
SCIDownloadPillView *donePill = [[SCIDownloadPillView alloc] init];
donePill.progressRing.hidden = YES;
donePill.subtitleLabel.text = nil;
[donePill setText:@"Saved to Photos"];
[donePill setText:useAlbum ? @"Saved to RyukGram" : @"Saved to Photos"];
UIView *hostView = topMostController().view;
if (hostView) {
[donePill showInView:hostView];
@@ -250,7 +240,22 @@
[SCIUtils showErrorHUDWithDescription:@"Failed to save to Photos"];
}
});
}];
};
if (useAlbum) {
[SCIPhotoAlbum saveFileToAlbum:fileURL completion:onDone];
} else {
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
NSString *ext = [[fileURL pathExtension] lowercaseString];
BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext];
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
opts.shouldMoveFile = YES;
[req addResourceWithType:(isVideo ? PHAssetResourceTypeVideo : PHAssetResourceTypePhoto)
fileURL:fileURL options:opts];
req.creationDate = [NSDate date];
} completionHandler:onDone];
}
}];
break;
}
+28 -6
View File
@@ -1,10 +1,32 @@
#import "../../Utils.h"
#import <objc/runtime.h>
#import <substrate.h>
// Demangled name: IGFeedPlayback.IGFeedPlaybackStrategy
%hook _TtC14IGFeedPlayback22IGFeedPlaybackStrategy
- (id)initWithShouldDisableAutoplay:(_Bool)autoplay {
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) return %orig(true);
// 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.
return %orig(autoplay);
static id (*orig_initStrategy2)(id, SEL, BOOL, BOOL);
static id new_initStrategy2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
return orig_initStrategy2(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) {
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
return orig_initStrategy3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
}
%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);
}
%end
+102
View File
@@ -0,0 +1,102 @@
// Copy comment text + download GIF from comment long-press menu
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static SCIDownloadDelegate *sciGifDl = nil;
static DownloadAction sciGifDownloadAction(void) {
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
return [method isEqualToString:@"photos"] ? saveToPhotos : share;
}
static id (*orig_commentCtxMenu)(id, SEL, id, id, CGPoint);
static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint point) {
UIContextMenuConfiguration *config = orig_commentCtxMenu(self, _cmd, cv, indexPath, point);
if (!config) return config;
Ivar commentIvar = class_getInstanceVariable([self class], "_longPressedComment");
id comment = commentIvar ? object_getIvar(self, commentIvar) : nil;
if (!comment) return config;
NSString *text = nil;
@try { text = ((id(*)(id,SEL))objc_msgSend)(comment, @selector(text)); } @catch (__unused id e) {}
NSString *gifId = nil;
@try {
SEL sel = NSSelectorFromString(@"gifMediaId");
if ([comment respondsToSelector:sel])
gifId = ((id(*)(id,SEL))objc_msgSend)(comment, sel);
} @catch (__unused id e) {}
NSString *gifURL = nil;
if (gifId.length) {
Ivar attIvar = class_getInstanceVariable([comment class], "_commentAttachment");
id att = attIvar ? object_getIvar(comment, attIvar) : nil;
if (att) {
Ivar urlIvar = class_getInstanceVariable([att class], "_image_imageURL");
if (urlIvar) {
id url = object_getIvar(att, urlIvar);
if ([url isKindOfClass:[NSString class]]) gifURL = url;
else if ([url isKindOfClass:[NSURL class]]) gifURL = [(NSURL *)url absoluteString];
}
}
}
BOOL hasText = text.length > 0;
BOOL hasGif = gifURL.length > 0;
if (!hasText && !hasGif) return config;
id origProvider = [config valueForKey:@"actionProvider"];
id<NSCopying> origIdent = [config valueForKey:@"identifier"];
UIContextMenuContentPreviewProvider origPreview = [config valueForKey:@"previewProvider"];
UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray<UIMenuElement *> *suggested) {
UIMenu *base = origProvider ? ((UIMenu *(^)(NSArray *))origProvider)(suggested)
: [UIMenu menuWithChildren:suggested];
NSMutableArray *extra = [NSMutableArray array];
if (hasText && [SCIUtils getBoolPref:@"copy_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Copy"
image:[UIImage systemImageNamed:@"doc.on.doc"]
identifier:nil
handler:^(__kindof UIAction *_) {
[UIPasteboard generalPasteboard].string = text;
}]];
}
if (hasGif && [SCIUtils getBoolPref:@"download_gif_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Download GIF"
image:[UIImage systemImageNamed:@"arrow.down.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
NSURL *url = [NSURL URLWithString:gifURL];
if (!url) return;
sciGifDl = [[SCIDownloadDelegate alloc] initWithAction:sciGifDownloadAction() showProgress:YES];
[sciGifDl downloadFileWithURL:url fileExtension:@"gif" hudLabel:nil];
}]];
}
if (!extra.count) return base;
NSMutableArray *kids = [base.children mutableCopy] ?: [NSMutableArray array];
NSUInteger insertIdx = kids.count > 0 ? kids.count - 1 : 0;
UIMenu *ourMenu = [UIMenu menuWithTitle:@"" image:nil identifier:nil
options:UIMenuOptionsDisplayInline children:extra];
[kids insertObject:ourMenu atIndex:insertIdx];
return [base menuByReplacingChildren:kids];
};
return [UIContextMenuConfiguration configurationWithIdentifier:origIdent
previewProvider:origPreview
actionProvider:wrapped];
}
__attribute__((constructor)) static void _commentActionsInit(void) {
Class cls = NSClassFromString(@"IGCommentThreadViewController");
if (!cls) return;
SEL s = @selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:);
if (class_getInstanceMethod(cls, s))
MSHookMessageEx(cls, s, (IMP)new_commentCtxMenu, (IMP *)&orig_commentCtxMenu);
}
+99
View File
@@ -0,0 +1,99 @@
// Rewrite Instagram share links — replace domain + optionally strip tracking params.
// Waits for IG's async clipboard write via changeCount, then rewrites once.
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static NSString *sciRewriteIGURL(NSString *url) {
if (!url.length) return url;
// Domain replacement
if ([SCIUtils getBoolPref:@"embed_links"]) {
NSString *domain = [SCIUtils getStringPref:@"embed_link_domain"];
if (!domain.length) domain = @"kkinstagram.com";
if (![url containsString:domain]) {
NSArray *igDomains = @[@"www.instagram.com", @"instagram.com", @"www.instagr.am", @"instagr.am"];
for (NSString *d in igDomains) {
NSRange r = [url rangeOfString:d];
if (r.location != NSNotFound) {
NSString *target = [d hasPrefix:@"www."]
? [NSString stringWithFormat:@"www.%@", domain] : domain;
url = [url stringByReplacingCharactersInRange:r withString:target];
break;
}
}
}
}
// Strip tracking params
if ([SCIUtils getBoolPref:@"strip_tracking_params"]) {
NSURLComponents *comps = [NSURLComponents componentsWithString:url];
if (comps.queryItems.count) {
NSArray *strip = @[@"igsh", @"ig_rid", @"utm_source", @"utm_medium", @"utm_campaign"];
NSMutableArray *clean = [NSMutableArray array];
for (NSURLQueryItem *q in comps.queryItems) {
if (![strip containsObject:q.name]) [clean addObject:q];
}
comps.queryItems = clean.count ? clean : nil;
NSString *result = comps.string;
if (result) url = result;
}
}
return url;
}
static BOOL sciShouldRewrite(void) {
return [SCIUtils getBoolPref:@"embed_links"] || [SCIUtils getBoolPref:@"strip_tracking_params"];
}
// Rewrite clipboard once after IG writes
static void sciPollAndRewrite(NSInteger countBefore, int polls, double interval) {
__block BOOL done = NO;
for (int i = 0; i < polls; i++) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((interval + i * interval) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (done) return;
if ([UIPasteboard generalPasteboard].changeCount == countBefore) return;
NSString *clip = [UIPasteboard generalPasteboard].string;
if (!clip || ![clip containsString:@"instagram"]) return;
NSString *rewritten = sciRewriteIGURL(clip);
if (![rewritten isEqualToString:clip]) {
[UIPasteboard generalPasteboard].string = rewritten;
done = YES;
} else {
done = YES;
}
});
}
}
// ============ Hooks ============
static void (*orig_copyLink)(id, SEL, id);
static void new_copyLink(id self, SEL _cmd, id vc) {
if (!sciShouldRewrite()) { orig_copyLink(self, _cmd, vc); return; }
NSInteger countBefore = [UIPasteboard generalPasteboard].changeCount;
orig_copyLink(self, _cmd, vc);
sciPollAndRewrite(countBefore, 30, 0.05);
}
static void (*orig_shareMore)(id, SEL, id);
static void new_shareMore(id self, SEL _cmd, id vc) {
if (!sciShouldRewrite()) { orig_shareMore(self, _cmd, vc); return; }
NSInteger countBefore = [UIPasteboard generalPasteboard].changeCount;
orig_shareMore(self, _cmd, vc);
sciPollAndRewrite(countBefore, 120, 0.1);
}
__attribute__((constructor)) static void _embedLinksInit(void) {
Class cls = NSClassFromString(@"IGExternalShareOptionsViewController");
if (!cls) return;
SEL copy = NSSelectorFromString(@"_shareToClipboardFromVC:");
if (class_getInstanceMethod(cls, copy))
MSHookMessageEx(cls, copy, (IMP)new_copyLink, (IMP *)&orig_copyLink);
SEL more = NSSelectorFromString(@"_shareToMoreFromVC:");
if (class_getInstanceMethod(cls, more))
MSHookMessageEx(cls, more, (IMP)new_shareMore, (IMP *)&orig_shareMore);
}
@@ -0,0 +1,120 @@
// Download highlight cover image from the profile long-press menu.
// Captures the long-pressed IGStoryTrayCell, finds the IGImageView inside it,
// and saves the cover using the user's download settings.
#import "../../Utils.h"
#import "../../Downloader/Download.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static SCIDownloadDelegate *sciHighlightDl = nil;
// Find the IGStoryTrayCell with an active long-press gesture
static UIView *sciFindLongPressedCell(UIView *root) {
Class cellCls = NSClassFromString(@"IGStoryTrayCell");
if (!cellCls) return nil;
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
if ([v isKindOfClass:cellCls]) {
for (UIGestureRecognizer *gr in v.gestureRecognizers) {
if ([gr isKindOfClass:[UILongPressGestureRecognizer class]] &&
(gr.state == UIGestureRecognizerStateBegan || gr.state == UIGestureRecognizerStateChanged))
return v;
}
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
return nil;
}
// Find the IGImageView inside a specific cell
static UIImage *sciCoverImageFromCell(UIView *cell) {
if (!cell) return nil;
Class igImageView = NSClassFromString(@"IGImageView");
if (!igImageView) igImageView = [UIImageView class];
NSMutableArray *stack = [NSMutableArray arrayWithObject:cell];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
if ([v isKindOfClass:igImageView] && [v isKindOfClass:[UIImageView class]]) {
UIImage *img = [(UIImageView *)v image];
if (img && img.size.width > 10) return img;
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
return nil;
}
static void sciSaveCoverImage(UIImage *image, UIViewController *presenter) {
if (!image) {
[SCIUtils showErrorHUDWithDescription:@"Could not find cover image"];
return;
}
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
if ([method isEqualToString:@"photos"]) {
// Save to Photos (respects RyukGram album pref)
NSData *data = UIImageJPEGRepresentation(image, 1.0);
if (!data) return;
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]]];
[data writeToFile:tmpPath atomically:YES];
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
sciHighlightDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
[sciHighlightDl downloadDidFinishWithFileURL:tmpURL];
} else {
// Share sheet
UIActivityViewController *activityVC = [[UIActivityViewController alloc]
initWithActivityItems:@[image] applicationActivities:nil];
if (presenter) [presenter presentViewController:activityVC animated:YES completion:nil];
}
}
// Stored reference to the long-pressed cell (captured at presentation time)
static __weak UIView *sciLongPressedHighlightCell = nil;
static void (*orig_present)(id, SEL, id, BOOL, id);
static void new_present(id self, SEL _cmd, id vc, BOOL animated, id completion) {
if ([SCIUtils getBoolPref:@"download_highlight_cover"] &&
[NSStringFromClass([vc class]) containsString:@"ActionSheet"] &&
[NSStringFromClass([self class]) containsString:@"Profile"]) {
// Capture the long-pressed cell NOW while the gesture is still active
UIView *cell = sciFindLongPressedCell([(UIViewController *)self view]);
sciLongPressedHighlightCell = cell;
if (cell) {
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
NSArray *actions = actIvar ? object_getIvar(vc, actIvar) : nil;
if (actions && actions.count >= 2 && actions.count <= 6) {
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
if (actionCls) {
__weak UIViewController *weakSelf = (UIViewController *)self;
void (^handler)(void) = ^{
UIImage *cover = sciCoverImageFromCell(sciLongPressedHighlightCell);
sciSaveCoverImage(cover, weakSelf);
};
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
id newAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
@"Download cover", nil, 0, handler, nil, nil);
if (newAction) {
NSMutableArray *newActions = [actions mutableCopy];
[newActions addObject:newAction];
object_setIvar(vc, actIvar, [newActions copy]);
}
}
}
}
}
orig_present(self, _cmd, vc, animated, completion);
}
__attribute__((constructor)) static void _highlightInit(void) {
MSHookMessageEx([UIViewController class], @selector(presentViewController:animated:completion:),
(IMP)new_present, (IMP *)&orig_present);
}
+69
View File
@@ -0,0 +1,69 @@
// Open links in external browser + strip IG tracking from URLs
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
// Extract the real URL from l.instagram.com redirects and strip tracking params
static NSURL *sciCleanBrowserURL(NSURL *url) {
if (![SCIUtils getBoolPref:@"strip_browser_tracking"]) return url;
if (!url) return url;
NSString *urlStr = url.absoluteString;
// Unwrap l.instagram.com/?u=ENCODED_URL&e=TRACKING redirects
if ([url.host isEqualToString:@"l.instagram.com"]) {
NSURLComponents *comps = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
for (NSURLQueryItem *q in comps.queryItems) {
if ([q.name isEqualToString:@"u"] && q.value.length) {
NSString *decoded = [q.value stringByRemovingPercentEncoding];
if (decoded) urlStr = decoded;
break;
}
}
}
// Strip common tracking params from the destination URL
NSURLComponents *comps = [NSURLComponents componentsWithString:urlStr];
if (comps.queryItems.count) {
NSSet *trackingParams = [NSSet setWithArray:@[
@"utm_source", @"utm_medium", @"utm_campaign", @"utm_content",
@"utm_term", @"utm_id", @"fbclid", @"igshid", @"igsh",
@"ig_rid", @"campaign_id", @"ad_id", @"aem"
]];
NSMutableArray *clean = [NSMutableArray array];
for (NSURLQueryItem *q in comps.queryItems) {
if (![trackingParams containsObject:q.name]) [clean addObject:q];
}
comps.queryItems = clean.count ? clean : nil;
}
NSURL *result = comps.URL;
return result ?: url;
}
%hook IGBrowserNavigationController
- (void)viewWillAppear:(BOOL)animated {
id session = ((id(*)(id,SEL))objc_msgSend)(self, @selector(browserSession));
Ivar urlIvar = session ? class_getInstanceVariable([session class], "_urlRequest") : nil;
NSURLRequest *req = urlIvar ? object_getIvar(session, urlIvar) : nil;
NSURL *url = req.URL;
if (url && [SCIUtils getBoolPref:@"open_links_external"]) {
NSURL *cleaned = sciCleanBrowserURL(url);
[[UIApplication sharedApplication] openURL:cleaned options:@{} completionHandler:nil];
[(UIViewController *)self dismissViewControllerAnimated:NO completion:nil];
return;
}
// For in-app browser: replace the URL request with the cleaned version
if (url && [SCIUtils getBoolPref:@"strip_browser_tracking"]) {
NSURL *cleaned = sciCleanBrowserURL(url);
if (![cleaned isEqual:url]) {
NSURLRequest *cleanReq = [NSURLRequest requestWithURL:cleaned];
object_setIvar(session, urlIvar, cleanReq);
}
}
%orig;
}
%end
+251
View File
@@ -0,0 +1,251 @@
#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;
}
// Own profile (centered title) → inject on the left to avoid crowding the
// plus/notifications/burger cluster. Other profiles → inject on the right.
NSArray *lb = [leftButtons isKindOfClass:[NSArray class]] ? (NSArray *)leftButtons : nil;
NSArray *rb = [rightButtons isKindOfClass:[NSArray class]] ? (NSArray *)rightButtons : nil;
BOOL isOwnProfile = titleIsCentered;
BOOL alreadyHas = NO;
for (id wrapper in (isOwnProfile ? lb : rb)) {
UIView *v = sci_safeValueForKey(wrapper, @"view");
if ([v isKindOfClass:[UIView class]] &&
[v.accessibilityIdentifier isEqualToString:@"sci-profile-copy-button"]) {
alreadyHas = YES;
break;
}
}
NSArray *patchedLeft = leftButtons;
NSArray *patchedRight = rightButtons;
if (!alreadyHas) {
Class wrapperCls = NSClassFromString(@"IGProfileNavigationHeaderViewButtonSwift.IGProfileNavigationHeaderViewButton");
// Mirror an existing button's type so IG lays ours out the same way
id sample = rb.firstObject ?: lb.firstObject;
NSInteger type = 0;
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) {
if (isOwnProfile) {
NSMutableArray *m = lb ? [lb mutableCopy] : [NSMutableArray array];
[m addObject:wrapper];
patchedLeft = m;
} else if (rb) {
NSMutableArray *m = [rb mutableCopy];
[m insertObject:wrapper atIndex:0];
patchedRight = m;
}
}
}
orig_configureHeaderView(self, _cmd, titleView, patchedLeft, patchedRight, 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);
}
+60 -7
View File
@@ -112,31 +112,60 @@ static void sciSetPlayViewOpacity(id cell, CGFloat opacity) {
}
}
// Force unmute by calling _didTapSoundButton on the section controller
// Swallow IG's "no sound" toast and remember the media so we don't retry it.
static NSString * const SCINoSoundToastText = @"This reel has no sound.";
static BOOL sciSuppressNoSoundToast = NO;
static BOOL sciSawNoSoundDuringUnmute = NO;
static NSMutableSet<NSString *> *sciNoAudioMediaIds = nil;
static NSString *sciMediaIdFor(id media) {
if (!media) return nil;
for (NSString *k in @[@"pk", @"mediaPk", @"mediaID", @"mpk"]) {
@try {
id v = [media valueForKey:k];
if (v) return [NSString stringWithFormat:@"%@", v];
} @catch (__unused id e) {}
}
return nil;
}
static void sciForceUnmuteCell(id videoCell) {
if (!videoCell) return;
Ivar delegateIvar = class_getInstanceVariable([videoCell class], "_delegate");
if (!delegateIvar) return;
id sectionCtrl = object_getIvar(videoCell, delegateIvar);
if (!sectionCtrl) return;
Ivar mediaIvar = class_getInstanceVariable([sectionCtrl class], "_media");
id media = mediaIvar ? object_getIvar(sectionCtrl, mediaIvar) : nil;
NSString *mediaId = sciMediaIdFor(media);
if (mediaId && [sciNoAudioMediaIds containsObject:mediaId]) return;
SEL isAudioSel = NSSelectorFromString(@"isAudioEnabled");
if (![sectionCtrl respondsToSelector:isAudioSel]) return;
BOOL audioOn = ((BOOL(*)(id,SEL))objc_msgSend)(sectionCtrl, isAudioSel);
if (audioOn) return;
SEL tapSel = NSSelectorFromString(@"_didTapSoundButton");
if ([sectionCtrl respondsToSelector:tapSel]) {
((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel);
if (![sectionCtrl respondsToSelector:tapSel]) return;
sciSuppressNoSoundToast = YES;
sciSawNoSoundDuringUnmute = NO;
((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel);
sciSuppressNoSoundToast = NO;
if (sciSawNoSoundDuringUnmute && mediaId) {
if (!sciNoAudioMediaIds) sciNoAudioMediaIds = [NSMutableSet new];
[sciNoAudioMediaIds addObject:mediaId];
}
}
%hook IGSundialViewerVideoCell
// Video playing/unpausing — use hidden (IG sets hidden=NO on next pause)
// hidden=YES on play; IG resets it on the next pause.
- (void)sundialVideoPlaybackViewDidStartPlaying:(id)view {
%orig;
if (sciIsPausePlayMode()) {
sciHidePlayView(self);
// Force unmute if in reels tab — this fires when the video ACTUALLY starts
// playing, guaranteed to have a ready section controller
if (sciIsInReelsTab) sciForceUnmuteCell(self);
}
}
@@ -226,7 +255,7 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
- (void)viewDidAppear:(BOOL)animated {
%orig;
sciIsInReelsTab = YES;
// Force unmute first reel — retry until the cell is ready
// Retry-until-ready: the first reel's cell may not be wired up yet.
if (sciIsPausePlayMode()) {
id feedVC = self;
for (int i = 0; i < 10; i++) {
@@ -256,6 +285,30 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
}
%end
%hook UILabel
- (void)setText:(NSString *)text {
if (sciSuppressNoSoundToast && [text isEqualToString:SCINoSoundToastText]) {
sciSawNoSoundDuringUnmute = YES;
%orig(@"");
self.hidden = YES;
// Container view is attached to a window after we return — detach the
// topmost non-window ancestor on the next tick to remove the outline.
__weak UILabel *weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
UILabel *s = weakSelf;
if (!s) return;
UIView *top = s;
while (top.superview && ![top.superview isKindOfClass:[UIWindow class]]) {
top = top.superview;
}
[top removeFromSuperview];
});
return;
}
%orig;
}
%end
// ============ RUNTIME HOOKS ============
%ctor {
+189
View File
@@ -0,0 +1,189 @@
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
// Password-locked reels use IGMediaOverlayProfileWithPasswordView as a blur overlay.
// The password is stored in the _asnwer ivar (IG typo). We read it at runtime,
// then provide buttons to auto-fill + submit or reveal + copy the password.
#define SCI_PW_BTN_TAG 1342
static NSString * _Nullable sciGetPassword(id overlayView) {
Class cls = [overlayView class];
while (cls && cls != [UIView class]) {
unsigned int count = 0;
Ivar *ivars = class_copyIvarList(cls, &count);
for (unsigned int i = 0; i < count; i++) {
const char *name = ivar_getName(ivars[i]);
if (name && strcmp(name, "_asnwer") == 0) {
id value = object_getIvar(overlayView, ivars[i]);
free(ivars);
if ([value isKindOfClass:[NSString class]] && [(NSString *)value length] > 0)
return (NSString *)value;
return nil;
}
}
if (ivars) free(ivars);
cls = class_getSuperclass(cls);
}
// Fallback: scan for any password-related string ivar
cls = [overlayView class];
while (cls && cls != [UIView class]) {
unsigned int count = 0;
Ivar *ivars = class_copyIvarList(cls, &count);
for (unsigned int i = 0; i < count; i++) {
const char *type = ivar_getTypeEncoding(ivars[i]);
if (!type || type[0] != '@') continue;
@try {
id value = object_getIvar(overlayView, ivars[i]);
if (![value isKindOfClass:[NSString class]] || [(NSString *)value length] == 0) continue;
const char *name = ivar_getName(ivars[i]);
if (!name) continue;
NSString *lower = [[NSString stringWithUTF8String:name] lowercaseString];
if ([lower containsString:@"answer"] || [lower containsString:@"asnwer"] ||
[lower containsString:@"password"] || [lower containsString:@"secret"]) {
free(ivars);
return (NSString *)value;
}
} @catch(id e) {}
}
if (ivars) free(ivars);
cls = class_getSuperclass(cls);
}
return nil;
}
static UITextField * _Nullable sciFindTextField(UIView *root) {
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
while (stack.count > 0) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ([v isKindOfClass:[UITextField class]]) return (UITextField *)v;
for (UIView *sub in v.subviews) [stack addObject:sub];
}
return nil;
}
static UIView * _Nullable sciFindSubmitButton(UIView *root) {
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
while (stack.count > 0) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ([NSStringFromClass([v class]) containsString:@"IGDSMediaTextButton"]) return v;
for (UIView *sub in v.subviews) [stack addObject:sub];
}
return nil;
}
%hook IGMediaOverlayProfileWithPasswordView
- (void)didMoveToSuperview {
%orig;
if (!self.superview) return;
if (![SCIUtils getBoolPref:@"unlock_password_reels"]) return;
[self sciAddButtons];
}
- (void)layoutSubviews {
%orig;
if (![SCIUtils getBoolPref:@"unlock_password_reels"]) return;
[self sciAddButtons];
}
%new - (void)sciAddButtons {
if ([self viewWithTag:SCI_PW_BTN_TAG]) return;
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightBold];
UIButton *unlockBtn = [UIButton buttonWithType:UIButtonTypeCustom];
unlockBtn.tag = SCI_PW_BTN_TAG;
[unlockBtn setImage:[UIImage systemImageNamed:@"lock.open.fill" withConfiguration:config] forState:UIControlStateNormal];
unlockBtn.tintColor = [UIColor colorWithRed:1.0 green:0.85 blue:0.0 alpha:1.0];
unlockBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.5];
unlockBtn.layer.cornerRadius = 20;
unlockBtn.translatesAutoresizingMaskIntoConstraints = NO;
[unlockBtn addTarget:self action:@selector(sciUnlockTapped) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:unlockBtn];
UIButton *eyeBtn = [UIButton buttonWithType:UIButtonTypeCustom];
eyeBtn.tag = SCI_PW_BTN_TAG + 1;
[eyeBtn setImage:[UIImage systemImageNamed:@"eye.fill" withConfiguration:config] forState:UIControlStateNormal];
eyeBtn.tintColor = [UIColor whiteColor];
eyeBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.5];
eyeBtn.layer.cornerRadius = 20;
eyeBtn.translatesAutoresizingMaskIntoConstraints = NO;
[eyeBtn addTarget:self action:@selector(sciShowPasswordTapped) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:eyeBtn];
[NSLayoutConstraint activateConstraints:@[
[unlockBtn.topAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.topAnchor constant:200],
[unlockBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
[unlockBtn.widthAnchor constraintEqualToConstant:40],
[unlockBtn.heightAnchor constraintEqualToConstant:40],
[eyeBtn.topAnchor constraintEqualToAnchor:unlockBtn.bottomAnchor constant:12],
[eyeBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
[eyeBtn.widthAnchor constraintEqualToConstant:40],
[eyeBtn.heightAnchor constraintEqualToConstant:40],
]];
}
%new - (void)sciUnlockTapped {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
return;
}
UITextField *textField = sciFindTextField(self);
if (!textField) {
[SCIUtils showErrorHUDWithDescription:@"No text field found"];
return;
}
textField.text = password;
[textField sendActionsForControlEvents:UIControlEventEditingChanged];
[[NSNotificationCenter defaultCenter] postNotificationName:UITextFieldTextDidChangeNotification object:textField];
if (textField.delegate) {
if ([textField.delegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:)])
[textField.delegate textField:textField shouldChangeCharactersInRange:NSMakeRange(0, 0) replacementString:password];
if ([textField.delegate respondsToSelector:@selector(textFieldDidChangeSelection:)])
[textField.delegate textFieldDidChangeSelection:textField];
}
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
UIView *submitBtn = sciFindSubmitButton(self);
if (submitBtn && [submitBtn isKindOfClass:[UIControl class]]) {
[(UIControl *)submitBtn setHidden:NO];
[(UIControl *)submitBtn sendActionsForControlEvents:UIControlEventTouchUpInside];
}
});
}
%new - (void)sciShowPasswordTapped {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
[haptic impactOccurred];
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
return;
}
[[UIPasteboard generalPasteboard] setString:password];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Password"
message:password
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Copied!" style:UIAlertActionStyleCancel handler:nil]];
UIViewController *topVC = topMostController();
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
}
%end
+37 -17
View File
@@ -28,27 +28,46 @@
}
%end
static BOOL sciReelRefreshBypassing = NO;
%hook IGSundialFeedViewController
- (void)_refreshReelsWithParamsForNetworkRequest:(NSInteger)arg1 userDidPullToRefresh:(BOOL)arg2 {
if ([SCIUtils getBoolPref:@"prevent_doom_scrolling"]) {
IGRefreshControl *_refreshControl = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
[self refreshControlDidEndFinishLoadingAnimation:_refreshControl];
IGRefreshControl *rc = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
[self refreshControlDidEndFinishLoadingAnimation:rc];
return;
}
if ([SCIUtils getBoolPref:@"refresh_reel_confirm"]) {
NSLog(@"[SCInsta] Reel refresh triggered");
[SCIUtils showConfirmation:^(void) { %orig(arg1, arg2); }
cancelHandler:^(void) {
IGRefreshControl *_refreshControl = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
[self refreshControlDidEndFinishLoadingAnimation:_refreshControl];
}
title:@"Refresh Reels"];
} else {
return %orig(arg1, arg2);
if (![(UIViewController *)self isViewLoaded] || sciReelRefreshBypassing || ![SCIUtils getBoolPref:@"refresh_reel_confirm"]) {
%orig(arg1, arg2);
return;
}
// Reset the refresh control state so pull-to-refresh can trigger again
IGRefreshControl *rc = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
Ivar stateIvar = class_getInstanceVariable([rc class], "_refreshState");
if (stateIvar) {
ptrdiff_t off = ivar_getOffset(stateIvar);
*(NSInteger *)((char *)(__bridge void *)rc + off) = 0;
}
if ([rc respondsToSelector:@selector(endRefreshing)])
((void(*)(id,SEL))objc_msgSend)(rc, @selector(endRefreshing));
[self refreshControlDidEndFinishLoadingAnimation:rc];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Refresh Reels?"
message:nil
preferredStyle:UIAlertControllerStyleAlert];
__weak id weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
sciReelRefreshBypassing = YES;
SEL rSel = @selector(_refreshReelsWithParamsForNetworkRequest:userDidPullToRefresh:);
((void(*)(id,SEL,NSInteger,BOOL))objc_msgSend)(weakSelf, rSel, arg1, arg2);
sciReelRefreshBypassing = NO;
}]];
UIViewController *presenter = (UIViewController *)self;
[presenter presentViewController:alert animated:YES completion:nil];
}
%end
@@ -67,15 +86,16 @@
}
}
- (void)_muteSwitchStateChanged:(id)changed {
if (![SCIUtils getBoolPref:@"disable_auto_unmuting_reels"]) {
extern BOOL sciStoryAudioBypass;
if (sciStoryAudioBypass || ![SCIUtils getBoolPref:@"disable_auto_unmuting_reels"]) {
%orig(changed);
}
}
// Block the announcer from broadcasting "audio enabled" state changes
- (void)_announceForDeviceStateChangesIfNeededForAudioEnabled:(BOOL)enabled reason:(NSInteger)reason {
// When pause/play mode is on, allow unmute (our force-unmute needs this path)
extern BOOL sciStoryAudioBypass;
BOOL pausePlayMode = [[SCIUtils getStringPref:@"reels_tap_control"] isEqualToString:@"pause"];
if ([SCIUtils getBoolPref:@"disable_auto_unmuting_reels"] && enabled && !pausePlayMode) {
if ([SCIUtils getBoolPref:@"disable_auto_unmuting_reels"] && enabled && !pausePlayMode && !sciStoryAudioBypass) {
return;
}
%orig;
+161 -294
View File
@@ -1,34 +1,27 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../Downloader/Download.h"
// Story seen receipt blocking + visual seen state blocking
#import "StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// === State ===
static BOOL sciSeenBypassActive = NO;
static NSMutableSet *sciAllowedSeenPKs = nil;
BOOL sciSeenBypassActive = NO;
BOOL sciAdvanceBypassActive = NO;
BOOL sciStorySeenToggleEnabled = NO; // toggle-mode session bypass
NSMutableSet *sciAllowedSeenPKs = nil;
// === Helpers ===
typedef id (*SCIMsgSend)(id, SEL);
typedef id (*SCIMsgSend1)(id, SEL, id);
extern BOOL sciIsCurrentStoryOwnerExcluded(void);
extern BOOL sciIsObjectStoryOwnerExcluded(id obj);
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 BOOL sciStorySeenToggleBypass(void) {
return [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"] && sciStorySeenToggleEnabled;
}
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) {
@@ -40,137 +33,35 @@ static BOOL sciIsPKAllowed(id media) {
static BOOL sciShouldBlockSeenNetwork() {
if (sciSeenBypassActive) return NO;
if (sciStorySeenToggleBypass()) return NO;
if (sciIsCurrentStoryOwnerExcluded()) return NO;
return [SCIUtils getBoolPref:@"no_seen_receipt"];
}
static BOOL sciShouldBlockSeenVisual() {
if (sciSeenBypassActive) return NO;
if (sciStorySeenToggleBypass()) return NO;
if (sciIsCurrentStoryOwnerExcluded()) return NO;
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;
// Per-instance gating for tray/item/ring hooks where the "current" story
// VC may not be the owner of the model in question.
static BOOL sciShouldBlockSeenVisualForObj(id obj) {
if (sciSeenBypassActive) return NO;
if (sciStorySeenToggleBypass()) return NO;
if (![SCIUtils getBoolPref:@"no_seen_receipt"] || ![SCIUtils getBoolPref:@"no_seen_visual"]) return NO;
if (sciIsObjectStoryOwnerExcluded(obj)) return NO;
return YES;
}
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,27 +73,22 @@ 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;
if (!sciAdvanceBypassActive && [SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
%orig;
}
- (void)advanceToNextReelForAutoScroll {
if ([SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
if (!sciAdvanceBypassActive && [SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
%orig;
}
%end
@@ -215,16 +101,16 @@ static void sciDownloadMedia(IGMedia *media) {
%end
%hook IGStoryTrayViewModel
- (void)markAsSeen { if (sciShouldBlockSeenVisual()) return; %orig; }
- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(YES); return; } %orig; }
- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisual()) return YES; return %orig; }
- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
- (BOOL)isSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; }
- (void)markAsSeen { if (sciShouldBlockSeenVisualForObj(self)) return; %orig; }
- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(YES); return; } %orig; }
- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisualForObj(self)) return YES; return %orig; }
- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; }
- (BOOL)isSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; }
%end
%hook IGStoryItem
- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
- (BOOL)hasSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; }
- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; }
- (BOOL)hasSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; }
%end
%hook IGStoryGradientRingView
@@ -233,152 +119,133 @@ static void sciDownloadMedia(IGMedia *media) {
- (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
%end
// ============ OVERLAY BUTTONS ============
// ============ STORY LIKE HOOKS ============
// Hooks all known like entry points to trigger mark-seen and auto-advance on like.
// Uses sciMarkSeenTapped: from OverlayButtons.xm for the actual seen flow.
%hook IGStoryFullscreenOverlayView
- (void)didMoveToSuperview {
static __weak UIViewController *sciActiveStoryVC = nil;
%hook IGStoryViewerViewController
- (void)viewDidAppear:(BOOL)animated {
%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]
]];
}
}
sciActiveStoryVC = self;
}
// ============ 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]];
}
- (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);
}
// Dedup guard — multiple hooks fire for the same like event
static uint64_t sciLastLikeAdvanceTime = 0;
static void sciAdvanceOnStoryLike(void) {
if (![SCIUtils getBoolPref:@"advance_on_story_like"]) return;
UIViewController *storyVC = sciActiveStoryVC;
if (!storyVC) return;
id sectionCtrl = sciFindSectionController(storyVC);
if (!sectionCtrl) return;
uint64_t now = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
if (now - sciLastLikeAdvanceTime < 500000000ULL) return;
sciLastLikeAdvanceTime = now;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciAdvanceBypassActive = YES;
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
if ([sectionCtrl respondsToSelector:advSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sectionCtrl, advSel, 1);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
id sc2 = storyVC ? sciFindSectionController(storyVC) : nil;
if (sc2) {
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
if ([sc2 respondsToSelector:resumeSel])
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
}
sciAdvanceBypassActive = NO;
});
});
}
static void sciOnStoryLike(void) {
sciMarkActiveStorySeen();
sciAdvanceOnStoryLike();
}
static void (*orig_didLikeSundial)(id, SEL, id);
static void new_didLikeSundial(id self, SEL _cmd, id pk) {
orig_didLikeSundial(self, _cmd, pk);
sciOnStoryLike();
}
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) sciOnStoryLike();
}
// IGUFIButton selected state: YES = heart filled (liked), NO = empty (not liked).
// handleStoryLikeTapWithButton: is a toggle — check state before orig to determine direction.
static void (*orig_handleLikeTap)(id, SEL, id);
static void new_handleLikeTap(id self, SEL _cmd, id button) {
BOOL isLike = [button isKindOfClass:[UIButton class]] && [(UIButton *)button isSelected];
orig_handleLikeTap(self, _cmd, button);
if (isLike) sciOnStoryLike();
}
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) sciOnStoryLike();
}
%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);
}
}
@@ -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);
@@ -0,0 +1,192 @@
// Download voice messages from DMs. Detects audio messages via the
// menuConfiguration hook, then injects a Download item into the long-press
// PrismMenu. Tries to convert to .m4a; falls back to the source extension
// (e.g. .ogg from web users) if AVFoundation can't decode the format.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
#import <AVFoundation/AVFoundation.h>
#import "../../Downloader/Download.h"
typedef id (*SCIMsgSendId)(id, SEL);
static inline id sciDAF(id obj, SEL sel) {
if (!obj || ![obj respondsToSelector:sel]) return nil;
return ((SCIMsgSendId)objc_msgSend)(obj, sel);
}
static BOOL sciAudioMenuPending = NO;
static id sciLastAudioViewModel = nil;
// Demangled: IGDirectMessageMenuConfiguration.IGDirectMessageMenuConfiguration
%hook _TtC32IGDirectMessageMenuConfiguration32IGDirectMessageMenuConfiguration
+ (id)menuConfigurationWithEligibleOptions:(id)options
messageViewModel:(id)arg2
contentType:(id)arg3
isSticker:(_Bool)arg4
isMusicSticker:(_Bool)arg5
directNuxManager:(id)arg6
sessionUserDefaults:(id)arg7
launcherSet:(id)arg8
userSession:(id)arg9
tapHandler:(id)arg10
{
if ([SCIUtils getBoolPref:@"download_audio_message"] &&
[arg3 isKindOfClass:[NSString class]] && [arg3 isEqualToString:@"voice_media"]) {
sciAudioMenuPending = YES;
sciLastAudioViewModel = arg2;
}
return %orig;
}
%end
// PrismMenu uses Swift classes with mangled names — hook via MSHookMessageEx in %ctor.
static id (*orig_prismMenuView_init3)(id, SEL, NSArray *, id, BOOL);
static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id header, BOOL edr) {
if (!sciAudioMenuPending) return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
sciAudioMenuPending = NO;
if (![SCIUtils getBoolPref:@"download_audio_message"])
return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
Class builderClass = NSClassFromString(@"IGDSPrismMenuItemBuilder");
Class elementClass = NSClassFromString(@"IGDSPrismMenuElement");
if (!builderClass || !elementClass || elements.count == 0)
return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
typedef id (*InitFn)(id, SEL, id);
typedef id (*WithFn)(id, SEL, id);
typedef id (*BuildFn)(id, SEL);
id capturedVM = sciLastAudioViewModel;
void (^handler)(void) = ^{
if (!capturedVM) return;
// vm -> audio (IGDirectAudio) -> _server_audio (IGAudio) -> playbackURL
id directAudio = nil;
@try { directAudio = [capturedVM valueForKey:@"audio"]; } @catch (NSException *e) {}
if (!directAudio) {
[SCIUtils showErrorHUDWithDescription:@"Could not get audio data. Try again after refreshing the chat."];
return;
}
Ivar serverAudioIvar = class_getInstanceVariable([directAudio class], "_server_audio");
id serverAudio = serverAudioIvar ? object_getIvar(directAudio, serverAudioIvar) : nil;
if (!serverAudio) {
[SCIUtils showErrorHUDWithDescription:@"Audio not loaded yet. Play the message first and try again."];
return;
}
NSURL *playbackURL = sciDAF(serverAudio, @selector(playbackURL));
if (!playbackURL) playbackURL = sciDAF(serverAudio, @selector(fallbackURL));
if (!playbackURL) {
[SCIUtils showErrorHUDWithDescription:@"No audio URL found. Try again after refreshing the chat."];
return;
}
UIView *topView = [UIApplication sharedApplication].keyWindow;
SCIDownloadPillView *pill = [[SCIDownloadPillView alloc] init];
[pill setText:@"Downloading audio..."];
[pill showInView:topView];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
downloadTaskWithURL:playbackURL
completionHandler:^(NSURL *tempURL, NSURLResponse *response, NSError *error) {
if (error || !tempURL) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill dismiss];
[SCIUtils showErrorHUDWithDescription:error.localizedDescription ?: @"Download failed. Try again."];
});
return;
}
// Try to convert to .m4a; on failure (e.g. Ogg/Opus) keep the source extension.
NSString *urlExt = [[playbackURL.path pathExtension] lowercaseString];
if (urlExt.length == 0) urlExt = @"m4a";
NSString *mediaId = sciDAF(serverAudio, @selector(mediaId)) ?: @"voice_message";
NSString *srcPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"tmp_%@.%@", mediaId, urlExt]];
NSURL *srcURL = [NSURL fileURLWithPath:srcPath];
[[NSFileManager defaultManager] removeItemAtURL:srcURL error:nil];
[[NSFileManager defaultManager] moveItemAtURL:tempURL toURL:srcURL error:nil];
void (^present)(NSURL *) = ^(NSURL *url) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill setText:@"Done!"];
[pill dismissAfterDelay:0.5];
[SCIUtils showShareVC:url];
});
};
NSString *m4aPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"audio_%@.m4a", mediaId]];
NSURL *m4aURL = [NSURL fileURLWithPath:m4aPath];
[[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil];
AVAsset *asset = [AVAsset assetWithURL:srcURL];
AVAssetExportSession *exp = [AVAssetExportSession
exportSessionWithAsset:asset presetName:AVAssetExportPresetAppleM4A];
exp.outputURL = m4aURL;
exp.outputFileType = AVFileTypeAppleM4A;
[exp exportAsynchronouslyWithCompletionHandler:^{
if (exp.status == AVAssetExportSessionStatusCompleted) {
[[NSFileManager defaultManager] removeItemAtURL:srcURL error:nil];
present(m4aURL);
return;
}
// Conversion failed — keep the original with its real extension.
[[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil];
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"audio_%@.%@", mediaId, urlExt]];
NSURL *outURL = [NSURL fileURLWithPath:outPath];
[[NSFileManager defaultManager] removeItemAtURL:outURL error:nil];
if (![[NSFileManager defaultManager] moveItemAtURL:srcURL toURL:outURL error:nil]) {
present(srcURL);
return;
}
present(outURL);
}];
}];
[task resume];
};
id builder = ((InitFn)objc_msgSend)([builderClass alloc], @selector(initWithTitle:), @"Download");
builder = ((WithFn)objc_msgSend)(builder, @selector(withImage:), [UIImage systemImageNamed:@"arrow.down.circle"]);
builder = ((WithFn)objc_msgSend)(builder, @selector(withHandler:), handler);
id menuItem = ((BuildFn)objc_msgSend)(builder, @selector(build));
if (!menuItem) return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
// Wrap in IGDSPrismMenuElement: clone _subtype from a sibling, attach the menuItem.
id templateEl = elements[0];
id newElement = [[templateEl class] new];
Ivar subtypeIvar = class_getInstanceVariable([templateEl class], "_subtype");
Ivar itemIvar = class_getInstanceVariable([templateEl class], "_item_menuItem");
if (!newElement || !subtypeIvar || !itemIvar)
return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
ptrdiff_t offset = ivar_getOffset(subtypeIvar);
*(uint64_t *)((uint8_t *)(__bridge void *)newElement + offset) =
*(uint64_t *)((uint8_t *)(__bridge void *)templateEl + offset);
object_setIvar(newElement, itemIvar, menuItem);
NSMutableArray *newElements = [NSMutableArray arrayWithObject:newElement];
[newElements addObjectsFromArray:elements];
return orig_prismMenuView_init3(self, _cmd, newElements, header, edr);
}
%ctor {
Class prismMenuView = objc_getClass("IGDSPrismMenu.IGDSPrismMenuView");
if (prismMenuView) {
SEL sel = @selector(initWithMenuElements:headerText:edrEnabled:);
if ([prismMenuView instancesRespondToSelector:sel])
MSHookMessageEx(prismMenuView, sel, (IMP)new_prismMenuView_init3, (IMP *)&orig_prismMenuView_init3);
}
}
@@ -0,0 +1,134 @@
// Per-chat exclusion list. Injects an Add/Remove item into the inbox row
// context menu, and tracks the currently-visible thread for the gating sites
// in SeenButtons / OverlayButtons / VisualMsgModifier. Storage lives in
// SCIExcludedThreads.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "SCIExcludedThreads.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
static id sci_safeKey(id obj, NSString *k) {
@try { return [obj valueForKey:k]; } @catch (__unused id e) { return nil; }
}
// Build a persistence-ready dict from a live IGDirectInboxThreadCellViewModel.
static NSDictionary *sci_entryFromVM(id vm) {
if (!vm) return nil;
NSString *tid = sci_safeKey(vm, @"threadId");
NSString *name = sci_safeKey(vm, @"threadName");
NSNumber *grp = sci_safeKey(vm, @"isGroupThread");
if (tid.length == 0) return nil;
NSMutableArray *users = [NSMutableArray array];
id active = sci_safeKey(vm, @"recentlyActiveUsers");
if ([active isKindOfClass:[NSArray class]]) {
for (id u in (NSArray *)active) {
id pk = sci_safeKey(u, @"pk");
id un = sci_safeKey(u, @"username");
id fn = sci_safeKey(u, @"fullName");
NSMutableDictionary *d = [NSMutableDictionary dictionary];
if (pk) d[@"pk"] = [NSString stringWithFormat:@"%@", pk];
if (un) d[@"username"] = [NSString stringWithFormat:@"%@", un];
if (fn) d[@"fullName"] = [NSString stringWithFormat:@"%@", fn];
if (d.count) [users addObject:d];
}
}
return @{
@"threadId": tid,
@"threadName": name ?: @"",
@"isGroup": @([grp boolValue]),
@"users": users,
};
}
// Inbox row context menu — wrap IG's UIContextMenuConfiguration to append our
// add/remove item without losing any of IG's own actions.
static id (*orig_ctxMenuCfg)(id, SEL, id);
static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
id cfg = orig_ctxMenuCfg(self, _cmd, indexPath);
if (![SCIExcludedThreads isFeatureEnabled]) return cfg;
if (![cfg isKindOfClass:[UIContextMenuConfiguration class]]) return cfg;
id adapter = sci_safeKey(self, @"listAdapter");
if (!adapter || ![indexPath respondsToSelector:@selector(section)]) return cfg;
NSInteger section = [(NSIndexPath *)indexPath section];
SEL secSel = NSSelectorFromString(@"sectionControllerForSection:");
if (![adapter respondsToSelector:secSel]) return cfg;
id secCtrl = ((id(*)(id,SEL,NSInteger))objc_msgSend)(adapter, secSel, section);
id vm = sci_safeKey(secCtrl, @"viewModel");
if (!vm) vm = sci_safeKey(secCtrl, @"item");
NSDictionary *entry = sci_entryFromVM(vm);
if (!entry) return cfg;
NSString *tid = entry[@"threadId"];
// actionProvider / previewProvider aren't public on UIContextMenuConfiguration
UIContextMenuConfiguration *orig = (UIContextMenuConfiguration *)cfg;
UIContextMenuActionProvider origProvider = sci_safeKey(orig, @"actionProvider");
id<NSCopying> origIdent = sci_safeKey(orig, @"identifier");
UIContextMenuContentPreviewProvider origPreview = sci_safeKey(orig, @"previewProvider");
UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray<UIMenuElement *> *suggested) {
UIMenu *base = origProvider ? origProvider(suggested) : [UIMenu menuWithChildren:suggested];
BOOL inList = [SCIExcludedThreads isInList:tid];
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *title = inList ? removeLabel : addLabel;
UIImage *img = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil
handler:^(__kindof UIAction *_) {
if (inList) {
[SCIExcludedThreads removeThreadId:tid];
} else {
[SCIExcludedThreads addOrUpdateEntry:entry];
}
}];
NSMutableArray *kids = [base.children mutableCopy] ?: [NSMutableArray array];
[kids addObject:toggle];
return [base menuByReplacingChildren:kids];
};
return [UIContextMenuConfiguration configurationWithIdentifier:origIdent
previewProvider:origPreview
actionProvider:wrapped];
}
// Active thread tracking. Set on viewWillAppear so visual-message viewMode
// reads it before the chat finishes loading. Only cleared on a real leave —
// a visual viewer modal pushed on top mustn't drop context.
%hook IGDirectThreadViewController
- (void)viewWillAppear:(BOOL)animated {
%orig;
NSString *tid = sci_safeKey(self, @"threadId");
if (tid) [SCIExcludedThreads setActiveThreadId:tid];
}
- (void)viewDidDisappear:(BOOL)animated {
%orig;
if (self.isMovingFromParentViewController || self.isBeingDismissed || self.parentViewController == nil) {
NSString *cur = [SCIExcludedThreads activeThreadId];
NSString *mine = sci_safeKey(self, @"threadId");
if (cur && mine && [cur isEqualToString:mine]) {
[SCIExcludedThreads setActiveThreadId:nil];
}
}
}
- (void)dealloc {
NSString *cur = [SCIExcludedThreads activeThreadId];
NSString *mine = sci_safeKey(self, @"threadId");
if (cur && mine && [cur isEqualToString:mine]) {
[SCIExcludedThreads setActiveThreadId:nil];
}
%orig;
}
%end
%ctor {
Class cls = NSClassFromString(@"IGDirectInboxViewController");
if (!cls) return;
SEL sel = NSSelectorFromString(@"networkingCoordinator_contextMenuConfigurationForThreadCellAtIndexPath:");
if (class_getInstanceMethod(cls, sel))
MSHookMessageEx(cls, sel, (IMP)new_ctxMenuCfg, (IMP *)&orig_ctxMenuCfg);
}
@@ -0,0 +1,258 @@
// Per-user story seen-receipt exclusions. Excluded users' stories behave
// normally (your view appears in their viewer list). Provides owner detection
// helpers, 3-dot menu injection, and overlay refresh utilities.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "StoryHelpers.h"
#import "SCIExcludedStoryUsers.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
NSDictionary *sciOwnerInfoFromObject(id obj);
// ============ Active story VC tracking ============
__weak UIViewController *sciActiveStoryViewerVC = nil;
%hook IGStoryViewerViewController
- (void)viewDidAppear:(BOOL)animated {
%orig;
sciActiveStoryViewerVC = self;
}
- (void)viewWillDisappear:(BOOL)animated {
if (sciActiveStoryViewerVC == (UIViewController *)self) sciActiveStoryViewerVC = nil;
%orig;
}
%end
// ============ Owner extraction ============
NSDictionary *sciOwnerInfoFromObject(id obj) {
if (!obj) return nil;
@try {
id pk = nil, un = nil, fn = nil;
if ([obj respondsToSelector:@selector(pk)])
pk = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(pk));
if ([obj respondsToSelector:@selector(username)])
un = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(username));
if ([obj respondsToSelector:@selector(fullName)])
fn = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(fullName));
if (pk && un) {
return @{ @"pk": [NSString stringWithFormat:@"%@", pk],
@"username": [NSString stringWithFormat:@"%@", un],
@"fullName": fn ? [NSString stringWithFormat:@"%@", fn] : @"" };
}
NSArray *nestedKeys = @[@"user", @"owner", @"author", @"reelUser", @"reelOwner"];
for (NSString *k in nestedKeys) {
@try {
id sub = [obj valueForKey:k];
if (sub && sub != obj) {
NSDictionary *d = sciOwnerInfoFromObject(sub);
if (d) return d;
}
} @catch (__unused id e) {}
}
} @catch (__unused id e) {}
return nil;
}
NSDictionary *sciOwnerInfoForStoryVC(UIViewController *vc) {
if (!vc) return nil;
@try {
id vm = ((id(*)(id, SEL))objc_msgSend)(vc, @selector(currentViewModel));
if (!vm) return nil;
id owner = nil;
@try { owner = [vm valueForKey:@"owner"]; } @catch (__unused id e) {}
if (!owner) return nil;
return sciOwnerInfoFromObject(owner);
} @catch (__unused id e) { return nil; }
}
NSDictionary *sciCurrentStoryOwnerInfo(void) {
return sciOwnerInfoForStoryVC(sciActiveStoryViewerVC);
}
// Find the section controller for a specific cell via ivar scan.
static id sciFindSectionControllerForCell(UICollectionViewCell *cell) {
Class sectionClass = NSClassFromString(@"IGStoryFullscreenSectionController");
if (!sectionClass || !cell) return nil;
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;
if ([val isKindOfClass:sectionClass]) { free(cIvars); return val; }
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;
}
static NSDictionary *sciOwnerInfoFromSectionController(id sc) {
if (!sc) return nil;
NSArray *tryKeys = @[@"viewModel", @"item", @"model", @"object"];
for (NSString *k in tryKeys) {
@try {
id obj = [sc valueForKey:k];
if (obj) {
NSDictionary *info = sciOwnerInfoFromObject(obj);
if (info) return info;
}
} @catch (__unused id e) {}
}
return sciOwnerInfoFromObject(sc);
}
// Per-cell owner lookup: walks from the overlay to its IGStoryFullscreenCell,
// finds the cell's section controller, and reads the owner. Gives the correct
// owner even when multiple cells are alive (pre-loaded adjacent reels).
NSDictionary *sciOwnerInfoForView(UIView *view) {
if (!view) return nil;
Class cellClass = NSClassFromString(@"IGStoryFullscreenCell");
UIView *cur = view;
UICollectionViewCell *cell = nil;
while (cur) {
if (cellClass && [cur isKindOfClass:cellClass]) { cell = (UICollectionViewCell *)cur; break; }
cur = cur.superview;
}
if (cell) {
id sc = sciFindSectionControllerForCell(cell);
NSDictionary *info = sciOwnerInfoFromSectionController(sc);
if (info) return info;
}
// Fallback: VC's currentViewModel
UIViewController *vc = sciFindVC(view, @"IGStoryViewerViewController");
return sciOwnerInfoForStoryVC(vc);
}
BOOL sciIsCurrentStoryOwnerExcluded(void) {
NSDictionary *info = sciCurrentStoryOwnerInfo();
if (!info) return NO;
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
}
BOOL sciIsObjectStoryOwnerExcluded(id obj) {
NSDictionary *info = sciOwnerInfoFromObject(obj);
if (!info) return NO;
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
}
// ============ Overlay utilities ============
void sciTriggerStoryMarkSeen(UIViewController *storyVC) {
if (!storyVC) return;
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
if (!overlayCls) return;
SEL markSel = @selector(sciMarkSeenTapped:);
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
if ([v isKindOfClass:overlayCls] && [v respondsToSelector:markSel]) {
((void(*)(id, SEL, id))objc_msgSend)(v, markSel, nil);
return;
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
}
void sciRefreshAllVisibleOverlays(UIViewController *storyVC) {
if (!storyVC) return;
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
if (!overlayCls) return;
SEL refreshSel = @selector(sciRefreshSeenButton);
SEL audioSel = @selector(sciRefreshAudioButton);
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
while (stack.count) {
UIView *v = stack.lastObject; [stack removeLastObject];
if ([v isKindOfClass:overlayCls]) {
if ([v respondsToSelector:refreshSel])
((void(*)(id, SEL))objc_msgSend)(v, refreshSel);
if ([v respondsToSelector:audioSel])
((void(*)(id, SEL))objc_msgSend)(v, audioSel);
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
}
// ============ 3-dot menu injection ============
// Hooks into the existing IGDSMenu hook in Tweak.x via sciMaybeAppendStoryExcludeMenuItem.
// Always present regardless of master toggle (fallback when eye affordance is hidden).
NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) {
if (!sciActiveStoryViewerVC) return items;
BOOL looksLikeStoryHeader = NO;
for (id it in items) {
@try {
id title = [it valueForKey:@"title"];
NSString *t = [NSString stringWithFormat:@"%@", title ?: @""];
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
[t isEqualToString:@"Hide"]) {
looksLikeStoryHeader = YES; break;
}
} @catch (__unused id e) {}
}
if (!looksLikeStoryHeader) return items;
NSDictionary *ownerInfo = sciCurrentStoryOwnerInfo();
if (!ownerInfo) return items;
NSString *pk = ownerInfo[@"pk"];
NSString *username = ownerInfo[@"username"] ?: @"";
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
// Bypass master toggle so the 3-dot fallback always shows
BOOL inList = [SCIExcludedStoryUsers isInList:pk];
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
NSString *title = inList ? removeLabel : addLabel;
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
void (^handler)(void) = ^{
if (inList) {
[SCIExcludedStoryUsers removePK:pk];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
// Removing in block_selected = normal behavior → mark seen
if (blockSelected) sciTriggerStoryMarkSeen(weakVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": pk, @"username": username, @"fullName": fullName
}];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
// Adding in block_all = normal behavior → mark seen
if (!blockSelected) sciTriggerStoryMarkSeen(weakVC);
}
sciRefreshAllVisibleOverlays(weakVC);
};
id newItem = nil;
@try {
SEL initSel = @selector(initWithTitle:image:handler:);
typedef id (*Init)(id, SEL, id, id, id);
newItem = ((Init)objc_msgSend)([menuItemCls alloc], initSel, title, nil, handler);
} @catch (__unused id e) { newItem = nil; }
if (!newItem) return items;
NSMutableArray *newItems = [items mutableCopy] ?: [NSMutableArray array];
[newItems addObject:newItem];
return [newItems copy];
}
@@ -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);
}
@@ -1,22 +1,742 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "SCIExcludedThreads.h"
#import <objc/runtime.h>
#import <substrate.h>
%hook IGDirectRealtimeIrisThreadDelta
+ (id)removeItemWithMessageId:(id)arg1 {
if ([SCIUtils getBoolPref:@"keep_deleted_message"]) {
arg1 = NULL;
// Keep-deleted messages.
//
// Pipeline: each iris delta is per-thread, so its threadId is stashed in TLS
// while the orig handler runs. The IGDirectMessageUpdate alloc hook stamps
// new updates with that tid. At apply time we classify each update; remote
// unsends get their _removeMessages_messageKeys cleared in place so IG's
// applicator runs but removes nothing.
//
// _removeMessages_reason: 0 = unsend, 2 = delete-for-you.
// ============ STATE ============
#define SCI_SENDER_MAP_MAX 4000
#define SCI_CONTENT_CLASSES_MAX 4000
#define SCI_PENDING_MAX 500
#define SCI_PRESERVED_MAX 200
#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds"
#define SCI_PRESERVED_TAG 1399
static NSString * const kSCIDeltaTidTLSKey = @"SCI.currentDeltaTid";
static const void *kSCIUpdateThreadIdKey = &kSCIUpdateThreadIdKey;
static BOOL sciLocalDeleteInProgress = NO;
static NSMutableArray *sciPendingUpdates = nil;
static NSMutableDictionary<NSString *, NSDate *> *sciDeleteForYouKeys = nil;
static NSMutableSet *sciPreservedIds = nil;
static NSMutableDictionary<NSString *, NSString *> *sciMessageContentClasses = nil;
static NSMutableDictionary<NSString *, NSString *> *sciSenderPkBySid = nil;
static NSMutableSet<NSString *> *sciPendingLocalSids = nil;
static void sciUpdateCellIndicator(id cell);
// ============ HELPERS ============
static NSString *sciGetCurrentDeltaTid(void) {
return [NSThread currentThread].threadDictionary[kSCIDeltaTidTLSKey];
}
static void sciSetCurrentDeltaTid(NSString *tid) {
NSMutableDictionary *td = [NSThread currentThread].threadDictionary;
if (tid) td[kSCIDeltaTidTLSKey] = tid;
else [td removeObjectForKey:kSCIDeltaTidTLSKey];
}
static BOOL sciKeepDeletedEnabled() {
return [SCIUtils getBoolPref:@"keep_deleted_message"];
}
static BOOL sciIndicateUnsentEnabled() {
return [SCIUtils getBoolPref:@"indicate_unsent_messages"];
}
NSMutableSet *sciGetPreservedIds() {
if (!sciPreservedIds) {
NSArray *saved = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_PRESERVED_IDS_KEY];
sciPreservedIds = saved ? [NSMutableSet setWithArray:saved] : [NSMutableSet set];
}
return sciPreservedIds;
}
static void sciSavePreservedIds() {
NSMutableSet *ids = sciGetPreservedIds();
while (ids.count > SCI_PRESERVED_MAX)
[ids removeObject:[ids anyObject]];
[[NSUserDefaults standardUserDefaults] setObject:[ids allObjects] forKey:SCI_PRESERVED_IDS_KEY];
}
void sciClearPreservedIds() {
[sciGetPreservedIds() removeAllObjects];
[[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY];
}
static NSMutableSet<NSString *> *sciGetPendingLocalSids() {
if (!sciPendingLocalSids) sciPendingLocalSids = [NSMutableSet set];
return sciPendingLocalSids;
}
static NSMutableDictionary<NSString *, NSString *> *sciGetSenderMap() {
if (!sciSenderPkBySid) sciSenderPkBySid = [NSMutableDictionary dictionary];
return sciSenderPkBySid;
}
static void sciTrackSenderPk(NSString *sid, NSString *pk) {
if (!sid.length || !pk.length) return;
NSMutableDictionary *m = sciGetSenderMap();
m[sid] = pk;
if (m.count > SCI_SENDER_MAP_MAX) {
NSArray *keys = [m allKeys];
for (NSUInteger i = 0; i < keys.count / 10; i++) [m removeObjectForKey:keys[i]];
}
}
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) {
NSArray *keys = [map allKeys];
for (NSUInteger i = 0; i < keys.count / 10; i++) [map removeObjectForKey:keys[i]];
}
}
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"];
}
// Walks IGWindow.userSession.user trying common pk field names. Cached.
static NSString *sciCurrentUserPk() {
static NSString *cached = nil;
if (cached) return cached;
@try {
for (UIWindow *w in [UIApplication sharedApplication].windows) {
id session = nil;
@try { session = [w valueForKey:@"userSession"]; } @catch (__unused id e) {}
if (!session) continue;
id user = nil;
@try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {}
if (!user) continue;
for (NSString *key in @[@"pk", @"instagramUserID", @"instagramUserId", @"userID", @"userId", @"identifier"]) {
@try {
id v = [user valueForKey:key];
if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) {
cached = [v copy];
return cached;
}
if ([v isKindOfClass:[NSNumber class]]) {
cached = [[(NSNumber *)v stringValue] copy];
return cached;
}
} @catch (__unused id e) {}
}
}
} @catch (__unused id e) {}
return nil;
}
static NSString *sciExtractServerId(id key) {
@try {
Ivar sidIvar = class_getInstanceVariable([key class], "_messageServerId");
if (sidIvar) {
NSString *sid = object_getIvar(key, sidIvar);
if ([sid isKindOfClass:[NSString class]] && sid.length > 0) return sid;
}
} @catch(id e) {}
return nil;
}
// ============ IRIS DELTA STAMPING ============
static NSString *sciDeltaThreadId(id delta) {
@try {
id payload = [delta valueForKey:@"payload"];
if (!payload) return nil;
Ivar tdIvar = class_getInstanceVariable([payload class], "_threadDeltaPayload");
id threadDelta = tdIvar ? object_getIvar(payload, tdIvar) : nil;
if (!threadDelta) return nil;
return [threadDelta valueForKey:@"threadId"];
} @catch (__unused id e) { return nil; }
}
static void (*orig_handleIrisDeltas)(id self, SEL _cmd, NSArray *deltas);
static void new_handleIrisDeltas(id self, SEL _cmd, NSArray *deltas) {
if (!deltas || deltas.count == 0) { orig_handleIrisDeltas(self, _cmd, deltas); return; }
for (id delta in deltas) {
sciSetCurrentDeltaTid(sciDeltaThreadId(delta));
@try { orig_handleIrisDeltas(self, _cmd, @[delta]); } @catch (__unused id e) {}
sciSetCurrentDeltaTid(nil);
}
}
// Some IG paths bypass the top-level handler and call the per-thread variant.
static void (*orig_handleIrisDeltasGrouped)(id self, SEL _cmd, NSArray *deltas);
static void new_handleIrisDeltasGrouped(id self, SEL _cmd, NSArray *deltas) {
if (!deltas || deltas.count == 0) { orig_handleIrisDeltasGrouped(self, _cmd, deltas); return; }
sciSetCurrentDeltaTid(sciDeltaThreadId(deltas.firstObject));
@try { orig_handleIrisDeltasGrouped(self, _cmd, deltas); } @catch (__unused id e) {}
sciSetCurrentDeltaTid(nil);
}
// ============ ALLOC TRACKING ============
static id (*orig_msgUpdate_alloc)(id self, SEL _cmd);
static id new_msgUpdate_alloc(id self, SEL _cmd) {
id instance = orig_msgUpdate_alloc(self, _cmd);
if (instance && sciKeepDeletedEnabled()) {
NSString *tid = sciGetCurrentDeltaTid();
if (tid) {
objc_setAssociatedObject(instance, kSCIUpdateThreadIdKey, tid,
OBJC_ASSOCIATION_COPY_NONATOMIC);
}
if (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array];
@synchronized(sciPendingUpdates) {
[sciPendingUpdates addObject:instance];
while (sciPendingUpdates.count > SCI_PENDING_MAX)
[sciPendingUpdates removeObjectAtIndex:0];
}
}
return instance;
}
// ============ REMOTE UNSEND DETECTION ============
static void sciPruneStaleDeleteForYouKeys() {
if (!sciDeleteForYouKeys) return;
NSDate *cutoff = [NSDate dateWithTimeIntervalSinceNow:-10.0];
for (NSString *k in [sciDeleteForYouKeys allKeys]) {
if ([sciDeleteForYouKeys[k] compare:cutoff] == NSOrderedAscending)
[sciDeleteForYouKeys removeObjectForKey:k];
}
}
// Clear the keys ivar in place — IG's later apply iterates an empty list.
static void sciNeuterRemoveUpdate(id update) {
@try {
Ivar ivar = class_getInstanceVariable([update class], "_removeMessages_messageKeys");
if (ivar) object_setIvar(update, ivar, nil);
} @catch (__unused id e) {}
}
static void sciProcessOneUpdate(id update, NSMutableSet<NSString *> *preserved) {
@try {
Ivar removeIvar = class_getInstanceVariable([update class], "_removeMessages_messageKeys");
if (!removeIvar) return;
NSArray *keys = object_getIvar(update, removeIvar);
if (!keys || keys.count == 0) return;
long long reason = -1;
Ivar reasonIvar = class_getInstanceVariable([update class], "_removeMessages_reason");
if (reasonIvar) {
ptrdiff_t off = ivar_getOffset(reasonIvar);
reason = *(long long *)((char *)(__bridge void *)update + off);
}
// reason 2 = delete-for-you. Track keys so the reason=0 follow-up
// (if any) can be recognised and let through.
if (reason == 2) {
NSDate *now = [NSDate date];
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) sciDeleteForYouKeys[sid] = now;
}
return;
}
if (reason != 0) return;
// Per-sid intent: sids the user just locally removed via a hooked
// mutation processor. Exact, raceless. Consumed on match.
{
NSMutableSet *pending = sciGetPendingLocalSids();
BOOL anyIntent = NO;
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid && [pending containsObject:sid]) { anyIntent = YES; break; }
}
if (anyIntent) {
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) [pending removeObject:sid];
}
return;
}
}
if (sciLocalDeleteInProgress) return;
// Delete-for-you follow-up: any tracked key → let the whole batch through.
BOOL anyMatched = NO;
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid && sciDeleteForYouKeys[sid]) { anyMatched = YES; break; }
}
if (anyMatched) {
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
}
return;
}
// Real remote unsend — preserve, skipping reactions/action-logs and
// any message recorded as sent by the current user.
NSString *myPk = sciCurrentUserPk();
for (id key in keys) {
NSString *sid = sciExtractServerId(key);
if (!sid) continue;
if (sciIsReactionRelatedMessage(sid)) continue;
NSString *senderPk = sciGetSenderMap()[sid];
if (senderPk && myPk && [senderPk isEqualToString:myPk]) continue;
[sciGetPreservedIds() addObject:sid];
[preserved addObject:sid];
}
} @catch (__unused id e) {}
}
// Classify and neuter every pending update stamped with `tid`. Excluded
// threads are passed through untouched.
static NSSet<NSString *> *sciNeuterAndPreserveForThread(NSString *tid) {
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
if (!sciPendingUpdates || tid.length == 0) return preserved;
if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary];
sciPruneStaleDeleteForYouKeys();
BOOL excluded = [SCIExcludedThreads shouldKeepDeletedBeBlockedForThreadId:tid];
@synchronized(sciPendingUpdates) {
NSMutableArray *remaining = [NSMutableArray array];
for (id update in sciPendingUpdates) {
NSString *stamp = objc_getAssociatedObject(update, kSCIUpdateThreadIdKey);
if (![stamp isEqualToString:tid]) {
[remaining addObject:update];
continue;
}
if (excluded) continue;
NSUInteger before = preserved.count;
sciProcessOneUpdate(update, preserved);
if (preserved.count > before) sciNeuterRemoveUpdate(update);
}
[sciPendingUpdates setArray:remaining];
}
if (preserved.count > 0) sciSavePreservedIds();
return preserved;
}
// ============ CACHE UPDATE HOOK ============
static void sciShowUnsentToast() {
UIView *hostView = [UIApplication sharedApplication].keyWindow;
if (!hostView) return;
UIView *pill = [[UIView alloc] init];
pill.backgroundColor = [UIColor colorWithRed:0.85 green:0.15 blue:0.15 alpha:0.95];
pill.layer.cornerRadius = 18;
pill.layer.shadowColor = [UIColor blackColor].CGColor;
pill.layer.shadowOpacity = 0.4;
pill.layer.shadowOffset = CGSizeMake(0, 2);
pill.layer.shadowRadius = 8;
pill.translatesAutoresizingMaskIntoConstraints = NO;
pill.alpha = 0;
UILabel *label = [[UILabel alloc] init];
label.text = @"A message was unsent";
label.textColor = [UIColor whiteColor];
label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
label.textAlignment = NSTextAlignmentCenter;
label.translatesAutoresizingMaskIntoConstraints = NO;
[pill addSubview:label];
[hostView addSubview:pill];
[NSLayoutConstraint activateConstraints:@[
[pill.topAnchor constraintEqualToAnchor:hostView.safeAreaLayoutGuide.topAnchor constant:8],
[pill.centerXAnchor constraintEqualToAnchor:hostView.centerXAnchor],
[pill.heightAnchor constraintEqualToConstant:36],
[label.centerXAnchor constraintEqualToAnchor:pill.centerXAnchor],
[label.centerYAnchor constraintEqualToAnchor:pill.centerYAnchor],
[label.leadingAnchor constraintEqualToAnchor:pill.leadingAnchor constant:20],
[label.trailingAnchor constraintEqualToAnchor:pill.trailingAnchor constant:-20],
]];
[UIView animateWithDuration:0.3 animations:^{ pill.alpha = 1; }];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
[UIView animateWithDuration:0.3 animations:^{ pill.alpha = 0; } completion:^(BOOL f) {
[pill removeFromSuperview];
}];
});
}
static void sciRefreshVisibleCellIndicators() {
Class cellClass = NSClassFromString(@"IGDirectMessageCell");
if (!cellClass) return;
UIWindow *window = [UIApplication sharedApplication].keyWindow;
NSMutableArray *stack = [NSMutableArray arrayWithObject:window];
while (stack.count > 0) {
UIView *v = stack.lastObject;
[stack removeLastObject];
if ([v isKindOfClass:cellClass]) {
sciUpdateCellIndicator(v);
continue;
}
for (UIView *sub in v.subviews) [stack addObject:sub];
}
}
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()) {
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
return;
}
return %orig(arg1);
}
%end
%hook IGDirectMessageUpdate
+ (id)removeMessageWithMessageId:(id)arg1{
if ([SCIUtils getBoolPref:@"keep_deleted_message"]) {
arg1 = NULL;
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
if ([updates isKindOfClass:[NSArray class]]) {
for (id tu in (NSArray *)updates) {
NSString *tid = nil;
@try { tid = [tu valueForKey:@"threadId"]; } @catch (__unused id e) {}
if (tid.length == 0) continue;
NSSet *p = sciNeuterAndPreserveForThread(tid);
if (p.count > 0) [preserved unionSet:p];
}
}
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
if (preserved.count > 0) {
dispatch_async(dispatch_get_main_queue(), ^{
sciRefreshVisibleCellIndicators();
if ([SCIUtils getBoolPref:@"unsent_message_toast"]) sciShowUnsentToast();
});
}
}
// ============ LOCAL DELETE TRACKING ============
// Hooked on the unsend mutation processor. Reads target sids straight off
// _messageKeys for the per-sid intent path; the time-window flag stays as
// a safety net for any sid the extraction may miss.
static void (*orig_removeMutation_execute)(id self, SEL _cmd, id handler, id pkg);
static void new_removeMutation_execute(id self, SEL _cmd, id handler, id pkg) {
@try {
Ivar mkIvar = class_getInstanceVariable([self class], "_messageKeys");
id keys = mkIvar ? object_getIvar(self, mkIvar) : nil;
if ([keys isKindOfClass:[NSArray class]]) {
static const char *kSidNames[] = {"_serverId", "_messageServerId"};
for (id k in (NSArray *)keys) {
NSString *sid = nil;
for (int ni = 0; ni < 2; ni++) {
Ivar sidIvar = class_getInstanceVariable([k class], kSidNames[ni]);
if (sidIvar) {
id v = object_getIvar(k, sidIvar);
if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) {
sid = v; break;
}
}
}
if (sid) [sciGetPendingLocalSids() addObject:sid];
}
}
} @catch (__unused id e) {}
sciLocalDeleteInProgress = YES;
orig_removeMutation_execute(self, _cmd, handler, pkg);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciLocalDeleteInProgress = NO;
});
}
// Sweeps every IGDirect*Outgoing*MutationProcessor and wraps its execute.
// IGDirectGenericOutgoingMutationProcessor is the empirical DFY signal —
// it fires for "Delete for you" but not for sends (sends use the *GraphQL*
// or NonMedia variants). Other classes are wrapped only when their name
// suggests removal, as a defensive net. Each class gets its own block so
// origImp is captured per-class.
static void sciHookAllRemovalMutationProcessors(void) {
unsigned int count = 0;
Class *all = objc_copyClassList(&count);
if (!all) return;
SEL execSel = NSSelectorFromString(@"executeWithResultHandler:accessoryPackage:");
Class baseUnsend = NSClassFromString(@"IGDirectMessageOutgoingUpdateRemoveMessagesMutationProcessor");
for (unsigned int i = 0; i < count; i++) {
Class c = all[i];
const char *cn = class_getName(c);
if (!cn) continue;
if (c == baseUnsend) continue;
if (strstr(cn, "MutationProcessor") == NULL) continue;
if (strstr(cn, "IGDirect") == NULL) continue;
if (strstr(cn, "Outgoing") == NULL) continue;
Method m = class_getInstanceMethod(c, execSel);
if (!m) continue;
BOOL isDfySignal = (strcmp(cn, "IGDirectGenericOutgoingMutationProcessor") == 0);
BOOL looksLikeRemoval = (strstr(cn, "Remove") != NULL ||
strstr(cn, "Delete") != NULL ||
strstr(cn, "Hide") != NULL ||
strstr(cn, "Visibility") != NULL);
if (!isDfySignal && !looksLikeRemoval) continue;
__block IMP origImp = method_getImplementation(m);
IMP newImp = imp_implementationWithBlock(^(id self, id handler, id pkg) {
sciLocalDeleteInProgress = YES;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciLocalDeleteInProgress = NO;
});
((void(*)(id, SEL, id, id))origImp)(self, execSel, handler, pkg);
});
IMP prev = class_replaceMethod(c, execSel, newImp, method_getTypeEncoding(m));
if (prev) origImp = prev;
}
free(all);
}
// ============ VISUAL INDICATOR ============
static NSString * _Nullable sciGetCellServerId(id cell) {
@try {
Ivar vmIvar = class_getInstanceVariable([cell class], "_viewModel");
if (!vmIvar) return nil;
id vm = object_getIvar(cell, vmIvar);
if (!vm) return nil;
SEL metaSel = NSSelectorFromString(@"messageMetadata");
if (![vm respondsToSelector:metaSel]) return nil;
id meta = ((id(*)(id,SEL))objc_msgSend)(vm, metaSel);
if (!meta) return nil;
Ivar keyIvar = class_getInstanceVariable([meta class], "_key");
if (!keyIvar) return nil;
id keyObj = object_getIvar(meta, keyIvar);
if (!keyObj) return nil;
Ivar sidIvar = class_getInstanceVariable([keyObj class], "_serverId");
if (!sidIvar) return nil;
NSString *serverId = object_getIvar(keyObj, sidIvar);
return [serverId isKindOfClass:[NSString class]] ? serverId : nil;
} @catch(id e) {}
return nil;
}
static BOOL sciCellIsPreserved(id cell) {
NSString *sid = sciGetCellServerId(cell);
return sid && [sciGetPreservedIds() containsObject:sid];
}
// Closest squarish ancestor (32-60pt, ~equal w/h) — the visible button wrapper.
static UIView *sciFindAccessoryWrapper(UIView *view) {
UIView *cur = view;
while (cur && cur.superview) {
CGRect f = cur.frame;
if (f.size.width >= 32 && f.size.width <= 60 &&
fabs(f.size.width - f.size.height) < 4) {
return cur;
}
cur = cur.superview;
}
return view;
}
// Hide trailing action buttons on preserved cells — they don't work and
// overlap the "Unsent" label.
static void sciSetTrailingButtonsHidden(UIView *cell, BOOL hidden) {
if (!cell) return;
Ivar accIvar = class_getInstanceVariable([cell class], "_tappableAccessoryViews");
if (!accIvar) return;
id accViews = object_getIvar(cell, accIvar);
if (![accViews isKindOfClass:[NSArray class]]) return;
for (UIView *v in (NSArray *)accViews) {
if (![v isKindOfClass:[UIView class]]) continue;
UIView *wrapper = sciFindAccessoryWrapper(v);
wrapper.hidden = hidden;
if (wrapper != v) v.hidden = hidden;
}
}
static void (*orig_addTappableAccessoryView)(id self, SEL _cmd, id view);
static void new_addTappableAccessoryView(id self, SEL _cmd, id view) {
orig_addTappableAccessoryView(self, _cmd, view);
if (sciIndicateUnsentEnabled() && sciCellIsPreserved(self)) {
if ([view isKindOfClass:[UIView class]]) {
UIView *wrapper = sciFindAccessoryWrapper((UIView *)view);
wrapper.hidden = YES;
if (wrapper != view) ((UIView *)view).hidden = YES;
}
}
}
static void sciUpdateCellIndicator(id cell) {
UIView *view = (UIView *)cell;
UIView *oldIndicator = [view viewWithTag:SCI_PRESERVED_TAG];
Ivar bubbleIvar = class_getInstanceVariable([cell class], "_messageContentContainerView");
UIView *bubble = bubbleIvar ? object_getIvar(cell, bubbleIvar) : nil;
if (!sciIndicateUnsentEnabled()) {
if (oldIndicator) [oldIndicator removeFromSuperview];
sciSetTrailingButtonsHidden(view, NO);
return;
}
NSString *serverId = sciGetCellServerId(cell);
BOOL isPreserved = serverId && [sciGetPreservedIds() containsObject:serverId];
if (!isPreserved) {
if (oldIndicator) [oldIndicator removeFromSuperview];
sciSetTrailingButtonsHidden(view, NO);
return;
}
sciSetTrailingButtonsHidden(view, YES);
if (oldIndicator) return;
UIView *parent = bubble ?: view;
UILabel *label = [[UILabel alloc] init];
label.tag = SCI_PRESERVED_TAG;
label.text = @"Unsent";
label.font = [UIFont italicSystemFontOfSize:10];
label.textColor = [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.9];
label.translatesAutoresizingMaskIntoConstraints = NO;
[parent addSubview:label];
[NSLayoutConstraint activateConstraints:@[
[label.leadingAnchor constraintEqualToAnchor:parent.trailingAnchor constant:4],
[label.centerYAnchor constraintEqualToAnchor:parent.centerYAnchor],
]];
}
static void (*orig_configureCell)(id self, SEL _cmd, id vm, id ringSpec, id launcherSet);
static void new_configureCell(id self, SEL _cmd, id vm, id ringSpec, id launcherSet) {
orig_configureCell(self, _cmd, vm, ringSpec, launcherSet);
// Capture serverId -> senderPk for every configured cell so the apply
// hook can identify "from me" messages and skip preserving them.
@try {
Ivar vmIvar = class_getInstanceVariable([self class], "_viewModel");
id vmObj = vmIvar ? object_getIvar(self, vmIvar) : nil;
SEL metaSel = NSSelectorFromString(@"messageMetadata");
id meta = (vmObj && [vmObj respondsToSelector:metaSel])
? ((id(*)(id,SEL))objc_msgSend)(vmObj, metaSel) : nil;
if (meta) {
Ivar keyIvar = class_getInstanceVariable([meta class], "_key");
id keyObj = keyIvar ? object_getIvar(meta, keyIvar) : nil;
Ivar sidIvar = keyObj ? class_getInstanceVariable([keyObj class], "_serverId") : NULL;
NSString *sid = sidIvar ? object_getIvar(keyObj, sidIvar) : nil;
Ivar pkIvar = class_getInstanceVariable([meta class], "_senderPk");
id pk = pkIvar ? object_getIvar(meta, pkIvar) : nil;
if ([sid isKindOfClass:[NSString class]] && [pk isKindOfClass:[NSString class]]) {
sciTrackSenderPk(sid, pk);
}
}
} @catch (__unused id e) {}
sciUpdateCellIndicator(self);
}
static void (*orig_cellLayoutSubviews)(id self, SEL _cmd);
static void new_cellLayoutSubviews(id self, SEL _cmd) {
orig_cellLayoutSubviews(self, _cmd);
sciUpdateCellIndicator(self);
}
// ============ ACTION LOG TRACKING ============
// IGDirectThreadActionLog is the local model for "X liked a message" rows.
// Recording the message id lets the unsend path skip these as bookkeeping.
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 {
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:");
if (class_getInstanceMethod(cacheClass, sel))
MSHookMessageEx(cacheClass, sel, (IMP)new_applyUpdates, (IMP *)&orig_applyUpdates);
}
Class irisClass = NSClassFromString(@"IGDirectRealtimeIrisDeltaHandler");
if (irisClass) {
SEL sel1 = NSSelectorFromString(@"handleIrisDeltas:");
if (class_getInstanceMethod(irisClass, sel1))
MSHookMessageEx(irisClass, sel1,
(IMP)new_handleIrisDeltas, (IMP *)&orig_handleIrisDeltas);
SEL sel2 = NSSelectorFromString(@"_handleIrisDeltasGroupedByThread:");
if (class_getInstanceMethod(irisClass, sel2))
MSHookMessageEx(irisClass, sel2,
(IMP)new_handleIrisDeltasGrouped, (IMP *)&orig_handleIrisDeltasGrouped);
}
Class cellClass = NSClassFromString(@"IGDirectMessageCell");
if (cellClass) {
SEL configSel = NSSelectorFromString(@"configureWithViewModel:ringViewSpecFactory:launcherSet:");
if (class_getInstanceMethod(cellClass, configSel))
MSHookMessageEx(cellClass, configSel,
(IMP)new_configureCell, (IMP *)&orig_configureCell);
SEL layoutSel = @selector(layoutSubviews);
MSHookMessageEx(cellClass, layoutSel,
(IMP)new_cellLayoutSubviews, (IMP *)&orig_cellLayoutSubviews);
SEL addAccSel = NSSelectorFromString(@"_addTappableAccessoryView:");
if (class_getInstanceMethod(cellClass, addAccSel))
MSHookMessageEx(cellClass, addAccSel,
(IMP)new_addTappableAccessoryView, (IMP *)&orig_addTappableAccessoryView);
}
Class removeMutationClass = NSClassFromString(@"IGDirectMessageOutgoingUpdateRemoveMessagesMutationProcessor");
if (removeMutationClass) {
SEL execSel = NSSelectorFromString(@"executeWithResultHandler:accessoryPackage:");
if (class_getInstanceMethod(removeMutationClass, execSel))
MSHookMessageEx(removeMutationClass, execSel,
(IMP)new_removeMutation_execute, (IMP *)&orig_removeMutation_execute);
}
sciHookAllRemovalMutationProcessors();
if (![SCIUtils getBoolPref:@"indicate_unsent_messages"]) {
sciClearPreservedIds();
}
return %orig(arg1);
}
%end
@@ -0,0 +1,602 @@
// Download + mark seen buttons on story/DM visual message overlay
#import "StoryHelpers.h"
#import "SCIExcludedThreads.h"
#import "SCIExcludedStoryUsers.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);
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();
}
}
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;
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;
}
}
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;
}
}
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;
}
}
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
// ============ Button injection ============
- (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]
]];
}
// Audio toggle button (left side, small)
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 ============
// 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) based on current owner + prefs. Idempotent.
%new - (void)sciRefreshSeenButton {
BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"];
BOOL storyBlockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
// In block_selected mode, show the eye for list management even if global toggle is off
if (!seenBlockingOn && !storyBlockSelected) return;
// Skip for DM visual messages inside an excluded thread
NSString *activeTid = [SCIExcludedThreads activeThreadId];
if (activeTid && [SCIExcludedThreads isInList:activeTid] && ![SCIExcludedThreads isBlockSelectedMode]) return;
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
NSString *ownerPK = ownerInfo[@"pk"] ?: @"";
BOOL ownerInList = ownerPK.length && [SCIExcludedStoryUsers isInList:ownerPK];
// block_all + in list: show remove icon (excluded user, behaves normally)
// block_selected + in list: show normal eye (blocked user, needs mark-seen)
// block_selected + not in list: show add icon
BOOL showExcludeIcon = ownerInList && !storyBlockSelected;
BOOL showAddIcon = storyBlockSelected && !ownerInList;
BOOL listBtnPref = [SCIUtils getBoolPref:@"story_excluded_show_unexclude_eye"];
BOOL hideForListedOwner = (showExcludeIcon || showAddIcon) && !listBtnPref;
BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"];
NSString *symName;
UIColor *tint;
if (showExcludeIcon) {
// block_all + in list: remove-from-exclude icon
symName = @"eye.slash.fill"; tint = SCIUtils.SCIColor_Primary;
} else if (storyBlockSelected && !ownerInList) {
// block_selected + not in list: add-to-block icon
symName = @"eye.slash"; tint = [UIColor whiteColor];
} else if (toggleMode) {
symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
} else {
symName = @"eye"; tint = [UIColor whiteColor];
}
UIButton *existing = (UIButton *)[self viewWithTag:1339];
if (hideForListedOwner) {
[existing removeFromSuperview];
return;
}
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];
}
// ============ Download handler ============
%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 {
id item = sciGetCurrentStoryItem(self);
IGMedia *media = sciExtractMediaFromItem(item);
if (media) {
sciDownloadWithConfirm(^{ sciDownloadMedia(media); });
return;
}
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]];
}
}
// ============ Seen button tap ============
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
NSString *ownerPK = ownerInfo[@"pk"];
BOOL inList = ownerPK && [SCIExcludedStoryUsers isInList:ownerPK];
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
// Block selected + not in list: tap to ADD to block list (with confirmation)
if (bs && !inList && ownerPK) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:[NSString stringWithFormat:@"Story seen receipts will be blocked for @%@.", ownerInfo[@"username"] ?: @""]
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers addOrUpdateEntry:@{
@"pk": ownerPK,
@"username": ownerInfo[@"username"] ?: @"",
@"fullName": ownerInfo[@"fullName"] ?: @""
}];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
// Block selected + in list: blocked story, tap = mark seen (long-press to remove)
if (bs && inList) {
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender);
return;
}
// Block all + in list: tap to remove from exclude list
if (inList) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude story seen?";
NSString *alertMsg = bs ? [NSString stringWithFormat:@"@%@ will no longer have seen receipts blocked.", ownerInfo[@"username"] ?: @""]
: [NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:bs ? @"Unblock" : @"Un-exclude" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers removePK:ownerPK];
[SCIUtils showToastForDuration:2.0 title:bs ? @"Unblocked" : @"Un-excluded"];
if (bs) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
// 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 ? @"Story read receipts enabled" : @"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;
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];
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
[sheet addAction:[UIAlertAction actionWithTitle:@"Mark seen" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn);
}]];
if (pk) {
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"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 ? @"Unblocked" : @"Un-excluded"];
if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
} else {
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Stories settings" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIUtils showSettingsVC:capturedWin atTopLevelEntry:@"Stories"];
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
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:@"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"];
// 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:@"Marked as viewed"];
return;
}
[SCIUtils showErrorHUDWithDescription:@"VC not found"];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"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,20 @@
// Persistent per-user exclusion list for story read-receipts. Lookup is by
// user pk (string). Excluded users get normal seen behavior — your view
// shows up in their viewer list as if RyukGram weren't installed.
#import <Foundation/Foundation.h>
@interface SCIExcludedStoryUsers : NSObject
+ (BOOL)isFeatureEnabled;
+ (BOOL)isBlockSelectedMode;
+ (BOOL)isUserPKExcluded:(NSString *)pk;
+ (BOOL)isInList:(NSString *)pk;
+ (NSDictionary *)entryForPK:(NSString *)pk;
+ (NSArray<NSDictionary *> *)allEntries;
+ (NSUInteger)count;
+ (void)addOrUpdateEntry:(NSDictionary *)entry; // {pk, username, fullName}
+ (void)removePK:(NSString *)pk;
@end
@@ -0,0 +1,78 @@
#import "SCIExcludedStoryUsers.h"
#import "../../Utils.h"
#define SCI_STORY_EXCL_KEY @"excluded_story_users"
#define SCI_STORY_INCL_KEY @"included_story_users"
@implementation SCIExcludedStoryUsers
+ (BOOL)isFeatureEnabled {
return [SCIUtils getBoolPref:@"enable_story_user_exclusions"];
}
+ (BOOL)isBlockSelectedMode {
return [[SCIUtils getStringPref:@"story_blocking_mode"] isEqualToString:@"block_selected"];
}
+ (NSString *)activeKey {
return [self isBlockSelectedMode] ? SCI_STORY_INCL_KEY : SCI_STORY_EXCL_KEY;
}
+ (NSArray<NSDictionary *> *)allEntries {
return [[NSUserDefaults standardUserDefaults] arrayForKey:[self activeKey]] ?: @[];
}
+ (NSUInteger)count { return [self allEntries].count; }
+ (void)saveAll:(NSArray *)entries {
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:[self activeKey]];
}
+ (NSDictionary *)entryForPK:(NSString *)pk {
if (pk.length == 0) return nil;
for (NSDictionary *e in [self allEntries]) {
if ([e[@"pk"] isEqualToString:pk]) return e;
}
return nil;
}
+ (BOOL)isInList:(NSString *)pk {
return [self entryForPK:pk] != nil;
}
+ (BOOL)isUserPKExcluded:(NSString *)pk {
if (![self isFeatureEnabled]) return NO;
BOOL inList = [self isInList:pk];
return [self isBlockSelectedMode] ? !inList : inList;
}
+ (void)addOrUpdateEntry:(NSDictionary *)entry {
NSString *pk = entry[@"pk"];
if (pk.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
NSInteger existingIdx = -1;
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
if ([all[i][@"pk"] isEqualToString:pk]) { existingIdx = i; break; }
}
NSMutableDictionary *merged = [entry mutableCopy];
if (existingIdx >= 0) {
NSDictionary *old = all[existingIdx];
if (old[@"addedAt"]) merged[@"addedAt"] = old[@"addedAt"];
all[existingIdx] = merged;
} else {
if (!merged[@"addedAt"]) merged[@"addedAt"] = @([[NSDate date] timeIntervalSince1970]);
[all addObject:merged];
}
[self saveAll:all];
}
+ (void)removePK:(NSString *)pk {
if (pk.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
[all filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
return ![e[@"pk"] isEqualToString:pk];
}]];
[self saveAll:all];
}
@end
@@ -0,0 +1,35 @@
// Persistent per-chat exclusion list for read-receipt features. Lookup is by
// canonical thread id (the MSYS string used by both inbox view models and
// IGDirectThreadViewController). Each entry carries a per-thread keep-deleted
// override that can force-include or force-exclude regardless of the global
// default.
#import <Foundation/Foundation.h>
typedef NS_ENUM(NSInteger, SCIKeepDeletedOverride) {
SCIKeepDeletedOverrideDefault = 0, // follow exclusions_default_keep_deleted
SCIKeepDeletedOverrideExcluded = 1, // force keep-deleted OFF for this thread
SCIKeepDeletedOverrideIncluded = 2, // force keep-deleted ON for this thread
};
@interface SCIExcludedThreads : NSObject
+ (BOOL)isFeatureEnabled;
+ (BOOL)isBlockSelectedMode; // YES = only listed chats get blocked
+ (BOOL)isThreadIdExcluded:(NSString *)threadId;
+ (BOOL)isInList:(NSString *)threadId; // raw list check, ignores mode
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId;
+ (NSDictionary *)entryForThreadId:(NSString *)threadId;
+ (NSArray<NSDictionary *> *)allEntries;
+ (NSUInteger)count;
+ (void)addOrUpdateEntry:(NSDictionary *)entry;
+ (void)removeThreadId:(NSString *)threadId;
+ (void)setKeepDeletedOverride:(SCIKeepDeletedOverride)mode forThreadId:(NSString *)threadId;
// Currently-visible thread, set by IGDirectThreadViewController hooks.
+ (void)setActiveThreadId:(NSString *)threadId;
+ (NSString *)activeThreadId;
+ (BOOL)isActiveThreadExcluded;
@end
@@ -0,0 +1,123 @@
#import "SCIExcludedThreads.h"
#import "../../Utils.h"
#define SCI_EXCL_KEY @"excluded_threads"
#define SCI_INCL_KEY @"included_threads"
@implementation SCIExcludedThreads
static NSString *sciActiveTid = nil;
+ (BOOL)isFeatureEnabled {
return [SCIUtils getBoolPref:@"enable_chat_exclusions"];
}
+ (BOOL)isBlockSelectedMode {
return [[SCIUtils getStringPref:@"chat_blocking_mode"] isEqualToString:@"block_selected"];
}
+ (NSString *)activeKey {
return [self isBlockSelectedMode] ? SCI_INCL_KEY : SCI_EXCL_KEY;
}
+ (NSArray<NSDictionary *> *)allEntries {
return [[NSUserDefaults standardUserDefaults] arrayForKey:[self activeKey]] ?: @[];
}
+ (NSUInteger)count { return [self allEntries].count; }
+ (void)saveAll:(NSArray *)entries {
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:[self activeKey]];
}
+ (NSDictionary *)entryForThreadId:(NSString *)threadId {
if (threadId.length == 0) return nil;
for (NSDictionary *e in [self allEntries]) {
if ([e[@"threadId"] isEqualToString:threadId]) return e;
}
return nil;
}
+ (BOOL)isInList:(NSString *)threadId {
return [self entryForThreadId:threadId] != nil;
}
+ (BOOL)isThreadIdExcluded:(NSString *)threadId {
if (![self isFeatureEnabled]) return NO;
BOOL inList = [self isInList:threadId];
return [self isBlockSelectedMode] ? !inList : inList;
}
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId {
if (![self isFeatureEnabled]) return NO;
NSDictionary *e = [self entryForThreadId:threadId];
if ([self isBlockSelectedMode]) {
// block_selected: listed chats are blocked
// NOT in list → normal chat → block keep-deleted if default pref is on
// IN list → blocked chat → keep-deleted should work (not blocked) unless overridden
if (!e) return [SCIUtils getBoolPref:@"exclusions_default_keep_deleted"];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
if (mode == SCIKeepDeletedOverrideExcluded) return YES;
if (mode == SCIKeepDeletedOverrideIncluded) return NO;
return NO; // default: keep-deleted works in blocked chats
}
// block_all: listed chats are excluded (behave normally)
if (!e) return NO;
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
if (mode == SCIKeepDeletedOverrideExcluded) return YES;
if (mode == SCIKeepDeletedOverrideIncluded) return NO;
return [SCIUtils getBoolPref:@"exclusions_default_keep_deleted"];
}
+ (void)addOrUpdateEntry:(NSDictionary *)entry {
NSString *tid = entry[@"threadId"];
if (tid.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
NSInteger existingIdx = -1;
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
if ([all[i][@"threadId"] isEqualToString:tid]) { existingIdx = i; break; }
}
NSMutableDictionary *merged = [entry mutableCopy];
if (existingIdx >= 0) {
NSDictionary *old = all[existingIdx];
if (old[@"addedAt"]) merged[@"addedAt"] = old[@"addedAt"];
if (old[@"keepDeletedOverride"]) merged[@"keepDeletedOverride"] = old[@"keepDeletedOverride"];
all[existingIdx] = merged;
} else {
if (!merged[@"addedAt"]) merged[@"addedAt"] = @([[NSDate date] timeIntervalSince1970]);
if (!merged[@"keepDeletedOverride"]) merged[@"keepDeletedOverride"] = @(SCIKeepDeletedOverrideDefault);
[all addObject:merged];
}
[self saveAll:all];
}
+ (void)removeThreadId:(NSString *)threadId {
if (threadId.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
[all filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
return ![e[@"threadId"] isEqualToString:threadId];
}]];
[self saveAll:all];
}
+ (void)setKeepDeletedOverride:(SCIKeepDeletedOverride)mode forThreadId:(NSString *)threadId {
if (threadId.length == 0) return;
NSMutableArray *all = [[self allEntries] mutableCopy];
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
if ([all[i][@"threadId"] isEqualToString:threadId]) {
NSMutableDictionary *m = [all[i] mutableCopy];
m[@"keepDeletedOverride"] = @(mode);
all[i] = m;
break;
}
}
[self saveAll:all];
}
+ (void)setActiveThreadId:(NSString *)threadId { sciActiveTid = [threadId copy]; }
+ (NSString *)activeThreadId { return sciActiveTid; }
+ (BOOL)isActiveThreadExcluded { return [self isThreadIdExcluded:sciActiveTid]; }
@end
+338 -45
View File
@@ -1,96 +1,389 @@
#import "../../InstagramHeaders.h"
#import "../../Tweak.h"
#import "../../Utils.h"
#import "SCIExcludedThreads.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// Returns the threadId for an IGDirectThreadViewController, or nil.
static NSString *sciThreadIdForVC(id vc) {
if (!vc) return nil;
@try { return [vc valueForKey:@"threadId"]; } @catch (__unused id e) { return nil; }
}
// Seen buttons (in DMs)
// - Enables no seen for messages
// - Enables unlimited views of DM visual messages
%hook IGTallNavigationBarView
- (void)setRightBarButtonItems:(NSArray <UIBarButtonItem *> *)items {
NSMutableArray *new_items = [[items filteredArrayUsingPredicate:
[NSPredicate predicateWithBlock:^BOOL(UIView *value, NSDictionary *_) {
if ([SCIUtils getBoolPref:@"hide_reels_blend"]) {
return ![value.accessibilityIdentifier isEqualToString:@"blend-button"];
}
return true;
BOOL dmSeenToggleEnabled = NO;
static NSInteger sciSeenAutoBypassCount = 0;
__weak IGDirectThreadViewController *sciActiveThreadVC = nil;
static BOOL sciIsSeenToggleMode() {
return [[SCIUtils getStringPref:@"seen_mode"] isEqualToString:@"toggle"];
}
static BOOL sciAutoInteractEnabled() {
if ([SCIExcludedThreads isActiveThreadExcluded]) return NO;
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_interact"];
}
BOOL sciAutoTypingEnabled() {
if ([SCIExcludedThreads isActiveThreadExcluded]) return NO;
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_typing"];
}
void sciDoAutoSeen(IGDirectThreadViewController *threadVC) {
sciSeenAutoBypassCount++;
[threadVC markLastMessageAsSeen];
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciSeenAutoBypassCount--;
});
}
// ============ AUTO SEEN ON SEND ============
static void (*orig_setHasSent)(id self, SEL _cmd, BOOL sent);
static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
orig_setHasSent(self, _cmd, sent);
if (!sent || !sciAutoInteractEnabled()) return;
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
sciDoAutoSeen((IGDirectThreadViewController *)self);
});
}
// ============ 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 ============
// Re-runs setRightBarButtonItems with the live items. The hook tags its own
// buttons so they get stripped and rebuilt against the new exclusion state.
static void sciRefreshNavBarItems(UIView *anchor) {
if (!anchor || ![anchor respondsToSelector:@selector(setRightBarButtonItems:)]) return;
NSArray *cur = [(id)anchor performSelector:@selector(rightBarButtonItems)];
[(id)anchor performSelector:@selector(setRightBarButtonItems:) withObject:cur];
}
static NSDictionary *sciEntryFromThreadVC(UIViewController *vc);
// Long-press menu shared by the seen button and the un-exclude button.
static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIWindow *window) {
BOOL inList = threadId && [SCIExcludedThreads isInList:threadId];
BOOL excluded = threadId && [SCIExcludedThreads isThreadIdExcluded:threadId];
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
BOOL seenFeatureOn = [SCIUtils getBoolPref:@"remove_lastseen"];
NSMutableArray<UIMenuElement *> *items = [NSMutableArray array];
if (seenFeatureOn && !excluded) {
BOOL toggleMode = sciIsSeenToggleMode();
NSString *title;
UIImage *img;
if (toggleMode) {
title = dmSeenToggleEnabled ? @"Disable read receipts" : @"Enable read receipts";
img = [UIImage systemImageNamed:dmSeenToggleEnabled ? @"eye.slash" : @"eye"];
} else {
title = @"Mark messages as seen";
img = [UIImage systemImageNamed:@"eye"];
}
UIAction *seenAction = [UIAction actionWithTitle:title image:img identifier:nil
handler:^(__kindof UIAction *_) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if (![nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) return;
if (toggleMode) {
dmSeenToggleEnabled = !dmSeenToggleEnabled;
if (dmSeenToggleEnabled) {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:@"Read receipts enabled"];
} else {
[SCIUtils showToastForDuration:2.0 title:@"Read receipts disabled"];
}
} else {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:@"Marked messages as seen"];
}
}];
[items addObject:seenAction];
}
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
NSString *toggleTitle = inList ? removeLabel : addLabel;
UIImage *toggleImg = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
__weak UIView *weakAnchor = anchor;
UIAction *toggle = [UIAction actionWithTitle:toggleTitle image:toggleImg identifier:nil
handler:^(__kindof UIAction *_) {
if (!threadId) return;
if (inList) {
[SCIExcludedThreads removeThreadId:threadId];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
// In block_selected, removing = normal behavior → mark seen
if (blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
}
} else {
UIViewController *anchorVC = [SCIUtils nearestViewControllerForView:anchor];
NSDictionary *entry = sciEntryFromThreadVC(anchorVC);
if (!entry) entry = @{ @"threadId": threadId, @"threadName": @"", @"isGroup": @NO, @"users": @[] };
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
// In block_all, excluding = normal behavior → mark seen
if (!blockSelected) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
}
}
sciRefreshNavBarItems(weakAnchor);
}];
if (excluded) toggle.attributes = UIMenuElementAttributesDestructive;
[items addObject:toggle];
UIAction *openSettings = [UIAction actionWithTitle:@"Messages settings"
image:[UIImage systemImageNamed:@"gear"]
identifier:nil
handler:^(__kindof UIAction *_) {
UIWindow *win = window;
if (!win) {
for (UIWindow *w in [UIApplication sharedApplication].windows) {
if (w.isKeyWindow) { win = w; break; }
}
}
[SCIUtils showSettingsVC:win atTopLevelEntry:@"Messages"];
}];
[items addObject:openSettings];
return [UIMenu menuWithTitle:@"" children:items];
}
// Extract thread info from an IGDirectThreadViewController
static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
if (!vc) return nil;
NSString *tid = sciThreadIdForVC(vc);
if (!tid) return nil;
NSString *name = @"";
NSMutableArray *users = [NSMutableArray array];
@try {
// Try to get thread title from navigation item
name = vc.navigationItem.title ?: @"";
// Try to get the thread object for user info
id thread = [vc valueForKey:@"thread"];
if (thread) {
id threadUsers = [thread valueForKey:@"users"];
if ([threadUsers isKindOfClass:[NSArray class]]) {
for (id u in (NSArray *)threadUsers) {
NSMutableDictionary *d = [NSMutableDictionary dictionary];
@try {
id pk = [u valueForKey:@"pk"];
id un = [u valueForKey:@"username"];
id fn = [u valueForKey:@"fullName"];
if (pk) d[@"pk"] = [NSString stringWithFormat:@"%@", pk];
if (un) d[@"username"] = [NSString stringWithFormat:@"%@", un];
if (fn) d[@"fullName"] = [NSString stringWithFormat:@"%@", fn];
} @catch (__unused id e) {}
if (d.count) [users addObject:d];
}
}
}
} @catch (__unused id e) {}
return @{ @"threadId": tid, @"threadName": name, @"isGroup": @NO, @"users": users };
}
%hook IGTallNavigationBarView
%new - (void)sciAddToListHandler:(UIBarButtonItem *)sender {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
NSDictionary *entry = sciEntryFromThreadVC(nearestVC);
if (!entry) return;
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:@"Read receipts will be blocked for this chat."
preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[nearestVC presentViewController:alert animated:YES completion:nil];
}
%new - (void)sciUnexcludeButtonHandler:(UIBarButtonItem *)sender {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *tid = sciThreadIdForVC(nearestVC);
if (!tid) return;
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude chat?";
NSString *alertMsg = bs ? @"Read receipts will no longer be blocked for this chat."
: @"This chat will resume normal read-receipt behavior.";
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
__weak typeof(self) weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedThreads removeThreadId:tid];
[SCIUtils showToastForDuration:2.0 title:@"Removed"];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[nearestVC presentViewController:alert animated:YES completion:nil];
}
- (void)setRightBarButtonItems:(NSArray <UIBarButtonItem *> *)items {
// Strip our own injected buttons so re-running this hook doesn't dupe them.
NSMutableArray *new_items = [[items filteredArrayUsingPredicate:
[NSPredicate predicateWithBlock:^BOOL(UIBarButtonItem *value, NSDictionary *_) {
NSString *aid = value.accessibilityIdentifier;
if ([aid isEqualToString:@"sci-seen-btn"] ||
[aid isEqualToString:@"sci-unex-btn"] ||
[aid isEqualToString:@"sci-visual-btn"]) return NO;
if ([SCIUtils getBoolPref:@"hide_reels_blend"])
return ![aid isEqualToString:@"blend-button"];
return YES;
}]
] mutableCopy];
// Messages seen
if ([SCIUtils getBoolPref:@"remove_lastseen"]) {
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.message"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
// setRightBarButtonItems: runs before viewDidAppear: fires, so the global
// active thread id isn't reliable here — read it directly from the VC.
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
NSString *navThreadId = sciThreadIdForVC(navNearestVC);
BOOL navExcluded = navThreadId && [SCIExcludedThreads isThreadIdExcluded:navThreadId];
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:)];
seenButton.accessibilityIdentifier = @"sci-seen-btn";
if (sciIsSeenToggleMode())
[seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
seenButton.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
[new_items addObject:seenButton];
}
// DM visual messages viewed
if ([SCIUtils getBoolPref:@"unlimited_replay"]) {
UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)];
[new_items addObject:dmVisualMsgsViewedButton];
// In block_all: show remove button for listed (excluded) chats
// In block_selected: show remove button for listed chats, or add button for non-listed chats
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
BOOL showListButton = [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"chat_quick_list_button"];
// block_all + in list: show remove button (no seen button shown for excluded chats)
// block_selected + NOT in list: show add-to-list button
// block_selected + in list: DON'T show (seen button already visible with long-press menu)
BOOL showRemoveBtn = !blockSelected && navInList && navExcluded;
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];
listBtn.accessibilityIdentifier = @"sci-unex-btn";
listBtn.tintColor = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
listBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
[new_items addObject:listBtn];
}
if (dmVisualMsgsViewedButtonEnabled) {
[dmVisualMsgsViewedButton setTintColor:SCIUtils.SCIColor_Primary];
} else {
[dmVisualMsgsViewedButton setTintColor:UIColor.labelColor];
}
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded) {
UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)];
dmVisualMsgsViewedButton.accessibilityIdentifier = @"sci-visual-btn";
[new_items addObject:dmVisualMsgsViewedButton];
[dmVisualMsgsViewedButton setTintColor:dmVisualMsgsViewedButtonEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
}
%orig([new_items copy]);
}
// Messages seen button
%new - (void)seenButtonHandler:(UIBarButtonItem *)sender {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
// ============ MESSAGES SEEN BUTTON ============
[SCIUtils showToastForDuration:2.5 title:@"Marked messages as seen"];
%new - (void)seenButtonHandler:(UIBarButtonItem *)sender {
if (sciIsSeenToggleMode()) {
dmSeenToggleEnabled = !dmSeenToggleEnabled;
[sender setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
if (dmSeenToggleEnabled) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.5 title:@"Read receipts enabled"];
} else {
[SCIUtils showToastForDuration:2.5 title:@"Read receipts disabled"];
}
} else {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.5 title:@"Marked messages as seen"];
}
}
}
// DM visual messages viewed button
// ============ DM VISUAL MESSAGES VIEWED BUTTON ============
%new - (void)dmVisualMsgsViewedButtonHandler:(UIBarButtonItem *)sender {
if (dmVisualMsgsViewedButtonEnabled) {
dmVisualMsgsViewedButtonEnabled = false;
[sender setTintColor:UIColor.labelColor];
[SCIUtils showToastForDuration:4.5 title:@"Visual messages can be replayed without expiring"];
}
else {
} else {
dmVisualMsgsViewedButtonEnabled = true;
[sender setTintColor:SCIUtils.SCIColor_Primary];
[SCIUtils showToastForDuration:4.5 title:@"Visual messages will now expire after viewing"];
}
}
%end
// Messages seen logic
// ============ SEEN BLOCKING LOGIC ============
%hook IGDirectThreadViewListAdapterDataSource
- (BOOL)shouldUpdateLastSeenMessage {
if ([SCIUtils getBoolPref:@"remove_lastseen"]) {
if ([SCIExcludedThreads isActiveThreadExcluded]) return %orig; // excluded → behave normally
if (sciIsSeenToggleMode() && dmSeenToggleEnabled) return %orig;
if (sciSeenAutoBypassCount > 0) return %orig;
return false;
}
return %orig;
}
%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
&& ![SCIExcludedThreads isActiveThreadExcluded]) 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
&& ![SCIExcludedThreads isActiveThreadExcluded]) return;
%orig;
}
%end
// ============ RUNTIME HOOKS ============
%ctor {
Class threadVCClass = NSClassFromString(@"IGDirectThreadViewController");
if (threadVCClass) {
SEL sentSel = NSSelectorFromString(@"setHasSentAMessageOrUpdate:");
if (class_getInstanceMethod(threadVCClass, sentSel)) {
MSHookMessageEx(threadVCClass, sentSel,
(IMP)new_setHasSent, (IMP *)&orig_setHasSent);
}
}
}
%end
@@ -0,0 +1,694 @@
// Send audio/video files as voice messages in DMs.
// Injects an Upload Audio item into the DM plus menu, runs the file through a
// trim UI, transcodes to AAC m4a (or passes formats IG accepts as-is), then
// hands the URL to IG's native voice pipeline.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <AVFoundation/AVFoundation.h>
typedef id (*SCIMsgSend)(id, SEL);
static inline id sciAF(id obj, SEL sel) {
if (!obj || ![obj respondsToSelector:sel]) return nil;
return ((SCIMsgSend)objc_msgSend)(obj, sel);
}
static __weak UIViewController *sciAudioThreadVC = nil;
static BOOL sciDMMenuPending = NO;
#pragma mark - Send audio through IG pipeline
static NSSet<NSString *> *sciPassthroughAudioExts(void);
static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
AVAsset *asset = [AVAsset assetWithURL:audioURL];
double duration = CMTimeGetSeconds(asset.duration);
// AVFoundation returns 0/NaN for containers it can't parse (e.g. Ogg).
if (duration <= 0 || isnan(duration)) duration = 1.0;
id voiceController = sciAF(threadVC, @selector(voiceController));
id voiceRecordVC = nil;
if (voiceController) {
Ivar vrIvar = class_getInstanceVariable([voiceController class], "_voiceRecordViewController");
voiceRecordVC = vrIvar ? object_getIvar(voiceController, vrIvar) : nil;
}
id waveform = nil;
Class wfClass = NSClassFromString(@"IGDirectAudioWaveform");
NSMutableArray *fallbackArr = [NSMutableArray array];
for (int i = 0; i < MAX(10, MIN((int)(duration * 10), 300)); i++)
[fallbackArr addObject:@(0.1 + arc4random_uniform(80) / 100.0)];
if (wfClass) {
NSArray *rawData = nil;
SEL genSel = @selector(generateWaveformDataFromAudioFile:maxLength:);
if ([wfClass respondsToSelector:genSel]) {
typedef id (*GenFn)(id, SEL, id, NSInteger);
rawData = ((GenFn)objc_msgSend)(wfClass, genSel, audioURL, (NSInteger)(duration * 10));
}
if (!rawData) rawData = fallbackArr;
SEL scaleSel = @selector(scaledArrayOfNumbers:);
if ([wfClass respondsToSelector:scaleSel]) {
typedef id (*ScaleFn)(id, SEL, id);
NSArray *scaled = ((ScaleFn)objc_msgSend)(wfClass, scaleSel, rawData);
if (scaled) rawData = scaled;
}
SEL initWF = @selector(initWithVolumeRecordingInterval:averageVolume:);
if ([wfClass instancesRespondToSelector:initWF]) {
typedef id (*InitFn)(id, SEL, double, id);
waveform = ((InitFn)objc_msgSend)([wfClass alloc], initWF, 0.1, rawData);
}
if (!waveform) {
waveform = [[wfClass alloc] init];
for (NSString *n in @[@"_averageVolume", @"_waveformData", @"_data", @"_volumes"]) {
Ivar iv = class_getInstanceVariable(wfClass, [n UTF8String]);
if (iv) { object_setIvar(waveform, iv, rawData); break; }
}
}
}
if (!waveform) waveform = fallbackArr;
@try {
SEL vmSel = @selector(visualMessageViewerPresentationManagerDidRecordAudioClipWithURL:waveform:duration:entryPoint:toReplyToMessageWithID:);
if ([threadVC respondsToSelector:vmSel]) {
typedef void (*Fn)(id, SEL, id, id, double, NSInteger, id);
((Fn)objc_msgSend)(threadVC, vmSel, audioURL, waveform, duration, (NSInteger)2, nil);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
return;
}
SEL s7 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:aiVoiceEffectApplied:sendButtonTypeTapped:);
if ([threadVC respondsToSelector:s7]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger, id, id);
((Fn)objc_msgSend)(threadVC, s7, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2, nil, nil);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
return;
}
SEL s5 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:);
if ([threadVC respondsToSelector:s5]) {
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger);
((Fn)objc_msgSend)(threadVC, s5, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2);
[SCIUtils showToastForDuration:1.5 title:@"Audio sent"];
return;
}
[SCIUtils showErrorHUDWithDescription:@"No voice send method found"];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Send failed: %@", e.reason]];
}
}
#pragma mark - Audio conversion with optional trim
// Unified failure alert: explains why, lets the user try sending raw, and links
// to the GitHub issues page for format requests.
static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewController *threadVC) {
NSString *fileExt = [[url pathExtension] lowercaseString];
NSString *displayExt = (fileExt.length > 0) ? [NSString stringWithFormat:@".%@", fileExt] : @"This file";
NSString *title = [NSString stringWithFormat:@"%@ can't be converted", displayExt];
NSString *msg = [NSString stringWithFormat:
@"iOS audio APIs couldn't process this file%@%@\n\n"
"You can try sending it to Instagram as-is — IG's server may accept it "
"(e.g. Opus/Ogg from web users), or it may silently fail.\n\n"
"If you'd like RyukGram to support this format natively, open an issue:\n"
"https://github.com/faroukbmiled/RyukGram/issues",
reason.length > 0 ? @":\n" : @".",
reason.length > 0 ? reason : @""];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Send anyway" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
sciSendAudioFile(url, weakVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Open GitHub" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[[UIApplication sharedApplication]
openURL:[NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram/issues"]
options:@{} completionHandler:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
UIViewController *presenter = threadVC ?: [UIApplication sharedApplication].keyWindow.rootViewController;
[presenter presentViewController:alert animated:YES completion:nil];
}
static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
CMTimeGetSeconds(trimRange.duration) > 0;
// Allowlisted formats skip AVFoundation entirely; trim is ignored since
// AVFoundation can't read their timelines anyway.
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
[SCIUtils showToastForDuration:1.5 title:isVideo ? @"Extracting audio..." : @"Converting..."];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
AVAsset *asset = [AVAsset assetWithURL:url];
AVAssetTrack *audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject];
if (!audioTrack) {
dispatch_async(dispatch_get_main_queue(), ^{
sciShowUnsupportedAlert(url, @"no audio track could be read", threadVC);
});
return;
}
AVMutableComposition *comp = [AVMutableComposition composition];
AVMutableCompositionTrack *ct = [comp addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
CMTimeRange sourceRange = hasTrim ? trimRange : CMTimeRangeMake(kCMTimeZero, asset.duration);
NSError *insertErr = nil;
[ct insertTimeRange:sourceRange ofTrack:audioTrack atTime:kCMTimeZero error:&insertErr];
if (insertErr) {
dispatch_async(dispatch_get_main_queue(), ^{
sciShowUnsupportedAlert(url, insertErr.localizedDescription, threadVC);
});
return;
}
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_exp_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
AVAssetExportSession *exp = [AVAssetExportSession exportSessionWithAsset:comp presetName:AVAssetExportPresetAppleM4A];
exp.outputURL = [NSURL fileURLWithPath:out];
exp.outputFileType = AVFileTypeAppleM4A;
[exp exportAsynchronouslyWithCompletionHandler:^{
dispatch_async(dispatch_get_main_queue(), ^{
if (exp.status == AVAssetExportSessionStatusCompleted) {
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
} else {
sciShowUnsupportedAlert(url, exp.error.localizedDescription, threadVC);
}
});
}];
});
}
// Extensions IG accepts as voice messages without conversion. Append after testing.
// m4a/aac — native iOS recording format
// ogg/opus — what web/desktop IG sends
static NSSet<NSString *> *sciPassthroughAudioExts(void) {
static NSSet *set;
static dispatch_once_t once;
dispatch_once(&once, ^{
set = [NSSet setWithArray:@[@"m4a", @"aac", @"ogg", @"opus"]];
});
return set;
}
static void sciConvertAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo) {
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
sciExportAndSend(url, threadVC, isVideo, kCMTimeRangeInvalid);
}
#pragma mark - Audio/Video trim VC
@interface SCITrimViewController : UIViewController
@property (nonatomic, strong) NSURL *mediaURL;
@property (nonatomic, assign) BOOL isVideo;
@property (nonatomic, strong) AVPlayer *player;
@property (nonatomic, strong) UILabel *durationLabel;
@property (nonatomic, strong) UILabel *rangeLabel;
@property (nonatomic, strong) UIView *trackView;
@property (nonatomic, strong) UIView *selectedRange;
@property (nonatomic, strong) UIView *leftHandle;
@property (nonatomic, strong) UIView *rightHandle;
@property (nonatomic, strong) UIView *playhead;
@property (nonatomic, strong) UIButton *playBtn;
@property (nonatomic, assign) double totalDuration;
@property (nonatomic, assign) double startTime;
@property (nonatomic, assign) double endTime;
@property (nonatomic, assign) BOOL isPlaying;
@property (nonatomic, strong) id timeObserver;
@property (nonatomic, weak) UIViewController *threadVC;
@end
static const CGFloat kTrackH = 56.0;
static const CGFloat kHandleW = 16.0;
static const CGFloat kHandleHitW = 48.0; // wide touch target
static const CGFloat kTrackMargin = 24.0;
@implementation SCITrimViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor colorWithRed:0.06 green:0.06 blue:0.08 alpha:1.0];
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
AVAsset *asset = [AVAsset assetWithURL:self.mediaURL];
self.totalDuration = CMTimeGetSeconds(asset.duration);
self.startTime = 0;
self.endTime = self.totalDuration;
CGFloat w = self.view.bounds.size.width;
CGFloat safeBottom = 34; // approximate safe area
CGFloat bottomY = self.view.bounds.size.height - safeBottom;
// ── send button (bottom, full width, thumb-reachable) ──
UIButton *sendBtn = [UIButton buttonWithType:UIButtonTypeSystem];
sendBtn.frame = CGRectMake(kTrackMargin, bottomY - 56, w - kTrackMargin * 2, 50);
sendBtn.backgroundColor = [UIColor systemBlueColor];
sendBtn.layer.cornerRadius = 14;
[sendBtn setTitle:@"Send Audio" forState:UIControlStateNormal];
[sendBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
sendBtn.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
[sendBtn addTarget:self action:@selector(sendTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:sendBtn];
// ── play/pause button ──
CGFloat playY = sendBtn.frame.origin.y - 64;
self.playBtn = [UIButton buttonWithType:UIButtonTypeCustom];
self.playBtn.frame = CGRectMake(w / 2 - 28, playY, 56, 56);
self.playBtn.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.1];
self.playBtn.layer.cornerRadius = 28;
UIImageSymbolConfiguration *playCfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
[self.playBtn setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:playCfg] forState:UIControlStateNormal];
self.playBtn.tintColor = [UIColor whiteColor];
[self.playBtn addTarget:self action:@selector(playPauseTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:self.playBtn];
// ── range label (above play button) ──
self.rangeLabel = [[UILabel alloc] initWithFrame:CGRectMake(kTrackMargin, playY - 36, w - kTrackMargin * 2, 24)];
self.rangeLabel.textColor = [UIColor whiteColor];
self.rangeLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightMedium];
self.rangeLabel.textAlignment = NSTextAlignmentCenter;
[self.view addSubview:self.rangeLabel];
// ── track (range selector) ──
CGFloat trackY = self.rangeLabel.frame.origin.y - kTrackH - 20;
// track background
self.trackView = [[UIView alloc] initWithFrame:CGRectMake(kTrackMargin, trackY, w - kTrackMargin * 2, kTrackH)];
self.trackView.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.08];
self.trackView.layer.cornerRadius = 10;
self.trackView.clipsToBounds = YES;
[self.view addSubview:self.trackView];
// generate waveform bars
[self generateWaveformBars];
// selected range overlay
self.selectedRange = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.trackView.bounds.size.width, kTrackH)];
self.selectedRange.backgroundColor = [UIColor colorWithRed:0.35 green:0.5 blue:1.0 alpha:0.25];
self.selectedRange.userInteractionEnabled = NO;
self.selectedRange.layer.cornerRadius = 10;
[self.trackView addSubview:self.selectedRange];
// left handle — wide invisible hit area with narrow visual handle inside
self.leftHandle = [[UIView alloc] initWithFrame:CGRectMake(-kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20)];
self.leftHandle.backgroundColor = [UIColor clearColor];
self.leftHandle.userInteractionEnabled = YES;
UIView *leftVisual = [self createHandleVisual];
leftVisual.frame = CGRectMake((kHandleHitW - kHandleW) / 2, 10, kHandleW, kTrackH);
leftVisual.layer.maskedCorners = kCALayerMinXMinYCorner | kCALayerMinXMaxYCorner;
leftVisual.tag = 7001;
[self.leftHandle addSubview:leftVisual];
[self.trackView addSubview:self.leftHandle];
UIPanGestureRecognizer *leftPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(leftHandlePan:)];
[self.leftHandle addGestureRecognizer:leftPan];
// right handle
CGFloat trackW = self.trackView.bounds.size.width;
self.rightHandle = [[UIView alloc] initWithFrame:CGRectMake(trackW - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20)];
self.rightHandle.backgroundColor = [UIColor clearColor];
self.rightHandle.userInteractionEnabled = YES;
UIView *rightVisual = [self createHandleVisual];
rightVisual.frame = CGRectMake((kHandleHitW - kHandleW) / 2, 10, kHandleW, kTrackH);
rightVisual.layer.maskedCorners = kCALayerMaxXMinYCorner | kCALayerMaxXMaxYCorner;
rightVisual.tag = 7001;
[self.rightHandle addSubview:rightVisual];
[self.trackView addSubview:self.rightHandle];
UIPanGestureRecognizer *rightPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(rightHandlePan:)];
[self.rightHandle addGestureRecognizer:rightPan];
// playhead
self.playhead = [[UIView alloc] initWithFrame:CGRectMake(0, 2, 2.5, kTrackH - 4)];
self.playhead.backgroundColor = [UIColor whiteColor];
self.playhead.layer.cornerRadius = 1.25;
self.playhead.hidden = YES;
[self.trackView addSubview:self.playhead];
// ── top area: icon + file info ──
CGFloat topAreaY = 70;
UIImageSymbolConfiguration *iconCfg = [UIImageSymbolConfiguration configurationWithPointSize:36 weight:UIImageSymbolWeightLight];
UIImageView *icon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:self.isVideo ? @"video.fill" : @"waveform"
withConfiguration:iconCfg]];
icon.tintColor = [UIColor colorWithWhite:1.0 alpha:0.5];
icon.contentMode = UIViewContentModeScaleAspectFit;
icon.frame = CGRectMake(w / 2 - 24, topAreaY, 48, 48);
[self.view addSubview:icon];
UILabel *nameLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, topAreaY + 56, w - 40, 20)];
nameLabel.text = [self.mediaURL lastPathComponent];
nameLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.4];
nameLabel.font = [UIFont systemFontOfSize:13];
nameLabel.textAlignment = NSTextAlignmentCenter;
nameLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
[self.view addSubview:nameLabel];
self.durationLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, topAreaY + 78, w - 40, 20)];
self.durationLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.3];
self.durationLabel.font = [UIFont systemFontOfSize:12];
self.durationLabel.textAlignment = NSTextAlignmentCenter;
self.durationLabel.text = [NSString stringWithFormat:@"Total: %@", [self formatTime:self.totalDuration]];
[self.view addSubview:self.durationLabel];
// ── cancel X button (top-left) ──
UIButton *cancelBtn = [UIButton buttonWithType:UIButtonTypeCustom];
cancelBtn.frame = CGRectMake(12, 50, 36, 36);
UIImageSymbolConfiguration *xCfg = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightMedium];
[cancelBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:xCfg] forState:UIControlStateNormal];
cancelBtn.tintColor = [UIColor colorWithWhite:1.0 alpha:0.6];
cancelBtn.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.08];
cancelBtn.layer.cornerRadius = 18;
[cancelBtn addTarget:self action:@selector(cancelTapped) forControlEvents:UIControlEventTouchUpInside];
[self.view addSubview:cancelBtn];
[self updateRangeUI];
}
- (void)generateWaveformBars {
CGFloat trackW = self.trackView.bounds.size.width;
int barCount = (int)(trackW / 4);
CGFloat barW = 2.0;
CGFloat gap = (trackW - barCount * barW) / (barCount - 1);
for (int i = 0; i < barCount; i++) {
CGFloat h = 8 + arc4random_uniform((unsigned int)(kTrackH - 16));
CGFloat x = i * (barW + gap);
UIView *bar = [[UIView alloc] initWithFrame:CGRectMake(x, (kTrackH - h) / 2, barW, h)];
bar.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.15];
bar.layer.cornerRadius = 1;
bar.tag = 8000 + i;
[self.trackView insertSubview:bar atIndex:0];
}
}
- (UIView *)createHandleVisual {
UIView *handle = [[UIView alloc] init];
handle.backgroundColor = [UIColor systemBlueColor];
handle.layer.cornerRadius = 4;
handle.userInteractionEnabled = NO;
UIView *grip = [[UIView alloc] initWithFrame:CGRectMake(5, kTrackH / 2 - 8, 6, 16)];
grip.userInteractionEnabled = NO;
for (int i = 0; i < 2; i++) {
UIView *line = [[UIView alloc] initWithFrame:CGRectMake(i * 4, 0, 1.5, 16)];
line.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.7];
line.layer.cornerRadius = 0.75;
[grip addSubview:line];
}
[handle addSubview:grip];
return handle;
}
- (CGFloat)timeToX:(double)time {
CGFloat trackW = self.trackView.bounds.size.width;
return (time / self.totalDuration) * trackW;
}
- (double)xToTime:(CGFloat)x {
CGFloat trackW = self.trackView.bounds.size.width;
double t = (x / trackW) * self.totalDuration;
return MAX(0, MIN(t, self.totalDuration));
}
- (void)leftHandlePan:(UIPanGestureRecognizer *)pan {
CGPoint translation = [pan translationInView:self.trackView];
[pan setTranslation:CGPointZero inView:self.trackView];
CGFloat centerX = CGRectGetMidX(self.leftHandle.frame) + translation.x;
double newTime = [self xToTime:centerX];
newTime = MAX(0, MIN(newTime, self.endTime - 0.5));
self.startTime = newTime;
[self updateRangeUI];
}
- (void)rightHandlePan:(UIPanGestureRecognizer *)pan {
CGPoint translation = [pan translationInView:self.trackView];
[pan setTranslation:CGPointZero inView:self.trackView];
CGFloat centerX = CGRectGetMidX(self.rightHandle.frame) + translation.x;
double newTime = [self xToTime:centerX];
newTime = MIN(self.totalDuration, MAX(newTime, self.startTime + 0.5));
self.endTime = newTime;
[self updateRangeUI];
}
- (void)updateRangeUI {
CGFloat leftX = [self timeToX:self.startTime];
CGFloat rightX = [self timeToX:self.endTime];
self.leftHandle.frame = CGRectMake(leftX - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20);
self.rightHandle.frame = CGRectMake(rightX - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20);
self.selectedRange.frame = CGRectMake(leftX, 0, rightX - leftX, kTrackH);
double sel = self.endTime - self.startTime;
self.rangeLabel.text = [NSString stringWithFormat:@"%@ — %@ (%@)",
[self formatTime:self.startTime], [self formatTime:self.endTime], [self formatDuration:sel]];
}
- (NSString *)formatTime:(double)secs {
int m = (int)secs / 60;
int s = (int)secs % 60;
return [NSString stringWithFormat:@"%d:%02d", m, s];
}
- (NSString *)formatDuration:(double)secs {
if (secs < 60) return [NSString stringWithFormat:@"%.1fs", secs];
int m = (int)secs / 60;
double s = secs - m * 60;
return [NSString stringWithFormat:@"%dm %.0fs", m, s];
}
- (void)playPauseTapped {
if (self.isPlaying) {
[self stopPlayback];
} else {
[self startPlayback];
}
}
- (void)startPlayback {
[self stopPlayback];
self.player = [AVPlayer playerWithURL:self.mediaURL];
[self.player seekToTime:CMTimeMakeWithSeconds(self.startTime, 600) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
self.playhead.hidden = NO;
self.isPlaying = YES;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
[self.playBtn setImage:[UIImage systemImageNamed:@"pause.fill" withConfiguration:cfg] forState:UIControlStateNormal];
__weak SCITrimViewController *weakSelf = self;
self.timeObserver = [self.player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(0.05, 600) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
SCITrimViewController *s = weakSelf;
if (!s) return;
double current = CMTimeGetSeconds(time);
if (current >= s.endTime) {
[s stopPlayback];
return;
}
CGFloat x = [s timeToX:current];
s.playhead.frame = CGRectMake(x - 1.25, 2, 2.5, kTrackH - 4);
}];
[self.player play];
}
- (void)stopPlayback {
if (self.timeObserver && self.player) {
[self.player removeTimeObserver:self.timeObserver];
}
self.timeObserver = nil;
[self.player pause];
self.player = nil;
self.isPlaying = NO;
self.playhead.hidden = YES;
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
[self.playBtn setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:cfg] forState:UIControlStateNormal];
}
- (void)cancelTapped {
[self stopPlayback];
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)sendTapped {
[self stopPlayback];
double dur = self.endTime - self.startTime;
if (dur < 0.5) {
[SCIUtils showErrorHUDWithDescription:@"Selection too short (min 0.5s)"];
return;
}
UIViewController *tvc = self.threadVC;
NSURL *url = self.mediaURL;
BOOL video = self.isVideo;
CMTimeRange trimRange = CMTimeRangeMake(CMTimeMakeWithSeconds(self.startTime, 600), CMTimeMakeWithSeconds(dur, 600));
[self dismissViewControllerAnimated:YES completion:^{
if (tvc) sciExportAndSend(url, tvc, video, trimRange);
}];
}
- (UIStatusBarStyle)preferredStatusBarStyle { return UIStatusBarStyleLightContent; }
@end
static void sciShowTrimVC(NSURL *url, BOOL isVideo, UIViewController *threadVC) {
SCITrimViewController *trimVC = [[SCITrimViewController alloc] init];
trimVC.mediaURL = url;
trimVC.isVideo = isVideo;
trimVC.threadVC = threadVC;
trimVC.modalPresentationStyle = UIModalPresentationFullScreen;
[threadVC presentViewController:trimVC animated:YES completion:nil];
}
#pragma mark - Show picker options
static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciAudioThreadVC = threadVC;
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Upload Audio"
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Audio/Video from Files" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
initWithDocumentTypes:@[@"public.audio", @"public.mpeg-4-audio", @"public.mp3", @"com.microsoft.waveform-audio",
@"public.aiff-audio", @"com.apple.m4a-audio",
@"public.movie", @"public.mpeg-4", @"com.apple.quicktime-movie"]
inMode:UIDocumentPickerModeImport];
#pragma clang diagnostic pop
picker.delegate = (id<UIDocumentPickerDelegate>)vc;
[vc presentViewController:picker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Video from Library" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
UIImagePickerController *imgPicker = [[UIImagePickerController alloc] init];
imgPicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
imgPicker.mediaTypes = @[@"public.movie"];
imgPicker.delegate = (id<UINavigationControllerDelegate, UIImagePickerControllerDelegate>)vc;
imgPicker.videoExportPreset = AVAssetExportPresetPassthrough;
imgPicker.allowsEditing = YES; // enables built-in video trimming
[vc presentViewController:imgPicker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[threadVC presentViewController:alert animated:YES completion:nil];
}
#pragma mark - Hook IGDSMenu to inject native menu item
%hook IGDSMenu
- (id)initWithMenuItems:(NSArray *)items edr:(BOOL)edr headerLabelText:(id)header {
if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return %orig;
// Only inject into DM plus menus — sciDMMenuPending is set right before
// this menu is created by the composer overflow button callback
if (!sciDMMenuPending) return %orig;
sciDMMenuPending = NO;
for (id item in items) {
id title = sciAF(item, @selector(title));
if ([title isKindOfClass:[NSString class]] && [title isEqualToString:@"Upload Audio"]) return %orig;
}
Class itemClass = NSClassFromString(@"IGDSMenuItem");
if (!itemClass) return %orig;
UIImage *img = [[UIImage systemImageNamed:@"waveform"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
void (^handler)(void) = ^{
UIViewController *threadVC = sciAudioThreadVC;
if (threadVC) sciShowUploadAudioOptions(threadVC);
};
SEL initSel = @selector(initWithTitle:image:handler:);
if (![itemClass instancesRespondToSelector:initSel]) return %orig;
typedef id (*InitFn)(id, SEL, id, id, id);
id audioItem = ((InitFn)objc_msgSend)([itemClass alloc], initSel, @"Upload Audio", img, handler);
if (!audioItem) return %orig;
NSMutableArray *newItems = [NSMutableArray arrayWithObject:audioItem];
[newItems addObjectsFromArray:items];
return %orig(newItems, edr, header);
}
%end
#pragma mark - Hook IGDirectThreadViewController
%hook IGDirectThreadViewController
- (void)composerOverflowButtonMenuWillPrepareExpandWithPlusButton:(id)plusButton {
%orig;
if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return;
sciAudioThreadVC = self;
sciDMMenuPending = YES;
}
// file picker delegate — show trim UI
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
NSURL *url = urls.firstObject;
if (!url) return;
// detect if it's a video file
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
}
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url {
if (!url) return;
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
}
%new - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {}
// video picker delegate — UIImagePickerController with allowsEditing handles trimming
%new - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info {
[picker dismissViewControllerAnimated:YES completion:nil];
NSURL *videoURL = info[UIImagePickerControllerMediaURL];
if (!videoURL) {
[SCIUtils showErrorHUDWithDescription:@"Could not get video URL"];
return;
}
// UIImagePickerController with allowsEditing already trimmed the video for us
sciConvertAndSend(videoURL, self, YES);
}
%new - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
[picker dismissViewControllerAnimated:YES completion:nil];
}
%end
@@ -0,0 +1,161 @@
// Story audio mute/unmute toggle. Posts mute-switch-state-changed to toggle
// IG's audio. Reads _audioEnabled on IGAudioStatusAnnouncer for icon state.
#import <AVFoundation/AVFoundation.h>
#import "StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *);
static id sciAudioAnnouncer = nil;
static BOOL sciIGAudioEnabled(void) {
if (!sciAudioAnnouncer) return NO;
Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled");
if (!ivar) return NO;
ptrdiff_t offset = ivar_getOffset(ivar);
return *(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset);
}
// ============ Volume KVO ============
@interface _SciVolumeObserver : NSObject
@end
@implementation _SciVolumeObserver
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
change:(NSDictionary *)change context:(void *)context {
dispatch_async(dispatch_get_main_queue(), ^{
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
});
}
@end
static _SciVolumeObserver *sciVolumeObserver = nil;
// ============ Public API ============
extern "C" {
BOOL sciStoryAudioBypass = NO;
void sciToggleStoryAudio(void) {
BOOL on = sciIGAudioEnabled();
sciStoryAudioBypass = YES;
[[NSNotificationCenter defaultCenter]
postNotificationName:@"mute-switch-state-changed"
object:nil
userInfo:@{@"mute-state": @(on ? 0 : 1)}];
sciStoryAudioBypass = NO;
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}
BOOL sciIsStoryAudioEnabled(void) {
return sciIGAudioEnabled();
}
static BOOL sciKVORegistered = NO;
void sciInitStoryAudioState(void) {
if (sciKVORegistered) return;
if (!sciVolumeObserver) sciVolumeObserver = [_SciVolumeObserver new];
@try {
[[AVAudioSession sharedInstance] addObserver:sciVolumeObserver
forKeyPath:@"outputVolume"
options:NSKeyValueObservingOptionNew
context:NULL];
sciKVORegistered = YES;
} @catch (__unused id e) {}
}
void sciResetStoryAudioState(void) {
if (!sciKVORegistered) return;
@try {
[[AVAudioSession sharedInstance] removeObserver:sciVolumeObserver forKeyPath:@"outputVolume"];
sciKVORegistered = NO;
} @catch (__unused id e) {}
}
} // extern "C"
// ============ Announcer hooks ============
static id (*orig_announcerInit)(id, SEL);
static id new_announcerInit(id self, SEL _cmd) {
id r = orig_announcerInit(self, _cmd);
sciAudioAnnouncer = self;
return r;
}
static void (*orig_announce)(id, SEL, BOOL, NSInteger);
static void new_announce(id self, SEL _cmd, BOOL enabled, NSInteger reason) {
orig_announce(self, _cmd, enabled, reason);
if (sciActiveStoryViewerVC) {
dispatch_async(dispatch_get_main_queue(), ^{
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
});
}
}
// ============ 3-dot menu item ============
extern "C" NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *items) {
if (!sciActiveStoryViewerVC) return items;
BOOL looksLikeStoryHeader = NO;
for (id it in items) {
@try {
NSString *t = [NSString stringWithFormat:@"%@", [it valueForKey:@"title"] ?: @""];
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
[t isEqualToString:@"Hide"]) { looksLikeStoryHeader = YES; break; }
} @catch (__unused id e) {}
}
if (!looksLikeStoryHeader) return items;
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
BOOL on = sciIGAudioEnabled();
NSString *title = on ? @"Mute story audio" : @"Unmute story audio";
void (^handler)(void) = ^{ sciToggleStoryAudio(); };
id newItem = nil;
@try {
typedef id (*Init)(id, SEL, id, id, id);
newItem = ((Init)objc_msgSend)([menuItemCls alloc],
@selector(initWithTitle:image:handler:), title, nil, handler);
} @catch (__unused id e) {}
if (!newItem) return items;
NSMutableArray *newItems = [items mutableCopy];
[newItems addObject:newItem];
return [newItems copy];
}
// ============ Ringer listener ============
static void sciRingerChanged(CFNotificationCenterRef center, void *observer,
CFNotificationName name, const void *object,
CFDictionaryRef userInfo) {
dispatch_async(dispatch_get_main_queue(), ^{
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
});
}
// ============ Init ============
__attribute__((constructor)) static void _storyAudioInit(void) {
CFNotificationCenterAddObserver(
CFNotificationCenterGetDarwinNotifyCenter(), NULL,
sciRingerChanged, CFSTR("com.apple.springboard.ringerstate"),
NULL, CFNotificationSuspensionBehaviorDeliverImmediately);
Class cls = NSClassFromString(@"IGAudioStatusAnnouncer");
if (!cls) return;
MSHookMessageEx(cls, @selector(init), (IMP)new_announcerInit, (IMP *)&orig_announcerInit);
SEL s = NSSelectorFromString(@"_announceForDeviceStateChangesIfNeededForAudioEnabled:reason:");
if (class_getInstanceMethod(cls, s))
MSHookMessageEx(cls, s, (IMP)new_announce, (IMP *)&orig_announce);
}
@@ -0,0 +1,98 @@
// Shared helpers for story/DM visual message features
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../Downloader/Download.h"
#import <objc/runtime.h>
#import <objc/message.h>
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;
}
@@ -1,21 +1,16 @@
#import "../../Utils.h"
#import "SCIExcludedThreads.h"
%hook IGDirectVisualMessage
- (NSInteger)viewMode {
NSInteger mode = %orig;
// * Modes *
// 0 - View Once
// 1 - Replayable
if ([SCIUtils getBoolPref:@"disable_view_once_limitations"]) {
if (mode == 0) {
mode = 1;
NSLog(@"[SCInsta] Modifying visual message from read-once to replayable");
}
// 0 = view once, 1 = replayable. Force view-once behavior to leak through
// when the active thread is excluded so the message expires normally.
if ([SCIUtils getBoolPref:@"disable_view_once_limitations"]
&& mode == 0
&& ![SCIExcludedThreads isActiveThreadExcluded]) {
return 1;
}
return mode;
}
%end
+20
View File
@@ -425,6 +425,12 @@
@interface IGSundialViewerNavigationBarOld : UIView
@end
@interface IGMediaOverlayProfileWithPasswordView : UIView
- (void)sciAddButtons;
- (void)sciUnlockTapped;
- (void)sciShowPasswordTapped;
@end
@interface IGUFIInteractionCountsView : UIView
@end
@@ -479,8 +485,22 @@
@interface IGDSMenuItem : NSObject
@end
@interface IGDirectAudioWaveform : NSObject
- (id)initWithVolumeRecordingInterval:(double)interval averageVolume:(NSArray *)volumes;
+ (NSArray *)generateWaveformDataFromAudioFile:(NSURL *)url maxLength:(NSInteger)maxLength;
+ (NSArray *)scaledArrayOfNumbers:(NSArray *)numbers;
@end
@interface IGDirectThreadViewController : UIViewController
- (void)markLastMessageAsSeen;
- (id)voiceController;
- (id)messageSenderFeatureController;
@end
@interface IGDirectMessageSenderFeatureController : NSObject
@end
@interface MDCoreDelta : NSObject
@end
@interface IGTabBarButton : UIButton
+26
View File
@@ -0,0 +1,26 @@
// Saves to a dedicated "RyukGram" album in the Photos library.
// Creates the album on first use. All RyukGram-initiated saves should go
// through here so the user can find their downloads in one place.
#import <Foundation/Foundation.h>
#import <Photos/Photos.h>
@interface SCIPhotoAlbum : NSObject
// Album name shown in the user's Photos app.
+ (NSString *)albumName;
// Asynchronously fetches (or creates on first use) the RyukGram album.
+ (void)fetchOrCreateAlbumWithCompletion:(void (^)(PHAssetCollection *album, NSError *error))completion;
// Saves a file at fileURL into the RyukGram album. The file is treated as a
// photo or video based on its extension. Calls completion on the main queue.
+ (void)saveFileToAlbum:(NSURL *)fileURL completion:(void (^)(BOOL success, NSError *error))completion;
// Watches the photo library for the next asset insertion and moves it into
// the RyukGram album. Used to capture saves performed via UIActivityViewController's
// "Save to Photos" activity, which we don't initiate ourselves.
//
// The watcher auto-unregisters after the first capture or after a timeout.
+ (void)watchForNextSavedAsset;
@end
+139
View File
@@ -0,0 +1,139 @@
#import "PhotoAlbum.h"
@interface SCIPhotoAlbumWatcher : NSObject <PHPhotoLibraryChangeObserver>
@property (nonatomic, strong) PHFetchResult<PHAsset *> *baseline;
@property (nonatomic, strong) NSTimer *timeoutTimer;
@end
static SCIPhotoAlbumWatcher *sciActiveWatcher = nil;
@implementation SCIPhotoAlbum
+ (NSString *)albumName {
return @"RyukGram";
}
+ (void)fetchOrCreateAlbumWithCompletion:(void (^)(PHAssetCollection *, NSError *))completion {
PHFetchOptions *opts = [[PHFetchOptions alloc] init];
opts.predicate = [NSPredicate predicateWithFormat:@"title = %@", [self albumName]];
PHFetchResult<PHAssetCollection *> *result = [PHAssetCollection
fetchAssetCollectionsWithType:PHAssetCollectionTypeAlbum
subtype:PHAssetCollectionSubtypeAlbumRegular
options:opts];
if (result.count > 0) {
if (completion) completion(result.firstObject, nil);
return;
}
__block NSString *placeholderId = nil;
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHAssetCollectionChangeRequest *req = [PHAssetCollectionChangeRequest
creationRequestForAssetCollectionWithTitle:[self albumName]];
placeholderId = req.placeholderForCreatedAssetCollection.localIdentifier;
} completionHandler:^(BOOL success, NSError *error) {
if (!success || !placeholderId) {
if (completion) completion(nil, error);
return;
}
PHFetchResult<PHAssetCollection *> *fetched = [PHAssetCollection
fetchAssetCollectionsWithLocalIdentifiers:@[placeholderId] options:nil];
if (completion) completion(fetched.firstObject, nil);
}];
}
+ (void)saveFileToAlbum:(NSURL *)fileURL completion:(void (^)(BOOL, NSError *))completion {
[self fetchOrCreateAlbumWithCompletion:^(PHAssetCollection *album, NSError *err) {
if (!album) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(NO, err);
});
return;
}
__block NSString *assetId = nil;
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
NSString *ext = [[fileURL pathExtension] lowercaseString];
BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext];
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
opts.shouldMoveFile = YES;
[req addResourceWithType:(isVideo ? PHAssetResourceTypeVideo : PHAssetResourceTypePhoto)
fileURL:fileURL options:opts];
req.creationDate = [NSDate date];
assetId = req.placeholderForCreatedAsset.localIdentifier;
PHAssetCollectionChangeRequest *albumReq =
[PHAssetCollectionChangeRequest changeRequestForAssetCollection:album];
[albumReq addAssets:@[req.placeholderForCreatedAsset]];
} completionHandler:^(BOOL success, NSError *changeErr) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) completion(success, changeErr);
});
}];
}];
}
+ (void)watchForNextSavedAsset {
// Replace any existing watcher — only the most recent share sheet matters
if (sciActiveWatcher) {
[[PHPhotoLibrary sharedPhotoLibrary] unregisterChangeObserver:sciActiveWatcher];
[sciActiveWatcher.timeoutTimer invalidate];
sciActiveWatcher = nil;
}
if ([PHPhotoLibrary authorizationStatus] != PHAuthorizationStatusAuthorized &&
[PHPhotoLibrary authorizationStatus] != PHAuthorizationStatusLimited) {
return;
}
SCIPhotoAlbumWatcher *watcher = [[SCIPhotoAlbumWatcher alloc] init];
PHFetchOptions *opts = [[PHFetchOptions alloc] init];
opts.sortDescriptors = @[[NSSortDescriptor sortDescriptorWithKey:@"creationDate" ascending:NO]];
watcher.baseline = [PHAsset fetchAssetsWithOptions:opts];
[[PHPhotoLibrary sharedPhotoLibrary] registerChangeObserver:watcher];
// Auto-unregister after 60s in case the user dismisses without saving
watcher.timeoutTimer = [NSTimer scheduledTimerWithTimeInterval:60.0
repeats:NO
block:^(NSTimer *t) {
if (sciActiveWatcher == watcher) {
[[PHPhotoLibrary sharedPhotoLibrary] unregisterChangeObserver:watcher];
sciActiveWatcher = nil;
}
}];
sciActiveWatcher = watcher;
}
@end
@implementation SCIPhotoAlbumWatcher
- (void)photoLibraryDidChange:(PHChange *)changeInstance {
PHFetchResultChangeDetails *details = [changeInstance changeDetailsForFetchResult:self.baseline];
if (!details || details.insertedObjects.count == 0) return;
NSArray<PHAsset *> *inserted = details.insertedObjects;
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
[SCIPhotoAlbum fetchOrCreateAlbumWithCompletion:^(PHAssetCollection *album, NSError *err) {}];
} completionHandler:^(BOOL success, NSError *error) {
// Add the inserted assets to the album in a separate transaction so the
// album exists by the time we reference it.
[SCIPhotoAlbum fetchOrCreateAlbumWithCompletion:^(PHAssetCollection *album, NSError *err) {
if (!album) return;
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
PHAssetCollectionChangeRequest *req =
[PHAssetCollectionChangeRequest changeRequestForAssetCollection:album];
[req addAssets:inserted];
} completionHandler:nil];
}];
}];
// One-shot
[[PHPhotoLibrary sharedPhotoLibrary] unregisterChangeObserver:self];
[self.timeoutTimer invalidate];
self.timeoutTimer = nil;
if (sciActiveWatcher == self) sciActiveWatcher = nil;
}
@end
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIEmbedDomainViewController : UIViewController <UITableViewDataSource, UITableViewDelegate>
@end
+130
View File
@@ -0,0 +1,130 @@
#import "SCIEmbedDomainViewController.h"
#import "../Utils.h"
#define SCI_CUSTOM_DOMAINS_KEY @"embed_custom_domains"
static NSArray *sciPresetDomains(void) {
return @[@"kkinstagram.com", @"ddinstagram.com", @"d.ddinstagram.com", @"g.ddinstagram.com"];
}
@interface SCIEmbedDomainViewController ()
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, copy) NSArray<NSString *> *customDomains;
@end
@implementation SCIEmbedDomainViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Embed domain";
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.tableView];
[NSLayoutConstraint activateConstraints:@[
[self.tableView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
]];
self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemAdd target:self action:@selector(addCustom)];
[self reload];
}
- (void)reload {
NSArray *stored = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_CUSTOM_DOMAINS_KEY];
self.customDomains = stored ?: @[];
[self.tableView reloadData];
}
- (void)addCustom {
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Add custom domain"
message:nil
preferredStyle:UIAlertControllerStyleAlert];
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) {
tf.placeholder = @"example.com";
tf.autocapitalizationType = UITextAutocapitalizationTypeNone;
tf.autocorrectionType = UITextAutocorrectionTypeNo;
tf.keyboardType = UIKeyboardTypeURL;
}];
[alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
NSString *domain = alert.textFields.firstObject.text;
domain = [domain stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
domain = [domain stringByReplacingOccurrencesOfString:@"https://" withString:@""];
domain = [domain stringByReplacingOccurrencesOfString:@"http://" withString:@""];
while ([domain hasSuffix:@"/"]) domain = [domain substringToIndex:domain.length - 1];
if (!domain.length || ![domain containsString:@"."]) return;
NSMutableArray *all = [self.customDomains mutableCopy];
if (![all containsObject:domain]) [all addObject:domain];
[[NSUserDefaults standardUserDefaults] setObject:all forKey:SCI_CUSTOM_DOMAINS_KEY];
[[NSUserDefaults standardUserDefaults] setObject:domain forKey:@"embed_link_domain"];
[self reload];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[self presentViewController:alert animated:YES completion:nil];
}
#pragma mark - Table
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; }
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
return section == 0 ? @"Presets" : @"Custom";
}
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return section == 0 ? (NSInteger)sciPresetDomains().count : (NSInteger)self.customDomains.count;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"cell"];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"cell"];
NSString *domain = indexPath.section == 0
? sciPresetDomains()[indexPath.row]
: self.customDomains[indexPath.row];
cell.textLabel.text = domain;
NSString *current = [SCIUtils getStringPref:@"embed_link_domain"];
if (!current.length) current = @"kkinstagram.com";
cell.accessoryType = [domain isEqualToString:current] ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone;
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
[tv deselectRowAtIndexPath:indexPath animated:YES];
NSString *domain = indexPath.section == 0
? sciPresetDomains()[indexPath.row]
: self.customDomains[indexPath.row];
[[NSUserDefaults standardUserDefaults] setObject:domain forKey:@"embed_link_domain"];
[tv reloadData];
}
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath {
if (indexPath.section == 0) return nil;
NSString *domain = self.customDomains[indexPath.row];
UIContextualAction *del = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive title:@"Delete"
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
NSMutableArray *all = [self.customDomains mutableCopy];
[all removeObject:domain];
[[NSUserDefaults standardUserDefaults] setObject:all forKey:SCI_CUSTOM_DOMAINS_KEY];
// Reset to default if deleted domain was selected
NSString *current = [SCIUtils getStringPref:@"embed_link_domain"];
if ([current isEqualToString:domain])
[[NSUserDefaults standardUserDefaults] setObject:@"kkinstagram.com" forKey:@"embed_link_domain"];
[self reload];
cb(YES);
}];
return [UISwipeActionsConfiguration configurationWithActions:@[del]];
}
- (BOOL)tableView:(UITableView *)tv canEditRowAtIndexPath:(NSIndexPath *)indexPath {
return indexPath.section == 1;
}
@end
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIExcludedChatsViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate>
@end
@@ -0,0 +1,290 @@
#import "SCIExcludedChatsViewController.h"
#import "../Features/StoriesAndMessages/SCIExcludedThreads.h"
@interface SCIExcludedChatsViewController ()
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UISearchBar *searchBar;
@property (nonatomic, copy) NSArray<NSDictionary *> *filtered;
@property (nonatomic, copy) NSString *query;
@property (nonatomic, assign) NSInteger sortMode;
@property (nonatomic, strong) UIBarButtonItem *sortBtn;
@property (nonatomic, strong) UIBarButtonItem *editBtn;
@property (nonatomic, strong) UIToolbar *batchToolbar;
@end
@implementation SCIExcludedChatsViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Chats";
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.searchBar = [[UISearchBar alloc] init];
self.searchBar.delegate = self;
self.searchBar.placeholder = @"Search by name or username";
[self.searchBar sizeToFit];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.tableHeaderView = self.searchBar;
self.tableView.allowsMultipleSelectionDuringEditing = YES;
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.tableView];
self.batchToolbar = [[UIToolbar alloc] init];
self.batchToolbar.translatesAutoresizingMaskIntoConstraints = NO;
self.batchToolbar.hidden = YES;
[self.view addSubview:self.batchToolbar];
[NSLayoutConstraint activateConstraints:@[
[self.tableView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:self.batchToolbar.topAnchor],
[self.batchToolbar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.batchToolbar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.batchToolbar.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
]];
self.sortBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"]
style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)];
self.editBtn = [[UIBarButtonItem alloc]
initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn];
[self reload];
}
- (void)toggleEdit {
BOOL entering = !self.tableView.isEditing;
[self.tableView setEditing:entering animated:YES];
self.editBtn.title = entering ? @"Done" : @"Select";
self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
self.batchToolbar.hidden = !entering;
if (entering) [self updateToolbar];
}
- (void)updateToolbar {
UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
del.tintColor = [UIColor systemRedColor];
UIBarButtonItem *kd = [[UIBarButtonItem alloc] initWithTitle:@"Keep-deleted" style:UIBarButtonItemStylePlain target:self action:@selector(batchKeepDeleted)];
self.batchToolbar.items = @[del, flex, kd];
}
- (void)removeSelected {
NSArray<NSIndexPath *> *sel = self.tableView.indexPathsForSelectedRows;
if (!sel.count) return;
for (NSIndexPath *ip in sel) {
NSDictionary *e = self.filtered[ip.row];
[SCIExcludedThreads removeThreadId:e[@"threadId"]];
}
[self toggleEdit];
[self reload];
}
- (void)batchKeepDeleted {
NSArray<NSIndexPath *> *sel = self.tableView.indexPathsForSelectedRows;
if (!sel.count) return;
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Set keep-deleted override" message:nil preferredStyle:UIAlertControllerStyleActionSheet];
void (^apply)(SCIKeepDeletedOverride) = ^(SCIKeepDeletedOverride mode) {
for (NSIndexPath *ip in sel) {
NSDictionary *e = self.filtered[ip.row];
[SCIExcludedThreads setKeepDeletedOverride:mode forThreadId:e[@"threadId"]];
}
[self toggleEdit];
[self reload];
};
[sheet addAction:[UIAlertAction actionWithTitle:@"Follow default" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
apply(SCIKeepDeletedOverrideDefault);
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Force ON (preserve unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
apply(SCIKeepDeletedOverrideIncluded);
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Force OFF (allow unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
apply(SCIKeepDeletedOverrideExcluded);
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.barButtonItem = self.batchToolbar.items.lastObject;
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)toggleSort {
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by"
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *titles = @[@"Recently added", @"Name (AZ)"];
for (NSInteger i = 0; i < (NSInteger)titles.count; i++) {
UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) {
self.sortMode = i;
[self reload];
}];
if (i == self.sortMode) [a setValue:@YES forKey:@"checked"];
[sheet addAction:a];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.barButtonItem = self.sortBtn;
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)reload {
NSArray *all = [SCIExcludedThreads allEntries];
NSString *q = [self.query lowercaseString];
if (q.length > 0) {
all = [all filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
if ([[e[@"threadName"] lowercaseString] containsString:q]) return YES;
for (NSDictionary *u in (NSArray *)e[@"users"]) {
if ([[u[@"username"] lowercaseString] containsString:q]) return YES;
if ([[u[@"fullName"] lowercaseString] containsString:q]) return YES;
}
return NO;
}]];
}
if (self.sortMode == 0) {
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
return [b[@"addedAt"] ?: @0 compare:a[@"addedAt"] ?: @0];
}];
} else {
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
return [a[@"threadName"] ?: @"" caseInsensitiveCompare:b[@"threadName"] ?: @""];
}];
}
self.filtered = all;
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *label = bs ? @"Included chats" : @"Excluded chats";
self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count];
[self.tableView reloadData];
}
#pragma mark - Search
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText {
self.query = searchText;
[self reload];
}
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { [searchBar resignFirstResponder]; }
#pragma mark - Table
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return self.filtered.count;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *reuse = @"sciExclCell";
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:reuse];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuse];
NSDictionary *e = self.filtered[indexPath.row];
NSString *name = e[@"threadName"] ?: @"(unknown)";
BOOL isGroup = [e[@"isGroup"] boolValue];
NSMutableArray *unames = [NSMutableArray array];
for (NSDictionary *u in (NSArray *)e[@"users"]) {
if (u[@"username"]) [unames addObject:[@"@" stringByAppendingString:u[@"username"]]];
}
NSString *subtitle = [unames componentsJoinedByString:@", "];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
NSString *kdLabel = (mode == SCIKeepDeletedOverrideExcluded) ? @" • Keep-deleted: OFF"
: (mode == SCIKeepDeletedOverrideIncluded) ? @" • Keep-deleted: ON"
: @"";
if (kdLabel.length) subtitle = [subtitle stringByAppendingString:kdLabel];
cell.textLabel.text = [NSString stringWithFormat:@"%@%@", isGroup ? @"👥 " : @"", name];
cell.detailTextLabel.text = subtitle;
cell.detailTextLabel.numberOfLines = 2;
cell.accessoryType = (isGroup || tv.isEditing) ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator;
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
if (tv.isEditing) return;
[tv deselectRowAtIndexPath:indexPath animated:YES];
NSDictionary *e = self.filtered[indexPath.row];
NSArray *users = e[@"users"];
if ([e[@"isGroup"] boolValue] || users.count != 1) return;
NSString *username = users.firstObject[@"username"];
if (!username) return;
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]];
if ([[UIApplication sharedApplication] canOpenURL:url])
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *e = self.filtered[indexPath.row];
NSString *tid = e[@"threadId"];
UIContextualAction *del = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive
title:@"Remove"
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
[SCIExcludedThreads removeThreadId:tid];
[self reload];
cb(YES);
}];
return [UISwipeActionsConfiguration configurationWithActions:@[del]];
}
- (UIContextMenuConfiguration *)tableView:(UITableView *)tv contextMenuConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath point:(CGPoint)point {
NSDictionary *e = self.filtered[indexPath.row];
NSString *tid = e[@"threadId"];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
return [UIContextMenuConfiguration configurationWithIdentifier:nil
previewProvider:nil
actionProvider:^UIMenu *(NSArray<UIMenuElement *> *_) {
UIAction *(^kdAction)(NSString *, SCIKeepDeletedOverride) = ^UIAction *(NSString *title, SCIKeepDeletedOverride v) {
UIAction *a = [UIAction actionWithTitle:title image:nil identifier:nil
handler:^(__kindof UIAction *_) {
[SCIExcludedThreads setKeepDeletedOverride:v forThreadId:tid];
[self reload];
}];
if (v == mode) a.state = UIMenuElementStateOn;
return a;
};
UIMenu *kdMenu = [UIMenu menuWithTitle:@"Keep-deleted override"
image:[UIImage systemImageNamed:@"trash.slash"]
identifier:nil
options:0
children:@[
kdAction(@"Follow default", SCIKeepDeletedOverrideDefault),
kdAction(@"Force ON (preserve unsends)", SCIKeepDeletedOverrideIncluded),
kdAction(@"Force OFF (allow unsends)", SCIKeepDeletedOverrideExcluded),
]];
UIAction *remove = [UIAction actionWithTitle:@"Remove from list"
image:[UIImage systemImageNamed:@"trash"]
identifier:nil
handler:^(__kindof UIAction *_) {
[SCIExcludedThreads removeThreadId:tid];
[self reload];
}];
remove.attributes = UIMenuElementAttributesDestructive;
return [UIMenu menuWithChildren:@[kdMenu, remove]];
}];
}
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv leadingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *e = self.filtered[indexPath.row];
NSString *tid = e[@"threadId"];
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
SCIKeepDeletedOverride next = (mode + 1) % 3;
NSString *title = (next == SCIKeepDeletedOverrideExcluded) ? @"KD: OFF"
: (next == SCIKeepDeletedOverrideIncluded) ? @"KD: ON"
: @"KD: default";
UIContextualAction *toggle = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleNormal
title:title
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
[SCIExcludedThreads setKeepDeletedOverride:next forThreadId:tid];
[self reload];
cb(YES);
}];
toggle.backgroundColor = [UIColor systemBlueColor];
return [UISwipeActionsConfiguration configurationWithActions:@[toggle]];
}
@end
@@ -0,0 +1,4 @@
#import <UIKit/UIKit.h>
@interface SCIExcludedStoryUsersViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate>
@end
@@ -0,0 +1,181 @@
#import "SCIExcludedStoryUsersViewController.h"
#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h"
@interface SCIExcludedStoryUsersViewController ()
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) UISearchBar *searchBar;
@property (nonatomic, copy) NSArray<NSDictionary *> *filtered;
@property (nonatomic, copy) NSString *query;
@property (nonatomic, assign) NSInteger sortMode;
@property (nonatomic, strong) UIBarButtonItem *sortBtn;
@property (nonatomic, strong) UIBarButtonItem *editBtn;
@property (nonatomic, strong) UIToolbar *batchToolbar;
@end
@implementation SCIExcludedStoryUsersViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.title = @"Story users";
self.view.backgroundColor = [UIColor systemBackgroundColor];
self.searchBar = [[UISearchBar alloc] init];
self.searchBar.delegate = self;
self.searchBar.placeholder = @"Search by username or name";
[self.searchBar sizeToFit];
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.tableHeaderView = self.searchBar;
self.tableView.allowsMultipleSelectionDuringEditing = YES;
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.tableView];
self.batchToolbar = [[UIToolbar alloc] init];
self.batchToolbar.translatesAutoresizingMaskIntoConstraints = NO;
self.batchToolbar.hidden = YES;
[self.view addSubview:self.batchToolbar];
[NSLayoutConstraint activateConstraints:@[
[self.tableView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:self.batchToolbar.topAnchor],
[self.batchToolbar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.batchToolbar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.batchToolbar.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
]];
self.sortBtn = [[UIBarButtonItem alloc]
initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"]
style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)];
self.editBtn = [[UIBarButtonItem alloc]
initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn];
[self reload];
}
- (void)toggleEdit {
BOOL entering = !self.tableView.isEditing;
[self.tableView setEditing:entering animated:YES];
self.editBtn.title = entering ? @"Done" : @"Select";
self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
self.batchToolbar.hidden = !entering;
if (entering) {
UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove Selected" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
del.tintColor = [UIColor systemRedColor];
self.batchToolbar.items = @[flex, del, flex];
}
}
- (void)removeSelected {
NSArray<NSIndexPath *> *sel = self.tableView.indexPathsForSelectedRows;
if (!sel.count) return;
for (NSIndexPath *ip in sel) {
NSDictionary *e = self.filtered[ip.row];
[SCIExcludedStoryUsers removePK:e[@"pk"]];
}
[self toggleEdit];
[self reload];
}
- (void)toggleSort {
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by"
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
NSArray *titles = @[@"Recently added", @"Username (AZ)"];
for (NSInteger i = 0; i < (NSInteger)titles.count; i++) {
UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) {
self.sortMode = i;
[self reload];
}];
if (i == self.sortMode) [a setValue:@YES forKey:@"checked"];
[sheet addAction:a];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.barButtonItem = self.sortBtn;
[self presentViewController:sheet animated:YES completion:nil];
}
- (void)reload {
NSArray *all = [SCIExcludedStoryUsers allEntries];
NSString *q = [self.query lowercaseString];
if (q.length > 0) {
all = [all filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
if ([[e[@"username"] lowercaseString] containsString:q]) return YES;
if ([[e[@"fullName"] lowercaseString] containsString:q]) return YES;
return NO;
}]];
}
if (self.sortMode == 0) {
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
return [b[@"addedAt"] ?: @0 compare:a[@"addedAt"] ?: @0];
}];
} else {
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
return [a[@"username"] ?: @"" caseInsensitiveCompare:b[@"username"] ?: @""];
}];
}
self.filtered = all;
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
NSString *label = bs ? @"Included users" : @"Excluded users";
self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count];
[self.tableView reloadData];
}
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText {
self.query = searchText;
[self reload];
}
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { [searchBar resignFirstResponder]; }
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
return self.filtered.count;
}
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *reuse = @"sciStoryExclCell";
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:reuse];
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuse];
NSDictionary *e = self.filtered[indexPath.row];
NSString *username = e[@"username"] ?: @"";
NSString *fullName = e[@"fullName"] ?: @"";
cell.textLabel.text = fullName.length ? fullName : (username.length ? [@"@" stringByAppendingString:username] : @"(unknown)");
cell.detailTextLabel.text = username.length ? [@"@" stringByAppendingString:username] : @"";
cell.accessoryType = tv.isEditing ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator;
return cell;
}
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
if (tv.isEditing) return;
[tv deselectRowAtIndexPath:indexPath animated:YES];
NSDictionary *e = self.filtered[indexPath.row];
NSString *username = e[@"username"];
if (!username.length) return;
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]];
if ([[UIApplication sharedApplication] canOpenURL:url])
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *e = self.filtered[indexPath.row];
NSString *pk = e[@"pk"];
UIContextualAction *del = [UIContextualAction
contextualActionWithStyle:UIContextualActionStyleDestructive
title:@"Remove"
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
[SCIExcludedStoryUsers removePK:pk];
[self reload];
cb(YES);
}];
return [UISwipeActionsConfiguration configurationWithActions:@[del]];
}
@end
+2
View File
@@ -41,6 +41,8 @@ typedef NS_ENUM(NSInteger, SCITableCell) {
@property (nonatomic, strong) UIMenu *baseMenu;
@property (nonatomic, copy, nullable) NSString *(^dynamicTitle)(void);
@property (nonatomic, strong) NSArray *navSections;
@property (nonatomic, strong) UIViewController *navViewController;
+12
View File
@@ -0,0 +1,12 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
@interface SCISettingsBackup : NSObject
+ (void)presentExport;
+ (void)presentImport;
@end
NS_ASSUME_NONNULL_END
+791
View File
@@ -0,0 +1,791 @@
#import "SCISettingsBackup.h"
#import "TweakSettings.h"
#import "SCISetting.h"
#import "../Utils.h"
#import "../Tweak.h"
#import <CoreImage/CoreImage.h>
#import <objc/runtime.h>
#import "../../modules/JGProgressHUD/JGProgressHUD.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,
};
@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
@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>
@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.obscuresBackgroundDuringPresentation = NO;
sc.searchBar.placeholder = @"Search settings";
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.searchController = sc;
self.allGroups = [SCISettingsBackup buildPreviewGroupsForSettings:self.mutableSettings];
self.visibleGroups = self.allGroups;
}
#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 ? @"Done editing" : @"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 ? @"Form view" : @"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:@"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)
@interface SCIBackupHelper : NSObject <UIDocumentPickerDelegate>
@property (nonatomic) BOOL expectingExportPick;
@end
@implementation SCIBackupHelper
+ (instancetype)shared {
static SCIBackupHelper *s;
static dispatch_once_t once;
dispatch_once(&once, ^{ s = [[SCIBackupHelper alloc] init]; });
return s;
}
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
if (self.expectingExportPick) {
self.expectingExportPick = NO;
[SCISettingsBackup showSuccessHUD:@"Settings exported"];
return;
}
NSURL *url = urls.firstObject;
if (!url) return;
BOOL access = [url startAccessingSecurityScopedResource];
NSData *data = [NSData dataWithContentsOfURL:url];
if (access) [url stopAccessingSecurityScopedResource];
if (!data) {
[SCISettingsBackup showError:@"Could not read file."];
return;
}
[SCISettingsBackup presentApplyConfirmationForData:data];
}
- (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {
self.expectingExportPick = NO;
}
@end
#pragma mark - SCISettingsBackup
@implementation SCISettingsBackup
#pragma mark Key discovery
// Extra NSUserDefaults keys that aren't surfaced through a settings cell but
// still need to round-trip via export/import (lists, structured data, etc.).
+ (NSArray<NSString *> *)extraDataKeys {
return @[
@"excluded_threads",
@"included_threads",
@"excluded_story_users",
@"included_story_users",
@"embed_custom_domains",
];
}
+ (NSSet<NSString *> *)allPrefKeys {
NSMutableSet *keys = [NSMutableSet set];
[self collectKeysFromSections:[SCITweakSettings sections] into:keys];
[keys addObjectsFromArray:[self extraDataKeys]];
return keys;
}
+ (void)collectKeysFromSections:(NSArray *)sections into:(NSMutableSet *)keys {
for (id section in sections) {
if (![section isKindOfClass:[NSDictionary class]]) continue;
NSArray *rows = ((NSDictionary *)section)[@"rows"];
for (id row in rows) {
if (![row isKindOfClass:[SCISetting class]]) continue;
SCISetting *s = row;
if (s.defaultsKey.length) [keys addObject:s.defaultsKey];
if (s.baseMenu) [self collectKeysFromMenu:s.baseMenu into:keys];
if (s.navSections) [self collectKeysFromSections:s.navSections into:keys];
}
}
}
+ (void)collectKeysFromMenu:(UIMenu *)menu into:(NSMutableSet *)keys {
for (id child in menu.children) {
if ([child isKindOfClass:[UIMenu class]]) {
[self collectKeysFromMenu:child into:keys];
} else if ([child isKindOfClass:[UICommand class]]) {
id pl = [(UICommand *)child propertyList];
if ([pl isKindOfClass:[NSDictionary class]]) {
NSString *k = ((NSDictionary *)pl)[@"defaultsKey"];
if ([k isKindOfClass:[NSString class]] && k.length) [keys addObject:k];
}
}
}
}
#pragma mark Snapshot / serialize / apply
+ (NSDictionary *)snapshotCurrentSettings {
NSMutableDictionary *out = [NSMutableDictionary dictionary];
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
for (NSString *key in [self allPrefKeys]) {
id v = [d objectForKey:key];
if (v && [NSJSONSerialization isValidJSONObject:@{@"v": v}]) {
out[key] = v;
}
}
return out;
}
+ (NSData *)serializeSettings:(NSDictionary *)settings {
NSError *err = nil;
NSData *data = [NSJSONSerialization dataWithJSONObject:(settings ?: @{})
options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys
error:&err];
if (err) NSLog(@"[SCInsta] backup: serialize failed: %@", err);
return data;
}
+ (NSDictionary *)parseSettingsFromData:(NSData *)data {
if (!data) return nil;
NSError *err = nil;
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:&err];
if (err || ![obj isKindOfClass:[NSDictionary class]]) return nil;
NSDictionary *root = obj;
NSDictionary *settings = root[@"settings"];
if ([settings isKindOfClass:[NSDictionary class]]) return settings;
return root;
}
+ (void)applySettings:(NSDictionary *)settings {
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
NSSet *known = [self allPrefKeys];
for (NSString *key in known) [d removeObjectForKey:key];
for (NSString *key in settings) {
if ([known containsObject:key]) {
[d setObject:settings[key] forKey:key];
}
}
[d synchronize];
}
#pragma mark Helpers
+ (NSString *)timestampString {
NSDateFormatter *fmt = [[NSDateFormatter alloc] init];
fmt.dateFormat = @"yyyyMMdd-HHmmss";
return [fmt stringFromDate:[NSDate date]];
}
+ (NSString *)prettyJSONForSettings:(NSDictionary *)settings {
NSData *d = [self serializeSettings:settings];
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 ? @"On" : @"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 ? @"On" : @"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];
[fb notificationOccurred:UINotificationFeedbackTypeSuccess];
UIView *host = nil;
for (UIWindow *w in [UIApplication sharedApplication].windows) {
if (w.isKeyWindow) { host = w; break; }
}
if (!host) host = topMostController().view;
if (!host) return;
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
HUD.textLabel.text = message;
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
[HUD showInView:host];
[HUD dismissAfterDelay:1.5];
}
+ (void)showError:(NSString *)message {
UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Import failed"
message:message
preferredStyle:UIAlertControllerStyleAlert];
[a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]];
[topMostController() presentViewController:a animated:YES completion:nil];
}
#pragma mark Export
+ (void)presentExport {
NSDictionary *snap = [self snapshotCurrentSettings];
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = @"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:@"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];
};
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
nav.modalPresentationStyle = UIModalPresentationFormSheet;
[topMostController() presentViewController:nav animated:YES completion:nil];
}
#pragma mark Import
+ (void)presentImport {
[self pickFromFiles];
}
+ (void)pickFromFiles {
UIDocumentPickerViewController *p =
[[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"public.json", @"public.text", @"public.data"]
inMode:UIDocumentPickerModeImport];
p.delegate = [SCIBackupHelper shared];
p.allowsMultipleSelection = NO;
[topMostController() presentViewController:p animated:YES completion:nil];
}
+ (void)presentApplyConfirmationForData:(NSData *)data {
NSDictionary *settings = [self parseSettingsFromData:data];
if (!settings) {
[self showError:@"File is not a valid RyukGram settings export."];
return;
}
SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init];
vc.title = @"Import preview";
vc.mutableSettings = [settings mutableCopy];
vc.primaryActionTitle = @"Apply";
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
UIAlertController *confirm =
[UIAlertController alertControllerWithTitle:@"Apply imported settings?"
message:@"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];
[confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[confirm addAction:[UIAlertAction actionWithTitle:@"Apply" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCISettingsBackup applySettings:previewVC.mutableSettings];
[previewVC dismissViewControllerAnimated:YES completion:^{
[SCISettingsBackup showSuccessHUD:@"Settings imported"];
[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];
}
@end
+114 -9
View File
@@ -2,12 +2,16 @@
static char rowStaticRef[] = "row";
@interface SCISettingsViewController () <UITableViewDataSource, UITableViewDelegate>
@interface SCISettingsViewController () <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating>
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, copy) NSArray *sections;
@property (nonatomic) BOOL reduceMargin;
@property (nonatomic, strong) UISearchController *searchController;
@property (nonatomic, copy) NSArray<NSDictionary *> *searchResults;
@property (nonatomic) BOOL isRoot;
@end
///
@@ -20,6 +24,7 @@ static char rowStaticRef[] = "row";
if (self) {
self.title = title;
self.reduceMargin = reduceMargin;
self.isRoot = reduceMargin; // root call uses reduceMargin=YES
// Exclude development cells from release builds
NSMutableArray *mutableSections = [sections mutableCopy];
@@ -64,6 +69,96 @@ static char rowStaticRef[] = "row";
self.tableView.delegate = self;
[self.view addSubview:self.tableView];
if (self.isRoot) {
UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil];
sc.searchResultsUpdater = self;
sc.obscuresBackgroundDuringPresentation = NO;
sc.searchBar.placeholder = @"Search settings";
self.navigationItem.searchController = sc;
self.navigationItem.hidesSearchBarWhenScrolling = NO;
self.searchController = sc;
self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc]
initWithBarButtonSystemItem:UIBarButtonSystemItemClose
target:self action:@selector(sciDismissSettings)];
}
}
- (void)sciDismissSettings {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)viewWillAppear:(BOOL)animated {
[super viewWillAppear:animated];
[self.tableView reloadData];
}
#pragma mark - Search
- (BOOL)isSearching {
return self.searchController.isActive && self.searchController.searchBar.text.length > 0;
}
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
NSString *q = searchController.searchBar.text ?: @"";
if (q.length == 0) {
self.searchResults = @[];
} else {
NSMutableArray *out = [NSMutableArray array];
[self collectMatchingFromSections:self.sections breadcrumb:@"" query:q into:out];
self.searchResults = out;
}
[self.tableView reloadData];
}
- (void)collectMatchingFromSections:(NSArray *)sections
breadcrumb:(NSString *)breadcrumb
query:(NSString *)q
into:(NSMutableArray *)out
{
for (id sectionObj in sections) {
if (![sectionObj isKindOfClass:[NSDictionary class]]) continue;
NSDictionary *section = sectionObj;
NSString *header = section[@"header"] ?: @"";
NSArray *rows = section[@"rows"];
for (id rowObj in rows) {
if (![rowObj isKindOfClass:[SCISetting class]]) continue;
SCISetting *row = rowObj;
NSString *titleHay = row.title ?: @"";
NSString *subHay = row.subtitle ?: @"";
BOOL matches = [titleHay rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound
|| [subHay rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound;
if (matches) {
NSMutableString *crumb = [NSMutableString string];
if (breadcrumb.length) [crumb appendString:breadcrumb];
if (header.length) {
if (crumb.length) [crumb appendString:@" "];
[crumb appendString:header];
}
[out addObject:@{ @"setting": row, @"breadcrumb": crumb ?: @"" }];
}
if (row.navSections) {
NSString *child = breadcrumb.length
? [NSString stringWithFormat:@"%@ %@", breadcrumb, row.title ?: @""]
: (row.title ?: @"");
[self collectMatchingFromSections:row.navSections breadcrumb:child query:q into:out];
}
}
}
}
- (SCISetting *)settingForIndexPath:(NSIndexPath *)indexPath breadcrumbOut:(NSString **)outCrumb {
if ([self isSearching]) {
if (indexPath.row >= (NSInteger)self.searchResults.count) return nil;
NSDictionary *entry = self.searchResults[indexPath.row];
if (outCrumb) *outCrumb = entry[@"breadcrumb"];
return entry[@"setting"];
}
return self.sections[indexPath.section][@"rows"][indexPath.row];
}
- (void)viewWillDisappear:(BOOL)animated {
@@ -89,17 +184,19 @@ static char rowStaticRef[] = "row";
// MARK: - UITableViewDataSource
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
SCISetting *row = self.sections[indexPath.section][@"rows"][indexPath.row];
NSString *searchBreadcrumb = nil;
SCISetting *row = [self settingForIndexPath:indexPath breadcrumbOut:&searchBreadcrumb];
if (!row) return nil;
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
UIListContentConfiguration *cellContentConfig = cell.defaultContentConfiguration;
cellContentConfig.text = row.title;
// Subtitle
if (row.subtitle.length) {
cellContentConfig.secondaryText = row.subtitle;
cellContentConfig.text = row.dynamicTitle ? row.dynamicTitle() : row.title;
// While searching, show the breadcrumb path instead of the row subtitle.
NSString *displaySubtitle = [self isSearching] && searchBreadcrumb.length ? searchBreadcrumb : row.subtitle;
if (displaySubtitle.length) {
cellContentConfig.secondaryText = displaySubtitle;
cellContentConfig.textToSecondaryTextVerticalPadding = 4.5;
}
@@ -209,25 +306,32 @@ static char rowStaticRef[] = "row";
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
if ([self isSearching]) return self.searchResults.count;
return [self.sections[section][@"rows"] count];
}
- (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section {
if ([self isSearching]) {
NSUInteger n = self.searchResults.count;
return n ? [NSString stringWithFormat:@"%lu result%@", (unsigned long)n, n == 1 ? @"" : @"s"] : @"No results";
}
return self.sections[section][@"header"];
}
- (NSString *)tableView:(UITableView *)tableView titleForFooterInSection:(NSInteger)section {
if ([self isSearching]) return nil;
return self.sections[section][@"footer"];
}
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView {
if ([self isSearching]) return 1;
return self.sections.count;
}
// MARK: - UITableViewDelegate
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
SCISetting *row = self.sections[indexPath.section][@"rows"][indexPath.row];
SCISetting *row = [self settingForIndexPath:indexPath breadcrumbOut:NULL];
if (!row) return;
if (row.type == SCITableCellLink) {
@@ -282,7 +386,8 @@ static char rowStaticRef[] = "row";
NSLog(@"Menu changed: %@", command.propertyList[@"value"]);
[self reloadCellForView:command.sender animated:YES];
[self.tableView reloadData];
if (properties[@"requiresRestart"]) {
[SCIUtils showRestartConfirmation];
}
+321 -44
View File
@@ -1,4 +1,10 @@
#import "TweakSettings.h"
#import "SCISettingsBackup.h"
#import "SCIExcludedChatsViewController.h"
#import "../Features/StoriesAndMessages/SCIExcludedThreads.h"
#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h"
#import "SCIExcludedStoryUsersViewController.h"
#import "SCIEmbedDomainViewController.h"
@implementation SCITweakSettings
@@ -18,7 +24,7 @@
@{
@"header": @"",
@"rows": @[
[SCISetting linkCellWithTitle:@"Donate" subtitle:@"Consider donating to support this tweak's development!" icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"]
[SCISetting linkCellWithTitle:@"RyukGram on GitHub" subtitle:[NSString stringWithFormat:@"%@ — view source, report issues, see releases", SCIVersionString] imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled/RyukGram"]
]
},
@{
@@ -32,12 +38,49 @@
@"rows": @[
[SCISetting switchCellWithTitle:@"Hide ads" subtitle:@"Removes all ads from the Instagram app" defaultsKey:@"hide_ads"],
[SCISetting switchCellWithTitle:@"Hide Meta AI" subtitle:@"Hides the meta ai buttons/functionality within the app" defaultsKey:@"hide_meta_ai"],
[SCISetting switchCellWithTitle:@"Copy description" subtitle:@"Copy description text fields by long-pressing on them" defaultsKey:@"copy_description"],
[SCISetting switchCellWithTitle:@"Do not save recent searches" subtitle:@"Search bars will no longer save your recent searches" defaultsKey:@"no_recent_searches"],
[SCISetting switchCellWithTitle:@"Copy description" subtitle:@"Copy description text fields by long-pressing on them" defaultsKey:@"copy_description"],
[SCISetting switchCellWithTitle:@"Profile copy button" subtitle:@"Adds a button next to the burger menu on profiles to copy username, name or bio" defaultsKey:@"profile_copy_button"],
[SCISetting switchCellWithTitle:@"Use detailed color picker" subtitle:@"Long press on the eyedropper tool in stories to customize the text color more precisely" defaultsKey:@"detailed_color_picker"],
[SCISetting switchCellWithTitle:@"Enable liquid glass buttons" subtitle:@"Enables experimental liquid glass buttons within the app" defaultsKey:@"liquid_glass_buttons" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Enable liquid glass surfaces" subtitle:@"Enables liquid glass for other elements, such as menus" defaultsKey:@"liquid_glass_surfaces" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Enable teen app icons" subtitle:@"When enabled, hold down on the Instagram logo to change the app icon" defaultsKey:@"teen_app_icons" requiresRestart:YES]
]
},
@{
@"header": @"Browser",
@"rows": @[
[SCISetting switchCellWithTitle:@"Open links in external browser" subtitle:@"Opens links in Safari instead of Instagram's in-app browser" defaultsKey:@"open_links_external"],
[SCISetting switchCellWithTitle:@"Strip tracking from links" subtitle:@"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" defaultsKey:@"strip_browser_tracking"],
]
},
@{
@"header": @"Sharing",
@"rows": @[
[SCISetting switchCellWithTitle:@"Replace domain in shared links" subtitle:@"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." defaultsKey:@"embed_links"],
({
SCISetting *s = [SCISetting buttonCellWithTitle:@"Embed domain"
subtitle:@""
icon:[SCISymbol symbolWithName:@"globe"]
action:^(void) {
UIWindow *win = nil;
for (UIWindow *w in [UIApplication sharedApplication].windows)
if (w.isKeyWindow) { win = w; break; }
UIViewController *top = win.rootViewController;
while (top.presentedViewController) top = top.presentedViewController;
if ([top isKindOfClass:[UINavigationController class]])
[(UINavigationController *)top pushViewController:[SCIEmbedDomainViewController new] animated:YES];
else if (top.navigationController)
[top.navigationController pushViewController:[SCIEmbedDomainViewController new] animated:YES];
}];
s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Embed domain: %@", [SCIUtils getStringPref:@"embed_link_domain"] ?: @"kkinstagram.com"]; };
s;
}),
[SCISetting switchCellWithTitle:@"Strip tracking params" subtitle:@"Removes igsh, utm_source, and other tracking parameters from shared links" defaultsKey:@"strip_tracking_params"],
]
},
@{
@"header": @"Comments",
@"rows": @[
[SCISetting switchCellWithTitle:@"Copy comment text" subtitle:@"Adds a copy option to the comment long-press menu" defaultsKey:@"copy_comment"],
[SCISetting switchCellWithTitle:@"Download GIF comments" subtitle:@"Adds a download option for GIF comments" defaultsKey:@"download_gif_comment"],
]
},
@{
@@ -57,6 +100,15 @@
[SCISetting switchCellWithTitle:@"Hide explore posts grid" subtitle:@"Hides the grid of suggested posts on the explore/search tab" defaultsKey:@"hide_explore_grid"],
[SCISetting switchCellWithTitle:@"Hide trending searches" subtitle:@"Hides the trending searches under the explore search bar" defaultsKey:@"hide_trending_searches"],
]
},
@{
@"header": @"Experimental features",
@"footer": @"These features rely on hidden Instagram flags and may not work on all accounts or versions.",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable liquid glass buttons" subtitle:@"Enables experimental liquid glass buttons" defaultsKey:@"liquid_glass_buttons" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Enable liquid glass surfaces" subtitle:@"Enables liquid glass for other elements" defaultsKey:@"liquid_glass_surfaces" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Enable teen app icons" subtitle:@"Hold down on the Instagram logo to change the app icon" defaultsKey:@"teen_app_icons" requiresRestart:YES]
]
}]
],
[SCISetting navigationCellWithTitle:@"Feed"
@@ -71,7 +123,8 @@
[SCISetting switchCellWithTitle:@"No suggested for you" subtitle:@"Hides suggested accounts for you to follow" defaultsKey:@"no_suggested_account"],
[SCISetting switchCellWithTitle:@"No suggested reels" subtitle:@"Hides suggested reels to watch" defaultsKey:@"no_suggested_reels"],
[SCISetting switchCellWithTitle:@"No suggested threads posts" subtitle:@"Hides suggested threads posts" defaultsKey:@"no_suggested_threads"],
[SCISetting switchCellWithTitle:@"Disable video autoplay" subtitle:@"Prevents videos on your feed from playing automatically" defaultsKey:@"disable_feed_autoplay"]
[SCISetting switchCellWithTitle:@"Disable video autoplay" subtitle:@"Prevents videos on your feed from playing automatically" defaultsKey:@"disable_feed_autoplay" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Hide repost button" subtitle:@"Hides the repost button on feed posts" defaultsKey:@"hide_feed_repost" requiresRestart:YES]
]
}]
],
@@ -85,13 +138,14 @@
[SCISetting switchCellWithTitle:@"Always show progress scrubber" subtitle:@"Forces the progress bar to appear on every reel" defaultsKey:@"reels_show_scrubber"],
[SCISetting switchCellWithTitle:@"Disable auto-unmuting reels" subtitle:@"Prevents reels from unmuting when the volume/silent button is pressed" defaultsKey:@"disable_auto_unmuting_reels" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Confirm reel refresh" subtitle:@"Shows an alert when you trigger a reels refresh" defaultsKey:@"refresh_reel_confirm"],
[SCISetting switchCellWithTitle:@"Unlock password-locked reels" subtitle:@"Shows buttons to reveal and auto-fill the password on locked reels" defaultsKey:@"unlock_password_reels"],
]
},
@{
@"header": @"Hiding",
@"rows": @[
[SCISetting switchCellWithTitle:@"Hide reels header" subtitle:@"Hides the top navigation bar when watching reels" defaultsKey:@"hide_reels_header"],
[SCISetting switchCellWithTitle:@"Hide reels blend button" subtitle:@"Hides the button in DMs to open a reels blend" defaultsKey:@"hide_reels_blend"]
[SCISetting switchCellWithTitle:@"Hide repost button" subtitle:@"Hides the repost button on the reels sidebar" defaultsKey:@"hide_reels_repost" requiresRestart:YES]
]
},
@{
@@ -112,15 +166,18 @@
[SCISetting switchCellWithTitle:@"Download feed posts" subtitle:@"Long-press with finger(s) to download posts in the home tab" defaultsKey:@"dw_feed_posts"],
[SCISetting switchCellWithTitle:@"Download reels" subtitle:@"Long-press with finger(s) on a reel to download" defaultsKey:@"dw_reels"],
[SCISetting switchCellWithTitle:@"Download stories" subtitle:@"Long-press with finger(s) while viewing someone's story to download" defaultsKey:@"dw_story"],
[SCISetting switchCellWithTitle:@"Save profile picture" subtitle:@"On someone's profile, click their profile picture to enlarge it, then hold to download" defaultsKey:@"save_profile"]
[SCISetting switchCellWithTitle:@"Save profile picture" subtitle:@"On someone's profile, click their profile picture to enlarge it, then hold to download" defaultsKey:@"save_profile"],
[SCISetting switchCellWithTitle:@"Download highlight cover" subtitle:@"Adds a download option to the highlight long-press menu on profiles" defaultsKey:@"download_highlight_cover"]
]
},
@{
@"header": @"Download method",
@"footer": @"When \"Save to RyukGram album\" is on, downloads and share-sheet \"Save to Photos\" picks are routed into a dedicated \"RyukGram\" album in your Photos library.",
@"rows": @[
[SCISetting menuCellWithTitle:@"Download method" subtitle:@"How to trigger downloads" menu:[self menus][@"dw_method"]],
[SCISetting menuCellWithTitle:@"Save action" subtitle:@"What happens after downloading" menu:[self menus][@"dw_save_action"]],
[SCISetting switchCellWithTitle:@"Confirm before download" subtitle:@"Show a confirmation dialog before starting a download" defaultsKey:@"dw_confirm"]
[SCISetting switchCellWithTitle:@"Confirm before download" subtitle:@"Show a confirmation dialog before starting a download" defaultsKey:@"dw_confirm"],
[SCISetting switchCellWithTitle:@"Save to RyukGram album" subtitle:@"Route saves into a dedicated album in Photos instead of the camera roll root" defaultsKey:@"save_to_ryukgram_album"]
]
},
@{
@@ -132,27 +189,157 @@
]
}]
],
[SCISetting navigationCellWithTitle:@"Stories and messages"
[SCISetting navigationCellWithTitle:@"Stories"
subtitle:@""
icon:[SCISymbol symbolWithName:@"rectangle.portrait.on.rectangle.portrait.angled"]
icon:[SCISymbol symbolWithName:@"circle.dashed"]
navSections:@[@{
@"header": @"Messages",
@"header": @"Seen receipts",
@"rows": @[
[SCISetting switchCellWithTitle:@"Keep deleted messages" subtitle:@"Saves deleted messages in chat conversations" defaultsKey:@"keep_deleted_message"],
[SCISetting switchCellWithTitle:@"Manually mark messages as seen" subtitle:@"Adds a button to DM threads, which will mark messages as seen" defaultsKey:@"remove_lastseen"],
[SCISetting switchCellWithTitle:@"Disable typing status" subtitle:@"Prevents the typing indicator from being shown to others when you're typing in DMs" defaultsKey:@"disable_typing_status"],
[SCISetting switchCellWithTitle:@"Disable story seen receipt" subtitle:@"Hides the notification for others when you view their story" defaultsKey:@"no_seen_receipt"],
[SCISetting switchCellWithTitle:@"Keep stories visually unseen" subtitle:@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" defaultsKey:@"no_seen_visual"],
[SCISetting switchCellWithTitle:@"Mark seen on story like" subtitle:@"Marks a story as seen the moment you tap the heart, even with seen blocking on" defaultsKey:@"seen_on_story_like"],
[SCISetting menuCellWithTitle:@"Manual seen button mode" subtitle:@"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" menu:[self menus][@"story_seen_mode"]],
]
},
@{
@"header": @"Visual messages & stories",
@"header": @"Playback",
@"rows": @[
[SCISetting switchCellWithTitle:@"Stop story auto-advance" subtitle:@"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" defaultsKey:@"stop_story_auto_advance"],
[SCISetting switchCellWithTitle:@"Advance when marking as seen" subtitle:@"Tapping the eye button to mark a story as seen advances to the next story automatically" defaultsKey:@"advance_on_mark_seen"],
[SCISetting switchCellWithTitle:@"Advance on story like" subtitle:@"Liking a story automatically advances to the next one after a short delay" defaultsKey:@"advance_on_story_like"],
]
},
@{
@"header": @"Story user list",
@"footer": @"Block all: all stories blocked — listed users are exceptions.\nBlock selected: only listed users are blocked — everything else is normal.\nBoth lists are saved independently.",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable story user list" subtitle:@"Master toggle. When off, the list is ignored" defaultsKey:@"enable_story_user_exclusions"],
[SCISetting menuCellWithTitle:@"Blocking mode" subtitle:@"Which stories get seen-receipt blocking" menu:[self menus][@"story_blocking_mode"]],
[SCISetting switchCellWithTitle:@"Quick list button in stories" subtitle:@"Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only" defaultsKey:@"story_excluded_show_unexclude_eye"],
({
SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list"
subtitle:@"Search, sort, swipe to remove"
icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"]
action:^(void) {
UIWindow *win = nil;
for (UIWindow *w in [UIApplication sharedApplication].windows) {
if (w.isKeyWindow) { win = w; break; }
}
UIViewController *top = win.rootViewController;
while (top.presentedViewController) top = top.presentedViewController;
if ([top isKindOfClass:[UINavigationController class]]) {
[(UINavigationController *)top pushViewController:[SCIExcludedStoryUsersViewController new] animated:YES];
} else if (top.navigationController) {
[top.navigationController pushViewController:[SCIExcludedStoryUsersViewController new] animated:YES];
}
}];
s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedStoryUsers count]]; };
s;
}),
]
},
@{
@"header": @"Audio",
@"rows": @[
[SCISetting switchCellWithTitle:@"Story audio toggle" subtitle:@"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu" defaultsKey:@"story_audio_toggle"],
]
},
@{
@"header": @"Other",
@"rows": @[
[SCISetting switchCellWithTitle:@"Disable instants creation" subtitle:@"Hides the functionality to create/send instants" defaultsKey:@"disable_instants_creation" requiresRestart:YES]
]
}]
],
[SCISetting navigationCellWithTitle:@"Messages"
subtitle:@""
icon:[SCISymbol symbolWithName:@"bubble.left.and.bubble.right"]
navSections:@[@{
@"header": @"Threads",
@"rows": @[
[SCISetting navigationCellWithTitle:@"Keep deleted messages"
subtitle:@"Preserve messages that others unsend"
icon:nil
navSections:@[@{
@"header": @"",
@"footer": @"⚠️ WARNING: Pull-to-refresh in the DMs tab CLEARS all preserved messages. Enable \"Warn before clearing on refresh\" below to get a confirmation dialog before this happens.",
@"rows": @[
[SCISetting switchCellWithTitle:@"Keep deleted messages" subtitle:@"Preserves messages that others unsend" defaultsKey:@"keep_deleted_message"],
[SCISetting switchCellWithTitle:@"Indicate unsent messages" subtitle:@"Shows an \"Unsent\" label on preserved messages" defaultsKey:@"indicate_unsent_messages"],
[SCISetting switchCellWithTitle:@"Unsent message notification" subtitle:@"Shows a notification pill when a message is unsent" defaultsKey:@"unsent_message_toast"],
[SCISetting switchCellWithTitle:@"Warn before clearing on refresh" subtitle:@"Show a confirmation dialog when pulling to refresh the DMs tab if preserved messages would be cleared" defaultsKey:@"warn_refresh_clears_preserved"],
]
}]
],
[SCISetting navigationCellWithTitle:@"Read receipts"
subtitle:@"Control when messages are marked as seen"
icon:nil
navSections:@[@{
@"header": @"",
@"rows": @[
[SCISetting switchCellWithTitle:@"Manually mark messages as seen" subtitle:@"Adds a button to DM threads to mark messages as seen" defaultsKey:@"remove_lastseen"],
[SCISetting menuCellWithTitle:@"Read receipt mode" subtitle:@"How the seen button behaves" menu:[self menus][@"seen_mode"]],
[SCISetting switchCellWithTitle:@"Auto mark seen on interact" subtitle:@"Locally marks messages as seen when you send any message" defaultsKey:@"seen_auto_on_interact"],
[SCISetting switchCellWithTitle:@"Auto mark seen on typing" subtitle:@"Marks messages as seen the moment you start typing in a DM (works even when typing status is hidden)" defaultsKey:@"seen_auto_on_typing"],
]
}]
],
[SCISetting switchCellWithTitle:@"Disable typing status" subtitle:@"Prevents the typing indicator from being shown to others when you're typing in DMs" defaultsKey:@"disable_typing_status"],
[SCISetting switchCellWithTitle:@"Hide reels blend button" subtitle:@"Hides the button in DMs to open a reels blend" defaultsKey:@"hide_reels_blend"],
]
},
@{
@"header": @"Chat list",
@"footer": @"Block all: all chats blocked — listed chats are exceptions.\nBlock selected: only listed chats are blocked — everything else is normal.\nBoth lists are saved independently. Long-press a chat in the inbox to add or remove.",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable chat list" subtitle:@"Master toggle. When off, the list is ignored" defaultsKey:@"enable_chat_exclusions"],
[SCISetting menuCellWithTitle:@"Blocking mode" subtitle:@"Which chats get read-receipt blocking" menu:[self menus][@"chat_blocking_mode"]],
({
SCISetting *s = [SCISetting switchCellWithTitle:@"" subtitle:@"" defaultsKey:@"exclusions_default_keep_deleted"];
s.dynamicTitle = ^{
BOOL bs = [[SCIUtils getStringPref:@"chat_blocking_mode"] isEqualToString:@"block_selected"];
return bs ? @"Block keep-deleted for unlisted chats"
: @"Block keep-deleted for excluded chats";
};
s.subtitle = @"Each chat can override this in the list";
s;
}),
[SCISetting switchCellWithTitle:@"Quick list button in chats" subtitle:@"Shows a button in DM threads to add/remove chats from the list. Long-press for more options" defaultsKey:@"chat_quick_list_button"],
({
SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list"
subtitle:@"Search, sort, swipe to remove or toggle keep-deleted"
icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"]
action:^(void) {
UIWindow *win = nil;
for (UIWindow *w in [UIApplication sharedApplication].windows) {
if (w.isKeyWindow) { win = w; break; }
}
UIViewController *top = win.rootViewController;
while (top.presentedViewController) top = top.presentedViewController;
if ([top isKindOfClass:[UINavigationController class]]) {
[(UINavigationController *)top pushViewController:[SCIExcludedChatsViewController new] animated:YES];
} else if (top.navigationController) {
[top.navigationController pushViewController:[SCIExcludedChatsViewController new] animated:YES];
}
}];
s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedThreads count]]; };
s;
}),
]
},
@{
@"header": @"Voice messages",
@"rows": @[
[SCISetting switchCellWithTitle:@"Send audio as file" subtitle:@"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages" defaultsKey:@"send_audio_as_file"],
[SCISetting switchCellWithTitle:@"Download voice messages" subtitle:@"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" defaultsKey:@"download_audio_message"],
]
},
@{
@"header": @"Visual messages",
@"rows": @[
[SCISetting switchCellWithTitle:@"Unlimited replay of visual messages" subtitle:@"Replays direct visual messages normal/once stories unlimited times (toggle with image check icon)" defaultsKey:@"unlimited_replay"],
[SCISetting switchCellWithTitle:@"Disable view-once limitations" subtitle:@"Makes view-once messages behave like normal visual messages (loopable/pauseable)" defaultsKey:@"disable_view_once_limitations"],
[SCISetting switchCellWithTitle:@"Disable screenshot detection" subtitle:@"Removes the screenshot-prevention features for visual messages in DMs" defaultsKey:@"remove_screenshot_alert"],
[SCISetting switchCellWithTitle:@"Disable story seen receipt" subtitle:@"Hides the notification for others when you view their story" defaultsKey:@"no_seen_receipt"],
[SCISetting switchCellWithTitle:@"Keep stories visually unseen" subtitle:@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" defaultsKey:@"no_seen_visual"],
[SCISetting switchCellWithTitle:@"Stop story auto-advance" subtitle:@"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" defaultsKey:@"stop_story_auto_advance"],
[SCISetting switchCellWithTitle:@"Disable instants creation" subtitle:@"Hides the functionality to create/send instants" defaultsKey:@"disable_instants_creation" requiresRestart:YES]
]
}]
],
@@ -206,6 +393,26 @@
@{
@"header": @"",
@"rows": @[
[SCISetting navigationCellWithTitle:@"Backup & Restore"
subtitle:@""
icon:[SCISymbol symbolWithName:@"arrow.up.arrow.down.square"]
navSections:@[@{
@"header": @"",
@"footer": @"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.",
@"rows": @[
[SCISetting buttonCellWithTitle:@"Export settings"
subtitle:@"Save settings as a JSON file"
icon:[SCISymbol symbolWithName:@"square.and.arrow.up"]
action:^(void) { [SCISettingsBackup presentExport]; }
],
[SCISetting buttonCellWithTitle:@"Import settings"
subtitle:@"Load settings from a JSON file"
icon:[SCISymbol symbolWithName:@"square.and.arrow.down"]
action:^(void) { [SCISettingsBackup presentImport]; }
]
]
}]
],
// [SCISetting navigationCellWithTitle:@"Experimental"
// subtitle:@""
// icon:[SCISymbol symbolWithName:@"testtube.2"]
@@ -221,33 +428,38 @@
// }
// ]
// ],
[SCISetting navigationCellWithTitle:@"Advanced"
subtitle:@""
icon:[SCISymbol symbolWithName:@"gearshape.2"]
navSections:@[@{
@"header": @"Settings",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable tweak settings quick-access" subtitle:@"Hold on the home tab to open RyukGram settings" defaultsKey:@"settings_shortcut" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Show tweak settings on app launch" subtitle:@"Automatically opens settings when the app launches" defaultsKey:@"tweak_settings_app_launch"],
[SCISetting switchCellWithTitle:@"Pause playback when opening settings" subtitle:@"Pauses any playing video/audio when settings opens" defaultsKey:@"settings_pause_playback"],
]
},
@{
@"header": @"Instagram",
@"rows": @[
[SCISetting switchCellWithTitle:@"Disable safe mode" subtitle:@"Prevents Instagram from resetting settings after crashes (at your own risk)" defaultsKey:@"disable_safe_mode"],
[SCISetting buttonCellWithTitle:@"Reset onboarding state"
subtitle:@""
icon:nil
action:^(void) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"SCInstaFirstRun"]; [SCIUtils showRestartConfirmation];}
],
]
}]
],
[SCISetting navigationCellWithTitle:@"Debug"
subtitle:@""
icon:[SCISymbol symbolWithName:@"ladybug"]
navSections:@[@{
@"header": @"FLEX",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable FLEX gesture" subtitle:@"Allows you to hold 5 fingers on the screen to open the FLEX explorer" defaultsKey:@"flex_instagram"],
[SCISetting switchCellWithTitle:@"Open FLEX on app launch" subtitle:@"Automatically opens the FLEX explorer when the app launches" defaultsKey:@"flex_app_launch"],
[SCISetting switchCellWithTitle:@"Open FLEX on app focus" subtitle:@"Automatically opens the FLEX explorer when the app is focused" defaultsKey:@"flex_app_start"]
]
},
@{
@"header": @"RyukGram",
@"rows": @[
[SCISetting switchCellWithTitle:@"Enable tweak settings quick-access" subtitle:@"Allows you to hold on the home tab to open the RyukGram settings" defaultsKey:@"settings_shortcut" requiresRestart:YES],
[SCISetting switchCellWithTitle:@"Show tweak settings on app launch" subtitle:@"Automatically opens the RyukGram settings when the app launches" defaultsKey:@"tweak_settings_app_launch"],
[SCISetting buttonCellWithTitle:@"Reset onboarding completion state"
subtitle:@""
icon:nil
action:^(void) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"SCInstaFirstRun"]; [SCIUtils showRestartConfirmation];}
],
]
},
@{
@"header": @"Instagram",
@"rows": @[
[SCISetting switchCellWithTitle:@"Disable safe mode" subtitle:@"Makes Instagram not reset settings after subsequent crashes (at your own risk)" defaultsKey:@"disable_safe_mode"]
[SCISetting switchCellWithTitle:@"Enable FLEX gesture" subtitle:@"Hold 5 fingers on the screen to open FLEX" defaultsKey:@"flex_instagram"],
[SCISetting switchCellWithTitle:@"Open FLEX on app launch" subtitle:@"Opens FLEX when the app launches" defaultsKey:@"flex_app_launch"],
[SCISetting switchCellWithTitle:@"Open FLEX on app focus" subtitle:@"Opens FLEX when the app is focused" defaultsKey:@"flex_app_start"]
]
},
@{
@@ -283,9 +495,10 @@
@{
@"header": @"Credits",
@"rows": @[
[SCISetting linkCellWithTitle:@"Original Developer" subtitle:@"SoCuul (SCInsta)" imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://github.com/SoCuul/SCInsta"],
[SCISetting linkCellWithTitle:@"Modded by" subtitle:@"Ryuk" imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled"],
[SCISetting linkCellWithTitle:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"]
[SCISetting linkCellWithTitle:@"Ryuk" subtitle:@"Developer" imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled"],
[SCISetting linkCellWithTitle:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"],
[SCISetting linkCellWithTitle:@"SoCuul" subtitle:@"Original SCInsta developer" imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://github.com/SoCuul/SCInsta"],
[SCISetting linkCellWithTitle:@"Donate to SoCuul" subtitle:@"Support the original developer" icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"]
],
@"footer": [NSString stringWithFormat:@"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul", SCIVersionString, [SCIUtils IGVersionString]]
}
@@ -322,6 +535,70 @@
+ (NSDictionary *)menus {
return @{
@"chat_blocking_mode": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Block all"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"chat_blocking_mode", @"value": @"block_all" }
],
[UICommand commandWithTitle:@"Block selected"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"chat_blocking_mode", @"value": @"block_selected" }
]
]],
@"story_blocking_mode": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Block all"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"story_blocking_mode", @"value": @"block_all" }
],
[UICommand commandWithTitle:@"Block selected"
image:nil
action:@selector(menuChanged:)
propertyList:@{ @"defaultsKey": @"story_blocking_mode", @"value": @"block_selected" }
]
]],
@"story_seen_mode": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Button"
image:nil
action:@selector(menuChanged:)
propertyList:@{
@"defaultsKey": @"story_seen_mode",
@"value": @"button"
}
],
[UICommand commandWithTitle:@"Toggle"
image:nil
action:@selector(menuChanged:)
propertyList:@{
@"defaultsKey": @"story_seen_mode",
@"value": @"toggle"
}
]
]],
@"seen_mode": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Button"
image:nil
action:@selector(menuChanged:)
propertyList:@{
@"defaultsKey": @"seen_mode",
@"value": @"button"
}
],
[UICommand commandWithTitle:@"Toggle"
image:nil
action:@selector(menuChanged:)
propertyList:@{
@"defaultsKey": @"seen_mode",
@"value": @"toggle"
}
]
]],
@"dw_save_action": [UIMenu menuWithChildren:@[
[UICommand commandWithTitle:@"Share sheet"
image:nil
+2 -1
View File
@@ -4,4 +4,5 @@
extern NSString *SCIVersionString;
// Variables that work across features
extern BOOL dmVisualMsgsViewedButtonEnabled; // Whether story dm unlimited views button is enabled
extern BOOL dmVisualMsgsViewedButtonEnabled; // Whether story dm unlimited views button is enabled
extern BOOL dmSeenToggleEnabled; // Whether read receipts toggle is active
+113 -17
View File
@@ -13,7 +13,7 @@
///////////////////////////////////////////////////////////
// * Tweak version *
NSString *SCIVersionString = @"v1.1.4";
NSString *SCIVersionString = @"v1.1.5.1";
// Variables that work across features
BOOL dmVisualMsgsViewedButtonEnabled = false;
@@ -25,10 +25,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
NSDictionary *sciDefaults = @{
@"hide_ads": @(YES),
@"copy_description": @(YES),
@"profile_copy_button": @(YES),
@"detailed_color_picker": @(YES),
@"remove_screenshot_alert": @(YES),
@"call_confirm": @(YES),
@"keep_deleted_message": @(YES),
@"keep_deleted_message": @(NO),
@"dw_feed_posts": @(YES),
@"dw_reels": @(YES),
@"dw_story": @(YES),
@@ -43,9 +44,42 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
@"swipe_nav_tabs": @"default",
@"enable_notes_customization": @(YES),
@"custom_note_themes": @(YES),
@"disable_auto_unmuting_reels": @(YES),
@"disable_auto_unmuting_reels": @(NO),
@"settings_shortcut": @(YES),
@"doom_scrolling_reel_count": @(1),
@"no_seen_visual": @(YES)
@"no_seen_visual": @(YES),
@"send_audio_as_file": @(YES),
@"download_audio_message": @(NO),
@"save_to_ryukgram_album": @(NO),
@"unlock_password_reels": @(YES),
@"seen_mode": @"button",
@"seen_auto_on_interact": @(NO),
@"seen_auto_on_typing": @(NO),
@"seen_on_story_like": @(NO),
@"advance_on_mark_seen": @(NO),
@"advance_on_story_like": @(NO),
@"indicate_unsent_messages": @(NO),
@"unsent_message_toast": @(NO),
@"warn_refresh_clears_preserved": @(NO),
@"enable_chat_exclusions": @(YES),
@"chat_blocking_mode": @"block_all",
@"exclusions_default_keep_deleted": @(NO),
@"chat_quick_list_button": @(YES),
@"enable_story_user_exclusions": @(YES),
@"story_blocking_mode": @"block_all",
@"story_excluded_show_unexclude_eye": @(YES),
@"story_seen_mode": @"button",
@"story_audio_toggle": @(NO),
@"settings_pause_playback": @(YES),
@"embed_links": @(NO),
@"embed_link_domain": @"kkinstagram.com",
@"strip_tracking_params": @(NO),
@"download_highlight_cover": @(YES),
@"open_links_external": @(NO),
@"strip_browser_tracking": @(NO),
@"hide_feed_repost": @(NO),
@"copy_comment": @(YES),
@"download_gif_comment": @(YES)
};
[[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults];
@@ -90,10 +124,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
- (void)applicationDidBecomeActive:(id)arg1 {
%orig;
if ([SCIUtils getBoolPref:@"flex_app_start"]) {
[[objc_getClass("FLEXManager") sharedManager] showExplorer];
}
}
%end
@@ -101,7 +136,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
- (_Bool)isLiquidGlassInAppNotificationEnabled {
return [SCIUtils liquidGlassEnabledBool:%orig];
}
- (_Bool)isLiquidGlassContextMenuEnabled{
- (_Bool)isLiquidGlassContextMenuEnabled {
return [SCIUtils liquidGlassEnabledBool:%orig];
}
- (_Bool)isLiquidGlassToastEnabled {
@@ -113,8 +148,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 +229,7 @@ shouldPersistLastBugReportId:(id)arg6
%hook IGDirectVisualMessageViewerController
- (void)screenshotObserverDidSeeScreenshotTaken:(id)arg1 { VOID_HANDLESCREENSHOT(%orig); }
- (void)screenshotObserverDidSeeActiveScreenCapture:(id)arg1 event:(NSInteger)arg2 { VOID_HANDLESCREENSHOT(%orig); }
%end
/////////////////////////////////////////////////////////////////////////////
@@ -589,7 +629,11 @@ shouldPersistLastBugReportId:(id)arg6
}
return %orig([filteredObjs copy], edr, headerLabelText);
extern NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *);
extern NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *);
NSArray *finalObjs = sciMaybeAppendStoryExcludeMenuItem([filteredObjs copy]);
finalObjs = sciMaybeAppendStoryAudioMenuItem(finalObjs);
return %orig(finalObjs, edr, headerLabelText);
}
%end
@@ -638,6 +682,19 @@ shouldPersistLastBugReportId:(id)arg6
}
%end
// Hide repost button in feed (requires restart)
%hook IGUFIInteractionCountsView
- (void)updateUFIWithButtonsConfig:(id)config interactionCountProvider:(id)provider {
%orig;
if (![SCIUtils getBoolPref:@"hide_feed_repost"]) return;
Ivar rv = class_getInstanceVariable(object_getClass(self), "_repostView");
if (rv) [object_getIvar((id)self, rv) setHidden:YES];
Ivar uv = class_getInstanceVariable(object_getClass(self), "_undoRepostButton");
if (uv) [object_getIvar((id)self, uv) setHidden:YES];
}
%end
%hook IGSundialViewerVerticalUFI
- (void)_didTapLikeButton:(id)arg1 {
if ([SCIUtils getBoolPref:@"like_confirm_reels"]) {
@@ -659,24 +716,28 @@ shouldPersistLastBugReportId:(id)arg6
}
}
- (void)_didTapRepostButton:(id)arg1 {
- (void)_didTapRepostButton {
if ([SCIUtils getBoolPref:@"hide_reels_repost"]) return;
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:@"hide_reels_repost"]) return;
if ([SCIUtils getBoolPref:@"repost_confirm"]) return;
%orig;
}
%end
// Hide repost button at the view model level so IG's layout handles the gap
%hook IGSundialViewerUFIViewModel
- (BOOL)shouldShowRepostButton {
if ([SCIUtils getBoolPref:@"hide_reels_repost"]) return NO;
return %orig;
}
%end
@@ -717,3 +778,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);
}
}
+1
View File
@@ -33,6 +33,7 @@
+ (void)showQuickLookVC:(NSArray<id> *)items;
+ (void)showShareVC:(id)item;
+ (void)showSettingsVC:(UIWindow *)window;
+ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle;
// Colours
+ (UIColor *)SCIColor_Primary;
+42 -1
View File
@@ -1,4 +1,6 @@
#import "Utils.h"
#import "PhotoAlbum.h"
#import "Settings/TweakSettings.h"
@implementation SCIUtils
@@ -99,16 +101,55 @@
acVC.popoverPresentationController.sourceView = topVC.view;
acVC.popoverPresentationController.sourceRect = CGRectMake(topVC.view.bounds.size.width / 2.0, topVC.view.bounds.size.height / 2.0, 1.0, 1.0);
}
// If the user picks "Save to Photos" from the share sheet, route the new
// asset into the RyukGram album via a one-shot photo library observer.
if ([self getBoolPref:@"save_to_ryukgram_album"]) {
[SCIPhotoAlbum watchForNextSavedAsset];
}
[topVC presentViewController:acVC animated:true completion:nil];
}
+ (void)showSettingsVC:(UIWindow *)window {
UIViewController *rootController = [window rootViewController];
SCISettingsViewController *settingsViewController = [SCISettingsViewController new];
UINavigationController *navigationController = [[UINavigationController alloc] initWithRootViewController:settingsViewController];
if ([SCIUtils getBoolPref:@"settings_pause_playback"])
navigationController.modalPresentationStyle = UIModalPresentationFullScreen;
[rootController presentViewController:navigationController animated:YES completion:nil];
}
// Open settings and push straight into a named top-level entry (e.g. "Messages").
+ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle {
UIViewController *rootController = [window rootViewController];
while (rootController.presentedViewController) rootController = rootController.presentedViewController;
SCISettingsViewController *root = [SCISettingsViewController new];
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root];
if ([SCIUtils getBoolPref:@"settings_pause_playback"])
nav.modalPresentationStyle = UIModalPresentationFullScreen;
NSArray *targetNavSections = nil;
for (NSDictionary *section in [SCITweakSettings sections]) {
for (SCISetting *row in section[@"rows"]) {
if (row.type == SCITableCellNavigation && [row.title isEqualToString:entryTitle]) {
targetNavSections = row.navSections;
break;
}
}
if (targetNavSections) break;
}
if (targetNavSections) {
SCISettingsViewController *child = [[SCISettingsViewController alloc]
initWithTitle:entryTitle sections:targetNavSections reduceMargin:NO];
child.title = entryTitle;
[nav pushViewController:child animated:NO];
}
[rootController presentViewController:nav animated:YES completion:nil];
}
// Colours
+ (UIColor *)SCIColor_Primary {
return [UIColor colorWithRed:0/255.0 green:152/255.0 blue:254/255.0 alpha:1];