diff --git a/README.md b/README.md index e2fe144..1412764 100644 --- a/README.md +++ b/README.md @@ -71,6 +71,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - 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 **\*** - 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 **\*** @@ -79,8 +80,11 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com ### Stories and messages - 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) **\*** - 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) **\*** - 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 @@ -123,7 +127,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - 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 as a workaround. +- 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). # Opening Tweak Settings diff --git a/src/Downloader/Download.m b/src/Downloader/Download.m index 91e555b..c452333 100644 --- a/src/Downloader/Download.m +++ b/src/Downloader/Download.m @@ -1,4 +1,5 @@ #import "Download.h" +#import "../PhotoAlbum.h" #import #pragma mark - SCIDownloadPillView @@ -217,30 +218,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 +235,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; } diff --git a/src/Features/StoriesAndMessages/DisableTypingStatus.x b/src/Features/StoriesAndMessages/DisableTypingStatus.x index b8e2251..15af2be 100644 --- a/src/Features/StoriesAndMessages/DisableTypingStatus.x +++ b/src/Features/StoriesAndMessages/DisableTypingStatus.x @@ -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); diff --git a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm index 8eead9a..8b53921 100644 --- a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm +++ b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm @@ -140,13 +140,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade dispatch_async(dispatch_get_main_queue(), ^{ [pill setText:@"Done!"]; [pill dismissAfterDelay:0.5]; - - UIActivityViewController *shareVC = [[UIActivityViewController alloc] - initWithActivityItems:@[finalURL] - applicationActivities:nil]; - UIViewController *top = [UIApplication sharedApplication].keyWindow.rootViewController; - while (top.presentedViewController) top = top.presentedViewController; - [top presentViewController:shareVC animated:YES completion:nil]; + [SCIUtils showShareVC:finalURL]; }); }]; }]; diff --git a/src/Features/StoriesAndMessages/InboxRefreshWarning.x b/src/Features/StoriesAndMessages/InboxRefreshWarning.x new file mode 100644 index 0000000..6529021 --- /dev/null +++ b/src/Features/StoriesAndMessages/InboxRefreshWarning.x @@ -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 +#import +#import + +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); +} diff --git a/src/Features/StoriesAndMessages/KeepDeletedMessages.x b/src/Features/StoriesAndMessages/KeepDeletedMessages.x index 550f43d..9940ebb 100644 --- a/src/Features/StoriesAndMessages/KeepDeletedMessages.x +++ b/src/Features/StoriesAndMessages/KeepDeletedMessages.x @@ -33,7 +33,7 @@ static NSMutableSet *sciPreservedIds = nil; #define SCI_PRESERVED_MAX 200 #define SCI_PRESERVED_TAG 1399 -static NSMutableSet *sciGetPreservedIds() { +NSMutableSet *sciGetPreservedIds() { if (!sciPreservedIds) { NSArray *saved = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_PRESERVED_IDS_KEY]; sciPreservedIds = saved ? [NSMutableSet setWithArray:saved] : [NSMutableSet set]; @@ -48,7 +48,7 @@ static void sciSavePreservedIds() { [[NSUserDefaults standardUserDefaults] setObject:[ids allObjects] forKey:SCI_PRESERVED_IDS_KEY]; } -static void sciClearPreservedIds() { +void sciClearPreservedIds() { [sciGetPreservedIds() removeAllObjects]; [[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY]; } diff --git a/src/Features/StoriesAndMessages/SeenButtons.x b/src/Features/StoriesAndMessages/SeenButtons.x index 50b4530..71004c8 100644 --- a/src/Features/StoriesAndMessages/SeenButtons.x +++ b/src/Features/StoriesAndMessages/SeenButtons.x @@ -12,6 +12,7 @@ BOOL dmSeenToggleEnabled = NO; static BOOL sciSeenAutoBypass = NO; +__weak IGDirectThreadViewController *sciActiveThreadVC = nil; static BOOL sciIsSeenToggleMode() { return [[SCIUtils getStringPref:@"seen_mode"] isEqualToString:@"toggle"]; @@ -21,7 +22,11 @@ static BOOL sciAutoInteractEnabled() { return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_interact"]; } -static void sciDoAutoSeen(IGDirectThreadViewController *threadVC) { +BOOL sciAutoTypingEnabled() { + return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_typing"]; +} + +void sciDoAutoSeen(IGDirectThreadViewController *threadVC) { sciSeenAutoBypass = YES; [threadVC markLastMessageAsSeen]; dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ @@ -40,6 +45,21 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) { }); } +// ============ AUTO SEEN ON TYPING ============ +// Tracks the visible thread VC so the typing-service hook (in +// DisableTypingStatus.x) can mark its messages as seen. + +%hook IGDirectThreadViewController +- (void)viewDidAppear:(BOOL)animated { + %orig; + sciActiveThreadVC = self; +} +- (void)viewWillDisappear:(BOOL)animated { + if (sciActiveThreadVC == self) sciActiveThreadVC = nil; + %orig; +} +%end + // ============ NAV BAR BUTTONS ============ %hook IGTallNavigationBarView diff --git a/src/PhotoAlbum.h b/src/PhotoAlbum.h new file mode 100644 index 0000000..6b2b838 --- /dev/null +++ b/src/PhotoAlbum.h @@ -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 +#import + +@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 diff --git a/src/PhotoAlbum.m b/src/PhotoAlbum.m new file mode 100644 index 0000000..87ddd86 --- /dev/null +++ b/src/PhotoAlbum.m @@ -0,0 +1,139 @@ +#import "PhotoAlbum.h" + +@interface SCIPhotoAlbumWatcher : NSObject +@property (nonatomic, strong) PHFetchResult *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 *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 *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 *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 diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index 6bcf520..dbc2615 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -118,10 +118,12 @@ }, @{ @"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"] ] }, @{ @@ -144,11 +146,12 @@ icon:nil navSections:@[@{ @"header": @"", - @"footer": @"Pull to refresh in the messages tab will remove preserved messages", + @"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"], ] }] ], @@ -161,6 +164,7 @@ [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"], ] }] ], diff --git a/src/Tweak.x b/src/Tweak.x index ad327ec..ba385a6 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -48,11 +48,14 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"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), @"indicate_unsent_messages": @(NO), - @"unsent_message_toast": @(NO) + @"unsent_message_toast": @(NO), + @"warn_refresh_clears_preserved": @(NO) }; [[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults]; diff --git a/src/Utils.m b/src/Utils.m index 4314f9a..1f0226b 100644 --- a/src/Utils.m +++ b/src/Utils.m @@ -1,4 +1,5 @@ #import "Utils.h" +#import "PhotoAlbum.h" @implementation SCIUtils @@ -99,6 +100,13 @@ 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 {