mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-04-22 04:06:10 +02:00
feat: Per-user story seen-receipt exclusions
feat: Story seen button mode (button / toggle) feat: Long-press menu on the story seen button (mark seen, exclude, settings) feat: Auto mark-seen on exclude for both stories and DM chats imp: Cleaner exclusion menu wording across stories and DMs imp: Tweak settings now update in real time for exclude ui imp: Ability to batch select in both stories and messages exclude UI
This commit is contained in:
@@ -98,7 +98,9 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Disable screenshot detection
|
||||
- Disable story seen receipt (blocks network upload, toggleable at runtime without restart) **\***
|
||||
- Keep stories visually unseen — keeps the colorful ring in the tray after viewing **\***
|
||||
- Manual mark story as seen — button on story overlay to selectively mark stories as seen **\***
|
||||
- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\***
|
||||
- Long-press the story seen button for quick actions **\***
|
||||
- Per-user story seen-receipt exclusions — exclude specific users so their stories behave normally. Manage via 3-dot menu, eye button long-press, or settings list **\***
|
||||
- Stop story auto-advance — stories won't auto-skip when the timer ends **\***
|
||||
- Story download button — download directly from the story overlay **\***
|
||||
- Download disappearing DM media (photos + videos) **\***
|
||||
|
||||
@@ -6,8 +6,16 @@
|
||||
|
||||
BOOL sciSeenBypassActive = NO;
|
||||
BOOL sciAdvanceBypassActive = NO;
|
||||
BOOL sciStorySeenToggleEnabled = NO; // toggle-mode session bypass
|
||||
NSMutableSet *sciAllowedSeenPKs = nil;
|
||||
|
||||
extern BOOL sciIsCurrentStoryOwnerExcluded(void);
|
||||
extern BOOL sciIsObjectStoryOwnerExcluded(id obj);
|
||||
|
||||
static BOOL sciStorySeenToggleBypass(void) {
|
||||
return [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"] && sciStorySeenToggleEnabled;
|
||||
}
|
||||
|
||||
void sciAllowSeenForPK(id media) {
|
||||
if (!media) return;
|
||||
id pk = sciCall(media, @selector(pk));
|
||||
@@ -25,14 +33,28 @@ static BOOL sciIsPKAllowed(id media) {
|
||||
|
||||
static BOOL sciShouldBlockSeenNetwork() {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (sciIsCurrentStoryOwnerExcluded()) return NO;
|
||||
return [SCIUtils getBoolPref:@"no_seen_receipt"];
|
||||
}
|
||||
|
||||
static BOOL sciShouldBlockSeenVisual() {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (sciIsCurrentStoryOwnerExcluded()) return NO;
|
||||
return [SCIUtils getBoolPref:@"no_seen_receipt"] && [SCIUtils getBoolPref:@"no_seen_visual"];
|
||||
}
|
||||
|
||||
// Per-instance gating for tray/item/ring hooks where the "current" story
|
||||
// VC may not be the owner of the model in question.
|
||||
static BOOL sciShouldBlockSeenVisualForObj(id obj) {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"] || ![SCIUtils getBoolPref:@"no_seen_visual"]) return NO;
|
||||
if (sciIsObjectStoryOwnerExcluded(obj)) return NO;
|
||||
return YES;
|
||||
}
|
||||
|
||||
// network seen blocking
|
||||
%hook IGStorySeenStateUploader
|
||||
- (void)uploadSeenStateWithMedia:(id)arg1 {
|
||||
@@ -79,16 +101,16 @@ static BOOL sciShouldBlockSeenVisual() {
|
||||
%end
|
||||
|
||||
%hook IGStoryTrayViewModel
|
||||
- (void)markAsSeen { if (sciShouldBlockSeenVisual()) return; %orig; }
|
||||
- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(YES); return; } %orig; }
|
||||
- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisual()) return YES; return %orig; }
|
||||
- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)isSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; }
|
||||
- (void)markAsSeen { if (sciShouldBlockSeenVisualForObj(self)) return; %orig; }
|
||||
- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(YES); return; } %orig; }
|
||||
- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisualForObj(self)) return YES; return %orig; }
|
||||
- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)isSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; }
|
||||
%end
|
||||
|
||||
%hook IGStoryItem
|
||||
- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)hasSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; }
|
||||
- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)hasSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; }
|
||||
%end
|
||||
|
||||
%hook IGStoryGradientRingView
|
||||
|
||||
@@ -73,8 +73,7 @@ static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
|
||||
UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray<UIMenuElement *> *suggested) {
|
||||
UIMenu *base = origProvider ? origProvider(suggested) : [UIMenu menuWithChildren:suggested];
|
||||
BOOL excluded = [SCIExcludedThreads isThreadIdExcluded:tid];
|
||||
NSString *title = excluded ? @"Remove from read-receipt exclusion"
|
||||
: @"Add to read-receipt exclusion";
|
||||
NSString *title = excluded ? @"Un-exclude chat" : @"Exclude chat";
|
||||
UIImage *img = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"];
|
||||
UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// Per-user story seen-receipt exclusions. Excluded users' stories behave
|
||||
// normally (your view appears in their viewer list). Provides owner detection
|
||||
// helpers, 3-dot menu injection, and overlay refresh utilities.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "StoryHelpers.h"
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
NSDictionary *sciOwnerInfoFromObject(id obj);
|
||||
|
||||
// ============ Active story VC tracking ============
|
||||
|
||||
__weak UIViewController *sciActiveStoryViewerVC = nil;
|
||||
|
||||
%hook IGStoryViewerViewController
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciActiveStoryViewerVC = self;
|
||||
}
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
if (sciActiveStoryViewerVC == (UIViewController *)self) sciActiveStoryViewerVC = nil;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ Owner extraction ============
|
||||
|
||||
NSDictionary *sciOwnerInfoFromObject(id obj) {
|
||||
if (!obj) return nil;
|
||||
@try {
|
||||
id pk = nil, un = nil, fn = nil;
|
||||
if ([obj respondsToSelector:@selector(pk)])
|
||||
pk = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(pk));
|
||||
if ([obj respondsToSelector:@selector(username)])
|
||||
un = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(username));
|
||||
if ([obj respondsToSelector:@selector(fullName)])
|
||||
fn = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(fullName));
|
||||
if (pk && un) {
|
||||
return @{ @"pk": [NSString stringWithFormat:@"%@", pk],
|
||||
@"username": [NSString stringWithFormat:@"%@", un],
|
||||
@"fullName": fn ? [NSString stringWithFormat:@"%@", fn] : @"" };
|
||||
}
|
||||
NSArray *nestedKeys = @[@"user", @"owner", @"author", @"reelUser", @"reelOwner"];
|
||||
for (NSString *k in nestedKeys) {
|
||||
@try {
|
||||
id sub = [obj valueForKey:k];
|
||||
if (sub && sub != obj) {
|
||||
NSDictionary *d = sciOwnerInfoFromObject(sub);
|
||||
if (d) return d;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSDictionary *sciOwnerInfoForStoryVC(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
@try {
|
||||
id vm = ((id(*)(id, SEL))objc_msgSend)(vc, @selector(currentViewModel));
|
||||
if (!vm) return nil;
|
||||
id owner = nil;
|
||||
@try { owner = [vm valueForKey:@"owner"]; } @catch (__unused id e) {}
|
||||
if (!owner) return nil;
|
||||
return sciOwnerInfoFromObject(owner);
|
||||
} @catch (__unused id e) { return nil; }
|
||||
}
|
||||
|
||||
NSDictionary *sciCurrentStoryOwnerInfo(void) {
|
||||
return sciOwnerInfoForStoryVC(sciActiveStoryViewerVC);
|
||||
}
|
||||
|
||||
// Find the section controller for a specific cell via ivar scan.
|
||||
static id sciFindSectionControllerForCell(UICollectionViewCell *cell) {
|
||||
Class sectionClass = NSClassFromString(@"IGStoryFullscreenSectionController");
|
||||
if (!sectionClass || !cell) return nil;
|
||||
unsigned int cCount = 0;
|
||||
Ivar *cIvars = class_copyIvarList([cell class], &cCount);
|
||||
for (unsigned int i = 0; i < cCount; i++) {
|
||||
const char *type = ivar_getTypeEncoding(cIvars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
id val = object_getIvar(cell, cIvars[i]);
|
||||
if (!val) continue;
|
||||
if ([val isKindOfClass:sectionClass]) { free(cIvars); return val; }
|
||||
unsigned int vCount = 0;
|
||||
Ivar *vIvars = class_copyIvarList([val class], &vCount);
|
||||
for (unsigned int j = 0; j < vCount; j++) {
|
||||
const char *type2 = ivar_getTypeEncoding(vIvars[j]);
|
||||
if (!type2 || type2[0] != '@') continue;
|
||||
id val2 = object_getIvar(val, vIvars[j]);
|
||||
if (val2 && [val2 isKindOfClass:sectionClass]) { free(vIvars); free(cIvars); return val2; }
|
||||
}
|
||||
if (vIvars) free(vIvars);
|
||||
}
|
||||
if (cIvars) free(cIvars);
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSDictionary *sciOwnerInfoFromSectionController(id sc) {
|
||||
if (!sc) return nil;
|
||||
NSArray *tryKeys = @[@"viewModel", @"item", @"model", @"object"];
|
||||
for (NSString *k in tryKeys) {
|
||||
@try {
|
||||
id obj = [sc valueForKey:k];
|
||||
if (obj) {
|
||||
NSDictionary *info = sciOwnerInfoFromObject(obj);
|
||||
if (info) return info;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
return sciOwnerInfoFromObject(sc);
|
||||
}
|
||||
|
||||
// Per-cell owner lookup: walks from the overlay to its IGStoryFullscreenCell,
|
||||
// finds the cell's section controller, and reads the owner. Gives the correct
|
||||
// owner even when multiple cells are alive (pre-loaded adjacent reels).
|
||||
NSDictionary *sciOwnerInfoForView(UIView *view) {
|
||||
if (!view) return nil;
|
||||
Class cellClass = NSClassFromString(@"IGStoryFullscreenCell");
|
||||
UIView *cur = view;
|
||||
UICollectionViewCell *cell = nil;
|
||||
while (cur) {
|
||||
if (cellClass && [cur isKindOfClass:cellClass]) { cell = (UICollectionViewCell *)cur; break; }
|
||||
cur = cur.superview;
|
||||
}
|
||||
if (cell) {
|
||||
id sc = sciFindSectionControllerForCell(cell);
|
||||
NSDictionary *info = sciOwnerInfoFromSectionController(sc);
|
||||
if (info) return info;
|
||||
}
|
||||
// Fallback: VC's currentViewModel
|
||||
UIViewController *vc = sciFindVC(view, @"IGStoryViewerViewController");
|
||||
return sciOwnerInfoForStoryVC(vc);
|
||||
}
|
||||
|
||||
BOOL sciIsCurrentStoryOwnerExcluded(void) {
|
||||
NSDictionary *info = sciCurrentStoryOwnerInfo();
|
||||
if (!info) return NO;
|
||||
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
|
||||
}
|
||||
|
||||
BOOL sciIsObjectStoryOwnerExcluded(id obj) {
|
||||
NSDictionary *info = sciOwnerInfoFromObject(obj);
|
||||
if (!info) return NO;
|
||||
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
|
||||
}
|
||||
|
||||
// ============ Overlay utilities ============
|
||||
|
||||
void sciTriggerStoryMarkSeen(UIViewController *storyVC) {
|
||||
if (!storyVC) return;
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
|
||||
if (!overlayCls) return;
|
||||
SEL markSel = @selector(sciMarkSeenTapped:);
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
if ([v isKindOfClass:overlayCls] && [v respondsToSelector:markSel]) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(v, markSel, nil);
|
||||
return;
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
}
|
||||
|
||||
void sciRefreshAllVisibleOverlays(UIViewController *storyVC) {
|
||||
if (!storyVC) return;
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
|
||||
if (!overlayCls) return;
|
||||
SEL refreshSel = @selector(sciRefreshSeenButton);
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
if ([v isKindOfClass:overlayCls] && [v respondsToSelector:refreshSel]) {
|
||||
((void(*)(id, SEL))objc_msgSend)(v, refreshSel);
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 3-dot menu injection ============
|
||||
// Hooks into the existing IGDSMenu hook in Tweak.x via sciMaybeAppendStoryExcludeMenuItem.
|
||||
// Always present regardless of master toggle (fallback when eye affordance is hidden).
|
||||
|
||||
NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) {
|
||||
if (!sciActiveStoryViewerVC) return items;
|
||||
BOOL looksLikeStoryHeader = NO;
|
||||
for (id it in items) {
|
||||
@try {
|
||||
id title = [it valueForKey:@"title"];
|
||||
NSString *t = [NSString stringWithFormat:@"%@", title ?: @""];
|
||||
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
|
||||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
|
||||
[t isEqualToString:@"Hide"]) {
|
||||
looksLikeStoryHeader = YES; break;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (!looksLikeStoryHeader) return items;
|
||||
|
||||
NSDictionary *ownerInfo = sciCurrentStoryOwnerInfo();
|
||||
if (!ownerInfo) return items;
|
||||
|
||||
NSString *pk = ownerInfo[@"pk"];
|
||||
NSString *username = ownerInfo[@"username"] ?: @"";
|
||||
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
|
||||
// Bypass master toggle so the 3-dot fallback always shows
|
||||
BOOL excluded = NO;
|
||||
for (NSDictionary *e in [SCIExcludedStoryUsers allEntries]) {
|
||||
if ([e[@"pk"] isEqualToString:pk]) { excluded = YES; break; }
|
||||
}
|
||||
|
||||
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
|
||||
if (!menuItemCls) return items;
|
||||
|
||||
NSString *title = excluded ? @"Un-exclude story seen" : @"Exclude story seen";
|
||||
|
||||
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
|
||||
void (^handler)(void) = ^{
|
||||
if (excluded) {
|
||||
[SCIExcludedStoryUsers removePK:pk];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Un-excluded"];
|
||||
} else {
|
||||
[SCIExcludedStoryUsers addOrUpdateEntry:@{
|
||||
@"pk": pk, @"username": username, @"fullName": fullName
|
||||
}];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Excluded"];
|
||||
sciTriggerStoryMarkSeen(weakVC);
|
||||
}
|
||||
sciRefreshAllVisibleOverlays(weakVC);
|
||||
};
|
||||
|
||||
id newItem = nil;
|
||||
@try {
|
||||
SEL initSel = @selector(initWithTitle:image:handler:);
|
||||
typedef id (*Init)(id, SEL, id, id, id);
|
||||
newItem = ((Init)objc_msgSend)([menuItemCls alloc], initSel, title, nil, handler);
|
||||
} @catch (__unused id e) { newItem = nil; }
|
||||
|
||||
if (!newItem) return items;
|
||||
|
||||
NSMutableArray *newItems = [items mutableCopy] ?: [NSMutableArray array];
|
||||
[newItems addObject:newItem];
|
||||
return [newItems copy];
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
// Download + mark seen buttons on story/DM visual message overlay
|
||||
#import "StoryHelpers.h"
|
||||
#import "SCIExcludedThreads.h"
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
|
||||
extern "C" BOOL sciSeenBypassActive;
|
||||
extern "C" BOOL sciAdvanceBypassActive;
|
||||
extern "C" NSMutableSet *sciAllowedSeenPKs;
|
||||
extern "C" void sciAllowSeenForPK(id);
|
||||
extern "C" BOOL sciIsCurrentStoryOwnerExcluded(void);
|
||||
extern "C" NSDictionary *sciCurrentStoryOwnerInfo(void);
|
||||
extern "C" NSDictionary *sciOwnerInfoForView(UIView *view);
|
||||
extern "C" BOOL sciStorySeenToggleEnabled;
|
||||
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC);
|
||||
extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC);
|
||||
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
|
||||
|
||||
static SCIDownloadDelegate *sciStoryVideoDl = nil;
|
||||
static SCIDownloadDelegate *sciStoryImageDl = nil;
|
||||
@@ -41,7 +49,6 @@ static void sciDownloadWithConfirm(void(^block)(void)) {
|
||||
}
|
||||
}
|
||||
|
||||
// get media from DM visual message VC
|
||||
static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
|
||||
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
|
||||
@@ -50,7 +57,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
|
||||
if (!msg) return;
|
||||
|
||||
// video
|
||||
id rawVideo = sciCall(msg, @selector(rawVideo));
|
||||
if (rawVideo) {
|
||||
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
|
||||
@@ -61,7 +67,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
}
|
||||
}
|
||||
|
||||
// photo via rawPhoto
|
||||
id rawPhoto = sciCall(msg, @selector(rawPhoto));
|
||||
if (rawPhoto) {
|
||||
NSURL *url = [SCIUtils getPhotoUrl:rawPhoto];
|
||||
@@ -72,7 +77,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
}
|
||||
}
|
||||
|
||||
// photo via imageSpecifier
|
||||
id imgSpec = sciCall(msg, NSSelectorFromString(@"imageSpecifier"));
|
||||
if (imgSpec) {
|
||||
NSURL *url = sciCall(imgSpec, @selector(url));
|
||||
@@ -83,7 +87,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
}
|
||||
}
|
||||
|
||||
// photo via _visualMediaInfo._media
|
||||
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
|
||||
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
|
||||
if (vmi) {
|
||||
@@ -100,11 +103,14 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
}
|
||||
|
||||
%hook IGStoryFullscreenOverlayView
|
||||
|
||||
// ============ Button injection ============
|
||||
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!self.superview) return;
|
||||
|
||||
// download button
|
||||
// Download button
|
||||
if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1340;
|
||||
@@ -125,50 +131,111 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
]];
|
||||
}
|
||||
|
||||
// mark seen button (stories: mark as seen, DMs: mark as viewed + dismiss)
|
||||
// Skip for DM visual messages inside an excluded thread — the button
|
||||
// would be a no-op there since we don't block visual seen anyway.
|
||||
if ([SCIUtils getBoolPref:@"no_seen_receipt"] && ![self viewWithTag:1339]
|
||||
&& ![SCIExcludedThreads isActiveThreadExcluded]) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1339;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
[btn setImage:[UIImage systemImageNamed:@"eye" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor whiteColor];
|
||||
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
btn.layer.cornerRadius = 18;
|
||||
btn.clipsToBounds = YES;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:self action:@selector(sciMarkSeenTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:btn];
|
||||
UIView *dlBtn = [self viewWithTag:1340];
|
||||
if (dlBtn) {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:dlBtn.leadingAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
} else {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
}
|
||||
// Seen button — deferred so the responder chain is wired up
|
||||
__weak UIView *weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIView *s = weakSelf;
|
||||
if (s && s.superview) ((void(*)(id, SEL))objc_msgSend)(s, @selector(sciRefreshSeenButton));
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Seen button lifecycle ============
|
||||
|
||||
// Rebuilds the eye button (tag 1339) based on current owner + prefs. Idempotent.
|
||||
%new - (void)sciRefreshSeenButton {
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
|
||||
if ([SCIExcludedThreads isActiveThreadExcluded]) return;
|
||||
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
|
||||
NSString *ownerPK = ownerInfo[@"pk"] ?: @"";
|
||||
BOOL ownerExcluded = ownerInfo && [SCIExcludedStoryUsers isUserPKExcluded:ownerPK];
|
||||
BOOL hideForExcludedOwner = ownerExcluded && ![SCIUtils getBoolPref:@"story_excluded_show_unexclude_eye"];
|
||||
BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"];
|
||||
|
||||
NSString *symName;
|
||||
UIColor *tint;
|
||||
if (ownerExcluded) {
|
||||
symName = @"eye.slash.fill"; tint = SCIUtils.SCIColor_Primary;
|
||||
} else if (toggleMode) {
|
||||
symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
|
||||
tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
|
||||
} else {
|
||||
symName = @"eye"; tint = [UIColor whiteColor];
|
||||
}
|
||||
|
||||
UIButton *existing = (UIButton *)[self viewWithTag:1339];
|
||||
|
||||
if (hideForExcludedOwner) {
|
||||
[existing removeFromSuperview];
|
||||
return;
|
||||
}
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
|
||||
if (existing) {
|
||||
[existing setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
existing.tintColor = tint;
|
||||
return;
|
||||
}
|
||||
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1339;
|
||||
[btn setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
btn.tintColor = tint;
|
||||
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
btn.layer.cornerRadius = 18;
|
||||
btn.clipsToBounds = YES;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:self action:@selector(sciSeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(sciSeenButtonLongPressed:)];
|
||||
lp.minimumPressDuration = 0.4;
|
||||
[btn addGestureRecognizer:lp];
|
||||
[self addSubview:btn];
|
||||
UIView *dlBtn = [self viewWithTag:1340];
|
||||
if (dlBtn) {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:dlBtn.leadingAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
} else {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh when story owner changes (overlay reuse across reels)
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
|
||||
static char kLastPKKey;
|
||||
static char kLastExclKey;
|
||||
NSDictionary *info = sciOwnerInfoForView(self);
|
||||
NSString *pk = info[@"pk"] ?: @"";
|
||||
BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk];
|
||||
NSString *prev = objc_getAssociatedObject(self, &kLastPKKey);
|
||||
NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey);
|
||||
BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded);
|
||||
if (!changed) return;
|
||||
objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
|
||||
}
|
||||
|
||||
// ============ Download handler ============
|
||||
|
||||
// download handler — works for both stories and DM visual messages
|
||||
%new - (void)sciDownloadTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
|
||||
@try {
|
||||
// story path
|
||||
id item = sciGetCurrentStoryItem(self);
|
||||
IGMedia *media = sciExtractMediaFromItem(item);
|
||||
if (media) {
|
||||
@@ -176,7 +243,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
return;
|
||||
}
|
||||
|
||||
// DM visual message path
|
||||
UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController");
|
||||
if (dmVC) {
|
||||
sciDownloadDMVisualMessage(dmVC);
|
||||
@@ -189,18 +255,101 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
}
|
||||
}
|
||||
|
||||
// mark seen handler — stories: allow-list approach, DMs: trigger viewed + dismiss
|
||||
// ============ Seen button tap ============
|
||||
|
||||
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
|
||||
BOOL excluded = ownerInfo && [SCIExcludedStoryUsers isUserPKExcluded:ownerInfo[@"pk"]];
|
||||
|
||||
// Excluded owner: tap to un-exclude
|
||||
if (excluded) {
|
||||
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
|
||||
UIAlertController *alert = [UIAlertController
|
||||
alertControllerWithTitle:@"Un-exclude story seen?"
|
||||
message:[NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""]
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"Un-exclude" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
|
||||
[SCIExcludedStoryUsers removePK:ownerInfo[@"pk"]];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Un-excluded"];
|
||||
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
[host presentViewController:alert animated:YES completion:nil];
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle mode
|
||||
if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) {
|
||||
sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
[sender setImage:[UIImage systemImageNamed:(sciStorySeenToggleEnabled ? @"eye.fill" : @"eye") withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
sender.tintColor = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
|
||||
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? @"Story read receipts enabled" : @"Story read receipts disabled"];
|
||||
return;
|
||||
}
|
||||
|
||||
// Button mode: mark seen once
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender);
|
||||
}
|
||||
|
||||
// ============ Seen button long-press menu ============
|
||||
|
||||
%new - (void)sciSeenButtonLongPressed:(UILongPressGestureRecognizer *)gr {
|
||||
if (gr.state != UIGestureRecognizerStateBegan) return;
|
||||
UIView *btn = gr.view;
|
||||
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
|
||||
if (!host) return;
|
||||
UIWindow *capturedWin = btn.window ?: self.window;
|
||||
if (!capturedWin) {
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) { if (w.isKeyWindow) { capturedWin = w; break; } }
|
||||
}
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
|
||||
NSString *pk = ownerInfo[@"pk"];
|
||||
NSString *username = ownerInfo[@"username"] ?: @"";
|
||||
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
|
||||
BOOL excluded = pk && [SCIExcludedStoryUsers isUserPKExcluded:pk];
|
||||
|
||||
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Mark seen" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn);
|
||||
}]];
|
||||
if (pk) {
|
||||
NSString *t = excluded ? @"Un-exclude story seen" : @"Exclude story seen";
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:t style:excluded ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
if (excluded) {
|
||||
[SCIExcludedStoryUsers removePK:pk];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Un-excluded"];
|
||||
} else {
|
||||
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Excluded"];
|
||||
sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
|
||||
}
|
||||
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}]];
|
||||
}
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Stories settings" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
[SCIUtils showSettingsVC:capturedWin atTopLevelEntry:@"Stories"];
|
||||
}]];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
sheet.popoverPresentationController.sourceView = btn;
|
||||
sheet.popoverPresentationController.sourceRect = btn.bounds;
|
||||
[host presentViewController:sheet animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// ============ Mark seen handler ============
|
||||
|
||||
%new - (void)sciMarkSeenTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
|
||||
if (sender) {
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
|
||||
}
|
||||
|
||||
@try {
|
||||
// story path
|
||||
// Story path
|
||||
UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController");
|
||||
if (storyVC) {
|
||||
// allow-list the current media PK for deferred upload
|
||||
id sectionCtrl = sciFindSectionController(storyVC);
|
||||
id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil;
|
||||
if (!storyItem) storyItem = sciGetCurrentStoryItem(self);
|
||||
@@ -238,28 +387,24 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
sciSeenBypassActive = NO;
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Marked as seen" subtitle:@"Will sync when leaving stories"];
|
||||
|
||||
// Advance to next story item if enabled — bypass the stop-auto-advance hook
|
||||
if ([SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) {
|
||||
// Advance to next story if enabled (skip when triggered programmatically via exclude)
|
||||
if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) {
|
||||
__block id secCtrl = sectionCtrl;
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciAdvanceBypassActive = YES;
|
||||
|
||||
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
|
||||
if ([secCtrl respondsToSelector:advSel]) {
|
||||
if ([secCtrl respondsToSelector:advSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1);
|
||||
}
|
||||
|
||||
// After advancing, kick playback on the new section controller
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
__strong __typeof(weakSelf) strongSelf = weakSelf;
|
||||
UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil;
|
||||
id sc2 = vc2 ? sciFindSectionController(vc2) : nil;
|
||||
if (sc2) {
|
||||
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
if ([sc2 respondsToSelector:resumeSel]) {
|
||||
if ([sc2 respondsToSelector:resumeSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
|
||||
}
|
||||
}
|
||||
sciAdvanceBypassActive = NO;
|
||||
});
|
||||
@@ -317,9 +462,8 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
|
||||
|
||||
%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.
|
||||
// ============ Chrome alpha sync ============
|
||||
|
||||
static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) return;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Persistent per-user exclusion list for story read-receipts. Lookup is by
|
||||
// user pk (string). Excluded users get normal seen behavior — your view
|
||||
// shows up in their viewer list as if RyukGram weren't installed.
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface SCIExcludedStoryUsers : NSObject
|
||||
|
||||
+ (BOOL)isFeatureEnabled;
|
||||
|
||||
+ (BOOL)isUserPKExcluded:(NSString *)pk;
|
||||
+ (NSDictionary *)entryForPK:(NSString *)pk;
|
||||
+ (NSArray<NSDictionary *> *)allEntries;
|
||||
+ (NSUInteger)count;
|
||||
|
||||
+ (void)addOrUpdateEntry:(NSDictionary *)entry; // {pk, username, fullName}
|
||||
+ (void)removePK:(NSString *)pk;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,65 @@
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
#import "../../Utils.h"
|
||||
|
||||
#define SCI_STORY_EXCL_KEY @"excluded_story_users"
|
||||
|
||||
@implementation SCIExcludedStoryUsers
|
||||
|
||||
+ (BOOL)isFeatureEnabled {
|
||||
return [SCIUtils getBoolPref:@"enable_story_user_exclusions"];
|
||||
}
|
||||
|
||||
+ (NSArray<NSDictionary *> *)allEntries {
|
||||
NSArray *raw = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_STORY_EXCL_KEY];
|
||||
return raw ?: @[];
|
||||
}
|
||||
|
||||
+ (NSUInteger)count { return [self allEntries].count; }
|
||||
|
||||
+ (void)saveAll:(NSArray *)entries {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:SCI_STORY_EXCL_KEY];
|
||||
}
|
||||
|
||||
+ (NSDictionary *)entryForPK:(NSString *)pk {
|
||||
if (pk.length == 0) return nil;
|
||||
for (NSDictionary *e in [self allEntries]) {
|
||||
if ([e[@"pk"] isEqualToString:pk]) return e;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
+ (BOOL)isUserPKExcluded:(NSString *)pk {
|
||||
if (![self isFeatureEnabled]) return NO;
|
||||
return [self entryForPK:pk] != nil;
|
||||
}
|
||||
|
||||
+ (void)addOrUpdateEntry:(NSDictionary *)entry {
|
||||
NSString *pk = entry[@"pk"];
|
||||
if (pk.length == 0) return;
|
||||
NSMutableArray *all = [[self allEntries] mutableCopy];
|
||||
NSInteger existingIdx = -1;
|
||||
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
|
||||
if ([all[i][@"pk"] isEqualToString:pk]) { existingIdx = i; break; }
|
||||
}
|
||||
NSMutableDictionary *merged = [entry mutableCopy];
|
||||
if (existingIdx >= 0) {
|
||||
NSDictionary *old = all[existingIdx];
|
||||
if (old[@"addedAt"]) merged[@"addedAt"] = old[@"addedAt"];
|
||||
all[existingIdx] = merged;
|
||||
} else {
|
||||
if (!merged[@"addedAt"]) merged[@"addedAt"] = @([[NSDate date] timeIntervalSince1970]);
|
||||
[all addObject:merged];
|
||||
}
|
||||
[self saveAll:all];
|
||||
}
|
||||
|
||||
+ (void)removePK:(NSString *)pk {
|
||||
if (pk.length == 0) return;
|
||||
NSMutableArray *all = [[self allEntries] mutableCopy];
|
||||
[all filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
|
||||
return ![e[@"pk"] isEqualToString:pk];
|
||||
}]];
|
||||
[self saveAll:all];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -117,7 +117,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
|
||||
[items addObject:seenAction];
|
||||
}
|
||||
|
||||
NSString *toggleTitle = excluded ? @"Remove from exclusion" : @"Add to exclusion";
|
||||
NSString *toggleTitle = excluded ? @"Un-exclude chat" : @"Exclude chat";
|
||||
UIImage *toggleImg = [UIImage systemImageNamed:excluded ? @"eye.fill" : @"eye.slash"];
|
||||
__weak UIView *weakAnchor = anchor;
|
||||
UIAction *toggle = [UIAction actionWithTitle:toggleTitle image:toggleImg identifier:nil
|
||||
@@ -125,13 +125,17 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
|
||||
if (!threadId) return;
|
||||
if (excluded) {
|
||||
[SCIExcludedThreads removeThreadId:threadId];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Removed from exclusion"];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Un-excluded"];
|
||||
} else {
|
||||
[SCIExcludedThreads addOrUpdateEntry:@{ @"threadId": threadId,
|
||||
@"threadName": @"",
|
||||
@"isGroup": @NO,
|
||||
@"users": @[] }];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Added to exclusion"];
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Excluded"];
|
||||
// Immediately mark seen since exclusion means normal behavior.
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
}
|
||||
sciRefreshNavBarItems(weakAnchor);
|
||||
}];
|
||||
|
||||
@@ -6,14 +6,17 @@
|
||||
@property (nonatomic, strong) UISearchBar *searchBar;
|
||||
@property (nonatomic, copy) NSArray<NSDictionary *> *filtered;
|
||||
@property (nonatomic, copy) NSString *query;
|
||||
@property (nonatomic, assign) NSInteger sortMode; // 0=added desc, 1=name asc
|
||||
@property (nonatomic, assign) NSInteger sortMode;
|
||||
@property (nonatomic, strong) UIBarButtonItem *sortBtn;
|
||||
@property (nonatomic, strong) UIBarButtonItem *editBtn;
|
||||
@property (nonatomic, strong) UIToolbar *batchToolbar;
|
||||
@end
|
||||
|
||||
@implementation SCIExcludedChatsViewController
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.title = @"Excluded chats";
|
||||
self.title = @"Chats";
|
||||
self.view.backgroundColor = [UIColor systemBackgroundColor];
|
||||
|
||||
self.searchBar = [[UISearchBar alloc] init];
|
||||
@@ -25,23 +28,89 @@
|
||||
self.tableView.dataSource = self;
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.tableHeaderView = self.searchBar;
|
||||
self.tableView.allowsMultipleSelectionDuringEditing = YES;
|
||||
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.tableView];
|
||||
|
||||
self.batchToolbar = [[UIToolbar alloc] init];
|
||||
self.batchToolbar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
self.batchToolbar.hidden = YES;
|
||||
[self.view addSubview:self.batchToolbar];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.tableView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
|
||||
[self.tableView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
|
||||
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.tableView.bottomAnchor constraintEqualToAnchor:self.batchToolbar.topAnchor],
|
||||
[self.batchToolbar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.batchToolbar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.batchToolbar.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
|
||||
]];
|
||||
|
||||
UIBarButtonItem *sortBtn = [[UIBarButtonItem alloc]
|
||||
self.sortBtn = [[UIBarButtonItem alloc]
|
||||
initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"]
|
||||
style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)];
|
||||
self.navigationItem.rightBarButtonItem = sortBtn;
|
||||
self.editBtn = [[UIBarButtonItem alloc]
|
||||
initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
|
||||
self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn];
|
||||
|
||||
[self reload];
|
||||
}
|
||||
|
||||
- (void)toggleEdit {
|
||||
BOOL entering = !self.tableView.isEditing;
|
||||
[self.tableView setEditing:entering animated:YES];
|
||||
self.editBtn.title = entering ? @"Done" : @"Select";
|
||||
self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
|
||||
self.batchToolbar.hidden = !entering;
|
||||
if (entering) [self updateToolbar];
|
||||
}
|
||||
|
||||
- (void)updateToolbar {
|
||||
UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
|
||||
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
|
||||
del.tintColor = [UIColor systemRedColor];
|
||||
UIBarButtonItem *kd = [[UIBarButtonItem alloc] initWithTitle:@"Keep-deleted" style:UIBarButtonItemStylePlain target:self action:@selector(batchKeepDeleted)];
|
||||
self.batchToolbar.items = @[del, flex, kd];
|
||||
}
|
||||
|
||||
- (void)removeSelected {
|
||||
NSArray<NSIndexPath *> *sel = self.tableView.indexPathsForSelectedRows;
|
||||
if (!sel.count) return;
|
||||
for (NSIndexPath *ip in sel) {
|
||||
NSDictionary *e = self.filtered[ip.row];
|
||||
[SCIExcludedThreads removeThreadId:e[@"threadId"]];
|
||||
}
|
||||
[self toggleEdit];
|
||||
[self reload];
|
||||
}
|
||||
|
||||
- (void)batchKeepDeleted {
|
||||
NSArray<NSIndexPath *> *sel = self.tableView.indexPathsForSelectedRows;
|
||||
if (!sel.count) return;
|
||||
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Set keep-deleted override" message:nil preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
void (^apply)(SCIKeepDeletedOverride) = ^(SCIKeepDeletedOverride mode) {
|
||||
for (NSIndexPath *ip in sel) {
|
||||
NSDictionary *e = self.filtered[ip.row];
|
||||
[SCIExcludedThreads setKeepDeletedOverride:mode forThreadId:e[@"threadId"]];
|
||||
}
|
||||
[self toggleEdit];
|
||||
[self reload];
|
||||
};
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Follow default" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
apply(SCIKeepDeletedOverrideDefault);
|
||||
}]];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Force ON (preserve unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
apply(SCIKeepDeletedOverrideIncluded);
|
||||
}]];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Force OFF (allow unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
apply(SCIKeepDeletedOverrideExcluded);
|
||||
}]];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
sheet.popoverPresentationController.barButtonItem = self.batchToolbar.items.lastObject;
|
||||
[self presentViewController:sheet animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)toggleSort {
|
||||
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by"
|
||||
message:nil
|
||||
@@ -58,7 +127,7 @@
|
||||
[sheet addAction:a];
|
||||
}
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
sheet.popoverPresentationController.barButtonItem = self.navigationItem.rightBarButtonItem;
|
||||
sheet.popoverPresentationController.barButtonItem = self.sortBtn;
|
||||
[self presentViewController:sheet animated:YES completion:nil];
|
||||
}
|
||||
|
||||
@@ -77,17 +146,15 @@
|
||||
}
|
||||
if (self.sortMode == 0) {
|
||||
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
|
||||
NSNumber *na = a[@"addedAt"] ?: @0, *nb = b[@"addedAt"] ?: @0;
|
||||
return [nb compare:na];
|
||||
return [b[@"addedAt"] ?: @0 compare:a[@"addedAt"] ?: @0];
|
||||
}];
|
||||
} else {
|
||||
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
|
||||
NSString *na = a[@"threadName"] ?: @"", *nb = b[@"threadName"] ?: @"";
|
||||
return [na caseInsensitiveCompare:nb];
|
||||
return [a[@"threadName"] ?: @"" caseInsensitiveCompare:b[@"threadName"] ?: @""];
|
||||
}];
|
||||
}
|
||||
self.filtered = all;
|
||||
self.title = [NSString stringWithFormat:@"Excluded chats (%lu)", (unsigned long)self.filtered.count];
|
||||
self.title = [NSString stringWithFormat:@"Chats (%lu)", (unsigned long)self.filtered.count];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
@@ -129,11 +196,12 @@
|
||||
cell.textLabel.text = [NSString stringWithFormat:@"%@%@", isGroup ? @"👥 " : @"", name];
|
||||
cell.detailTextLabel.text = subtitle;
|
||||
cell.detailTextLabel.numberOfLines = 2;
|
||||
cell.accessoryType = isGroup ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator;
|
||||
cell.accessoryType = (isGroup || tv.isEditing) ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (tv.isEditing) return;
|
||||
[tv deselectRowAtIndexPath:indexPath animated:YES];
|
||||
NSDictionary *e = self.filtered[indexPath.row];
|
||||
NSArray *users = e[@"users"];
|
||||
@@ -141,9 +209,8 @@
|
||||
NSString *username = users.firstObject[@"username"];
|
||||
if (!username) return;
|
||||
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]];
|
||||
if ([[UIApplication sharedApplication] canOpenURL:url]) {
|
||||
if ([[UIApplication sharedApplication] canOpenURL:url])
|
||||
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
|
||||
}
|
||||
}
|
||||
|
||||
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCIExcludedStoryUsersViewController : UIViewController <UITableViewDataSource, UITableViewDelegate, UISearchBarDelegate>
|
||||
@end
|
||||
@@ -0,0 +1,179 @@
|
||||
#import "SCIExcludedStoryUsersViewController.h"
|
||||
#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h"
|
||||
|
||||
@interface SCIExcludedStoryUsersViewController ()
|
||||
@property (nonatomic, strong) UITableView *tableView;
|
||||
@property (nonatomic, strong) UISearchBar *searchBar;
|
||||
@property (nonatomic, copy) NSArray<NSDictionary *> *filtered;
|
||||
@property (nonatomic, copy) NSString *query;
|
||||
@property (nonatomic, assign) NSInteger sortMode;
|
||||
@property (nonatomic, strong) UIBarButtonItem *sortBtn;
|
||||
@property (nonatomic, strong) UIBarButtonItem *editBtn;
|
||||
@property (nonatomic, strong) UIToolbar *batchToolbar;
|
||||
@end
|
||||
|
||||
@implementation SCIExcludedStoryUsersViewController
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.title = @"Story users";
|
||||
self.view.backgroundColor = [UIColor systemBackgroundColor];
|
||||
|
||||
self.searchBar = [[UISearchBar alloc] init];
|
||||
self.searchBar.delegate = self;
|
||||
self.searchBar.placeholder = @"Search by username or name";
|
||||
[self.searchBar sizeToFit];
|
||||
|
||||
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped];
|
||||
self.tableView.dataSource = self;
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.tableHeaderView = self.searchBar;
|
||||
self.tableView.allowsMultipleSelectionDuringEditing = YES;
|
||||
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.tableView];
|
||||
|
||||
self.batchToolbar = [[UIToolbar alloc] init];
|
||||
self.batchToolbar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
self.batchToolbar.hidden = YES;
|
||||
[self.view addSubview:self.batchToolbar];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.tableView.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
|
||||
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.tableView.bottomAnchor constraintEqualToAnchor:self.batchToolbar.topAnchor],
|
||||
[self.batchToolbar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.batchToolbar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.batchToolbar.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor],
|
||||
]];
|
||||
|
||||
self.sortBtn = [[UIBarButtonItem alloc]
|
||||
initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"]
|
||||
style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)];
|
||||
self.editBtn = [[UIBarButtonItem alloc]
|
||||
initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)];
|
||||
self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn];
|
||||
|
||||
[self reload];
|
||||
}
|
||||
|
||||
- (void)toggleEdit {
|
||||
BOOL entering = !self.tableView.isEditing;
|
||||
[self.tableView setEditing:entering animated:YES];
|
||||
self.editBtn.title = entering ? @"Done" : @"Select";
|
||||
self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain;
|
||||
self.batchToolbar.hidden = !entering;
|
||||
if (entering) {
|
||||
UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil];
|
||||
UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove Selected" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)];
|
||||
del.tintColor = [UIColor systemRedColor];
|
||||
self.batchToolbar.items = @[flex, del, flex];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)removeSelected {
|
||||
NSArray<NSIndexPath *> *sel = self.tableView.indexPathsForSelectedRows;
|
||||
if (!sel.count) return;
|
||||
for (NSIndexPath *ip in sel) {
|
||||
NSDictionary *e = self.filtered[ip.row];
|
||||
[SCIExcludedStoryUsers removePK:e[@"pk"]];
|
||||
}
|
||||
[self toggleEdit];
|
||||
[self reload];
|
||||
}
|
||||
|
||||
- (void)toggleSort {
|
||||
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by"
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
NSArray *titles = @[@"Recently added", @"Username (A–Z)"];
|
||||
for (NSInteger i = 0; i < (NSInteger)titles.count; i++) {
|
||||
UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i]
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) {
|
||||
self.sortMode = i;
|
||||
[self reload];
|
||||
}];
|
||||
if (i == self.sortMode) [a setValue:@YES forKey:@"checked"];
|
||||
[sheet addAction:a];
|
||||
}
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
|
||||
sheet.popoverPresentationController.barButtonItem = self.sortBtn;
|
||||
[self presentViewController:sheet animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)reload {
|
||||
NSArray *all = [SCIExcludedStoryUsers allEntries];
|
||||
NSString *q = [self.query lowercaseString];
|
||||
if (q.length > 0) {
|
||||
all = [all filteredArrayUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
|
||||
if ([[e[@"username"] lowercaseString] containsString:q]) return YES;
|
||||
if ([[e[@"fullName"] lowercaseString] containsString:q]) return YES;
|
||||
return NO;
|
||||
}]];
|
||||
}
|
||||
if (self.sortMode == 0) {
|
||||
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
|
||||
return [b[@"addedAt"] ?: @0 compare:a[@"addedAt"] ?: @0];
|
||||
}];
|
||||
} else {
|
||||
all = [all sortedArrayUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) {
|
||||
return [a[@"username"] ?: @"" caseInsensitiveCompare:b[@"username"] ?: @""];
|
||||
}];
|
||||
}
|
||||
self.filtered = all;
|
||||
self.title = [NSString stringWithFormat:@"Story users (%lu)", (unsigned long)self.filtered.count];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)searchText {
|
||||
self.query = searchText;
|
||||
[self reload];
|
||||
}
|
||||
- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { [searchBar resignFirstResponder]; }
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
|
||||
return self.filtered.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
static NSString *reuse = @"sciStoryExclCell";
|
||||
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:reuse];
|
||||
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuse];
|
||||
|
||||
NSDictionary *e = self.filtered[indexPath.row];
|
||||
NSString *username = e[@"username"] ?: @"";
|
||||
NSString *fullName = e[@"fullName"] ?: @"";
|
||||
|
||||
cell.textLabel.text = fullName.length ? fullName : (username.length ? [@"@" stringByAppendingString:username] : @"(unknown)");
|
||||
cell.detailTextLabel.text = username.length ? [@"@" stringByAppendingString:username] : @"";
|
||||
cell.accessoryType = tv.isEditing ? UITableViewCellAccessoryNone : UITableViewCellAccessoryDisclosureIndicator;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (tv.isEditing) return;
|
||||
[tv deselectRowAtIndexPath:indexPath animated:YES];
|
||||
NSDictionary *e = self.filtered[indexPath.row];
|
||||
NSString *username = e[@"username"];
|
||||
if (!username.length) return;
|
||||
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]];
|
||||
if ([[UIApplication sharedApplication] canOpenURL:url])
|
||||
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
|
||||
}
|
||||
|
||||
- (UISwipeActionsConfiguration *)tableView:(UITableView *)tv trailingSwipeActionsConfigurationForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
NSDictionary *e = self.filtered[indexPath.row];
|
||||
NSString *pk = e[@"pk"];
|
||||
UIContextualAction *del = [UIContextualAction
|
||||
contextualActionWithStyle:UIContextualActionStyleDestructive
|
||||
title:@"Remove"
|
||||
handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) {
|
||||
[SCIExcludedStoryUsers removePK:pk];
|
||||
[self reload];
|
||||
cb(YES);
|
||||
}];
|
||||
return [UISwipeActionsConfiguration configurationWithActions:@[del]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -41,6 +41,8 @@ typedef NS_ENUM(NSInteger, SCITableCell) {
|
||||
|
||||
@property (nonatomic, strong) UIMenu *baseMenu;
|
||||
|
||||
@property (nonatomic, copy, nullable) NSString *(^dynamicTitle)(void);
|
||||
|
||||
@property (nonatomic, strong) NSArray *navSections;
|
||||
@property (nonatomic, strong) UIViewController *navViewController;
|
||||
|
||||
|
||||
@@ -404,6 +404,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) {
|
||||
+ (NSArray<NSString *> *)extraDataKeys {
|
||||
return @[
|
||||
@"excluded_threads",
|
||||
@"excluded_story_users",
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -81,6 +81,11 @@ static char rowStaticRef[] = "row";
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
[super viewWillAppear:animated];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
#pragma mark - Search
|
||||
|
||||
- (BOOL)isSearching {
|
||||
@@ -178,7 +183,7 @@ static char rowStaticRef[] = "row";
|
||||
UITableViewCell *cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:nil];
|
||||
UIListContentConfiguration *cellContentConfig = cell.defaultContentConfiguration;
|
||||
|
||||
cellContentConfig.text = row.title;
|
||||
cellContentConfig.text = row.dynamicTitle ? row.dynamicTitle() : row.title;
|
||||
|
||||
// While searching, show the breadcrumb path instead of the row subtitle.
|
||||
NSString *displaySubtitle = [self isSearching] && searchBreadcrumb.length ? searchBreadcrumb : row.subtitle;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
#import "SCISettingsBackup.h"
|
||||
#import "SCIExcludedChatsViewController.h"
|
||||
#import "../Features/StoriesAndMessages/SCIExcludedThreads.h"
|
||||
#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h"
|
||||
#import "SCIExcludedStoryUsersViewController.h"
|
||||
|
||||
@implementation SCITweakSettings
|
||||
|
||||
@@ -147,6 +149,7 @@
|
||||
[SCISetting switchCellWithTitle:@"Disable story seen receipt" subtitle:@"Hides the notification for others when you view their story" defaultsKey:@"no_seen_receipt"],
|
||||
[SCISetting switchCellWithTitle:@"Keep stories visually unseen" subtitle:@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" defaultsKey:@"no_seen_visual"],
|
||||
[SCISetting switchCellWithTitle:@"Mark seen on story like" subtitle:@"Marks a story as seen the moment you tap the heart, even with seen blocking on" defaultsKey:@"seen_on_story_like"],
|
||||
[SCISetting menuCellWithTitle:@"Manual seen button mode" subtitle:@"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" menu:[self menus][@"story_seen_mode"]],
|
||||
]
|
||||
},
|
||||
@{
|
||||
@@ -156,6 +159,34 @@
|
||||
[SCISetting switchCellWithTitle:@"Advance when marking as seen" subtitle:@"Tapping the eye button to mark a story as seen advances to the next story automatically" defaultsKey:@"advance_on_mark_seen"],
|
||||
]
|
||||
},
|
||||
@{
|
||||
@"header": @"Excluded users",
|
||||
@"footer": @"Excluded users' stories behave normally — your view shows up in their viewer list. Add via the long-press menu on the eye button while viewing a story, or via the 3-dot menu on the story header.",
|
||||
@"rows": @[
|
||||
[SCISetting switchCellWithTitle:@"Enable story user exclusions" subtitle:@"Master toggle. When off, exclusions are ignored" defaultsKey:@"enable_story_user_exclusions"],
|
||||
[SCISetting switchCellWithTitle:@"Show un-exclude eye on excluded users" subtitle:@"When viewing an excluded user's story, the eye button appears so you can un-exclude with one tap. Off = use the 3-dot menu only" defaultsKey:@"story_excluded_show_unexclude_eye"],
|
||||
({
|
||||
SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list"
|
||||
subtitle:@"Search, sort, swipe to remove"
|
||||
icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"]
|
||||
action:^(void) {
|
||||
UIWindow *win = nil;
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { win = w; break; }
|
||||
}
|
||||
UIViewController *top = win.rootViewController;
|
||||
while (top.presentedViewController) top = top.presentedViewController;
|
||||
if ([top isKindOfClass:[UINavigationController class]]) {
|
||||
[(UINavigationController *)top pushViewController:[SCIExcludedStoryUsersViewController new] animated:YES];
|
||||
} else if (top.navigationController) {
|
||||
[top.navigationController pushViewController:[SCIExcludedStoryUsersViewController new] animated:YES];
|
||||
}
|
||||
}];
|
||||
s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedStoryUsers count]]; };
|
||||
s;
|
||||
}),
|
||||
]
|
||||
},
|
||||
@{
|
||||
@"header": @"Other",
|
||||
@"rows": @[
|
||||
@@ -207,22 +238,26 @@
|
||||
@"rows": @[
|
||||
[SCISetting switchCellWithTitle:@"Enable chat exclusions" subtitle:@"Master toggle. When off, the inbox menu item disappears and exclusions are ignored" defaultsKey:@"enable_chat_exclusions"],
|
||||
[SCISetting switchCellWithTitle:@"Default: also exclude keep-deleted" subtitle:@"Excluded chats also bypass keep-deleted-messages by default. Each chat can override this in the list" defaultsKey:@"exclusions_default_keep_deleted"],
|
||||
[SCISetting buttonCellWithTitle:[NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedThreads count]]
|
||||
({
|
||||
SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list"
|
||||
subtitle:@"Search, sort, swipe to remove or toggle keep-deleted"
|
||||
icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"]
|
||||
action:^(void) {
|
||||
UIWindow *win = nil;
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { win = w; break; }
|
||||
}
|
||||
UIViewController *top = win.rootViewController;
|
||||
while (top.presentedViewController) top = top.presentedViewController;
|
||||
if ([top isKindOfClass:[UINavigationController class]]) {
|
||||
[(UINavigationController *)top pushViewController:[SCIExcludedChatsViewController new] animated:YES];
|
||||
} else if (top.navigationController) {
|
||||
[top.navigationController pushViewController:[SCIExcludedChatsViewController new] animated:YES];
|
||||
}
|
||||
}],
|
||||
UIWindow *win = nil;
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { win = w; break; }
|
||||
}
|
||||
UIViewController *top = win.rootViewController;
|
||||
while (top.presentedViewController) top = top.presentedViewController;
|
||||
if ([top isKindOfClass:[UINavigationController class]]) {
|
||||
[(UINavigationController *)top pushViewController:[SCIExcludedChatsViewController new] animated:YES];
|
||||
} else if (top.navigationController) {
|
||||
[top.navigationController pushViewController:[SCIExcludedChatsViewController new] animated:YES];
|
||||
}
|
||||
}];
|
||||
s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedThreads count]]; };
|
||||
s;
|
||||
}),
|
||||
]
|
||||
},
|
||||
@{
|
||||
@@ -428,6 +463,25 @@
|
||||
|
||||
+ (NSDictionary *)menus {
|
||||
return @{
|
||||
@"story_seen_mode": [UIMenu menuWithChildren:@[
|
||||
[UICommand commandWithTitle:@"Button"
|
||||
image:nil
|
||||
action:@selector(menuChanged:)
|
||||
propertyList:@{
|
||||
@"defaultsKey": @"story_seen_mode",
|
||||
@"value": @"button"
|
||||
}
|
||||
],
|
||||
[UICommand commandWithTitle:@"Toggle"
|
||||
image:nil
|
||||
action:@selector(menuChanged:)
|
||||
propertyList:@{
|
||||
@"defaultsKey": @"story_seen_mode",
|
||||
@"value": @"toggle"
|
||||
}
|
||||
]
|
||||
]],
|
||||
|
||||
@"seen_mode": [UIMenu menuWithChildren:@[
|
||||
[UICommand commandWithTitle:@"Button"
|
||||
image:nil
|
||||
|
||||
+7
-2
@@ -61,7 +61,10 @@ BOOL dmVisualMsgsViewedButtonEnabled = false;
|
||||
@"warn_refresh_clears_preserved": @(NO),
|
||||
@"enable_chat_exclusions": @(YES),
|
||||
@"exclusions_default_keep_deleted": @(NO),
|
||||
@"unexclude_inbox_button": @(YES)
|
||||
@"unexclude_inbox_button": @(YES),
|
||||
@"enable_story_user_exclusions": @(YES),
|
||||
@"story_excluded_show_unexclude_eye": @(YES),
|
||||
@"story_seen_mode": @"button"
|
||||
};
|
||||
[[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults];
|
||||
|
||||
@@ -611,7 +614,9 @@ shouldPersistLastBugReportId:(id)arg6
|
||||
|
||||
}
|
||||
|
||||
return %orig([filteredObjs copy], edr, headerLabelText);
|
||||
extern NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *);
|
||||
NSArray *finalObjs = sciMaybeAppendStoryExcludeMenuItem([filteredObjs copy]);
|
||||
return %orig(finalObjs, edr, headerLabelText);
|
||||
}
|
||||
%end
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
// Open settings and push straight into a named top-level entry (e.g. "Messages").
|
||||
+ (void)showSettingsVC:(UIWindow *)window atTopLevelEntry:(NSString *)entryTitle {
|
||||
UIViewController *rootController = [window rootViewController];
|
||||
while (rootController.presentedViewController) rootController = rootController.presentedViewController;
|
||||
SCISettingsViewController *root = [SCISettingsViewController new];
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:root];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user