mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-05-01 08:37:57 +02:00
feat: Mark seen on story like
feat: Added copy button in profile page to copy various profile information feat: Added export/import settings option - With Searchable, collapsible, editable preview before saving or applying imp: Search bar in tweak settings imp: Hide custom story buttons when zooming (follows ig buttons) bug: Fix a bug in keep deleted messages marking removed reactions as unset messages
This commit is contained in:
@@ -40,3 +40,4 @@ upstream-scinsta
|
||||
*.dylib
|
||||
deploy.sh
|
||||
PENDING_CHANGES.md
|
||||
wrapper/
|
||||
|
||||
@@ -22,6 +22,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Hide ads
|
||||
- Hide Meta AI
|
||||
- Copy description
|
||||
- Profile copy button **\***
|
||||
- Do not save recent searches
|
||||
- Use detailed (native) color picker
|
||||
- Enable liquid glass buttons
|
||||
@@ -85,6 +86,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- 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) **\***
|
||||
- Mark seen on story like **\***
|
||||
- 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,6 +125,14 @@ 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
|
||||
|
||||
### Backup & Restore **\***
|
||||
- Export RyukGram settings as a JSON file or scannable QR code
|
||||
- Import settings from a file in Files or a QR code from your photo library
|
||||
- Searchable, collapsible, editable preview before saving or applying
|
||||
|
||||
### Optimization
|
||||
- Automatically clears unneeded cache folders, reducing the size of your Instagram installation
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../../modules/JGProgressHUD/JGProgressHUD.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// Profile page copy button: hooks IG's native nav header builder to insert
|
||||
// a copy button alongside IG's own buttons, then opens a menu to copy
|
||||
// username/name/bio.
|
||||
|
||||
@interface IGProfileViewController : UIViewController
|
||||
@end
|
||||
|
||||
static id sci_safeValueForKey(id obj, NSString *key) {
|
||||
@try { return [obj valueForKey:key]; }
|
||||
@catch (__unused NSException *e) { return nil; }
|
||||
}
|
||||
|
||||
static id sci_valueForAnyKey(id obj, NSArray<NSString *> *keys) {
|
||||
for (NSString *k in keys) {
|
||||
id v = sci_safeValueForKey(obj, k);
|
||||
if (v && v != [NSNull null]) return v;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id sci_findUserOnVC(UIViewController *vc) {
|
||||
id user = sci_valueForAnyKey(vc, @[@"user", @"userGQL", @"profileUser", @"loggedInUser", @"currentUser"]);
|
||||
if (user) return user;
|
||||
|
||||
Class userCls = NSClassFromString(@"IGUser");
|
||||
Class c = [vc class];
|
||||
while (c && c != [NSObject class]) {
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList(c, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
id v = object_getIvar(vc, ivars[i]);
|
||||
if (userCls && [v isKindOfClass:userCls]) {
|
||||
free(ivars);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
c = class_getSuperclass(c);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static UIViewController *sci_findProfileVC(UIView *view) {
|
||||
Class profileCls = NSClassFromString(@"IGProfileViewController");
|
||||
UIResponder *r = view;
|
||||
while (r) {
|
||||
if (profileCls && [r isKindOfClass:profileCls]) return (UIViewController *)r;
|
||||
r = [r nextResponder];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sci_copyAndToast(NSString *value, NSString *label) {
|
||||
if (value.length == 0) return;
|
||||
[UIPasteboard generalPasteboard].string = value;
|
||||
|
||||
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
|
||||
HUD.textLabel.text = [NSString stringWithFormat:@"Copied %@", label];
|
||||
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
|
||||
UIView *host = nil;
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { host = w; break; }
|
||||
}
|
||||
if (host) {
|
||||
[HUD showInView:host];
|
||||
[HUD dismissAfterDelay:1.5];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton target for the copy button so we don't have to track lifetime.
|
||||
@interface SCIProfileCopyTarget : NSObject
|
||||
+ (instancetype)shared;
|
||||
- (void)handleTap:(UIButton *)sender;
|
||||
@end
|
||||
|
||||
@implementation SCIProfileCopyTarget
|
||||
+ (instancetype)shared {
|
||||
static SCIProfileCopyTarget *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [[SCIProfileCopyTarget alloc] init]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
- (void)handleTap:(UIButton *)sender {
|
||||
UIViewController *vc = sci_findProfileVC(sender);
|
||||
if (!vc) {
|
||||
NSLog(@"[SCInsta] copy button: no IGProfileViewController in responder chain");
|
||||
return;
|
||||
}
|
||||
|
||||
id user = sci_findUserOnVC(vc);
|
||||
if (!user) {
|
||||
NSLog(@"[SCInsta] copy button: no IGUser found on %@", vc.class);
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *username = [sci_valueForAnyKey(user, @[@"username"]) description];
|
||||
NSString *fullName = [sci_valueForAnyKey(user, @[@"fullName", @"fullname", @"name"]) description];
|
||||
NSString *biography = [sci_valueForAnyKey(user, @[@"biography", @"bio", @"profileBiography"]) description];
|
||||
|
||||
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
|
||||
username, fullName, (unsigned long)biography.length);
|
||||
|
||||
UIAlertController *menu = [UIAlertController alertControllerWithTitle:@"Copy from profile"
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
|
||||
if (username.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:[NSString stringWithFormat:@"Copy username (@%@)", username]
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]];
|
||||
}
|
||||
if (fullName.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Copy name"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]];
|
||||
}
|
||||
if (biography.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Copy bio"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]];
|
||||
}
|
||||
|
||||
NSMutableArray *parts = [NSMutableArray array];
|
||||
if (username.length) [parts addObject:[NSString stringWithFormat:@"Username: @%@", username]];
|
||||
if (fullName.length) [parts addObject:[NSString stringWithFormat:@"Name: %@", fullName]];
|
||||
if (biography.length) [parts addObject:[NSString stringWithFormat:@"Bio:\n%@", biography]];
|
||||
|
||||
if (parts.count >= 2) {
|
||||
NSString *combined = [parts componentsJoinedByString:@"\n\n"];
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Copy all"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]];
|
||||
}
|
||||
|
||||
if (menu.actions.count == 0) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Nothing to copy" style:UIAlertActionStyleDefault handler:nil]];
|
||||
}
|
||||
|
||||
[menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
|
||||
if (sender) {
|
||||
menu.popoverPresentationController.sourceView = sender;
|
||||
menu.popoverPresentationController.sourceRect = sender.bounds;
|
||||
}
|
||||
|
||||
[vc presentViewController:menu animated:YES completion:nil];
|
||||
}
|
||||
@end
|
||||
|
||||
static UIView *sci_buildCopyButton(void) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
btn.accessibilityIdentifier = @"sci-profile-copy-button";
|
||||
btn.accessibilityLabel = @"Copy profile info";
|
||||
UIImageSymbolConfiguration *cfg =
|
||||
[UIImageSymbolConfiguration configurationWithPointSize:16
|
||||
weight:UIImageSymbolWeightRegular];
|
||||
UIImage *icon = [[UIImage systemImageNamed:@"doc.on.doc"] imageByApplyingSymbolConfiguration:cfg];
|
||||
[btn setImage:icon forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor labelColor];
|
||||
btn.frame = CGRectMake(0, 0, 24, 44);
|
||||
[btn addTarget:[SCIProfileCopyTarget shared]
|
||||
action:@selector(handleTap:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
return btn;
|
||||
}
|
||||
|
||||
static void (*orig_configureHeaderView)(id, SEL, id, id, id, BOOL);
|
||||
|
||||
static void hooked_configureHeaderView(id self, SEL _cmd,
|
||||
id titleView,
|
||||
id leftButtons,
|
||||
id rightButtons,
|
||||
BOOL titleIsCentered) {
|
||||
if (![SCIUtils getBoolPref:@"profile_copy_button"]) {
|
||||
orig_configureHeaderView(self, _cmd, titleView, leftButtons, rightButtons, titleIsCentered);
|
||||
return;
|
||||
}
|
||||
|
||||
NSArray *patched = rightButtons;
|
||||
if ([rightButtons isKindOfClass:[NSArray class]] && [(NSArray *)rightButtons count] > 0) {
|
||||
NSArray *rb = (NSArray *)rightButtons;
|
||||
|
||||
BOOL alreadyHas = NO;
|
||||
for (id wrapper in rb) {
|
||||
UIView *v = sci_safeValueForKey(wrapper, @"view");
|
||||
if ([v isKindOfClass:[UIView class]] &&
|
||||
[v.accessibilityIdentifier isEqualToString:@"sci-profile-copy-button"]) {
|
||||
alreadyHas = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!alreadyHas) {
|
||||
Class wrapperCls = NSClassFromString(@"IGProfileNavigationHeaderViewButtonSwift.IGProfileNavigationHeaderViewButton");
|
||||
// Mirror an existing button's type so IG's layout treats ours the same.
|
||||
NSInteger type = 0;
|
||||
id sample = rb.firstObject;
|
||||
id typeVal = sci_safeValueForKey(sample, @"type");
|
||||
if ([typeVal respondsToSelector:@selector(integerValue)]) {
|
||||
type = [typeVal integerValue];
|
||||
}
|
||||
|
||||
UIView *btn = sci_buildCopyButton();
|
||||
id wrapper = nil;
|
||||
if (wrapperCls) {
|
||||
wrapper = [wrapperCls alloc];
|
||||
SEL initSel = @selector(initWithType:view:);
|
||||
if ([wrapper respondsToSelector:initSel]) {
|
||||
id (*ctor)(id, SEL, NSInteger, id) =
|
||||
(id (*)(id, SEL, NSInteger, id))objc_msgSend;
|
||||
wrapper = ctor(wrapper, initSel, type, btn);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapper) {
|
||||
NSMutableArray *m = [rb mutableCopy];
|
||||
[m insertObject:wrapper atIndex:0];
|
||||
patched = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
orig_configureHeaderView(self, _cmd, titleView, leftButtons, patched, titleIsCentered);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = objc_getClass("IGProfileNavigationSwift.IGProfileNavigationHeaderView");
|
||||
if (!cls) return;
|
||||
SEL sel = @selector(configureWithTitleView:leftButtons:rightButtons:titleIsCentered:);
|
||||
if (![cls instancesRespondToSelector:sel]) return;
|
||||
MSHookMessageEx(cls, sel,
|
||||
(IMP)hooked_configureHeaderView,
|
||||
(IMP *)&orig_configureHeaderView);
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
// Story seen receipt blocking + visual seen state blocking
|
||||
#import "StoryHelpers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
BOOL sciSeenBypassActive = NO;
|
||||
NSMutableSet *sciAllowedSeenPKs = nil;
|
||||
@@ -92,3 +95,99 @@ static BOOL sciShouldBlockSeenVisual() {
|
||||
- (void)setSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
- (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
%end
|
||||
|
||||
// ============ MARK SEEN ON STORY LIKE ============
|
||||
// Story likes are dispatched through several classes — IGSundialViewerControlsOverlayController,
|
||||
// IGStoryLikesInteractionControllingImpl, and IGSundialLikeButton. Hook every
|
||||
// known entry so any UI path (heart button, future double-tap, etc.) is caught.
|
||||
// On a like, we defer to the manual seen-button handler in OverlayButtons.xm
|
||||
// (sciMarkSeenTapped:) which is the only flow IG actually treats as a real seen.
|
||||
|
||||
static __weak UIViewController *sciActiveStoryVC = nil;
|
||||
|
||||
%hook IGStoryViewerViewController
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciActiveStoryVC = self;
|
||||
}
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
if (sciActiveStoryVC == (UIViewController *)self) sciActiveStoryVC = nil;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
static UIView *sciFindStoryOverlayView(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
Class targetCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!targetCls) return nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:targetCls]) return v;
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciMarkActiveStorySeen(void) {
|
||||
if (![SCIUtils getBoolPref:@"seen_on_story_like"]) return;
|
||||
UIView *overlay = sciFindStoryOverlayView(sciActiveStoryVC);
|
||||
if (!overlay) return;
|
||||
SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:");
|
||||
if ([overlay respondsToSelector:sel]) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
|
||||
}
|
||||
}
|
||||
|
||||
static void (*orig_didLikeSundial)(id, SEL, id);
|
||||
static void new_didLikeSundial(id self, SEL _cmd, id pk) {
|
||||
orig_didLikeSundial(self, _cmd, pk);
|
||||
sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
static void (*orig_overlaySetIsLiked)(id, SEL, BOOL, BOOL);
|
||||
static void new_overlaySetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
|
||||
orig_overlaySetIsLiked(self, _cmd, isLiked, animated);
|
||||
if (isLiked) sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
static void (*orig_handleLikeTap)(id, SEL, id);
|
||||
static void new_handleLikeTap(id self, SEL _cmd, id button) {
|
||||
orig_handleLikeTap(self, _cmd, button);
|
||||
sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
static void (*orig_likeButtonSetIsLiked)(id, SEL, BOOL, BOOL);
|
||||
static void new_likeButtonSetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
|
||||
orig_likeButtonSetIsLiked(self, _cmd, isLiked, animated);
|
||||
if (isLiked) sciMarkActiveStorySeen();
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class overlayCtl = NSClassFromString(@"IGSundialViewerControlsOverlayController");
|
||||
if (overlayCtl) {
|
||||
SEL didLike = NSSelectorFromString(@"didLikeSundialWithMediaPK:");
|
||||
if (class_getInstanceMethod(overlayCtl, didLike))
|
||||
MSHookMessageEx(overlayCtl, didLike, (IMP)new_didLikeSundial, (IMP *)&orig_didLikeSundial);
|
||||
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(overlayCtl, setLiked))
|
||||
MSHookMessageEx(overlayCtl, setLiked, (IMP)new_overlaySetIsLiked, (IMP *)&orig_overlaySetIsLiked);
|
||||
}
|
||||
|
||||
Class likesImpl = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
|
||||
if (likesImpl) {
|
||||
SEL handleTap = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
|
||||
if (class_getInstanceMethod(likesImpl, handleTap))
|
||||
MSHookMessageEx(likesImpl, handleTap, (IMP)new_handleLikeTap, (IMP *)&orig_handleLikeTap);
|
||||
}
|
||||
|
||||
Class likeBtn = NSClassFromString(@"IGSundialViewerUFI.IGSundialLikeButton");
|
||||
if (likeBtn) {
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(likeBtn, setLiked))
|
||||
MSHookMessageEx(likeBtn, setLiked, (IMP)new_likeButtonSetIsLiked, (IMP *)&orig_likeButtonSetIsLiked);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -28,6 +28,13 @@ static NSMutableArray *sciPendingUpdates = nil;
|
||||
// Server message ID -> timestamp the reason=2 (delete-for-you) was observed.
|
||||
static NSMutableDictionary<NSString *, NSDate *> *sciDeleteForYouKeys = nil;
|
||||
static NSMutableSet *sciPreservedIds = nil;
|
||||
// Server message ID -> content class name for messages we recognize as
|
||||
// reaction/action-log bookkeeping (e.g. "X liked a message" thread entries).
|
||||
// Populated by hooking the data model class init. Used to skip preserving
|
||||
// these IDs when their remove arrives.
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciMessageContentClasses = nil;
|
||||
#define SCI_CONTENT_CLASSES_MAX 4000
|
||||
#define SCI_PENDING_MAX 50
|
||||
|
||||
#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds"
|
||||
#define SCI_PRESERVED_MAX 200
|
||||
@@ -53,6 +60,34 @@ void sciClearPreservedIds() {
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY];
|
||||
}
|
||||
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciGetContentClasses() {
|
||||
if (!sciMessageContentClasses) sciMessageContentClasses = [NSMutableDictionary dictionary];
|
||||
return sciMessageContentClasses;
|
||||
}
|
||||
|
||||
static void sciTrackInsertedMessage(NSString *sid, NSString *className) {
|
||||
if (!sid.length || !className.length) return;
|
||||
NSMutableDictionary *map = sciGetContentClasses();
|
||||
map[sid] = className;
|
||||
if (map.count > SCI_CONTENT_CLASSES_MAX) {
|
||||
// Drop ~10% oldest by simply removing arbitrary keys
|
||||
NSArray *keys = [map allKeys];
|
||||
for (NSUInteger i = 0; i < keys.count / 10; i++) [map removeObjectForKey:keys[i]];
|
||||
}
|
||||
}
|
||||
|
||||
// Returns YES if the message at this server ID is known to be reaction-related
|
||||
// (action log entry, reaction record, etc.) — i.e. should never be preserved.
|
||||
static BOOL sciIsReactionRelatedMessage(NSString *sid) {
|
||||
if (!sid.length) return NO;
|
||||
NSString *className = sciGetContentClasses()[sid];
|
||||
if (!className.length) return NO;
|
||||
return [className containsString:@"Reaction"] ||
|
||||
[className containsString:@"ActionLog"] ||
|
||||
[className containsString:@"reaction"] ||
|
||||
[className containsString:@"actionLog"];
|
||||
}
|
||||
|
||||
// ============ ALLOC TRACKING ============
|
||||
|
||||
static id (*orig_msgUpdate_alloc)(id self, SEL _cmd);
|
||||
@@ -62,13 +97,14 @@ static id new_msgUpdate_alloc(id self, SEL _cmd) {
|
||||
if (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array];
|
||||
@synchronized(sciPendingUpdates) {
|
||||
[sciPendingUpdates addObject:instance];
|
||||
while (sciPendingUpdates.count > 10)
|
||||
while (sciPendingUpdates.count > SCI_PENDING_MAX)
|
||||
[sciPendingUpdates removeObjectAtIndex:0];
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
|
||||
// ============ REMOTE UNSEND DETECTION ============
|
||||
|
||||
static NSString *sciExtractServerId(id key) {
|
||||
@@ -92,13 +128,17 @@ static void sciPruneStaleDeleteForYouKeys() {
|
||||
}
|
||||
}
|
||||
|
||||
static BOOL sciConsumeRemoteUnsend() {
|
||||
if (!sciPendingUpdates) return NO;
|
||||
// Walks every pending IGDirectMessageUpdate, preserves the IDs of any reason=0
|
||||
// remove that isn't a delete-for-you follow-up, and returns the set of preserved
|
||||
// IDs. The caller decides whether to actually block + show a toast based on
|
||||
// whether those IDs match real (rendered) messages.
|
||||
static NSSet<NSString *> *sciConsumePendingPreserves() {
|
||||
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
|
||||
if (!sciPendingUpdates) return preserved;
|
||||
if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary];
|
||||
|
||||
sciPruneStaleDeleteForYouKeys();
|
||||
|
||||
BOOL shouldBlock = NO;
|
||||
@synchronized(sciPendingUpdates) {
|
||||
for (id update in [sciPendingUpdates copy]) {
|
||||
@try {
|
||||
@@ -114,8 +154,7 @@ static BOOL sciConsumeRemoteUnsend() {
|
||||
reason = *(long long *)((char *)(__bridge void *)update + off);
|
||||
}
|
||||
|
||||
// Delete-for-you initiator: remember the keys for the upcoming
|
||||
// reason=0 follow-up so we don't block it.
|
||||
// Delete-for-you initiator — remember keys for the follow-up.
|
||||
if (reason == 2) {
|
||||
NSDate *now = [NSDate date];
|
||||
for (id key in keys) {
|
||||
@@ -125,44 +164,53 @@ static BOOL sciConsumeRemoteUnsend() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (reason == 0 && !sciLocalDeleteInProgress) {
|
||||
// If every key matches a recent delete-for-you, this is the
|
||||
// expected follow-up — let it through.
|
||||
BOOL allMatched = YES;
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
|
||||
}
|
||||
if (allMatched) {
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (reason != 0 || sciLocalDeleteInProgress) continue;
|
||||
|
||||
// Otherwise this is a genuine remote unsend — preserve the
|
||||
// affected message IDs and block the entire apply call.
|
||||
// If every key matches a recent delete-for-you, drop the
|
||||
// tracking entries and let it through (it's the follow-up).
|
||||
BOOL allMatched = YES;
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (!sid || !sciDeleteForYouKeys[sid]) { allMatched = NO; break; }
|
||||
}
|
||||
if (allMatched) {
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid) [sciGetPreservedIds() addObject:sid];
|
||||
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
|
||||
}
|
||||
sciSavePreservedIds();
|
||||
shouldBlock = YES;
|
||||
break;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Real remove — preserve only keys whose content class isn't a
|
||||
// known reaction / action-log entry. Reaction events also fire
|
||||
// reason=0 removes for the action-log record they create.
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (!sid) continue;
|
||||
if (sciIsReactionRelatedMessage(sid)) continue;
|
||||
[sciGetPreservedIds() addObject:sid];
|
||||
[preserved addObject:sid];
|
||||
}
|
||||
} @catch(id e) {}
|
||||
}
|
||||
[sciPendingUpdates removeAllObjects];
|
||||
}
|
||||
return shouldBlock;
|
||||
if (preserved.count > 0) sciSavePreservedIds();
|
||||
return preserved;
|
||||
}
|
||||
|
||||
// ============ CACHE UPDATE HOOK ============
|
||||
|
||||
static void (*orig_applyUpdates)(id self, SEL _cmd, id updates, id completion, id userAccess);
|
||||
static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id userAccess) {
|
||||
if (sciKeepDeletedEnabled() && sciConsumeRemoteUnsend()) {
|
||||
if (!sciKeepDeletedEnabled()) {
|
||||
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
|
||||
return;
|
||||
}
|
||||
|
||||
NSSet<NSString *> *preserved = sciConsumePendingPreserves();
|
||||
|
||||
if (preserved.count > 0) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// Refresh visible cells so newly preserved messages show the
|
||||
// "Unsent" indicator immediately without waiting for a scroll.
|
||||
@@ -371,15 +419,52 @@ static void new_cellLayoutSubviews(id self, SEL _cmd) {
|
||||
sciUpdateCellIndicator(self);
|
||||
}
|
||||
|
||||
// ============ ACTION LOG TRACKING ============
|
||||
//
|
||||
// IGDirectThreadActionLog is the local data-model class for "X liked a
|
||||
// message" thread entries. IG instantiates one whenever an action log row
|
||||
// is created — reaction add/remove, theme change, etc. We hook its full
|
||||
// init, grab the message ID via the messageId getter, and store the class
|
||||
// name in our content-class map. Later when a remove for that ID arrives,
|
||||
// the consume path recognizes it as bookkeeping and skips preserving it.
|
||||
static id (*orig_actionLogFullInit)(id, SEL, id, id, id, id, id, BOOL, BOOL, id);
|
||||
static id new_actionLogFullInit(id self, SEL _cmd,
|
||||
id message, id title, id textAttributes, id textParts,
|
||||
id actionLogType, BOOL collapsible, BOOL hidden, id genAIMetadata) {
|
||||
id result = orig_actionLogFullInit(self, _cmd, message, title, textAttributes, textParts,
|
||||
actionLogType, collapsible, hidden, genAIMetadata);
|
||||
@try {
|
||||
SEL midSel = @selector(messageId);
|
||||
if ([result respondsToSelector:midSel]) {
|
||||
id mid = ((id(*)(id, SEL))objc_msgSend)(result, midSel);
|
||||
if ([mid isKindOfClass:[NSString class]]) {
|
||||
sciTrackInsertedMessage(mid, @"IGDirectThreadActionLog");
|
||||
}
|
||||
}
|
||||
} @catch(id e) {}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ RUNTIME HOOKS ============
|
||||
|
||||
%ctor {
|
||||
// Action log entries (e.g. "X liked a message") — record their message IDs
|
||||
// when IG creates them so we can later recognize a remove for those IDs as
|
||||
// action-log bookkeeping rather than a real unsend.
|
||||
Class actionLogCls = NSClassFromString(@"IGDirectThreadActionLog");
|
||||
if (actionLogCls) {
|
||||
SEL fullInit = NSSelectorFromString(@"initWithMessage:title:textAttributes:textParts:actionLogType:collapsible:hidden:genAIMetadata:");
|
||||
if (class_getInstanceMethod(actionLogCls, fullInit))
|
||||
MSHookMessageEx(actionLogCls, fullInit, (IMP)new_actionLogFullInit, (IMP *)&orig_actionLogFullInit);
|
||||
}
|
||||
|
||||
Class msgUpdateClass = NSClassFromString(@"IGDirectMessageUpdate");
|
||||
if (msgUpdateClass) {
|
||||
MSHookMessageEx(object_getClass(msgUpdateClass), @selector(alloc),
|
||||
(IMP)new_msgUpdate_alloc, (IMP *)&orig_msgUpdate_alloc);
|
||||
}
|
||||
|
||||
|
||||
Class cacheClass = NSClassFromString(@"IGDirectCacheUpdatesApplicator");
|
||||
if (cacheClass) {
|
||||
SEL sel = NSSelectorFromString(@"_applyThreadUpdates:completion:userAccess:");
|
||||
|
||||
@@ -155,6 +155,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// download handler — works for both stories and DM visual messages
|
||||
%new - (void)sciDownloadTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
@@ -280,4 +281,32 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
|
||||
}
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Mirror IG's chrome alpha onto our injected seen + download buttons so they
|
||||
// fade in sync during hold/zoom. Walks up from a fading sibling to find the
|
||||
// IGStoryFullscreenOverlayView and updates its tagged subviews.
|
||||
static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) return;
|
||||
UIView *cur = self_;
|
||||
while (cur) {
|
||||
for (UIView *sib in cur.superview.subviews) {
|
||||
if (![sib isKindOfClass:overlayCls]) continue;
|
||||
UIView *seen = [sib viewWithTag:1339];
|
||||
UIView *dl = [sib viewWithTag:1340];
|
||||
if (seen) seen.alpha = alpha;
|
||||
if (dl) dl.alpha = alpha;
|
||||
return;
|
||||
}
|
||||
cur = cur.superview;
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGStoryFullscreenHeaderView
|
||||
- (void)setAlpha:(CGFloat)alpha {
|
||||
%orig;
|
||||
sciSyncStoryButtonsAlpha((UIView *)self, alpha);
|
||||
}
|
||||
%end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface SCISettingsBackup : NSObject
|
||||
|
||||
+ (void)presentExport;
|
||||
+ (void)presentImport;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,890 @@
|
||||
#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 prefs as JSON file or QR, import from 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, strong, nullable) UIImage *qrImage;
|
||||
@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) UISearchController *searchController;
|
||||
@property (nonatomic, strong) UIBarButtonItem *editToggleItem;
|
||||
@property (nonatomic) BOOL editMode;
|
||||
@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)]];
|
||||
}
|
||||
self.editToggleItem = [[UIBarButtonItem alloc] initWithTitle:@"Edit"
|
||||
style:UIBarButtonItemStylePlain
|
||||
target:self
|
||||
action:@selector(toggleEditMode)];
|
||||
[rightItems addObject:self.editToggleItem];
|
||||
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;
|
||||
|
||||
if (self.qrImage) {
|
||||
CGFloat headerHeight = 280;
|
||||
UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, table.bounds.size.width, headerHeight)];
|
||||
UIImageView *qr = [[UIImageView alloc] init];
|
||||
qr.image = self.qrImage;
|
||||
qr.contentMode = UIViewContentModeScaleAspectFit;
|
||||
qr.layer.magnificationFilter = kCAFilterNearest;
|
||||
qr.backgroundColor = [UIColor whiteColor];
|
||||
qr.layer.cornerRadius = 12;
|
||||
qr.layer.masksToBounds = YES;
|
||||
qr.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[header addSubview:qr];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[qr.centerXAnchor constraintEqualToAnchor:header.centerXAnchor],
|
||||
[qr.topAnchor constraintEqualToAnchor:header.topAnchor constant:20],
|
||||
[qr.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-20],
|
||||
[qr.widthAnchor constraintEqualToConstant:240],
|
||||
[qr.heightAnchor constraintEqualToConstant:240],
|
||||
]];
|
||||
table.tableHeaderView = header;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
- (void)toggleEditMode {
|
||||
self.editMode = !self.editMode;
|
||||
self.editToggleItem.title = self.editMode ? @"Done" : @"Edit";
|
||||
self.editToggleItem.style = self.editMode ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (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;
|
||||
+ (NSData *)decodeQRDataFromImage:(UIImage *)image;
|
||||
+ (void)presentApplyConfirmationForData:(NSData *)data;
|
||||
+ (void)pickFromFiles;
|
||||
+ (void)pickFromPhotos;
|
||||
+ (NSArray<SCIBackupPreviewGroup *> *)buildPreviewGroupsForSettings:(NSDictionary *)values;
|
||||
@end
|
||||
|
||||
#pragma mark - Helper singleton (delegates for pickers)
|
||||
|
||||
@interface SCIBackupHelper : NSObject <UIDocumentPickerDelegate, UIImagePickerControllerDelegate, UINavigationControllerDelegate>
|
||||
@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;
|
||||
}
|
||||
|
||||
- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary<UIImagePickerControllerInfoKey,id> *)info {
|
||||
UIImage *image = info[UIImagePickerControllerOriginalImage];
|
||||
[picker dismissViewControllerAnimated:YES completion:^{
|
||||
NSData *data = [SCISettingsBackup decodeQRDataFromImage:image];
|
||||
if (!data) {
|
||||
[SCISettingsBackup showError:@"No RyukGram QR code found in the selected photo."];
|
||||
return;
|
||||
}
|
||||
[SCISettingsBackup presentApplyConfirmationForData:data];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
|
||||
[picker dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - SCISettingsBackup
|
||||
|
||||
@implementation SCISettingsBackup
|
||||
|
||||
#pragma mark Key discovery
|
||||
|
||||
+ (NSSet<NSString *> *)allPrefKeys {
|
||||
NSMutableSet *keys = [NSMutableSet set];
|
||||
[self collectKeysFromSections:[SCITweakSettings sections] into:keys];
|
||||
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 {
|
||||
NSDictionary *wrapped = @{
|
||||
@"app": @"RyukGram",
|
||||
@"version": SCIVersionString ?: @"unknown",
|
||||
@"settings": settings ?: @{}
|
||||
};
|
||||
NSError *err = nil;
|
||||
NSData *data = [NSJSONSerialization dataWithJSONObject:wrapped
|
||||
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 QR
|
||||
|
||||
+ (UIImage *)qrCodeForData:(NSData *)data {
|
||||
if (!data) return nil;
|
||||
CIFilter *filter = [CIFilter filterWithName:@"CIQRCodeGenerator"];
|
||||
if (!filter) return nil;
|
||||
[filter setValue:data forKey:@"inputMessage"];
|
||||
[filter setValue:@"M" forKey:@"inputCorrectionLevel"];
|
||||
CIImage *output = filter.outputImage;
|
||||
if (!output) return nil;
|
||||
output = [output imageByApplyingTransform:CGAffineTransformMakeScale(8, 8)];
|
||||
return [UIImage imageWithCIImage:output];
|
||||
}
|
||||
|
||||
+ (NSData *)decodeQRDataFromImage:(UIImage *)image {
|
||||
if (!image) return nil;
|
||||
CIImage *ci = image.CIImage;
|
||||
if (!ci && image.CGImage) ci = [CIImage imageWithCGImage:image.CGImage];
|
||||
if (!ci) return nil;
|
||||
CIDetector *det = [CIDetector detectorOfType:CIDetectorTypeQRCode
|
||||
context:nil
|
||||
options:@{CIDetectorAccuracy: CIDetectorAccuracyHigh}];
|
||||
NSArray *features = [det featuresInImage:ci];
|
||||
for (CIQRCodeFeature *f in features) {
|
||||
if ([f isKindOfClass:[CIQRCodeFeature class]] && f.messageString) {
|
||||
return [f.messageString dataUsingEncoding:NSUTF8StringEncoding];
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
#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.qrImage = [self qrCodeForData:[self serializeSettings:snap]];
|
||||
vc.primaryActionTitle = @"Save";
|
||||
vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) {
|
||||
NSData *data = [self serializeSettings:previewVC.mutableSettings];
|
||||
UIImage *qr = [self qrCodeForData:data];
|
||||
|
||||
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Save settings"
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Save as JSON file"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) {
|
||||
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];
|
||||
}]];
|
||||
if (qr) {
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Save QR code as image"
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) {
|
||||
// Flatten CIImage-backed QR into a CGImage-backed UIImage so the share sheet can save it.
|
||||
UIGraphicsImageRendererFormat *fmt = [UIGraphicsImageRendererFormat defaultFormat];
|
||||
fmt.scale = 1.0;
|
||||
CGSize sz = CGSizeMake(900, 1020);
|
||||
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz format:fmt];
|
||||
UIImage *flat = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
|
||||
[[UIColor whiteColor] setFill];
|
||||
[ctx fillRect:CGRectMake(0, 0, sz.width, sz.height)];
|
||||
NSString *title = @"RyukGram settings";
|
||||
NSString *subtitle = [NSString stringWithFormat:@"Scan in RyukGram → Backup & Restore → Import\n%@", SCIVersionString ?: @""];
|
||||
NSDictionary *titleAttrs = @{
|
||||
NSFontAttributeName: [UIFont systemFontOfSize:54 weight:UIFontWeightBold],
|
||||
NSForegroundColorAttributeName: [UIColor blackColor],
|
||||
};
|
||||
NSMutableParagraphStyle *p = [NSMutableParagraphStyle new];
|
||||
p.alignment = NSTextAlignmentCenter;
|
||||
NSDictionary *subAttrs = @{
|
||||
NSFontAttributeName: [UIFont systemFontOfSize:22 weight:UIFontWeightRegular],
|
||||
NSForegroundColorAttributeName: [UIColor darkGrayColor],
|
||||
NSParagraphStyleAttributeName: p,
|
||||
};
|
||||
NSMutableParagraphStyle *pc = [NSMutableParagraphStyle new];
|
||||
pc.alignment = NSTextAlignmentCenter;
|
||||
NSDictionary *titleAttrsCentered = @{
|
||||
NSFontAttributeName: titleAttrs[NSFontAttributeName],
|
||||
NSForegroundColorAttributeName: titleAttrs[NSForegroundColorAttributeName],
|
||||
NSParagraphStyleAttributeName: pc,
|
||||
};
|
||||
[title drawInRect:CGRectMake(40, 30, sz.width - 80, 70) withAttributes:titleAttrsCentered];
|
||||
[subtitle drawInRect:CGRectMake(40, 100, sz.width - 80, 60) withAttributes:subAttrs];
|
||||
[qr drawInRect:CGRectMake(60, 180, sz.width - 120, sz.width - 120)];
|
||||
}];
|
||||
UIActivityViewController *share =
|
||||
[[UIActivityViewController alloc] initWithActivityItems:@[flat] applicationActivities:nil];
|
||||
share.completionWithItemsHandler = ^(UIActivityType _Nullable activityType, BOOL completed, NSArray * _Nullable returnedItems, NSError * _Nullable activityError) {
|
||||
if (completed) [SCISettingsBackup showSuccessHUD:@"QR code saved"];
|
||||
};
|
||||
share.popoverPresentationController.sourceView = previewVC.view;
|
||||
share.popoverPresentationController.sourceRect = CGRectMake(previewVC.view.bounds.size.width / 2,
|
||||
previewVC.view.bounds.size.height / 2,
|
||||
1, 1);
|
||||
[previewVC presentViewController:share animated:YES completion:nil];
|
||||
}]];
|
||||
}
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
sheet.popoverPresentationController.barButtonItem = previewVC.navigationItem.rightBarButtonItem;
|
||||
[previewVC presentViewController:sheet 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 {
|
||||
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Import settings"
|
||||
message:@"Importing will reset all RyukGram settings to defaults and apply the imported values."
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"From Files" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
[self pickFromFiles];
|
||||
}]];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"From Photos (QR code)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
[self pickFromPhotos];
|
||||
}]];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
|
||||
UIViewController *top = topMostController();
|
||||
sheet.popoverPresentationController.sourceView = top.view;
|
||||
sheet.popoverPresentationController.sourceRect = CGRectMake(top.view.bounds.size.width / 2,
|
||||
top.view.bounds.size.height / 2,
|
||||
1, 1);
|
||||
[top presentViewController:sheet animated:YES completion:nil];
|
||||
}
|
||||
|
||||
+ (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)pickFromPhotos {
|
||||
UIImagePickerController *p = [[UIImagePickerController alloc] init];
|
||||
p.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
|
||||
p.delegate = [SCIBackupHelper shared];
|
||||
[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.qrImage = nil;
|
||||
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
|
||||
@@ -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,83 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
#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 +171,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;
|
||||
|
||||
// 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 +293,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) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#import "TweakSettings.h"
|
||||
#import "SCISettingsBackup.h"
|
||||
|
||||
@implementation SCITweakSettings
|
||||
|
||||
@@ -18,7 +19,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"]
|
||||
]
|
||||
},
|
||||
@{
|
||||
@@ -33,6 +34,7 @@
|
||||
[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:@"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:@"Do not save recent searches" subtitle:@"Search bars will no longer save your recent searches" defaultsKey:@"no_recent_searches"],
|
||||
[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],
|
||||
@@ -181,6 +183,7 @@
|
||||
[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:@"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 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]
|
||||
]
|
||||
@@ -236,6 +239,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 or QR code, and import them later from Files or a photo. 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 as a file or scannable QR code"
|
||||
icon:[SCISymbol symbolWithName:@"square.and.arrow.up"]
|
||||
action:^(void) { [SCISettingsBackup presentExport]; }
|
||||
],
|
||||
[SCISetting buttonCellWithTitle:@"Import settings"
|
||||
subtitle:@"From a file or QR code in your library"
|
||||
icon:[SCISymbol symbolWithName:@"square.and.arrow.down"]
|
||||
action:^(void) { [SCISettingsBackup presentImport]; }
|
||||
]
|
||||
]
|
||||
}]
|
||||
],
|
||||
// [SCISetting navigationCellWithTitle:@"Experimental"
|
||||
// subtitle:@""
|
||||
// icon:[SCISymbol symbolWithName:@"testtube.2"]
|
||||
@@ -315,7 +338,8 @@
|
||||
@"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:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"],
|
||||
[SCISetting linkCellWithTitle:@"Donate to SCInsta dev" subtitle:@"Support SoCuul" 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]]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ 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),
|
||||
@@ -53,6 +54,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
|
||||
@"seen_mode": @"button",
|
||||
@"seen_auto_on_interact": @(NO),
|
||||
@"seen_auto_on_typing": @(NO),
|
||||
@"seen_on_story_like": @(NO),
|
||||
@"indicate_unsent_messages": @(NO),
|
||||
@"unsent_message_toast": @(NO),
|
||||
@"warn_refresh_clears_preserved": @(NO)
|
||||
|
||||
Reference in New Issue
Block a user