diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f603ccdc..60153b77 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: Build and Release NeoFreeBird +name: Build NeoFreeBird on: workflow_dispatch: @@ -19,7 +19,7 @@ on: type: string deploy_format: description: "Deployment format" - default: rootfull + default: sideloaded required: true type: choice options: @@ -27,6 +27,16 @@ on: - sideloaded - trollstore - rootless + twitter_branding: + description: "Set app name to Twitter (IPA builds only)" + default: true + required: false + type: boolean + resource_pack_url: + description: "Override app icons (.ZIP) (IPA builds only)" + default: "https://files.catbox.moe/dq8zon.zip" + required: false + type: string commit_id: description: "(Optional) Commit ID to build at" default: "" @@ -34,12 +44,12 @@ on: type: string upload_artifact: description: "Upload iPA as artifact (Public)" - default: false + default: true required: false type: boolean create_release: description: "Create a draft release (Private)" - default: true + default: false required: false type: boolean @@ -50,13 +60,13 @@ concurrency: jobs: build: name: Build BHTwitter - runs-on: macos-14 + runs-on: macos-latest permissions: contents: write steps: - name: Checkout Main - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: path: main ref: ${{ github.event.inputs.commit_id || github.ref }} @@ -70,7 +80,7 @@ jobs: echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" - name: Download Theos - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: repository: theos/theos ref: master @@ -83,7 +93,7 @@ jobs: - name: iOS SDK Caching id: SDK - uses: actions/cache@v4 + uses: actions/cache@v5 env: cache-name: iOS-${{ inputs.sdk_version }}-SDK with: @@ -122,11 +132,20 @@ jobs: env: IPA_URL: ${{ inputs.decrypted_ipa_url }} + - name: Prepare Image Pack + if: inputs.resource_pack_url != '' && (inputs.deploy_format == 'sideloaded' || inputs.deploy_format == 'trollstore') + run: | + wget "$IMAGE_URL" --no-verbose -O main/resource-pack.zip + env: + IMAGE_URL: ${{ inputs.resource_pack_url }} + - name: Build Package run: | cd ${{ github.workspace }}/main sed -i '' "s/^TARGET.*$/TARGET := iphone:clang:${{ inputs.sdk_version }}:${{ inputs.target_version }}/" Makefile - ./build.sh --${{ inputs.deploy_format }} + IMAGE_FLAG="" + if [ -f resource-pack.zip ]; then IMAGE_FLAG="--resource-pack resource-pack.zip"; fi + ./build.sh --${{ inputs.deploy_format }}${{ inputs.twitter_branding && ' --twitter-branding' || '' }} $IMAGE_FLAG env: THEOS: ${{ github.workspace }}/theos @@ -134,9 +153,10 @@ jobs: if: inputs.deploy_format == 'sideloaded' || inputs.deploy_format == 'trollstore' run: | mv "main/packages/$(ls -t main/packages | head -n1)" \ - "main/packages/NeoFreeBird-${{ inputs.deploy_format }}_${{ env.BHTWITTER_VERSION }}_${{ env.X_VERSION }}.${IPA_EXT}" + "main/packages/NeoFreeBird-${BRANDING}-${{ inputs.deploy_format }}_${{ env.BHTWITTER_VERSION }}_${{ env.X_VERSION }}.${IPA_EXT}" env: IPA_EXT: ${{ inputs.deploy_format == 'trollstore' && 'tipa' || 'ipa' }} + BRANDING: ${{ inputs.twitter_branding && 'Twitter' || 'X' }} - name: Pass package name id: package_name @@ -145,7 +165,7 @@ jobs: - name: Upload Artifact if: ${{ inputs.upload_artifact }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: BHTwitter_${{ env.BHTWITTER_VERSION }} path: ${{ github.workspace }}/main/packages/${{ steps.package_name.outputs.package }} @@ -154,7 +174,7 @@ jobs: - name: Create Draft Release if: ${{ inputs.create_release }} id: create_release - uses: softprops/action-gh-release@v1 + uses: softprops/action-gh-release@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..f27b7e2c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,180 @@ +name: Release + +on: + workflow_dispatch: + inputs: + sdk_version: + description: "iOS SDK Version" + default: "16.5" + required: true + type: string + target_version: + description: "Target iOS Version" + default: "14.0" + required: true + type: string + decrypted_ipa_url: + description: "Direct URL of the decrypted X ipa (required for sideloaded/trollstore)" + default: "" + required: true + type: string + resource_pack_url: + description: "Custom app icon pack (.ZIP) used for the branded IPA builds" + default: "https://files.catbox.moe/dq8zon.zip" + required: false + type: string + commit_id: + description: "(Optional) Commit ID to build at" + default: "" + required: false + type: string + create_release: + description: "Collect every build into a single draft release" + default: true + required: false + type: boolean + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + name: Build ${{ matrix.deploy_format }}${{ matrix.branded && ' (Twitter)' || matrix.ipa && ' (X)' || '' }} + runs-on: macos-latest + permissions: + contents: write + strategy: + fail-fast: false + matrix: + include: + # Non-IPA formats: branding does not apply, so a single build each. + - deploy_format: rootfull + ipa: false + branded: false + - deploy_format: rootless + ipa: false + branded: false + # IPA formats: built both with and without custom branding. + - deploy_format: sideloaded + ipa: true + branded: false + - deploy_format: sideloaded + ipa: true + branded: true + - deploy_format: trollstore + ipa: true + branded: false + - deploy_format: trollstore + ipa: true + branded: true + + steps: + - name: Checkout Main + uses: actions/checkout@v6 + with: + path: main + ref: ${{ github.event.inputs.commit_id || github.ref }} + submodules: recursive + + - name: Install Dependencies + run: brew install make dpkg ldid + + - name: Add GNU Make to PATH + run: | + echo "$(brew --prefix make)/libexec/gnubin" >> "$GITHUB_PATH" + + - name: Download Theos + uses: actions/checkout@v6 + with: + repository: theos/theos + ref: master + path: theos + submodules: recursive + + - name: Install cyan + if: matrix.ipa + run: pip install https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip + + - name: iOS SDK Caching + id: SDK + uses: actions/cache@v5 + env: + cache-name: iOS-${{ inputs.sdk_version }}-SDK + with: + path: theos/sdks/ + key: ${{ env.cache-name }} + restore-keys: ${{ env.cache-name }} + + - name: Download iOS SDK + if: steps.SDK.outputs.cache-hit != 'true' + run: | + # Only download the specific SDK version + git clone -n --depth=1 --filter=tree:0 https://github.com/theos/sdks/ + cd sdks + git sparse-checkout set --no-cone iPhoneOS${{ inputs.sdk_version }}.sdk + git checkout + mv ./*.sdk "${THEOS}/sdks" + env: + THEOS: ${{ github.workspace }}/theos + + - name: Get BHTwitter Version + run: | + BHTWITTER_VERSION=$(awk '/Version:/ {print $2}' main/control) + echo "BHTWITTER_VERSION=${BHTWITTER_VERSION}" >> "$GITHUB_ENV" + echo "$BHTWITTER_VERSION" + + - name: Prepare X iPA + if: matrix.ipa + run: | + wget "$IPA_URL" --no-verbose -O main/packages/com.atebits.Tweetie2.ipa + unzip -q main/packages/com.atebits.Tweetie2.ipa -d main/tmp + # Get the version number of the X app and store it + X_VERSION=$(grep -A 1 'CFBundleShortVersionString' main/tmp/Payload/Twitter.app/Info.plist | + grep '' | awk -F'[><]' '{print $3}') + echo "X_VERSION=${X_VERSION}" >> "$GITHUB_ENV" + echo "$X_VERSION" + env: + IPA_URL: ${{ inputs.decrypted_ipa_url }} + + - name: Prepare Image Pack + if: matrix.branded && inputs.resource_pack_url != '' + run: | + wget "$IMAGE_URL" --no-verbose -O main/resource-pack.zip + env: + IMAGE_URL: ${{ inputs.resource_pack_url }} + + - name: Build Package + run: | + cd ${{ github.workspace }}/main + sed -i '' "s/^TARGET.*$/TARGET := iphone:clang:${{ inputs.sdk_version }}:${{ inputs.target_version }}/" Makefile + IMAGE_FLAG="" + if [ -f resource-pack.zip ]; then IMAGE_FLAG="--resource-pack resource-pack.zip"; fi + ./build.sh --${{ matrix.deploy_format }}${{ matrix.branded && ' --twitter-branding' || '' }} $IMAGE_FLAG + env: + THEOS: ${{ github.workspace }}/theos + + - name: Rename IPA to include IPA version + if: matrix.ipa + run: | + mv "main/packages/$(ls -t main/packages | head -n1)" \ + "main/packages/NeoFreeBird-${{ matrix.deploy_format }}${BRAND_SUFFIX}_${{ env.BHTWITTER_VERSION }}_${{ env.X_VERSION }}.${IPA_EXT}" + env: + IPA_EXT: ${{ matrix.deploy_format == 'trollstore' && 'tipa' || 'ipa' }} + BRAND_SUFFIX: ${{ matrix.branded && '-Twitter' || '-X' }} + + - name: Pass package name + id: package_name + run: | + echo "package=$(ls -t main/packages | head -n1)" >> "$GITHUB_OUTPUT" + + - name: Add to Draft Release + if: ${{ inputs.create_release }} + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: v${{ env.BHTWITTER_VERSION }} + name: v${{ env.BHTWITTER_VERSION }} - BHTwitter + files: main/packages/${{ steps.package_name.outputs.package }} + draft: true diff --git a/.gitignore b/.gitignore index e571c771..170fd01a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,8 @@ classdump/ autopush.sh *.ipa -packages/stuff/* +packages/ +!packages/PUT_IPA_HERE + +*.md +!README.md diff --git a/.gitmodules b/.gitmodules index fb37463c..620027d3 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "libflex/FLEX"] path = libflex/FLEX url = https://github.com/FLEXTool/FLEX +[submodule "zxPluginsInject"] + path = zxPluginsInject + url = https://github.com/asdfzxcvbn/zxPluginsInject diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..ff5d93f3 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,6 @@ +{ + "recommendations": [ + "ms-vscode.cpptools-extension-pack", + "tale.logos-vscode" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..a3cf67ed --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.h": "objective-c" + } +} diff --git a/BHDownloadInlineButton.h b/BHDownloadInlineButton.h index 62ff45b3..6b79c6c1 100644 --- a/BHDownloadInlineButton.h +++ b/BHDownloadInlineButton.h @@ -1,6 +1,6 @@ // // BHDownloadInlineButton.h -// NeoFreeBird, fixed for Twitter 10.94 / 10.94.1 +// NeoFreeBird // // Original author: BandarHelal at 09/04/2022 // Modified by: actuallyaridan at 27/04/2025 @@ -9,67 +9,14 @@ @import UIKit; #import "BHTManager.h" -@class T1StatusInlineActionsView; // Forward declaration instead of assuming it's imported - NS_ASSUME_NONNULL_BEGIN -@interface BHDownloadInlineButton : UIButton -{ - NSUInteger _displayType; - NSUInteger _inlineActionType; - __weak T1StatusInlineActionsView *_delegate; // Added weak reference - id _buttonAnimator; - id _viewModel; -} - -+ (CGSize)buttonImageSizeUsingViewModel:(id)viewModel - options:(NSUInteger)options - overrideButtonSize:(CGSize)overrideSize - account:(id)account; - -@property (nonatomic, weak) T1StatusInlineActionsView *delegate; // Changed to weak -@property (nonatomic, strong, nullable) id buttonAnimator; -@property (nonatomic, assign) UIEdgeInsets hitTestEdgeInsets; -@property (nonatomic, assign) UIEdgeInsets touchInsets; -@property (nonatomic, assign) NSUInteger inlineActionType; -@property (nonatomic, assign) NSUInteger displayType; -@property (nonatomic, strong, nullable) id viewModel; - -- (void)setTouchInsets:(UIEdgeInsets)touchInsets; -- (nullable id)_t1_imageNamed:(NSString *)name - fitSize:(CGSize)fitSize - fillColor:(nullable id)fillColor; -- (BOOL)shouldShowCount; -- (double)extraWidth; -- (CGFloat)trailingEdgeInset; -- (NSUInteger)touchInsetPriority; -- (NSUInteger)alternateInlineActionType; -- (NSUInteger)visibility; -- (nullable NSString *)actionSheetTitle; -- (BOOL)enabled; - -// Status update methods -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)displayTextOptions - animated:(BOOL)animated; - -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)displayTextOptions - animated:(BOOL)animated - featureSwitches:(nullable id)featureSwitches; - -// Initializers -- (instancetype)initWithOptions:(NSUInteger)options - overrideSize:(nullable id)overrideSize - account:(nullable id)account; +// Presents the download quality/options sheet for a tweet's media. Formerly an +// inline action-bar button; now driven from the tweet overflow (3-dot) menu. +@interface BHDownloadInlineButton : NSObject -- (instancetype)initWithInlineActionType:(NSUInteger)inlineActionType - options:(NSUInteger)options - overrideSize:(nullable id)overrideSize - account:(nullable id)account; +- (void)presentDownloadOptionsForMediaEntities:(NSArray *)mediaEntities; @end -NS_ASSUME_NONNULL_END \ No newline at end of file +NS_ASSUME_NONNULL_END diff --git a/BHDownloadInlineButton.m b/BHDownloadInlineButton.m index 0fd80d56..47886123 100644 --- a/BHDownloadInlineButton.m +++ b/BHDownloadInlineButton.m @@ -1,6 +1,6 @@ // // BHDownloadInlineButton.m -// NeoFreeBird, fixed for Twitter 10.94 / 10.94.1 +// NeoFreeBird // // Original author: BandarHelal at 09/04/2022 // Modified by: actuallyaridan at 27/04/2025 @@ -8,8 +8,6 @@ #import "BHDownloadInlineButton.h" #import -#import -#import "Colours/Colours.h" #import "BHTBundle/BHTBundle.h" #pragma mark - Helpers @@ -19,23 +17,6 @@ return top; } -static char kHitTestEdgeInsetsKey; // associated‑object key - -// Convenience shim to invoke a superclass selector that isn’t visible at compile‑time -static void _bh_callSuperIfPossible(__unsafe_unretained id self, - SEL sel, - id a1, - NSUInteger a2, - NSUInteger a3, - BOOL a4, - id a5) -{ - struct objc_super sup = { .receiver = self, .super_class = class_getSuperclass(object_getClass(self)) }; - if (class_getInstanceMethod(sup.super_class, sel)) { - ((void (*)(struct objc_super *, SEL, id, NSUInteger, NSUInteger, BOOL, id))objc_msgSendSuper)(&sup, sel, a1, a2, a3, a4, a5); - } -} - #pragma mark - BHDownloadInlineButton @interface BHDownloadInlineButton () @property (nonatomic, strong) JGProgressHUD *hud; @@ -43,125 +24,11 @@ @interface BHDownloadInlineButton () @implementation BHDownloadInlineButton -#pragma mark ••• Class helpers -+ (CGSize)buttonImageSizeUsingViewModel:(id)viewModel - options:(NSUInteger)options - overrideButtonSize:(CGSize)overrideSize - account:(id)account -{ - return CGSizeZero; // let host lay the image out -} - -#pragma mark ••• Status updates -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)textOptions - animated:(BOOL)animated - featureSwitches:(id)featureSwitches -{ - _bh_callSuperIfPossible(self, _cmd, status, options, textOptions, animated, featureSwitches); - [self _bh_applyTint]; -} - -- (void)statusDidUpdate:(id)status - options:(NSUInteger)options - displayTextOptions:(NSUInteger)textOptions - animated:(BOOL)animated -{ - _bh_callSuperIfPossible(self, _cmd, status, options, textOptions, animated, nil); - [self _bh_applyTint]; -} - -- (void)_bh_applyTint { - id dlg = self.delegate.delegate; - if ([dlg isKindOfClass:objc_getClass("T1SlideshowStatusView")] || - [dlg isKindOfClass:objc_getClass("T1ImmersiveExploreCardView")] || - [dlg isKindOfClass:objc_getClass("T1TwitterSwift.ImmersiveExploreCardViewHelper")]) - { - self.tintColor = UIColor.whiteColor; - } else { - self.tintColor = [UIColor colorFromHexString:@"6D6E70"]; - } -} - -#pragma mark ••• Init -- (instancetype)initWithOptions:(NSUInteger)options overrideSize:(id)overrideSize account:(id)account { - if ((self = [super initWithFrame:CGRectZero])) { - [self _bh_commonInitWithInlineType:131]; - } - return self; -} - -- (instancetype)initWithInlineActionType:(NSUInteger)actionType - options:(NSUInteger)options - overrideSize:(id)overrideSize - account:(id)account -{ - if ((self = [super initWithFrame:CGRectZero])) { - [self _bh_commonInitWithInlineType:actionType]; - } - return self; -} - -- (void)_bh_commonInitWithInlineType:(NSUInteger)type { - self.inlineActionType = type; - self.tintColor = [UIColor colorFromHexString:@"6D6E70"]; - [self setImage:[UIImage systemImageNamed:@"arrow.down"] forState:UIControlStateNormal]; - [self addTarget:self action:@selector(DownloadHandler:) forControlEvents:UIControlEventTouchUpInside]; -} - -// Twitter asks subclasses (+ class) for a custom glyph via this selector. -- (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } -+ (id)_t1_imageNamed:(id)name fitSize:(CGSize)size fillColor:(id)fill { return nil; } - -#pragma mark ••• Hit‑testing tweaks -- (void)setTouchInsets:(UIEdgeInsets)insets { - if ([self.delegate.delegate isKindOfClass:objc_getClass("T1StandardStatusInlineActionsViewAdapter")]) { - self.imageEdgeInsets = insets; - [self setHitTestEdgeInsets:insets]; - } -} - -- (void)setHitTestEdgeInsets:(UIEdgeInsets)insets { - objc_setAssociatedObject(self, &kHitTestEdgeInsetsKey, - [NSValue value:&insets withObjCType:@encode(UIEdgeInsets)], - OBJC_ASSOCIATION_RETAIN_NONATOMIC); -} - -- (UIEdgeInsets)hitTestEdgeInsets { - NSValue *val = objc_getAssociatedObject(self, &kHitTestEdgeInsetsKey); - if (val) { UIEdgeInsets e; [val getValue:&e]; return e; } - return UIEdgeInsetsZero; -} - -- (BOOL)pointInside:(CGPoint)pt withEvent:(UIEvent *)evt { - if (UIEdgeInsetsEqualToEdgeInsets(self.hitTestEdgeInsets, UIEdgeInsetsZero) || !self.enabled || self.isHidden) { - return [super pointInside:pt withEvent:evt]; - } - return CGRectContainsPoint(UIEdgeInsetsInsetRect(self.bounds, self.hitTestEdgeInsets), pt); -} - -#pragma mark ••• Inline‑action metrics (instance + class) -#define BH_METRIC(name, value) \ - - (typeof(value))name { return value; } \ - + (typeof(value))name { return value; } - -BH_METRIC(extraWidth, 40.0) -BH_METRIC(extraWidthWithStyle, 40.0) -BH_METRIC(trailingEdgeInset, 6.0) -BH_METRIC(visibility, (NSUInteger)1) -BH_METRIC(alternateInlineActionType, (NSUInteger)6) -BH_METRIC(touchInsetPriority, (NSUInteger)2) -BH_METRIC(shouldShowCount, NO) -BH_METRIC(displayType, (NSUInteger)0) - -#undef BH_METRIC - #pragma mark ••• Download handler -- (void)DownloadHandler:(UIButton *)sender { +- (void)presentDownloadOptionsForMediaEntities:(NSArray *)mediaEntities { @try { NSAttributedString *titleString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_MENU_TITLE"] - attributes:@{ NSFontAttributeName : [[objc_getClass("TAEStandardFontGroup") sharedFontGroup] headline2BoldFont], + attributes:@{ NSFontAttributeName : [BHTManager menuTitleFont], NSForegroundColorAttributeName : UIColor.labelColor }]; TFNActiveTextItem *title = [[objc_getClass("TFNActiveTextItem") alloc] initWithTextModel:[[objc_getClass("TFNAttributedTextModel") alloc] initWithAttributedString:titleString] activeRanges:nil]; @@ -204,37 +71,27 @@ - (void)DownloadHandler:(UIButton *)sender { }; // Media enumeration - BOOL isSlideShow = [self.delegate.delegate isKindOfClass:objc_getClass("T1SlideshowStatusView")]; - if (isSlideShow) { - T1SlideshowStatusView *slide = self.delegate.delegate; - for (TFSTwitterEntityMediaVideoVariant *variant in slide.media.videoInfo.variants) { + if (mediaEntities.count > 1) { + [mediaEntities enumerateObjectsUsingBlock:^(TFSTwitterEntityMedia *obj, NSUInteger idx, BOOL *stop) { + if (obj.mediaType == 2 || obj.mediaType == 3) { + TFNActionItem *videoGroup = [objc_getClass("TFNActionItem") actionItemWithTitle:[NSString stringWithFormat:@"Video %lu", (unsigned long)idx + 1] + imageName:@"arrow_down_circle_stroke" action:^{ + for (TFSTwitterEntityMediaVideoVariant *variant in obj.videoInfo.variants) { + if ([variant.contentType isEqualToString:@"video/mp4"]) [innerActions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; + if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [innerActions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; + } + TFNMenuSheetViewController *inner = [[objc_getClass("TFNMenuSheetViewController") alloc] initWithActionItems:innerActions.copy]; + [inner tfnPresentedCustomPresentFromViewController:BHTopMostController() animated:YES completion:nil]; + }]; + [actions addObject:videoGroup]; + } + }]; + } else if (mediaEntities.firstObject) { + TFSTwitterEntityMedia *first = mediaEntities.firstObject; + for (TFSTwitterEntityMediaVideoVariant *variant in first.videoInfo.variants) { if ([variant.contentType isEqualToString:@"video/mp4"]) [actions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [actions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; } - } else { - NSArray *mediaEntities = self.delegate.viewModel.representedMediaEntities; - if (mediaEntities.count > 1) { - [mediaEntities enumerateObjectsUsingBlock:^(TFSTwitterEntityMedia *obj, NSUInteger idx, BOOL *stop) { - if (obj.mediaType == 2 || obj.mediaType == 3) { - TFNActionItem *videoGroup = [objc_getClass("TFNActionItem") actionItemWithTitle:[NSString stringWithFormat:@"Video %lu", (unsigned long)idx + 1] - imageName:@"arrow_down_circle_stroke" action:^{ - for (TFSTwitterEntityMediaVideoVariant *variant in obj.videoInfo.variants) { - if ([variant.contentType isEqualToString:@"video/mp4"]) [innerActions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; - if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [innerActions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; - } - TFNMenuSheetViewController *inner = [[objc_getClass("TFNMenuSheetViewController") alloc] initWithActionItems:innerActions.copy]; - [inner tfnPresentedCustomPresentFromViewController:BHTopMostController() animated:YES completion:nil]; - }]; - [actions addObject:videoGroup]; - } - }]; - } else if (mediaEntities.firstObject) { - TFSTwitterEntityMedia *first = mediaEntities.firstObject; - for (TFSTwitterEntityMediaVideoVariant *variant in first.videoInfo.variants) { - if ([variant.contentType isEqualToString:@"video/mp4"]) [actions addObject:makeMP4Item([NSURL URLWithString:variant.url])]; - if ([variant.contentType isEqualToString:@"application/x-mpegURL"]) [actions addObject:makeM3U8Item([NSURL URLWithString:variant.url])]; - } - } } TFNMenuSheetViewController *sheet = [[objc_getClass("TFNMenuSheetViewController") alloc] initWithActionItems:actions.copy]; @@ -292,8 +149,4 @@ - (void)downloadDidFailureWithError:(NSError *)error { } } -#pragma mark ••• Required by Twitter runtime -- (BOOL)enabled { return YES; } -- (NSString *)actionSheetTitle { return @"BHDownload"; } -- (NSUInteger)inlineActionType { return self->_inlineActionType; } @end diff --git a/BHTManager.h b/BHTManager.h index 6b7f15ba..901561c6 100644 --- a/BHTManager.h +++ b/BHTManager.h @@ -12,6 +12,8 @@ + (NSString *)getDownloadingPersent:(float)per; + (void)cleanCache; + (NSString *)getVideoQuality:(NSString *)url; ++ (id)sharedFontGroup; ++ (UIFont *)menuTitleFont; + (BOOL)isVideoCell:(id )model; + (bool)isDMVideoCell:(T1InlineMediaView *)view; + (BOOL)doesContainDigitsOnly:(NSString *)string; @@ -34,6 +36,7 @@ + (BOOL)DisableVODCaptions; + (BOOL)Padlock; + (BOOL)OldStyle; ++ (BOOL)bypassAgeVerification; + (BOOL)changeFont; + (BOOL)FLEX; + (BOOL)autoHighestLoad; @@ -44,6 +47,7 @@ + (BOOL)hideSpacesBar; + (BOOL)disableRTL; + (BOOL)alwaysOpenSafari; ++ (BOOL)replyInWebView; + (BOOL)hideWhoToFollow; + (BOOL)hideTopicsToFollow; + (BOOL)hideBlueVerified; @@ -54,21 +58,23 @@ + (BOOL)stripTrackingParams; + (BOOL)alwaysFollowingPage; + (BOOL)stopHidingTabBar; -+ (BOOL)changeBackground; -+ (bool)backgroundImage; + (BOOL)hideBookmarkButton; ++ (BOOL)hideDownvoteButton; + (BOOL)customVoice; + (BOOL)RestoreTweetLabels; + (BOOL)disableMediaTab; + (BOOL)disableArticles; ++ (BOOL)hideCustomTimelines; ++ (BOOL)hideTrends; + (BOOL)disableHighlights; + (BOOL)hideGrokAnalyze; ++ (BOOL)restoreTwitterNames; ++ (BOOL)isTwitterBranded; + (BOOL)hideFollowButton; + (BOOL)restoreFollowButton; + (BOOL)squareAvatars; + (BOOL)restoreVideoTimestamp; -+ (BOOL)dmAvatars; + (BOOL)classicTabBarEnabled; + (BOOL)restoreTabLabels; + (BOOL)noTabBarHiding; @@ -79,14 +85,12 @@ + (NSString *)translateAPIKey; + (NSString *)translateModel; -+ (BOOL)dmComposeBarV2; + (BOOL)replySorting; -+ (BOOL)dmVoiceCreation; + (void)clearSourceLabelCache; + (BOOL)restoreReplyContext; -+ (BOOL)disableXChat; -@end ++ (BOOL)isAttestationBypassEnabled; +@end diff --git a/BHTManager.m b/BHTManager.m index 6b6f5a13..910ee4a4 100644 --- a/BHTManager.m +++ b/BHTManager.m @@ -6,7 +6,6 @@ // #import "BHTManager.h" -#import "SettingsViewController.h" #import "BHTBundle/BHTBundle.h" #import "ModernSettingsViewController.h" @@ -60,6 +59,16 @@ + (NSString *)getDownloadingPersent:(float)per { NSNumber *number = [NSNumber numberWithFloat:per]; return [numberFormatter stringFromNumber:number]; } ++ (id)sharedFontGroup { + id group = [objc_getClass("TFNUIDefaultFontGroup") sharedFontGroup]; + if (!group) group = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + return group; +} ++ (UIFont *)menuTitleFont { + UIFont *font = [[self sharedFontGroup] headline2BoldFont]; + if (!font) font = [UIFont boldSystemFontOfSize:17.0]; + return font; +} + (NSString *)getVideoQuality:(NSString *)url { NSMutableArray *q = [NSMutableArray new]; NSArray *splits = [url componentsSeparatedByString:@"/"]; @@ -85,7 +94,9 @@ + (NSString *)getVideoQuality:(NSString *)url { return [NSString stringWithFormat:@"%@x%@", q.firstObject, q.lastObject]; } + (BOOL)isVideoCell:(id )model { - return model.isMediaEntityVideo || model.isGIF; + BOOL isMediaEntityVideo = [model respondsToSelector:@selector(isMediaEntityVideo)] && model.isMediaEntityVideo; + BOOL isGIF = [model respondsToSelector:@selector(isGIF)] && model.isGIF; + return isMediaEntityVideo || isGIF; } + (void)save:(NSURL *)url { [[PHPhotoLibrary sharedPhotoLibrary] performChangesAndWait:^{ @@ -108,7 +119,7 @@ + (MediaInformation *)getM3U8Information:(NSURL *)mediaURL { } + (TFNMenuSheetViewController *)newFFmpegDownloadSheet:(MediaInformation *)mediaInformation downloadingURL:(NSURL *)downloadingURL progressView:(JGProgressHUD *)hud { NSAttributedString *AttString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_MENU_TITLE"] attributes:@{ - NSFontAttributeName: [[objc_getClass("TAEStandardFontGroup") sharedFontGroup] headline2BoldFont], + NSFontAttributeName: [BHTManager menuTitleFont], NSForegroundColorAttributeName: UIColor.labelColor }]; TFNActiveTextItem *title = [[objc_getClass("TFNActiveTextItem") alloc] initWithTextModel:[[objc_getClass("TFNAttributedTextModel") alloc] initWithAttributedString:AttString] activeRanges:nil]; @@ -171,7 +182,10 @@ + (BOOL)HideTopics { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_topics"]; } + (BOOL)DisableVODCaptions { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dis_VODCaptions"]; + // The settings toggle ("No video captions") writes @"video_layer_caption"; + // this used to read an unexposed @"dis_VODCaptions" key, so the switch did + // nothing. Read the key the UI actually sets. + return [[NSUserDefaults standardUserDefaults] boolForKey:@"video_layer_caption"]; } + (BOOL)UndoTweet { return [[NSUserDefaults standardUserDefaults] boolForKey:@"undo_tweet"]; @@ -188,6 +202,9 @@ + (BOOL)Padlock { + (BOOL)OldStyle { return [[NSUserDefaults standardUserDefaults] boolForKey:@"old_style"]; } ++ (BOOL)bypassAgeVerification { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"bypass_age_verification"]; +} + (BOOL)changeFont { return [[NSUserDefaults standardUserDefaults] boolForKey:@"en_font"]; } @@ -218,6 +235,9 @@ + (BOOL)disableRTL { + (BOOL)alwaysOpenSafari { return [[NSUserDefaults standardUserDefaults] boolForKey:@"openInBrowser"]; } ++ (BOOL)replyInWebView { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"reply_in_webview"]; +} + (BOOL)hideWhoToFollow { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_who_to_follow"]; } @@ -251,15 +271,12 @@ + (BOOL)stopHidingTabBar { + (BOOL)noTabBarHiding { return [[NSUserDefaults standardUserDefaults] boolForKey:@"no_tab_bar_hiding"]; } -+ (BOOL)changeBackground { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"change_msg_background"]; -} -+ (bool)backgroundImage { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"background_image"]; -} + (BOOL)hideBookmarkButton { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_bookmark_button"]; } ++ (BOOL)hideDownvoteButton { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_downvote_button"]; +} + (BOOL)customVoice { return [[NSUserDefaults standardUserDefaults] boolForKey:@"custom_voice_upload"]; } @@ -275,6 +292,12 @@ + (BOOL)disableMediaTab { + (BOOL)disableArticles { return [[NSUserDefaults standardUserDefaults] boolForKey:@"disableArticles"]; } ++ (BOOL)hideCustomTimelines { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_custom_timelines"]; +} ++ (BOOL)hideTrends { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_trends"]; +} + (BOOL)disableHighlights { return [[NSUserDefaults standardUserDefaults] boolForKey:@"disableHighlights"]; @@ -285,6 +308,23 @@ + (BOOL)hideGrokAnalyze { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_grok_analyze"]; } ++ (BOOL)restoreTwitterNames { + NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; + if ([defaults objectForKey:@"restore_twitter_names"] == nil) { + return [BHTManager isTwitterBranded]; + } + return [defaults boolForKey:@"restore_twitter_names"]; +} + ++ (BOOL)isTwitterBranded { + static BOOL branded = NO; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + branded = [[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleDisplayName"] isEqual:@"Twitter"]; + }); + return branded; +} + + (BOOL)hideFollowButton { return [[NSUserDefaults standardUserDefaults] boolForKey:@"hide_follow_button"]; } @@ -304,10 +344,6 @@ + (BOOL)restoreVideoTimestamp { return [[NSUserDefaults standardUserDefaults] boolForKey:@"restore_video_timestamp"]; } -+ (BOOL)dmAvatars { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dm_avatars"]; -} - + (BOOL)classicTabBarEnabled { return [[NSUserDefaults standardUserDefaults] boolForKey:@"tab_bar_theming"]; } @@ -342,7 +378,6 @@ + (NSString *)translateModel { } + (UIViewController *)BHTSettingsWithAccount:(TFNTwitterAccount *)twAccount { - // Always use ModernSettingsViewController now return [[ModernSettingsViewController alloc] initWithAccount:twAccount]; } @@ -363,25 +398,16 @@ + (BOOL)doesContainNonDigitsOnly:(NSString *)string { return containsNonDigitsOnly; } -+ (BOOL)dmComposeBarV2 { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dm_compose_bar_v2_enabled"]; -} - + (BOOL)replySorting { return [[NSUserDefaults standardUserDefaults] boolForKey:@"reply_sorting_enabled"]; } -+ (BOOL)dmVoiceCreation { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"dm_voice_creation_enabled"]; -} - + (BOOL)restoreReplyContext { return [[NSUserDefaults standardUserDefaults] boolForKey:@"restore_reply_context"]; } -+ (BOOL)disableXChat { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"disable_xchat"]; ++ (BOOL)isAttestationBypassEnabled { + return [[NSUserDefaults standardUserDefaults] boolForKey:@"attestation_bypass_enabled"]; } @end - diff --git a/LegacyLogin/BHTLegacyLoginViewController.h b/LegacyLogin/BHTLegacyLoginViewController.h new file mode 100644 index 00000000..b417ad6a --- /dev/null +++ b/LegacyLogin/BHTLegacyLoginViewController.h @@ -0,0 +1,8 @@ +#import + +// Reimplementation of 9.67's built-in xAuth login form for 11.99. +@interface BHTLegacyLoginViewController : UIViewController ++ (void)presentLoginFrom:(UIViewController *)presenter; + ++ (UINavigationController *)loginRootNavigationController; +@end diff --git a/LegacyLogin/BHTLegacyLoginViewController.m b/LegacyLogin/BHTLegacyLoginViewController.m new file mode 100644 index 00000000..331aecc9 --- /dev/null +++ b/LegacyLogin/BHTLegacyLoginViewController.m @@ -0,0 +1,629 @@ +#import "BHTLegacyLoginViewController.h" +#import "../JGProgressHUD/JGProgressHUD.h" +#import +#import +#import +#import + +// Password login (no reset), matching 9.67's built-in sign-in: +// 1. Generate ui_metrics from x.com/i/js_inst (anti-bot token). +// 2. xauth_password -> OAuth token directly, or a 2FA challenge +// 3. If 2FA is required, present the app's own T1LoginChallengeWebViewController (via +// T1LoginChallengeFactory), which loads the 2FA URL and polls xauth_challenge +// until it returns an account. +// 4. Add the account and switch to it. + +typedef void (^BHTCmdCompletion)(BOOL success, id response, id parseError); + +typedef id (*BHTPwInitIMP)(id, SEL, + id context, id accountID, id authContext, id identifier, id password, id simCountryCode, + id httpConfig, BOOL supportOneFactor, id knownDeviceToken, id uiMetrics, id authTokenStorage, + id source, id builder, id completion); + + +#pragma mark - Runtime helpers + +static long long BHTUserId(id resp, SEL sel) { + if (!resp || ![resp respondsToSelector:sel]) { + return 0; + } + return ((long long (*)(id, SEL))objc_msgSend)(resp, sel); +} + +static id BHTPerform0(id target, SEL selector) { + if (!target || ![target respondsToSelector:selector]) { + return nil; + } + return ((id (*)(id, SEL))objc_msgSend)(target, selector); +} + +// API error 243 ("client not privileged"/too many attempts) is likely rate limiting +// and can be bypassed by switching on a VPN. +static BOOL BHTIsRateLimit(id error) { + if (![error isKindOfClass:[NSError class]]) { + return NO; + } + + NSError *e = error; + if (e.code == 243) { + return YES; + } + + for (id value in [e.userInfo allValues]) { + if ([value isKindOfClass:[NSNumber class]] && [value integerValue] == 243) { + return YES; + } + } + + return [[e description] rangeOfString:@"243"].location != NSNotFound; +} + + +#pragma mark - Command / service accessors + +static id BHTGuestAccountID(void) { + void *sym = dlsym(RTLD_DEFAULT, "TFSTwitterAPIGuestAccountID"); + return sym ? (__bridge id)(*(void **)sym) : nil; +} + +static id BHTLoader(void) { + return BHTPerform0(objc_getClass("TFSTwitterServiceRunner"), @selector(APICommandLoader)); +} + +static id BHTContext(void) { + return BHTPerform0(objc_getClass("TFSTwitterServiceRunner"), @selector(APICommandContext)); +} + +static id BHTBuilder(const char *className) { + Class cls = objc_getClass(className); + return cls ? [[cls alloc] init] : nil; +} + +static id BHTStorage(void) { + Class cls = objc_getClass("T1OnboardingAuthTokenStorage"); + return cls ? [[cls alloc] init] : nil; +} + +static id BHTKnownDeviceToken(void) { + return BHTPerform0(objc_getClass("TFNTwitterAccount"), @selector(knownDeviceToken)); +} + +static id BHTHTTPConfig(void) { + Class cls = objc_getClass("TNUServiceHTTPConfiguration"); + if (!cls) { + return nil; + } + + SEL sel = @selector(configurationForForegroundRetriableRequestWithTotalPermittedRetryCount:); + return ((id (*)(id, SEL, unsigned long long))objc_msgSend)(cls, sel, 10); +} + + +#pragma mark - Account finalization + +static void BHTRegisterAccount(id account) { + if (!account) { + return; + } + + Class twitterCls = objc_getClass("TFNTwitter"); + id shared = BHTPerform0(twitterCls, @selector(sharedTwitter)); + id service = BHTPerform0(shared, @selector(accountService)); + + @try { + if (service && [service respondsToSelector:@selector(addAccount:)]) { + ((void (*)(id, SEL, id))objc_msgSend)(service, @selector(addAccount:), account); + } + + if ([twitterCls respondsToSelector:@selector(saveSharedTwitter)]) { + ((void (*)(id, SEL))objc_msgSend)(twitterCls, @selector(saveSharedTwitter)); + } + + if ([account respondsToSelector:@selector(refreshForced:source:)]) { + ((void (*)(id, SEL, BOOL, unsigned long long))objc_msgSend)(account, @selector(refreshForced:source:), NO, 0); + } + + Class notifCls = objc_getClass("TFSAccountNotification"); + id name = BHTPerform0(notifCls, @selector(TFSAccountsDidChange)); + if ([name isKindOfClass:[NSString class]]) { + [[NSNotificationCenter defaultCenter] postNotificationName:name object:shared userInfo:nil]; + } + } @catch (NSException *ex) { + } +} + +static void BHTSwitchToAccount(id account) { + id host = BHTPerform0(objc_getClass("T1HostViewController"), @selector(sharedHostViewController)); + if (host && [host respondsToSelector:@selector(viewAccount:animated:)]) { + ((void (*)(id, SEL, id, BOOL))objc_msgSend)(host, @selector(viewAccount:animated:), account, YES); + } +} + + +#pragma mark - ui_metrics injection + +// Hooks fetch/XHR/sendBeacon inside the js_inst page and forwards the requested URLs, +// so we can take the anti-bot `result=` token. +static NSString *const kJSInstJS = + @"(function(){function rep(u){try{window.webkit.messageHandlers.bht.postMessage(String(u));}catch(e){}}" + @"var of=window.fetch;if(of){window.fetch=function(){try{rep(arguments[0]&&arguments[0].url?arguments[0].url:arguments[0]);}catch(e){}return of.apply(this,arguments);};}" + @"var oo=XMLHttpRequest.prototype.open;XMLHttpRequest.prototype.open=function(m,u){try{rep(u);}catch(e){}return oo.apply(this,arguments);};" + @"if(navigator.sendBeacon){var sb=navigator.sendBeacon.bind(navigator);navigator.sendBeacon=function(u,d){try{rep(u);}catch(e){}return sb(u,d);};}})();"; + + +@interface BHTLegacyLoginViewController () + +@property (nonatomic, strong) UITextField *userField; +@property (nonatomic, strong) UITextField *passField; +@property (nonatomic, strong) UIButton *actionButton; +@property (nonatomic, strong) UILabel *infoLabel; +@property (nonatomic, strong) JGProgressHUD *hud; + +@property (nonatomic, strong) WKWebView *instWebView; +@property (nonatomic, copy) NSString *uiMetrics; +@property (nonatomic, copy) void (^metricsCallback)(NSString *); +@property (nonatomic, assign) BOOL metricsDone; + +@property (nonatomic, assign) BOOL asRootScreen; // YES when installed as the signed-out screen + +@end + + +@implementation BHTLegacyLoginViewController + +#pragma mark - Presentation + ++ (BOOL)bht_isOurs:(UIViewController *)vc { + if ([vc isKindOfClass:[BHTLegacyLoginViewController class]]) { + return YES; + } + + if ([vc isKindOfClass:[UINavigationController class]]) { + id root = ((UINavigationController *)vc).viewControllers.firstObject; + return [root isKindOfClass:[BHTLegacyLoginViewController class]]; + } + + return NO; +} + ++ (void)presentLoginFrom:(UIViewController *)presenter { + if (!presenter) { + return; + } + + // Return if the form is already anywhere in the presentation chain + for (UIViewController *vc = presenter; vc; vc = vc.presentedViewController) { + if ([self bht_isOurs:vc]) { + return; + } + } + + while (presenter.presentedViewController) { + presenter = presenter.presentedViewController; + } + + BHTLegacyLoginViewController *login = [[BHTLegacyLoginViewController alloc] init]; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:login]; + nav.modalPresentationStyle = UIModalPresentationFullScreen; + + [presenter presentViewController:nav animated:YES completion:nil]; +} + ++ (UINavigationController *)loginRootNavigationController { + BHTLegacyLoginViewController *login = [[BHTLegacyLoginViewController alloc] init]; + login.asRootScreen = YES; + + return [[UINavigationController alloc] initWithRootViewController:login]; +} + +#pragma mark - View setup + +- (void)viewDidLoad { + [super viewDidLoad]; + + self.view.backgroundColor = [UIColor systemBackgroundColor]; + self.title = @"Log in"; + + if (!self.asRootScreen) { + self.navigationItem.leftBarButtonItem = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel + target:self + action:@selector(cancelTapped)]; + } + + self.infoLabel = [self label:@"Log in with your username and password.\n\nGoogle and Apple sign-in aren't supported. If your account uses one of those, add a password to it first."]; + + self.userField = [self field:@"Username, email or phone" secure:NO]; + self.userField.keyboardType = UIKeyboardTypeEmailAddress; + // Tag the fields so iOS Password AutoFill recognises the form and offers + // saved logins / the Passwords key in the QuickType bar (the native + // equivalent of autocomplete=username / current-password on a web form). + self.userField.textContentType = UITextContentTypeUsername; + + self.passField = [self field:@"Password" secure:YES]; + self.passField.textContentType = UITextContentTypePassword; + + self.actionButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.actionButton setTitle:@"Log in" forState:UIControlStateNormal]; + self.actionButton.titleLabel.font = [UIFont boldSystemFontOfSize:18]; + self.actionButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.actionButton addTarget:self action:@selector(actionTapped) forControlEvents:UIControlEventTouchUpInside]; + + NSArray *fields = @[self.infoLabel, self.userField, self.passField, self.actionButton]; + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:fields]; + stack.axis = UILayoutConstraintAxisVertical; + stack.spacing = 14; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:stack]; + + [NSLayoutConstraint activateConstraints:@[ + [stack.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:24], + [stack.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor constant:32], + [stack.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-32], + [self.userField.heightAnchor constraintEqualToConstant:44], + [self.passField.heightAnchor constraintEqualToConstant:44], + ]]; +} + +- (UITextField *)field:(NSString *)placeholder secure:(BOOL)secure { + UITextField *field = [[UITextField alloc] init]; + field.placeholder = placeholder; + field.secureTextEntry = secure; + field.borderStyle = UITextBorderStyleRoundedRect; + field.autocapitalizationType = UITextAutocapitalizationTypeNone; + field.autocorrectionType = UITextAutocorrectionTypeNo; + field.translatesAutoresizingMaskIntoConstraints = NO; + + return field; +} + +- (UILabel *)label:(NSString *)text { + UILabel *label = [[UILabel alloc] init]; + label.numberOfLines = 0; + label.font = [UIFont systemFontOfSize:14]; + label.textColor = [UIColor secondaryLabelColor]; + label.text = text; + label.translatesAutoresizingMaskIntoConstraints = NO; + + return label; +} + +#pragma mark - Actions + +- (void)cancelTapped { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)actionTapped { + [self.view endEditing:YES]; + [self startLogin]; +} + +- (void)showHUD:(NSString *)text { + self.hud = [JGProgressHUD progressHUDWithStyle:JGProgressHUDStyleDark]; + self.hud.textLabel.text = text; + [self.hud showInView:self.view]; +} + +#pragma mark - ui_metrics + +- (void)generateUIMetrics:(void (^)(NSString *))then { + self.uiMetrics = nil; + self.metricsDone = NO; + self.metricsCallback = then; + + WKWebViewConfiguration *cfg = [[WKWebViewConfiguration alloc] init]; + cfg.websiteDataStore = [WKWebsiteDataStore nonPersistentDataStore]; + [cfg.userContentController addScriptMessageHandler:self name:@"bht"]; + + WKUserScript *script = [[WKUserScript alloc] initWithSource:kJSInstJS + injectionTime:WKUserScriptInjectionTimeAtDocumentStart + forMainFrameOnly:NO]; + [cfg.userContentController addUserScript:script]; + + self.instWebView = [[WKWebView alloc] initWithFrame:self.view.bounds configuration:cfg]; + self.instWebView.navigationDelegate = self; + self.instWebView.alpha = 0.02; + [self.view addSubview:self.instWebView]; + + NSURL *url = [NSURL URLWithString:@"https://x.com/i/js_inst?native=true"]; + [self.instWebView loadRequest:[NSURLRequest requestWithURL:url]]; + + // Give up after a while so a failed js_inst load can't wedge the login. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(12 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self finishMetrics]; + }); +} + +- (void)finishMetrics { + if (self.metricsDone) { + return; + } + self.metricsDone = YES; + + [self.instWebView removeFromSuperview]; + self.instWebView = nil; + + void (^cb)(NSString *) = self.metricsCallback; + self.metricsCallback = nil; + + if (cb) { + cb(self.uiMetrics); + } +} + +- (void)consumeURL:(NSString *)urlString { + if (self.uiMetrics || ![urlString containsString:@"result="]) { + return; + } + + NSURLComponents *components = [NSURLComponents componentsWithURL:[NSURL URLWithString:urlString] + resolvingAgainstBaseURL:NO]; + for (NSURLQueryItem *item in components.queryItems) { + if ([item.name isEqualToString:@"result"] && item.value.length) { + self.uiMetrics = item.value; + dispatch_async(dispatch_get_main_queue(), ^{ + [self finishMetrics]; + }); + return; + } + } +} + +- (void)userContentController:(WKUserContentController *)controller didReceiveScriptMessage:(WKScriptMessage *)message { + if ([message.body isKindOfClass:[NSString class]]) { + [self consumeURL:message.body]; + } +} + +- (void)webView:(WKWebView *)webView + decidePolicyForNavigationAction:(WKNavigationAction *)action + decisionHandler:(void (^)(WKNavigationActionPolicy))handler { + [self consumeURL:action.request.URL.absoluteString]; + handler(WKNavigationActionPolicyAllow); +} + +#pragma mark - Step 1: password + +- (void)startLogin { + NSString *user = self.userField.text ?: @""; + NSString *pass = self.passField.text ?: @""; + if (user.length == 0 || pass.length == 0) { + [self alert:@"Missing input" msg:@"Enter username and password."]; + return; + } + + [self showHUD:@"Verifying…"]; + + [self generateUIMetrics:^(NSString *metrics) { + self.hud.textLabel.text = @"Signing in…"; + + Class cmdCls = objc_getClass("TFSTwitterAPIXAuthPasswordCommand"); + if (!cmdCls || !BHTLoader() || !BHTContext()) { + [self.hud dismiss]; + [self alert:@"Unavailable" msg:@"Login classes missing."]; + return; + } + + __weak typeof(self) ws = self; + BHTCmdCompletion completion = ^(BOOL ok, id resp, id err) { + dispatch_async(dispatch_get_main_queue(), ^{ + [ws handlePassword:ok response:resp error:err]; + }); + }; + + @try { + SEL sel = @selector(initWithContext:accountID:authContext:identifier:password:simCountryCode:httpRequestConfiguration:supportOneFactorAuthorization:knownDeviceToken:uiMetrics:authTokenStorage:source:responseModelBuilder:completionBlock:); + BHTPwInitIMP imp = (BHTPwInitIMP)objc_msgSend; + + id cmd = imp([cmdCls alloc], sel, + BHTContext(), BHTGuestAccountID(), nil, user, pass, nil, + BHTHTTPConfig(), NO, BHTKnownDeviceToken(), metrics, BHTStorage(), nil, + BHTBuilder("TFSTwitterXAuthPasswordResponseBuilder"), [completion copy]); + if (!cmd) { + [self.hud dismiss]; + [self alert:@"Unavailable" msg:@"Could not build command."]; + return; + } + + ((void (*)(id, SEL, id))objc_msgSend)(BHTLoader(), @selector(startCommand:), cmd); + } @catch (NSException *ex) { + [self.hud dismiss]; + [self alert:@"Crash avoided" msg:ex.reason ?: ex.description]; + } + }]; +} + +- (void)handlePassword:(BOOL)ok response:(id)resp error:(id)err { + [self.hud dismiss]; + + if (!ok) { + [self alertError:err title:@"Login failed"]; + return; + } + + id token = BHTPerform0(resp, @selector(token)); + id secret = BHTPerform0(resp, @selector(tokenSecret)); + if (token && secret) { + id screenName = BHTPerform0(resp, @selector(screenName)) ?: BHTPerform0(resp, @selector(username)); + [self buildAndAddAccountWithToken:token + secret:secret + screenName:screenName + userId:BHTUserId(resp, @selector(userId))]; + return; + } + + if (!BHTPerform0(resp, @selector(loginVerificationRequestId))) { + [self alert:@"Unexpected response" msg:@"No token and no challenge."]; + return; + } + + [self presentChallengeForResponse:resp]; +} + +#pragma mark - Step 2: 2FA / login-verification (web challenge) + +- (void)presentChallengeForResponse:(id)resp { + id requestID = BHTPerform0(resp, @selector(loginVerificationRequestId)); + id urlString = BHTPerform0(resp, @selector(challengeURLString)); + + long long userID = BHTUserId(resp, @selector(loginVerificationUserId)); + if (!userID) { + userID = BHTUserId(resp, @selector(userId)); + } + + long long loginType = 0; + long long cause = 0; + if ([resp respondsToSelector:@selector(loginVerificationRequestType)]) { + loginType = ((int (*)(id, SEL))objc_msgSend)(resp, @selector(loginVerificationRequestType)); + } + if ([resp respondsToSelector:@selector(loginVerificationRequestCause)]) { + cause = ((int (*)(id, SEL))objc_msgSend)(resp, @selector(loginVerificationRequestCause)); + } + + if (!requestID || !urlString) { + [self alert:@"Unexpected response" msg:@"Challenge is missing its request id or URL."]; + return; + } + + BOOL securityKey = NO; + Class tps = objc_getClass("TPSDeviceFeatureSwitches"); + if (tps && [tps respondsToSelector:@selector(isSecurityKeyAuthEnabled)]) { + securityKey = ((BOOL (*)(id, SEL))objc_msgSend)(tps, @selector(isSecurityKeyAuthEnabled)); + } + + Class factoryCls = objc_getClass("T1LoginChallengeFactory"); + id host = BHTPerform0(objc_getClass("T1HostViewController"), @selector(sharedHostViewController)); + if (!factoryCls || !host) { + [self alert:@"Unavailable" msg:@"Challenge/host classes missing."]; + return; + } + + @try { + SEL sel = @selector(loginChallengeWithMode:loginType:requestID:user:userID:URLString:loginCause:); + id (*imp)(id, SEL, long long, long long, id, id, long long, id, long long) = + (id (*)(id, SEL, long long, long long, id, id, long long, id, long long))objc_msgSend; + + id challenge = imp(factoryCls, sel, + securityKey ? 1 : 0, loginType, requestID, + self.userField.text ?: @"", userID, urlString, cause); + if (!challenge) { + [self alert:@"Unavailable" msg:@"Could not build challenge."]; + return; + } + + void (^added)(id, id) = ^(id challengeVC, id account) { + BHTRegisterAccount(account); + + void (^switchBlock)(void) = ^{ + BHTSwitchToAccount(account); + }; + + UIViewController *h = BHTPerform0(objc_getClass("T1HostViewController"), @selector(sharedHostViewController)); + if (h.presentedViewController) { + [h dismissViewControllerAnimated:YES completion:switchBlock]; + } else { + switchBlock(); + } + }; + + if ([challenge respondsToSelector:@selector(setDidAddAccountBlock:)]) { + ((void (*)(id, SEL, id))objc_msgSend)(challenge, @selector(setDidAddAccountBlock:), [added copy]); + } + + if ([host respondsToSelector:@selector(setLoginChallengeProvider:)]) { + ((void (*)(id, SEL, id))objc_msgSend)(host, @selector(setLoginChallengeProvider:), challenge); + } + + void (^present)(void) = ^{ + SEL presentSel = @selector(presentLoginChallengeFromViewController:animated:completion:); + ((void (*)(id, SEL, id, BOOL, id))objc_msgSend)(challenge, presentSel, host, YES, (id)nil); + }; + + id flow = nil; + if ([host respondsToSelector:@selector(signedOutOnboardingFlow)]) { + flow = BHTPerform0(host, @selector(signedOutOnboardingFlow)); + } + + if (flow && [flow respondsToSelector:@selector(completeFlowAnimated:completion:)]) { + ((void (*)(id, SEL, BOOL, id))objc_msgSend)(flow, @selector(completeFlowAnimated:completion:), NO, present); + } else { + present(); + } + } @catch (NSException *ex) { + [self alert:@"Crash avoided" msg:ex.reason ?: ex.description]; + } +} + +#pragma mark - Account + +- (void)buildAndAddAccountWithToken:(id)token secret:(id)secret screenName:(id)screenName userId:(long long)userId { + if (!token || !secret) { + [self alert:@"Unexpected response" msg:@"No token in response."]; + return; + } + + Class accountCls = objc_getClass("TFNTwitterAccount"); + id account = ((id (*)(id, SEL, id, long long))objc_msgSend)([accountCls alloc], @selector(initWithUsername:userID:), screenName, userId); + + if ([account respondsToSelector:@selector(updateUserInfoAndCredentialsWithToken:secret:username:)]) { + ((void (*)(id, SEL, id, id, id))objc_msgSend)(account, @selector(updateUserInfoAndCredentialsWithToken:secret:username:), token, secret, screenName); + } + + [self addAndSwitchToAccount:account]; +} + +- (void)addAndSwitchToAccount:(id)account { + if (!account) { + [self alert:@"Login failed" msg:@"No account was returned."]; + return; + } + + BHTRegisterAccount(account); + + void (^switchBlock)(void) = ^{ + BHTSwitchToAccount(account); + }; + + UIViewController *popup = self.presentingViewController; + if (!popup) { + switchBlock(); + return; + } + + UIViewController *dismisser = popup.presentingViewController ?: popup; + [dismisser dismissViewControllerAnimated:YES completion:switchBlock]; +} + +#pragma mark - Alerts + +- (NSString *)errorText:(id)error { + if ([error isKindOfClass:[NSError class]]) { + NSError *e = error; + return [NSString stringWithFormat:@"%@ (%ld)\n%@", e.domain, (long)e.code, [e.userInfo description] ?: @""]; + } + + return error ? [error description] : @"Unknown error"; +} + +- (void)alertError:(id)err title:(NSString *)title { + NSString *details = [self errorText:err]; + + if (BHTIsRateLimit(err)) { + NSString *msg = [NSString stringWithFormat:@"Too many attempts. Wait a while or switch network/VPN, then try again.\n\nDetails:\n%@", details]; + [self alert:@"Likely rate limited (243)" msg:msg]; + return; + } + + [self alert:title msg:details]; +} + +- (void)alert:(NSString *)title msg:(NSString *)message { + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title + message:message + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:alert animated:YES completion:nil]; +} + +@end diff --git a/Makefile b/Makefile index 15531657..6a4e495f 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ DEBUG = 1 TWEAK_NAME = BHTwitter -BHTwitter_FILES = Tweak.x ModernSettingsViewController.m $(wildcard *.m BHDownload/*.m BHTBundle/*.m Colours/*.m JGProgressHUD/*.m SAMKeychain/*.m AppIcon/*.m CustomTabBar/*.m ThemeColor/*.m) +BHTwitter_FILES = Tweak.x ModernSettingsViewController.m $(wildcard *.m BHDownload/*.m BHTBundle/*.m Colours/*.m JGProgressHUD/*.m SAMKeychain/*.m AppIcon/*.m CustomTabBar/*.m ThemeColor/*.m LegacyLogin/*.m) BHTwitter_FRAMEWORKS = UIKit Foundation AVFoundation AVKit CoreMotion GameController VideoToolbox Accelerate CoreMedia CoreImage CoreGraphics ImageIO Photos CoreServices SystemConfiguration SafariServices Security QuartzCore WebKit SceneKit BHTwitter_PRIVATE_FRAMEWORKS = Preferences BHTwitter_EXTRA_FRAMEWORKS = Cephei CepheiPrefs CepheiUI @@ -16,7 +16,7 @@ BHTwitter_CFLAGS = -fobjc-arc -Wno-deprecated-declarations -Wno-nullability-comp include $(THEOS_MAKE_PATH)/tweak.mk ifdef SIDELOADED -SUBPROJECTS += libflex keychainfix +SUBPROJECTS += libflex zxPluginsInject else SUBPROJECTS += libflex endif diff --git a/ModernSettingsViewController.m b/ModernSettingsViewController.m index f3944c54..c01b0174 100644 --- a/ModernSettingsViewController.m +++ b/ModernSettingsViewController.m @@ -6,6 +6,7 @@ // #import "ModernSettingsViewController.h" +#import "BHTManager.h" #import "BHTBundle/BHTBundle.h" #import "BHDimPalette.h" #import "Colours/Colours.h" @@ -31,7 +32,7 @@ @interface TweetsSettingsViewController : UIViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account; @end -@interface MessagesSettingsViewController : UIViewController +@interface SearchSettingsViewController : UIViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account; @end @@ -130,7 +131,7 @@ - (void)setupViews { self.titleLabel = [[UILabel alloc] init]; self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.titleLabel.textColor = [UIColor labelColor]; [self.contentView addSubview:self.titleLabel]; @@ -214,7 +215,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [self updateIconColors]; [self updateSubtitleColor]; if (previousTraitCollection.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory) { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; } @@ -236,7 +237,7 @@ - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSStr - (void)setupViews { self.titleLabel = [[UILabel alloc] init]; self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.titleLabel.textColor = [UIColor labelColor]; [self.contentView addSubview:self.titleLabel]; @@ -284,7 +285,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { self.backgroundColor = [BHDimPalette currentBackgroundColor]; [self updateChevronColor]; if (previousTraitCollection.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory) { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; } } @@ -305,7 +306,7 @@ - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSStr - (void)setupViews { self.titleLabel = [[UILabel alloc] init]; self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.titleLabel.textColor = [UIColor labelColor]; [self.contentView addSubview:self.titleLabel]; @@ -377,7 +378,7 @@ - (void)traitCollectionDidChange:(UITraitCollection *)previousTraitCollection { [self updateChevronColor]; [self updateSubtitleColor]; if (previousTraitCollection.preferredContentSizeCategory != self.traitCollection.preferredContentSizeCategory) { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; } @@ -428,7 +429,7 @@ - (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvent } - (void)applyTheme { - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; self.titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; self.subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -540,7 +541,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger detailLabel.numberOfLines = 0; detailLabel.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_PLACEHOLDER_DETAIL_TEXT"]; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; if (fontGroup) { if ([fontGroup respondsToSelector:@selector(bodyBoldFont)]) { titleLabel.font = [fontGroup performSelector:@selector(bodyBoldFont)]; @@ -585,27 +586,6 @@ - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSIntege @implementation ModernSettingsViewController -- (void)colorPickerViewControllerDidSelectColor:(UIColorPickerViewController *)viewController { - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - - UIColor *selectedColor = viewController.selectedColor; - if ([selectedColor respondsToSelector:@selector(hexString)]) { - [[NSUserDefaults standardUserDefaults] setObject:selectedColor.hexString forKey:@"background_color"]; - } else { - // Fallback: convert to hex manually - CGFloat r, g, b, a; - [selectedColor getRed:&r green:&g blue:&b alpha:&a]; - NSString *hexString = [NSString stringWithFormat:@"#%02lX%02lX%02lX", - lroundf(r * 255), - lroundf(g * 255), - lroundf(b * 255)]; - [[NSUserDefaults standardUserDefaults] setObject:hexString forKey:@"background_color"]; - } - - [[NSUserDefaults standardUserDefaults] synchronize]; -} - #pragma mark - Section Headers - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { @@ -620,7 +600,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger subtitleLabel.numberOfLines = 0; subtitleLabel.textAlignment = NSTextAlignmentLeft; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; subtitleLabel.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -671,7 +651,7 @@ - (UIView *)headerViewWithTitle:(NSString *)title { titleLabel.translatesAutoresizingMaskIntoConstraints = NO; titleLabel.text = title; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; titleLabel.font = [fontGroup performSelector:@selector(headline1BoldFont)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -748,9 +728,6 @@ - (void)setupSections { @{ @"title": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_TITLE"], @"subtitle": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_SUBTITLE"], @"icon": @"search_stroke", @"action": @"showSearchSettings" }, - @{ @"title": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_TITLE"], - @"subtitle": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_SUBTITLE"], - @"icon": @"messages_stroke", @"action": @"showMessagesSettings" }, @{ @"title": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_WEB_TITLE"], @"subtitle": [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_WEB_SUBTITLE"], @"icon": @"globe_stroke", @"action": @"showWebSettings" }, @@ -775,7 +752,7 @@ - (void)setupDeveloperCells { @{ @"title": @"Thea 🐾", @"username": @"nyaathea", @"avatarURL": @"https://unavatar.io/github/nyathea?fallback=https://neofreebird.com/images/theameoww.png", @"userID": @"1541742676009226241" }, @{ @"title": @"timi2506", @"username": @"timi2506", @"avatarURL": @"https://unavatar.io/github/timi2506?fallback=https://neofreebird.com/images/timi2506.png", @"userID": @"1684856685486063616" } ]; - + self.coolKidsCells = @[ @{ @"title": @"Eevee", @"username": @"whoeevee1", @"avatarURL": @"https://unavatar.io/github/whoeevee?fallback=https://neofreebird.com/images/whoeevee.png", @"userID": @"1547956497342115844" }, @{ @"title": @"zxcvbn", @"username": @"zxxvbn0", @"avatarURL": @"https://unavatar.io/x/zxxvbn0?fallback=https://neofreebird.com/images/zxxvbn0.png", @"userID": @"1678444396717514760" } @@ -996,7 +973,7 @@ - (void)configureDeveloperCell:(UITableViewCell *)cell withDeveloper:(NSDictiona UIImageView *avatarImageView = [cell.contentView viewWithTag:100]; UILabel *nameLabel = [cell.contentView viewWithTag:101]; UILabel *usernameLabel = [cell.contentView viewWithTag:102]; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; id currentPalette = [settings currentColorPalette]; @@ -1093,11 +1070,6 @@ - (void)showTweetsSettings { [self.navigationController pushViewController:vc animated:YES]; } -- (void)showMessagesSettings { - MessagesSettingsViewController *vc = [[MessagesSettingsViewController alloc] initWithAccount:self.account]; - [self.navigationController pushViewController:vc animated:YES]; -} - - (void)showBrandingSettings { BrandingSettingsViewController *vc = [[BrandingSettingsViewController alloc] initWithAccount:self.account]; [self.navigationController pushViewController:vc animated:YES]; @@ -1114,9 +1086,7 @@ - (void)showDebugSettings { } - (void)showSearchSettings { - ModernSettingsPlaceholderViewController *vc = - [[ModernSettingsPlaceholderViewController alloc] initWithAccount:self.account - titleKey:@"MODERN_SETTINGS_SEARCH_TITLE"]; + SearchSettingsViewController *vc = [[SearchSettingsViewController alloc] initWithAccount:self.account]; [self.navigationController pushViewController:vc animated:YES]; } @@ -1246,7 +1216,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_TWITTER_BLUE_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1399,7 +1369,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MEDIA_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1522,7 +1492,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_PROFILES_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1561,20 +1531,20 @@ - (void)showRestartRequiredAlert:(NSString *)messageKey { [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_NOW_BUTTON_TITLE"] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) {exit(0);}]]; [self presentViewController:alert animated:YES completion:nil]; } - + @end // ============================== -// TweetsSettingsViewController (cleaned - old_style removed) +// SearchSettingsViewController // ============================== -@interface TweetsSettingsViewController () +@interface SearchSettingsViewController () @property (nonatomic, strong) TFNTwitterAccount *account; @property (nonatomic, strong) UITableView *tableView; @property (nonatomic, strong) NSArray *settings; @end -@implementation TweetsSettingsViewController +@implementation SearchSettingsViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account { if ((self = [super init])) { @@ -1591,7 +1561,7 @@ - (void)viewDidLoad { } - (void)setupNav { - NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_TWEETS_TITLE"]; + NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_TITLE"]; if (self.account) { self.navigationItem.titleView = [objc_getClass("TFNTitleView") titleViewWithTitle:title subtitle:self.account.displayUsername]; } else { @@ -1617,16 +1587,9 @@ - (void)setupTable { - (void)buildSettingsList { self.settings = @[ - @{ @"key": @"TweetToImage", @"titleKey": @"TWEET_TO_IMAGE_OPTION_TITLE", @"subtitleKey": @"TWEET_TO_IMAGE_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"like_con", @"titleKey": @"LIKE_CONFIRM_OPTION_TITLE", @"subtitleKey": @"LIKE_CONFIRM_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"tweet_con", @"titleKey": @"TWEET_CONFIRM_OPTION_TITLE", @"subtitleKey": @"TWEET_CONFIRM_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"hide_blue_verified", @"titleKey": @"HIDE_BLUE_VERIFIED_OPTION_TITLE", @"subtitleKey": @"HIDE_BLUE_VERIFIED_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"hide_view_count", @"titleKey": @"HIDE_VIEW_COUNT_OPTION_TITLE", @"subtitleKey": @"HIDE_VIEW_COUNT_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, - @{ @"key": @"hide_bookmark_button", @"titleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_TITLE", @"subtitleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"disableSensitiveTweetWarnings", @"titleKey": @"DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE", @"subtitleKey": @"", @"default": @YES, @"type": @"toggle" }, - @{ @"key": @"hide_grok_analyze", @"titleKey": @"HIDE_GROK_ANALYZE_BUTTON_TITLE", @"subtitleKey": @"HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, - @{ @"key": @"reply_sorting_enabled", @"titleKey": @"REPLY_SORTING_TITLE", @"subtitleKey": @"REPLY_SORTING_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"restore_reply_context", @"titleKey": @"RESTORE_REPLY_CONTEXT_TITLE", @"subtitleKey": @"RESTORE_REPLY_CONTEXT_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" } + @{ @"key": @"no_his", @"titleKey": @"NO_HISTORY_OPTION_TITLE", @"subtitleKey": @"NO_HISTORY_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_trends", @"titleKey": @"HIDE_TRENDS_OPTION_TITLE", @"subtitleKey": @"HIDE_TRENDS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_trend_videos", @"titleKey": @"HIDE_TREND_VIDEOS_OPTION_TITLE", @"subtitleKey": @"HIDE_TREND_VIDEOS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } ]; } @@ -1657,9 +1620,9 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 0)]; UILabel *label = [[UILabel alloc] init]; label.translatesAutoresizingMaskIntoConstraints = NO; - label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_TWEETS_SUBTITLE"]; + label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_SEARCH_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1680,46 +1643,25 @@ - (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSIntege return UITableViewAutomaticDimension; } +- (void)switchChanged:(UISwitch *)sender { + NSString *key = objc_getAssociatedObject(sender, @"prefKey"); + if (key) { + [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:key]; + } +} + @end // ============================== -// MessagesSettingsViewController +// TweetsSettingsViewController (cleaned - old_style removed) // ============================== -@interface MessagesSettingsViewController () +@interface TweetsSettingsViewController () @property (nonatomic, strong) TFNTwitterAccount *account; @property (nonatomic, strong) UITableView *tableView; @property (nonatomic, strong) NSArray *settings; @end -@implementation MessagesSettingsViewController - -#pragma mark - Image Picker Delegate -- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { - NSFileManager *manager = [NSFileManager defaultManager]; - NSString *docPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).firstObject; - NSURL *oldImgPath = info[UIImagePickerControllerImageURL]; - NSURL *newImgPath = [[NSURL fileURLWithPath:docPath] URLByAppendingPathComponent:@"msg_background.png"]; - - if ([manager fileExistsAtPath:newImgPath.path]) { - [manager removeItemAtURL:newImgPath error:nil]; - } - [manager copyItemAtURL:oldImgPath toURL:newImgPath error:nil]; - - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - [[NSUserDefaults standardUserDefaults] synchronize]; - - [picker dismissViewControllerAnimated:YES completion:nil]; -} - -#pragma mark - Color Picker Delegate -- (void)colorPickerViewControllerDidSelectColor:(UIColorPickerViewController *)viewController { - [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] setObject:viewController.selectedColor.hexString forKey:@"background_color"]; - [[NSUserDefaults standardUserDefaults] synchronize]; -} +@implementation TweetsSettingsViewController - (instancetype)initWithAccount:(TFNTwitterAccount *)account { if ((self = [super init])) { @@ -1736,7 +1678,7 @@ - (void)viewDidLoad { } - (void)setupNav { - NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_TITLE"]; + NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_TWEETS_TITLE"]; if (self.account) { self.navigationItem.titleView = [objc_getClass("TFNTitleView") titleViewWithTitle:title subtitle:self.account.displayUsername]; } else { @@ -1757,17 +1699,23 @@ - (void)setupTable { self.tableView.showsHorizontalScrollIndicator = NO; self.tableView.estimatedRowHeight = 80; [self.tableView registerClass:[ModernSettingsToggleCell class] forCellReuseIdentifier:@"ToggleCell"]; - [self.tableView registerClass:[ModernSettingsSimpleButtonCell class] forCellReuseIdentifier:@"SimpleButtonCell"]; [self.view addSubview:self.tableView]; } - (void)buildSettingsList { self.settings = @[ - @{ @"key": @"dm_avatars", @"titleKey": @"DM_AVATARS_TITLE", @"subtitleKey": @"DM_AVATARS_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"dm_compose_bar_v2_enabled", @"titleKey": @"DM_COMPOSE_BAR_V2_TITLE", @"subtitleKey": @"DM_COMPOSE_BAR_V2_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - @{ @"key": @"dm_voice_creation_enabled", @"titleKey": @"DM_VOICE_CREATION_TITLE", @"subtitleKey": @"DM_VOICE_CREATION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, - //@{ @"key": @"disable_xchat", @"titleKey": @"DISABLE_XCHAT_OPTION_TITLE", @"subtitleKey": @"DISABLE_XCHAT_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, - @{ @"titleKey": @"CUSTOM_DIRECT_BACKGROUND_VIEW_TITLE", @"subtitleKey": @"CUSTOM_DIRECT_BACKGROUND_VIEW_DETAIL_TITLE", @"action": @"showCustomBackgroundOptions:", @"type": @"button" } + @{ @"key": @"TweetToImage", @"titleKey": @"TWEET_TO_IMAGE_OPTION_TITLE", @"subtitleKey": @"TWEET_TO_IMAGE_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"like_con", @"titleKey": @"LIKE_CONFIRM_OPTION_TITLE", @"subtitleKey": @"LIKE_CONFIRM_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"tweet_con", @"titleKey": @"TWEET_CONFIRM_OPTION_TITLE", @"subtitleKey": @"TWEET_CONFIRM_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_blue_verified", @"titleKey": @"HIDE_BLUE_VERIFIED_OPTION_TITLE", @"subtitleKey": @"HIDE_BLUE_VERIFIED_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_view_count", @"titleKey": @"HIDE_VIEW_COUNT_OPTION_TITLE", @"subtitleKey": @"HIDE_VIEW_COUNT_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, + @{ @"key": @"hide_bookmark_button", @"titleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_TITLE", @"subtitleKey": @"HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"hide_downvote_button", @"titleKey": @"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE", @"subtitleKey": @"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"disableSensitiveTweetWarnings", @"titleKey": @"DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE", @"subtitleKey": @"", @"default": @YES, @"type": @"toggle" }, + @{ @"key": @"bypass_age_verification", @"titleKey": @"BYPASS_AGE_VERIFICATION_OPTION_TITLE", @"subtitleKey": @"BYPASS_AGE_VERIFICATION_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, + @{ @"key": @"hide_grok_analyze", @"titleKey": @"HIDE_GROK_ANALYZE_BUTTON_TITLE", @"subtitleKey": @"HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, + @{ @"key": @"reply_sorting_enabled", @"titleKey": @"REPLY_SORTING_TITLE", @"subtitleKey": @"REPLY_SORTING_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"restore_reply_context", @"titleKey": @"RESTORE_REPLY_CONTEXT_TITLE", @"subtitleKey": @"RESTORE_REPLY_CONTEXT_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" } ]; } @@ -1777,51 +1725,30 @@ - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { NSDictionary *settingData = self.settings[indexPath.row]; - NSString *type = settingData[@"type"]; - if ([type isEqualToString:@"button"]) { - ModernSettingsSimpleButtonCell *cell = [tableView dequeueReusableCellWithIdentifier:@"SimpleButtonCell" forIndexPath:indexPath]; - NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:settingData[@"titleKey"]]; - [cell configureWithTitle:title]; - return cell; - } else { - ModernSettingsToggleCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ToggleCell" forIndexPath:indexPath]; - NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:settingData[@"titleKey"]]; - NSString *subtitleKey = settingData[@"subtitleKey"]; - NSString *subtitle = (subtitleKey.length > 0) ? [[BHTBundle sharedBundle] localizedStringForKey:subtitleKey] : @""; - [cell configureWithTitle:title subtitle:subtitle]; - NSString *key = settingData[@"key"]; - BOOL isEnabled = [[[NSUserDefaults standardUserDefaults] objectForKey:key] ?: settingData[@"default"] boolValue]; - cell.toggleSwitch.on = isEnabled; - objc_setAssociatedObject(cell.toggleSwitch, @"prefKey", key, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - [cell addTarget:self action:@selector(switchChanged:) forControlEvents:UIControlEventValueChanged]; - return cell; - } + ModernSettingsToggleCell *cell = [tableView dequeueReusableCellWithIdentifier:@"ToggleCell" forIndexPath:indexPath]; + NSString *title = [[BHTBundle sharedBundle] localizedStringForKey:settingData[@"titleKey"]]; + NSString *subtitleKey = settingData[@"subtitleKey"]; + NSString *subtitle = (subtitleKey.length > 0) ? [[BHTBundle sharedBundle] localizedStringForKey:subtitleKey] : @""; + [cell configureWithTitle:title subtitle:subtitle]; + NSString *key = settingData[@"key"]; + BOOL isEnabled = [[[NSUserDefaults standardUserDefaults] objectForKey:key] ?: settingData[@"default"] boolValue]; + cell.toggleSwitch.on = isEnabled; + objc_setAssociatedObject(cell.toggleSwitch, @"prefKey", key, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + [cell addTarget:self action:@selector(switchChanged:) forControlEvents:UIControlEventValueChanged]; + return cell; } - (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [tableView deselectRowAtIndexPath:indexPath animated:YES]; - NSDictionary *data = self.settings[indexPath.row]; - if ([data[@"type"] isEqualToString:@"button"]) { - NSString *actionName = data[@"action"]; - if (actionName) { - SEL action = NSSelectorFromString(actionName); - if ([self respondsToSelector:action]) { -#pragma clang diagnostic push -#pragma clang diagnostic ignored "-Warc-performSelector-leaks" - [self performSelector:action withObject:data]; -#pragma clang diagnostic pop - } - } - } } - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 0)]; UILabel *label = [[UILabel alloc] init]; label.translatesAutoresizingMaskIntoConstraints = NO; - label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_MESSAGES_SUBTITLE"]; + label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_TWEETS_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -1849,58 +1776,6 @@ - (void)switchChanged:(UISwitch *)sender { } } -- (void)showCustomBackgroundOptions:(NSDictionary *)sender { - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"NeoFreeBird" message:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_VIEW_DETAIL_TITLE"] preferredStyle:UIAlertControllerStyleActionSheet]; - if (alert.popoverPresentationController != nil) { - UITableViewCell *cell = [self.tableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:self.settings.count - 1 inSection:0]]; - alert.popoverPresentationController.sourceView = cell; - alert.popoverPresentationController.sourceRect = cell.bounds; - } - UIAlertAction *imageAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_1"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [self showImagePicker]; - }]; - UIAlertAction *colorAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_2"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [self showColorPicker]; - }]; - UIAlertAction *resetAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_3"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [self resetBackgroundCustomization]; - }]; - UIAlertAction *cancel = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]; - [alert addAction:imageAction]; - [alert addAction:colorAction]; - [alert addAction:resetAction]; - [alert addAction:cancel]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)showImagePicker { - UIImagePickerController *imagePicker = [[UIImagePickerController alloc] init]; - imagePicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; - imagePicker.delegate = (id)self; - [self presentViewController:imagePicker animated:YES completion:nil]; -} - -- (void)showColorPicker { - if (@available(iOS 14.0, *)) { - UIColorPickerViewController *colorPicker = [[UIColorPickerViewController alloc] init]; - colorPicker.delegate = (id)self; - [self presentViewController:colorPicker animated:YES completion:nil]; - } -} - -- (void)resetBackgroundCustomization { - [[NSUserDefaults standardUserDefaults] setBool:NO forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESET_COMPLETE_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"BACKGROUND_RESET_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] - style:UIAlertActionStyleDefault - handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - @end // ============================== @@ -1962,9 +1837,9 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ - @{@"key": @"notif_replace_post_with_tweet", @"titleKey": @"NOTIF_REPLACE_POST_WITH_TWEET_OPTION_TITLE", @"subtitleKey": @"NOTIF_REPLACE_POST_WITH_TWEET_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, - @{@"key": @"refresh_pill_label", @"titleKey": @"REFRESH_PILL_OPTION_TITLE", @"subtitleKey": @"REFRESH_PILL_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"}, - @{@"key": @"color_twitter_icon_in_top_bar", @"titleKey": @"COLOR_TWITTER_ICON_OPTION_TITLE", @"subtitleKey": @"COLOR_TWITTER_ICON_DETAIL_TITLE", @"default": @YES, @"type": @"toggle"} + @{@"key": @"restore_twitter_names", @"titleKey": @"RESTORE_TWITTER_NAMES_OPTION_TITLE", @"subtitleKey": @"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE", @"default": @([BHTManager isTwitterBranded]), @"type": @"toggle"}, + @{@"key": @"refresh_pill_label", @"titleKey": @"REFRESH_PILL_OPTION_TITLE", @"subtitleKey": @"REFRESH_PILL_DETAIL_TITLE", @"default": @([BHTManager isTwitterBranded]), @"type": @"toggle"}, + @{@"key": @"color_twitter_icon_in_top_bar", @"titleKey": @"COLOR_TWITTER_ICON_OPTION_TITLE", @"subtitleKey": @"COLOR_TWITTER_ICON_DETAIL_TITLE", @"default": @([BHTManager isTwitterBranded]), @"type": @"toggle"} ]; [self.tableView reloadData]; } @@ -2014,7 +1889,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_BRANDING_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); @@ -2108,7 +1983,9 @@ - (void)setupTable { - (void)buildSettingsList { self.toggles = @[ - @{ @"key": @"restore_tweet_labels", @"titleKey": @"ENABLE_TWEET_LABELS_OPTION_TITLE", @"subtitleKey": @"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } + @{ @"key": @"restore_tweet_labels", @"titleKey": @"ENABLE_TWEET_LABELS_OPTION_TITLE", @"subtitleKey": @"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" }, + @{ @"key": @"attestation_bypass_enabled", @"titleKey": @"ATTESTATION_BYPASS_OPTION_TITLE", @"subtitleKey": @"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE", @"default": @YES, @"type": @"toggle" }, + @{ @"key": @"reply_in_webview", @"titleKey": @"REPLY_IN_WEBVIEW_OPTION_TITLE", @"subtitleKey": @"REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } ]; [self updateVisibleToggles]; [self.tableView reloadData]; @@ -2203,7 +2080,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_EXPERIMENTAL_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -2437,7 +2314,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_WEB_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -2647,7 +2524,7 @@ - (void)setupTable { } - (void)buildSettingsList { - self.toggles = @[ @{ @"key": @"flex_twitter", @"titleKey": @"FLEX_OPTION_TITLE", @"subtitleKey": @"FLEX_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } + self.toggles = @[ @{ @"key": @"flex_twitter", @"titleKey": @"FLEX_OPTION_TITLE", @"subtitleKey": @"FLEX_OPTION_DETAIL_TITLE", @"default": @NO, @"type": @"toggle" } ]; [self updateVisibleToggles]; [self.tableView reloadData]; @@ -2742,7 +2619,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_DEBUG_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -2921,9 +2798,8 @@ - (void)buildToggleList { @{ @"key": @"hide_topics", @"titleKey": @"HIDE_TOPICS_OPTION_TITLE", @"subtitleKey": @"HIDE_TOPICS_OPTION_DETAIL_TITLE", @"default": @YES }, @{ @"key": @"hide_topics_to_follow", @"titleKey": @"HIDE_TOPICS_TO_FOLLOW_OPTION", @"subtitleKey": @"HIDE_TOPICS_TO_FOLLOW_OPTION_DETAIL_TITLE", @"default": @YES }, @{ @"key": @"hide_who_to_follow", @"titleKey": @"HIDE_WHO_FOLLOW_OPTION", @"subtitleKey": @"HIDE_WHO_FOLLOW_OPTION_DETAIL_TITLE", @"default": @YES }, - @{ @"key": @"no_his", @"titleKey": @"NO_HISTORY_OPTION_TITLE", @"subtitleKey": @"NO_HISTORY_OPTION_DETAIL_TITLE", @"default": @NO }, - @{ @"key": @"hide_trend_videos", @"titleKey": @"HIDE_TREND_VIDEOS_OPTION_TITLE", @"subtitleKey": @"HIDE_TREND_VIDEOS_OPTION_DETAIL_TITLE", @"default": @NO }, @{ @"key": @"hide_spaces", @"titleKey": @"HIDE_SPACE_OPTION_TITLE", @"subtitleKey": @"", @"default": @NO }, + @{ @"key": @"hide_custom_timelines", @"titleKey": @"HIDE_CUSTOM_TIMELINES_OPTION_TITLE", @"subtitleKey": @"HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE", @"default": @NO }, @{ @"key": @"no_tab_bar_hiding", @"titleKey": @"STOP_HIDING_TAB_BAR_TITLE", @"subtitleKey": @"STOP_HIDING_TAB_BAR_DETAIL_TITLE", @"default": @YES }, @{ @"key": @"tab_bar_theming", @"titleKey": @"CLASSIC_TAB_BAR_SETTINGS_TITLE", @"subtitleKey": @"CLASSIC_TAB_BAR_SETTINGS_DETAIL", @"default": @NO }, @{ @"key": @"restore_tab_labels", @"titleKey": @"RESTORE_TAB_LABELS_TITLE", @"subtitleKey": @"RESTORE_TAB_LABELS_DETAIL", @"default": @NO }, @@ -3024,7 +2900,7 @@ - (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger label.translatesAutoresizingMaskIntoConstraints = NO; label.text = [[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_SETTINGS_LAYOUT_SUBTITLE"]; label.numberOfLines = 0; - id fontGroup = [objc_getClass("TAEStandardFontGroup") sharedFontGroup]; + id fontGroup = [BHTManager sharedFontGroup]; label.font = [fontGroup performSelector:@selector(subtext2Font)]; Class TAEColorSettingsCls = objc_getClass("TAEColorSettings"); id settings = [TAEColorSettingsCls sharedSettings]; @@ -3147,4 +3023,4 @@ - (void)fontPickerViewControllerDidPickFont:(UIFontPickerViewController *)viewCo [viewController.navigationController popViewControllerAnimated:YES]; } -@end \ No newline at end of file +@end diff --git a/README.md b/README.md index 32dd48fd..9f7776c9 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@
NeoFreeBird-BHTwitter - + # NeoFreeBird-BHTwitter (tweak) The ultimate way to tweak your Twitter/X experience.

-> [!WARNING] -> Please do not create issues regarding sign in or Tweeting.
Twitter/X have added Attestation to prevent the use of third-paty or modified clients. We cannot do anything against this. Please do not create new issues regarding this. +> [!WARNING] +> Please do not create issues regarding sign in or Tweeting.
Twitter/X have added Attestation to prevent the use of third-paty or modified clients. We cannot do anything against this. Please do not create new issues regarding this. | | | | |:-------------------------:|:-------------------------:|:-------------------------:| @@ -18,6 +18,9 @@ ## Using your computer 1. Install [Theos](https://github.com/theos/theos). + +> Note for Linux users: the current toolchain that ships with Theos is too outdated to build successfully ("Undefined symbols for architecture arm64"). Download the latest toolchain for your distro from [here](https://github.com/L1ghtmann/swift-toolchain-linux/releases/latest) and replace `$THEOS/toolchain/linux` with its contents. + 2. Install [cyan](https://github.com/asdfzxcvbn/pyzule-rw) if you want sideload or TrollStore builds. 3. Clone the NeoFreeBird-BHTwitter repository: @@ -102,10 +105,10 @@ Just run: Result: `com.bandarhl.bhtwitter_4.2_iphoneos-arm.deb` inside `packages`. -> [!NOTE] +> [!NOTE] > These builds are considered beta
This repo is meant for NeoFreeBird, which builds this for specific versions of Twitter. You can of course build this for your own app without using the main NeoFreeBird app, but please note your build will not be supported by the NeoFreeBird team if you do. -> [!NOTE] -> This repo is forked from BHTwitter.
It will merge patches upstream to BHTwitter when it's considered mostly done. +> [!NOTE] +> This repo is forked from BHTwitter.
It will merge patches upstream to BHTwitter when it's considered mostly done. diff --git a/SettingsViewController.h b/SettingsViewController.h deleted file mode 100755 index f8619204..00000000 --- a/SettingsViewController.h +++ /dev/null @@ -1,27 +0,0 @@ -// -// SettingsViewController.h (legacy) -// NeoFreeBird -// -// Created by BandarHelal on 25/11/2021. -// - -#import "TWHeaders.h" -#import -#import - -typedef NS_ENUM(NSInteger, DynamicSpecifierOperatorType) { - EqualToOperatorType, - NotEqualToOperatorType, - GreaterThanOperatorType, - LessThanOperatorType, -}; - -@interface SettingsViewController : HBListController -- (instancetype)initWithTwitterAccount:(TFNTwitterAccount *)account; -@end - -@interface BHButtonTableViewCell : HBTintedTableCell -@end - -@interface BHSwitchTableCell : PSSwitchTableCell -@end \ No newline at end of file diff --git a/SettingsViewController.m b/SettingsViewController.m deleted file mode 100644 index 001661f2..00000000 --- a/SettingsViewController.m +++ /dev/null @@ -1,1367 +0,0 @@ -// -// SettingsViewController.m (legacy) -// NeoFreeBird -// -// Created by BandarHelal -// Modified by actuallyaridan & nyaathea -// - - -#import "SettingsViewController.h" -#import "BHTBundle/BHTBundle.h" -#import "Colours/Colours.h" -#import "AppIcon/BHAppIconViewController.h" -#import "ThemeColor/BHColorThemeViewController.h" -#import "CustomTabBar/BHCustomTabBarViewController.h" -#import "BHTManager.h" -#import "BHDimPalette.h" - -// Import external function to get theme color -extern UIColor *BHTCurrentAccentColor(void); - -// Interface declaration for TFNFloatingActionButton -@interface TFNFloatingActionButton : UIView -- (void)hideAnimated:(_Bool)animated completion:(id)completion; -@end - -typedef NS_ENUM(NSInteger, TwitterFontWeight) { - TwitterFontWeightRegular, - TwitterFontWeightMedium, - TwitterFontWeightSemibold, - TwitterFontWeightBold -}; - -typedef NS_ENUM(NSInteger, TwitterFontStyle) { - TwitterFontStyleRegular, - TwitterFontStyleSemibold, - TwitterFontStyleBold -}; - -static UIFont *TwitterChirpFont(TwitterFontStyle style) { - switch (style) { - case TwitterFontStyleBold: - return [UIFont fontWithName:@"ChirpUIVF_wght3200000_opsz150000" size:17] ?: - [UIFont systemFontOfSize:17 weight:UIFontWeightBold]; - - case TwitterFontStyleSemibold: - return [UIFont fontWithName:@"ChirpUIVF_wght2BC0000_opszE0000" size:14] ?: - [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold]; - - case TwitterFontStyleRegular: - default: - return [UIFont fontWithName:@"ChirpUIVF_wght1900000_opszE0000" size:12] ?: - [UIFont systemFontOfSize:12 weight:UIFontWeightRegular]; - } -} - -@interface SettingsViewController () -@property (nonatomic, strong) TFNTwitterAccount *twAccount; -@property (nonatomic, assign) BOOL hasDynamicSpecifiers; -@property (nonatomic, retain) NSMutableDictionary *dynamicSpecifiers; -@end - -@implementation SettingsViewController - -#pragma mark - UITableView Setup -- (instancetype)init { - self = [super init]; - if (self) { - [self setupAppearance]; - } - return self; -} -- (instancetype)initWithTwitterAccount:(TFNTwitterAccount *)account { - self = [super init]; - if (self) { - self.twAccount = account; - [self setupAppearance]; - [self.navigationController.navigationBar setPrefersLargeTitles:false]; - [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"bh_color_theme_selectedColor" options:NSKeyValueObservingOptionNew context:nil]; - [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"T1ColorSettingsPrimaryColorOptionKey" options:NSKeyValueObservingOptionNew context:nil]; - // [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"tab_bar_theming"]; // Ensure it's removed if previously added - } - return self; -} -- (void)dealloc { - [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"bh_color_theme_selectedColor"]; - [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"T1ColorSettingsPrimaryColorOptionKey"]; - // [[NSUserDefaults standardUserDefaults] removeObserver:self forKeyPath:@"tab_bar_theming"]; // Ensure it's removed -} - -- (void)setupAppearance { - TAEColorSettings *colorSettings = [objc_getClass("TAEColorSettings") sharedSettings]; - UIColor *primaryColor; - - if ([[NSUserDefaults standardUserDefaults] objectForKey:@"bh_color_theme_selectedColor"]) { - primaryColor = [[[colorSettings currentColorPalette] colorPalette] primaryColorForOption:[[NSUserDefaults standardUserDefaults] integerForKey:@"bh_color_theme_selectedColor"]]; - } else if ([[NSUserDefaults standardUserDefaults] objectForKey:@"T1ColorSettingsPrimaryColorOptionKey"]) { - primaryColor = [[[colorSettings currentColorPalette] colorPalette] primaryColorForOption:[[NSUserDefaults standardUserDefaults] integerForKey:@"T1ColorSettingsPrimaryColorOptionKey"]]; - } else { - primaryColor = nil; - } - - // Use the primaryColor to update UI elements - if (primaryColor) { - self.view.tintColor = primaryColor; - } -} - -- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context { - if ([keyPath isEqualToString:@"bh_color_theme_selectedColor"] || [keyPath isEqualToString:@"T1ColorSettingsPrimaryColorOptionKey"]) { - [self setupAppearance]; - } - // Removed tab_bar_theming observation and BHTTabBarThemingChanged notification -} - -- (void)viewWillAppear:(BOOL)animated { - [super viewWillAppear:animated]; - - // Find and hide the floating action button using recursive search - for (UIWindow *window in [UIApplication sharedApplication].windows) { - [self findAndHideFloatingActionButtonInView:window]; - } -} - -- (void)findAndHideFloatingActionButtonInView:(UIView *)view { - // Direct check for the current view - if ([view isKindOfClass:NSClassFromString(@"TFNFloatingActionButton")]) { - [(TFNFloatingActionButton *)view hideAnimated:YES completion:nil]; - return; - } - - // Recursively check subviews - for (UIView *subview in view.subviews) { - [self findAndHideFloatingActionButtonInView:subview]; - } -} - -- (void)viewDidLoad { - if (self.twAccount != nil) { - self.navigationItem.titleView = [objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"NFB_SETTINGS_TITLE"] subtitle:self.twAccount.displayUsername]; - } else { - self.title = [[BHTBundle sharedBundle] - localizedStringForKey:@"NFB_SETTINGS_TITLE"]; - } - - [super viewDidLoad]; - - // Set the background color based on the theme mode using BHDimPalette - UIColor *backgroundColor = [BHDimPalette currentBackgroundColor]; - self.view.backgroundColor = backgroundColor; - self.table.backgroundColor = backgroundColor; - - self.table.separatorColor = [UIColor separatorColor]; - - // Apply theme color to table - self.table.tintColor = BHTCurrentAccentColor(); - - // Remove extra separators below content - self.table.tableFooterView = [UIView new]; - self.table.separatorStyle = UITableViewCellSeparatorStyleNone; - - if (@available(iOS 15.0, *)) { - self.table.sectionHeaderTopPadding = 8; - } - - // These ensure cells align with headers - self.table.separatorInset = UIEdgeInsetsMake(0, 16, 0, 0); - self.table.layoutMargins = UIEdgeInsetsMake(0, 16, 0, 16); -} - -- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { - if (section == 0) { - UIView *header = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 40)]; - - // Set background color for dim mode if needed - if ([BHDimPalette isDimMode]) { - header.backgroundColor = [BHDimPalette dimModeColor]; - } - - UILabel *detail = [UILabel new]; - detail.translatesAutoresizingMaskIntoConstraints = NO; - detail.font = [TwitterChirpFont(TwitterFontStyleRegular) fontWithSize:12]; - detail.textColor = [UIColor secondaryLabelColor]; - detail.numberOfLines = 0; - detail.textAlignment = NSTextAlignmentLeft; - detail.text = [[BHTBundle sharedBundle] localizedStringForKey:@"NFB_SETTINGS_DETAIL"]; - - [header addSubview:detail]; - [NSLayoutConstraint activateConstraints:@[ - [detail.leadingAnchor constraintEqualToAnchor:header.leadingAnchor constant:16], - [detail.trailingAnchor constraintEqualToAnchor:header.trailingAnchor constant:-16], - [detail.topAnchor constraintEqualToAnchor:header.topAnchor constant:-12], - [detail.bottomAnchor constraintEqualToAnchor:header.bottomAnchor constant:-8] - ]]; - - return header; - } - NSString *title = [self tableView:tableView titleForHeaderInSection:section]; - if (!title) { - return nil; - } - - UIView *headerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 52)]; - - // Set background color for dim mode if needed - if ([BHDimPalette isDimMode]) { - headerView.backgroundColor = [BHDimPalette dimModeColor]; - } - - // Top separator - modified to extend full width - if (section != 1) { - UIView *topSeparator = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 0.5)]; - topSeparator.backgroundColor = [UIColor separatorColor]; - topSeparator.autoresizingMask = UIViewAutoresizingFlexibleWidth; - [headerView addSubview:topSeparator]; - } - - // Header label - use attributed text with bold font - UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(16, 16, tableView.frame.size.width - 32, 28)]; - - // Use attributed string to ensure bold rendering - NSDictionary *attrs = @{ - NSFontAttributeName: TwitterChirpFont(TwitterFontStyleBold), - NSForegroundColorAttributeName: [UIColor labelColor] - }; - label.attributedText = [[NSAttributedString alloc] initWithString:title attributes:attrs]; - - [headerView addSubview:label]; - - return headerView; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section { - if (section == 0) { - return 52; // or whatever height you prefer - } - return 52; // or your default -} - -- (UIView *)tableView:(UITableView *)tableView viewForFooterInSection:(NSInteger)section { - NSString *footerText = [self tableView:tableView titleForFooterInSection:section]; - if (!footerText) { - return nil; - } - - UIView *footerView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, tableView.frame.size.width, 44)]; - - // Set background color for dim mode if needed - if ([BHDimPalette isDimMode]) { - footerView.backgroundColor = [BHDimPalette dimModeColor]; - } - - UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(16, 8, tableView.frame.size.width - 32, 36)]; - label.text = footerText; - label.font = TwitterChirpFont(TwitterFontStyleRegular); // 12pt regular - label.textColor = [UIColor secondaryLabelColor]; - label.numberOfLines = 0; - [footerView addSubview:label]; - - return footerView; -} - -- (CGFloat)tableView:(UITableView *)tableView heightForFooterInSection:(NSInteger)section { - NSString *footerText = [self tableView:tableView titleForFooterInSection:section]; - if (!footerText) { - return CGFLOAT_MIN; // Use minimal height when no footer - } - - // Calculate dynamic height - CGFloat width = tableView.frame.size.width - 32; - CGRect rect = [footerText boundingRectWithSize:CGSizeMake(width, CGFLOAT_MAX) - options:NSStringDrawingUsesLineFragmentOrigin - attributes:@{NSFontAttributeName: TwitterChirpFont(TwitterFontStyleRegular)} - context:nil]; - - return ceil(rect.size.height) + 24; // Top/bottom padding -} - -- (void)tableView:(UITableView *)tableView willDisplayCell:(UITableViewCell *)cell forRowAtIndexPath:(NSIndexPath *)indexPath { - // Remove any default separator insets - cell.separatorInset = UIEdgeInsetsMake(0, 0, 0, CGRectGetWidth(tableView.bounds)); - - // Set cell background color using BHDimPalette - cell.backgroundColor = [BHDimPalette currentBackgroundColor]; - - // Remove selection highlight if needed - cell.selectionStyle = UITableViewCellSelectionStyleDefault; - - // Only apply tint color, don't modify other aspects - cell.tintColor = BHTCurrentAccentColor(); -} - -- (UITableViewStyle)tableViewStyle { - return UITableViewStyleGrouped; -} - -- (PSSpecifier *)newSectionWithTitle:(NSString *)header footer:(NSString *)footer { - PSSpecifier *section = [PSSpecifier preferenceSpecifierNamed:header target:self set:nil get:nil detail:nil cell:PSGroupCell edit:nil]; - if (footer != nil) { - [section setProperty:footer forKey:@"footerText"]; - } - return section; -} -- (PSSpecifier *)newSwitchCellWithTitle:(NSString *)titleText detailTitle:(NSString *)detailText key:(NSString *)keyText defaultValue:(BOOL)defValue changeAction:(SEL)changeAction { - PSSpecifier *switchCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:PSSwitchCell edit:nil]; - - [switchCell setProperty:keyText forKey:@"key"]; - [switchCell setProperty:keyText forKey:@"id"]; - [switchCell setProperty:@YES forKey:@"big"]; - [switchCell setProperty:BHSwitchTableCell.class forKey:@"cellClass"]; - [switchCell setProperty:NSBundle.mainBundle.bundleIdentifier forKey:@"defaults"]; - [switchCell setProperty:@(defValue) forKey:@"default"]; - [switchCell setProperty:NSStringFromSelector(changeAction) forKey:@"switchAction"]; - if (detailText != nil) { - [switchCell setProperty:detailText forKey:@"subtitle"]; - } - return switchCell; -} -- (PSSpecifier *)newButtonCellWithTitle:(NSString *)titleText detailTitle:(NSString *)detailText dynamicRule:(NSString *)rule action:(SEL)action { - PSSpecifier *buttonCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:PSButtonCell edit:nil]; - - [buttonCell setButtonAction:action]; - [buttonCell setProperty:@YES forKey:@"big"]; - [buttonCell setProperty:BHButtonTableViewCell.class forKey:@"cellClass"]; - if (detailText != nil ){ - [buttonCell setProperty:detailText forKey:@"subtitle"]; - } - if (rule != nil) { - [buttonCell setProperty:@44 forKey:@"height"]; - [buttonCell setProperty:rule forKey:@"dynamicRule"]; - } - return buttonCell; -} -- (PSSpecifier *)newHBLinkCellWithTitle:(NSString *)titleText detailTitle:(NSString *)detailText url:(NSString *)url { - PSSpecifier *HBLinkCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:PSButtonCell edit:nil]; - - [HBLinkCell setButtonAction:@selector(hb_openURL:)]; - [HBLinkCell setProperty:HBLinkTableCell.class forKey:@"cellClass"]; - [HBLinkCell setProperty:url forKey:@"url"]; - if (detailText != nil) { - [HBLinkCell setProperty:detailText forKey:@"subtitle"]; - } - return HBLinkCell; -} -- (PSSpecifier *)newHBTwitterCellWithTitle:(NSString *)titleText twitterUsername:(NSString *)user customAvatarURL:(NSString *)avatarURL { - PSSpecifier *TwitterCell = [PSSpecifier preferenceSpecifierNamed:titleText target:self set:@selector(setPreferenceValue:specifier:) get:@selector(readPreferenceValue:) detail:nil cell:1 edit:nil]; - - [TwitterCell setButtonAction:@selector(hb_openURL:)]; - [TwitterCell setProperty:HBTwitterCell.class forKey:@"cellClass"]; - [TwitterCell setProperty:user forKey:@"user"]; - [TwitterCell setProperty:@YES forKey:@"big"]; - [TwitterCell setProperty:@56 forKey:@"height"]; - [TwitterCell setProperty:avatarURL forKey:@"iconURL"]; - return TwitterCell; -} -- (NSArray *)specifiers { - if (!_specifiers) { - - PSSpecifier *subtitleSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"APP_ICON_HEADER_TITLE"] - footer:nil]; - - -PSSpecifier *tweetsSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"TWEETS_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *profilesSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"PROFILES_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *searchSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"SEARCH_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *messagesSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] - localizedStringForKey:@"MESSAGES_SECTION_HEADER_TITLE"] - footer:nil]; - -PSSpecifier *photosVideosSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"PHOTOS_VIDEOS_SECTION_HEADER_TITLE"] footer:nil]; - - PSSpecifier *mainSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"MAIN_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *twitterBlueSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWITTER_BLUE_SECTION_HEADER_TITLE"] footer:[[BHTBundle sharedBundle] localizedStringForKey:@"TWITTER_BLUE_SECTION_FOOTER_TITLE"]]; - PSSpecifier *layoutSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LAYOUT_CUS_SECTION_HEADER_TITLE"] footer:[[BHTBundle sharedBundle] localizedStringForKey:@"LAYOUT_CUS_SECTION_FOOTER_TITLE"]]; - PSSpecifier *debug = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DEBUG_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *legalSection = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LEGAL_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *developer = [self newSectionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DEVELOPER_SECTION_HEADER_TITLE"] footer:nil]; - PSSpecifier *updatesSection = [self newSectionWithTitle:@"Check out our Twitter account!" footer:[NSString stringWithFormat:@"NeoFreeBird-BHTwitter v%@", [[BHTBundle sharedBundle] BHTwitterVersion]]]; - - PSSpecifier *download = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_VIDEOS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_VIDEOS_OPTION_DETAIL_TITLE"] key:@"dw_v" defaultValue:true changeAction:nil]; - - PSSpecifier *directSave = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DIRECT_SAVE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DIRECT_SAVE_OPTION_DETAIL_TITLE"] key:@"direct_save" defaultValue:false changeAction:nil]; - - PSSpecifier *hideAds = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_ADS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_ADS_OPTION_DETAIL_TITLE"] key:@"hide_promoted" defaultValue:true changeAction:nil]; - - PSSpecifier *customVoice = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UPLOAD_CUSTOM_VOICE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UPLOAD_CUSTOM_VOICE_OPTION_DETAIL_TITLE"] key:@"custom_voice_upload" defaultValue:true changeAction:nil]; - - PSSpecifier *hideTopics = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_OPTION_DETAIL_TITLE"] key:@"hide_topics" defaultValue:false changeAction:nil]; - - PSSpecifier *hideTopicsToFollow = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_TO_FOLLOW_OPTION"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TOPICS_TO_FOLLOW_OPTION_DETAIL_TITLE"] key:@"hide_topics_to_follow" defaultValue:false changeAction:nil]; - - PSSpecifier *hideWhoToFollow = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_WHO_FOLLOW_OPTION"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_WHO_FOLLOW_OPTION_DETAIL_TITLE"] key:@"hide_who_to_follow" defaultValue:false changeAction:nil]; - - PSSpecifier *hidePremiumOffer = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_PREMIUM_OFFER_OPTION"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_PREMIUM_OFFER_OPTION_DETAIL_TITLE"] key:@"hide_premium_offer" defaultValue:false changeAction:nil]; - - PSSpecifier *hideTrendVideos = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TREND_VIDEOS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_TREND_VIDEOS_OPTION_DETAIL_TITLE"] key:@"hide_trend_videos" defaultValue:false changeAction:nil]; - - PSSpecifier *restoreReplyContext = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_REPLY_CONTEXT_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_REPLY_CONTEXT_DETAIL_TITLE"] key:@"restore_reply_context" defaultValue:false changeAction:nil]; - - PSSpecifier *videoLayerCaption = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_VIDEO_LAYER_CAPTIONS_OPTION_TITLE"] detailTitle:nil key:@"video_layer_caption" defaultValue:false changeAction:nil]; - - PSSpecifier *noHistory = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"NO_HISTORY_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"NO_HISTORY_OPTION_DETAIL_TITLE"] key:@"no_his" defaultValue:false changeAction:nil]; - - PSSpecifier *bioTranslate = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BIO_TRANSLATE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BIO_TRANSLATE_OPTION_DETAIL_TITLE"] key:@"bio_translate" defaultValue:false changeAction:nil]; - - PSSpecifier *likeConfrim = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LIKE_CONFIRM_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LIKE_CONFIRM_OPTION_DETAIL_TITLE"] key:@"like_con" defaultValue:false changeAction:nil]; - - PSSpecifier *tweetConfirm = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_CONFIRM_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_CONFIRM_OPTION_DETAIL_TITLE"] key:@"tweet_con" defaultValue:false changeAction:nil]; - - PSSpecifier *followConfirm = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FOLLOW_CONFIRM_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FOLLOW_CONFIRM_OPTION_DETAIL_TITLE"] key:@"follow_con" defaultValue:false changeAction:nil]; - - PSSpecifier *padLock = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"PADLOCK_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"PADLOCK_OPTION_DETAIL_TITLE"] key:@"padlock" defaultValue:false changeAction:nil]; - - PSSpecifier *disableXChat = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_XCHAT_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_XCHAT_OPTION_DETAIL_TITLE"] key:@"disable_xchat" defaultValue:false changeAction:nil]; - - PSSpecifier *autoHighestLoad = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"AUTO_HIGHEST_LOAD_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"AUTO_HIGHEST_LOAD_OPTION_DETAIL_TITLE"] key:@"autoHighestLoad" defaultValue:true changeAction:nil]; - - PSSpecifier *disableSensitiveTweetWarnings = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE"] detailTitle:nil key:@"disableSensitiveTweetWarnings" defaultValue:true changeAction:nil]; - - PSSpecifier *copyProfileInfo = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"COPY_PROFILE_INFO_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"COPY_PROFILE_INFO_OPTION_DETAIL_TITLE"] key:@"CopyProfileInfo" defaultValue:false changeAction:nil]; - - PSSpecifier *tweetToImage = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_TO_IMAGE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TWEET_TO_IMAGE_OPTION_DETAIL_TITLE"] key:@"TweetToImage" defaultValue:false changeAction:nil]; - - PSSpecifier *hideSpace = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_SPACE_OPTION_TITLE"] detailTitle:nil key:@"hide_spaces" defaultValue:false changeAction:nil]; - - PSSpecifier *disableRTL = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_RTL_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_RTL_OPTION_DETAIL_TITLE"] key:@"dis_rtl" defaultValue:false changeAction:nil]; - - PSSpecifier *restoreTweetLabels = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TWEET_LABELS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TWEET_LABELS_OPTION_DETAIL_TITLE"] key:@"restore_tweet_labels" defaultValue:false changeAction:nil]; - - PSSpecifier *alwaysOpenSafari = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ALWAYS_OPEN_SAFARI_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ALWAYS_OPEN_SAFARI_OPTION_DETAIL_TITLE"] key:@"openInBrowser" defaultValue:false changeAction:nil]; - - PSSpecifier *stripTrackingParams = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"STRIP_URL_TRACKING_PARAMETERS_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"STRIP_URL_TRACKING_PARAMETERS_DETAIL_TITLE"] key:@"strip_tracking_params" defaultValue:false changeAction:nil]; - - PSSpecifier *urlHost = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SELECT_URL_HOST_AFTER_COPY_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"tweet_url_host"] dynamicRule:@"strip_tracking_params, ==, 0" action:@selector(showURLHostSelectionViewController:)]; - - PSSpecifier *enableTranslate = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TRANSLATE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ENABLE_TRANSLATE_OPTION_DETAIL_TITLE"] key:@"enable_translate" defaultValue:false changeAction:nil]; - - PSSpecifier *translateEndpoint = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_ENDPOINT_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"translate_endpoint"] ?: @"Default Gemini API" dynamicRule:@"enable_translate, ==, 0" action:@selector(showTranslateEndpointInput:)]; - - NSString *apiKeyDetail = [[NSUserDefaults standardUserDefaults] objectForKey:@"translate_api_key"]; - if (apiKeyDetail && apiKeyDetail.length > 0) { - apiKeyDetail = @"••••••••••••••••"; - } else { - apiKeyDetail = @"Not Set"; - } - PSSpecifier *translateAPIKey = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_API_KEY_OPTION_TITLE"] detailTitle:apiKeyDetail dynamicRule:@"enable_translate, ==, 0" action:@selector(showTranslateAPIKeyInput:)]; - - PSSpecifier *translateModel = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_MODEL_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"translate_model"] ?: @"gemini-1.5-flash" dynamicRule:@"enable_translate, ==, 0" action:@selector(showTranslateModelInput:)]; - - // Twitter bule section - PSSpecifier *undoTweet = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UNDO_TWEET_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"UNDO_TWEET_OPTION_DETAIL_TITLE"] key:@"undo_tweet" defaultValue:false changeAction:nil]; - - PSSpecifier *appTheme = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"THEME_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"THEME_OPTION_DETAIL_TITLE"] dynamicRule:nil action:@selector(showThemeViewController:)]; - - PSSpecifier *appIcon = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"APP_ICON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"APP_ICON_DETAIL_TITLE"] dynamicRule:nil action:@selector(showBHAppIconViewController:)]; - - PSSpecifier *customTabBarVC = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_TAB_BAR_OPTION_TITLE"] detailTitle:nil dynamicRule:nil action:@selector(showCustomTabBarVC:)]; - - // Layout customization section - PSSpecifier *customDirectBackgroundView = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_VIEW_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_VIEW_DETAIL_TITLE"] dynamicRule:nil action:@selector(showCustomBackgroundViewViewController:)]; - - PSSpecifier *OldStyle = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ORIG_TWEET_STYLE_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ORIG_TWEET_STYLE_OPTION_DETAIL_TITLE"] key:@"old_style" defaultValue:false changeAction:nil]; - - PSSpecifier *stopHidingTabBar = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"STOP_HIDING_TAB_BAR_TITLE"] detailTitle:@"Keeps the tab bar visible and prevents fading" key:@"no_tab_bar_hiding" defaultValue:false changeAction:nil]; - - PSSpecifier *dmAvatars = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_AVATARS_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_AVATARS_DETAIL_TITLE"] key:@"dm_avatars" defaultValue:false changeAction:nil]; - - PSSpecifier *dmComposeBarV2 = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_COMPOSE_BAR_V2_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_COMPOSE_BAR_V2_DETAIL_TITLE"] - key:@"dm_compose_bar_v2_enabled" - defaultValue:false - changeAction:nil]; - - PSSpecifier *dmVoiceCreation = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_VOICE_CREATION_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DM_VOICE_CREATION_DETAIL_TITLE"] - key:@"dm_voice_creation_enabled" - defaultValue:false - changeAction:nil]; - - PSSpecifier *tabBarTheming = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLASSIC_TAB_BAR_SETTINGS_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLASSIC_TAB_BAR_SETTINGS_DETAIL"] - key:@"tab_bar_theming" - defaultValue:false - changeAction:@selector(tabBarThemingAction:)]; - - PSSpecifier *restoreTabLabels = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_TAB_LABELS_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_TAB_LABELS_DETAIL"] - key:@"restore_tab_labels" - defaultValue:false - changeAction:@selector(restoreTabLabelsAction:)]; - - PSSpecifier *hideBlueVerified = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_BLUE_VERIFIED_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_BLUE_VERIFIED_OPTION_DETAIL_TITLE"] key:@"hide_blue_verified" defaultValue:false changeAction:nil]; - - PSSpecifier *hideViewCount = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_VIEW_COUNT_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_VIEW_COUNT_OPTION_DETAIL_TITLE"] key:@"hide_view_count" defaultValue:false changeAction:nil]; - - PSSpecifier *hideBookmarkButton = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_MARKBOOK_BUTTON_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE"] key:@"hide_bookmark_button" defaultValue:false changeAction:nil]; - - PSSpecifier *forceFullFrame = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FORCE_TWEET_FULL_FRAME_TITLE"] detailTitle:nil key:@"force_tweet_full_frame" defaultValue:false changeAction:nil]; - - PSSpecifier *showScrollIndicator = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SHOW_SCOLL_INDICATOR_OPTION_TITLE"] detailTitle:nil key:@"showScollIndicator" defaultValue:false changeAction:nil]; - - PSSpecifier *font = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FONT_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FONT_OPTION_DETAIL_TITLE"] key:@"en_font" defaultValue:false changeAction:nil]; - - PSSpecifier *regularFontsPicker = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"REQULAR_FONTS_PICKER_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"bhtwitter_font_1"] dynamicRule:@"en_font, ==, 0" action:@selector(showRegularFontPicker:)]; - - PSSpecifier *boldFontsPicker = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"BOLD_FONTS_PICKER_OPTION_TITLE"] detailTitle:[[NSUserDefaults standardUserDefaults] objectForKey:@"bhtwitter_font_2"] dynamicRule:@"en_font, ==, 0" action:@selector(showBoldFontPicker:)]; - - PSSpecifier *disableMediaTab = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_MEDIA_TAB_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_MEDIA_TAB_OPTION_DETAIL_TITLE"] key:@"disableMediaTab" defaultValue:false changeAction:nil]; - - PSSpecifier *disableArticles = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_ARTICLES_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_ARTICLES_OPTION_DETAIL_TITLE"] key:@"disableArticles" defaultValue:false changeAction:nil]; - - PSSpecifier *disableHighlights = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_HIGHLIGHTS_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DISABLE_HIGHLIGHTS_OPTION_DETAIL_TITLE"] key:@"disableHighlights" defaultValue:false changeAction:nil]; - - // New UI Customization toggles - PSSpecifier *hideGrokAnalyze = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_GROK_ANALYZE_BUTTON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE"] key:@"hide_grok_analyze" defaultValue:false changeAction:nil]; - - PSSpecifier *hideFollowButton = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_FOLLOW_BUTTON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"HIDE_FOLLOW_BUTTON_DETAIL_TITLE"] key:@"hide_follow_button" defaultValue:false changeAction:nil]; - - PSSpecifier *restoreFollowButton = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_FOLLOW_BUTTON_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_FOLLOW_BUTTON_DETAIL_TITLE"] key:@"restore_follow_button" defaultValue:false changeAction:nil]; - - - - PSSpecifier *squareAvatars = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SQUARE_AVATARS_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"SQUARE_AVATARS_DETAIL_TITLE"] - key:@"square_avatars" - defaultValue:false - changeAction:@selector(squareAvatarsAction:)]; - - PSSpecifier *replySorting = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"REPLY_SORTING_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"REPLY_SORTING_DETAIL_TITLE"] - key:@"reply_sorting_enabled" - defaultValue:false - changeAction:nil]; - - PSSpecifier *restoreVideoTimestamp = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_VIDEO_TIMESTAMP_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTORE_VIDEO_TIMESTAMP_DETAIL_TITLE"] key:@"restore_video_timestamp" defaultValue:false changeAction:nil]; - - - // debug section - PSSpecifier *flex = [self newSwitchCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FLEX_OPTION_TITLE"] detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"FLEX_OPTION_DETAIL_TITLE"] key:@"flex_twitter" defaultValue:false changeAction:@selector(FLEXAction:)]; - - PSSpecifier *modernLayout = [self newSwitchCellWithTitle:@"Enable Modern Layout" detailTitle:@"Switches to the new, redesigned settings UI." key:@"enable_modern_layout" defaultValue:false changeAction:@selector(modernLayoutAction:)]; - - PSSpecifier *clearSourceLabelCache = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLEAR_SOURCE_LABEL_CACHE_TITLE"] - detailTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CLEAR_SOURCE_LABEL_CACHE_DETAIL_TITLE"] - dynamicRule:nil - action:@selector(clearSourceLabelCacheAction:)]; - - // legal section - PSSpecifier *acknowledgements = [self newButtonCellWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"LEGAL_BUTTON_TITLE"] detailTitle:nil dynamicRule:nil action:@selector(showAcknowledgements:)]; - - // dvelopers section - - PSSpecifier *actuallyaridan = [self newHBTwitterCellWithTitle:@"aridan" twitterUsername:@"actuallyaridan" customAvatarURL:@"https://unavatar.io/x/actuallyaridan"]; - PSSpecifier *timi2506 = [self newHBTwitterCellWithTitle:@"timi2506" twitterUsername:@"timi2506" customAvatarURL:@"https://unavatar.io/x/timi2506"]; - PSSpecifier *nyathea = [self newHBTwitterCellWithTitle:@"nyathea" twitterUsername:@"nyaathea" customAvatarURL:@"https://unavatar.io/x/nyaathea"]; - PSSpecifier *bandarHL = [self newHBTwitterCellWithTitle:@"BandarHelal" twitterUsername:@"BandarHL" customAvatarURL:@"https://unavatar.io/x/BandarHL"]; - PSSpecifier *neoFreeBird = [self newHBTwitterCellWithTitle:@"NeoFreeBird" twitterUsername:@"NeoFreeBird" customAvatarURL:@"https://unavatar.io/x/NeoFreeBird"]; - - // Override button actions to use Twitter URL scheme - [actuallyaridan setButtonAction:@selector(openAridanTwitter:)]; - [timi2506 setButtonAction:@selector(openTimiTwitter:)]; - [nyathea setButtonAction:@selector(openNyatheaTwitter:)]; - [bandarHL setButtonAction:@selector(openBandarTwitter:)]; - [neoFreeBird setButtonAction:@selector(openNeoFreeBirdTwitter:)]; - - _specifiers = [NSMutableArray arrayWithArray:@[ - subtitleSection, - - - mainSection, // 0 - customVoice, - hideTopics, - hideTopicsToFollow, - hideWhoToFollow, - padLock, - alwaysOpenSafari, - stripTrackingParams, - urlHost, - enableTranslate, - translateEndpoint, - translateAPIKey, - translateModel, - - tweetsSection, // 1 - OldStyle, - tweetToImage, - restoreTweetLabels, - likeConfrim, - tweetConfirm, - hideBlueVerified, - hideViewCount, - hideBookmarkButton, - disableSensitiveTweetWarnings, - hideGrokAnalyze, - squareAvatars, - replySorting, - restoreReplyContext, - - profilesSection, // 2 - followConfirm, - copyProfileInfo, - bioTranslate, - disableMediaTab, - disableArticles, - disableHighlights, - hideFollowButton, - restoreFollowButton, - - searchSection, // 3 - noHistory, - hideTrendVideos, - - messagesSection, // 4 - dmAvatars, - dmComposeBarV2, - dmVoiceCreation, - disableXChat, - customDirectBackgroundView, - - photosVideosSection, // 5 - videoLayerCaption, - autoHighestLoad, - forceFullFrame, - restoreVideoTimestamp, - - twitterBlueSection, // 6 - undoTweet, - download, - directSave, - hideAds, - hidePremiumOffer, - appTheme, - appIcon, - customTabBarVC, - - layoutSection, // 7 - hideSpace, - stopHidingTabBar, - tabBarTheming, - restoreTabLabels, - disableRTL, - showScrollIndicator, - font, - regularFontsPicker, - boldFontsPicker, - - legalSection, // 8 - acknowledgements, - - debug, // 9 - flex, - modernLayout, - clearSourceLabelCache, - - developer, // 10 - actuallyaridan, - timi2506, - nyathea, - bandarHL, - - updatesSection, // 11 - neoFreeBird - ]]; - - [self collectDynamicSpecifiersFromArray:_specifiers]; - } - - return _specifiers; -} -- (void)reloadSpecifiers { - [super reloadSpecifiers]; - - [self collectDynamicSpecifiersFromArray:self.specifiers]; -} -- (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath { - if (self.hasDynamicSpecifiers) { - PSSpecifier *dynamicSpecifier = [self specifierAtIndexPath:indexPath]; - BOOL __block shouldHide = false; - - [self.dynamicSpecifiers enumerateKeysAndObjectsUsingBlock:^(id _Nonnull key, id _Nonnull obj, BOOL * _Nonnull stop) { - NSMutableArray *specifiers = obj; - if ([specifiers containsObject:dynamicSpecifier]) { - shouldHide = [self shouldHideSpecifier:dynamicSpecifier]; - - UITableViewCell *specifierCell = [dynamicSpecifier propertyForKey:PSTableCellKey]; - specifierCell.clipsToBounds = shouldHide; - } - }]; - if (shouldHide) { - return 0; - } - } - - return UITableViewAutomaticDimension; -} - -- (void)collectDynamicSpecifiersFromArray:(NSArray *)array { - if (!self.dynamicSpecifiers) { - self.dynamicSpecifiers = [NSMutableDictionary new]; - - } else { - [self.dynamicSpecifiers removeAllObjects]; - } - - for (PSSpecifier *specifier in array) { - NSString *dynamicSpecifierRule = [specifier propertyForKey:@"dynamicRule"]; - - if (dynamicSpecifierRule.length > 0) { - NSArray *ruleComponents = [dynamicSpecifierRule componentsSeparatedByString:@", "]; - - if (ruleComponents.count == 3) { - NSString *opposingSpecifierID = [ruleComponents objectAtIndex:0]; - if ([self.dynamicSpecifiers objectForKey:opposingSpecifierID]) { - NSMutableArray *specifiers = [[self.dynamicSpecifiers objectForKey:opposingSpecifierID] mutableCopy]; - [specifiers addObject:specifier]; - - - [self.dynamicSpecifiers removeObjectForKey:opposingSpecifierID]; - [self.dynamicSpecifiers setObject:specifiers forKey:opposingSpecifierID]; - } else { - [self.dynamicSpecifiers setObject:[NSMutableArray arrayWithArray:@[specifier]] forKey:opposingSpecifierID]; - } - - } else { - [NSException raise:NSInternalInconsistencyException format:@"dynamicRule key requires three components (Specifier ID, Comparator, Value To Compare To). You have %ld of 3 (%@) for specifier '%@'.", ruleComponents.count, dynamicSpecifierRule, [specifier propertyForKey:PSTitleKey]]; - } - } - } - - self.hasDynamicSpecifiers = (self.dynamicSpecifiers.count > 0); -} -- (DynamicSpecifierOperatorType)operatorTypeForString:(NSString *)string { - NSDictionary *operatorValues = @{ @"==" : @(EqualToOperatorType), @"!=" : @(NotEqualToOperatorType), @">" : @(GreaterThanOperatorType), @"<" : @(LessThanOperatorType) }; - return [operatorValues[string] intValue]; -} -- (BOOL)shouldHideSpecifier:(PSSpecifier *)specifier { - if (specifier) { - NSString *dynamicSpecifierRule = [specifier propertyForKey:@"dynamicRule"]; - NSArray *ruleComponents = [dynamicSpecifierRule componentsSeparatedByString:@", "]; - - PSSpecifier *opposingSpecifier = [self specifierForID:[ruleComponents objectAtIndex:0]]; - id opposingValue = [self readPreferenceValue:opposingSpecifier]; - id requiredValue = [ruleComponents objectAtIndex:2]; - - if ([opposingValue isKindOfClass:NSNumber.class]) { - DynamicSpecifierOperatorType operatorType = [self operatorTypeForString:[ruleComponents objectAtIndex:1]]; - - switch (operatorType) { - case EqualToOperatorType: - return ([opposingValue intValue] == [requiredValue intValue]); - break; - - case NotEqualToOperatorType: - return ([opposingValue intValue] != [requiredValue intValue]); - break; - - case GreaterThanOperatorType: - return ([opposingValue intValue] > [requiredValue intValue]); - break; - - case LessThanOperatorType: - return ([opposingValue intValue] < [requiredValue intValue]); - break; - } - } - - if ([opposingValue isKindOfClass:NSString.class]) { - return [opposingValue isEqualToString:requiredValue]; - } - - if ([opposingValue isKindOfClass:NSArray.class]) { - return [opposingValue containsObject:requiredValue]; - } - } - - return NO; -} - -- (void)setPreferenceValue:(id)value specifier:(PSSpecifier *)specifier { - NSUserDefaults *Prefs = [NSUserDefaults standardUserDefaults]; - [Prefs setValue:value forKey:[specifier identifier]]; - - if (self.hasDynamicSpecifiers) { - NSString *specifierID = [specifier propertyForKey:PSIDKey]; - PSSpecifier *dynamicSpecifier = [self.dynamicSpecifiers objectForKey:specifierID]; - - if (dynamicSpecifier) { - [self.table beginUpdates]; - [self.table endUpdates]; - } - } -} -- (id)readPreferenceValue:(PSSpecifier *)specifier { - NSUserDefaults *Prefs = [NSUserDefaults standardUserDefaults]; - return [Prefs valueForKey:[specifier identifier]]?:[specifier properties][@"default"]; -} - - -- (void)fontPickerViewControllerDidPickFont:(UIFontPickerViewController *)viewController { - NSString *fontName = viewController.selectedFontDescriptor.fontAttributes[UIFontDescriptorNameAttribute]; - NSString *fontFamily = viewController.selectedFontDescriptor.fontAttributes[UIFontDescriptorFamilyAttribute]; - - if (viewController.configuration.includeFaces) { - PSSpecifier *fontSpecifier = [self specifierForID:@"Bold Font"]; - [[NSUserDefaults standardUserDefaults] setObject:fontName forKey:@"bhtwitter_font_2"]; - [fontSpecifier setProperty:fontName forKey:@"subtitle"]; - } else { - PSSpecifier *fontSpecifier = [self specifierForID:@"Font"]; - [[NSUserDefaults standardUserDefaults] setObject:fontFamily forKey:@"bhtwitter_font_1"]; - [fontSpecifier setProperty:fontName forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - [viewController.navigationController popViewControllerAnimated:true]; -} -- (void)showRegularFontPicker:(PSSpecifier *)specifier { - UIFontPickerViewControllerConfiguration *configuration = [[UIFontPickerViewControllerConfiguration alloc] init]; - [configuration setFilteredTraits:UIFontDescriptorClassMask]; - [configuration setIncludeFaces:false]; - - UIFontPickerViewController *fontPicker = [[UIFontPickerViewController alloc] initWithConfiguration:configuration]; - fontPicker.delegate = self; - - if (self.twAccount != nil) { - [fontPicker.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:@"Choose Font" subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:fontPicker animated:true]; -} -- (void)showBoldFontPicker:(PSSpecifier *)specifier { - UIFontPickerViewControllerConfiguration *configuration = [[UIFontPickerViewControllerConfiguration alloc] init]; - [configuration setIncludeFaces:true]; - [configuration setFilteredTraits:UIFontDescriptorClassModernSerifs]; - [configuration setFilteredTraits:UIFontDescriptorClassMask]; - - UIFontPickerViewController *fontPicker = [[UIFontPickerViewController alloc] initWithConfiguration:configuration]; - fontPicker.delegate = self; - - if (self.twAccount != nil) { - [fontPicker.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:@"Choose Font" subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:fontPicker animated:true]; -} -- (void)showAcknowledgements:(PSSpecifier *)specifier { - T1RichTextFormatViewController *acknowledgementsVC = [[objc_getClass("T1RichTextFormatViewController") alloc] initWithRichTextFormatDocumentPath:[[BHTBundle sharedBundle] pathForFile:@"Acknowledgements.rtf"].path]; - if (self.twAccount != nil) { - [acknowledgementsVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"ACKNOWLEDGEMENTS_SETTINGS_NAVIGATION_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:acknowledgementsVC animated:true]; -} -- (void)showCustomTabBarVC:(PSSpecifier *)specifier { - BHCustomTabBarViewController *customTabBarVC = [[BHCustomTabBarViewController alloc] init]; - if (self.twAccount != nil) { - [customTabBarVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_TAB_BAR_SETTINGS_NAVIGATION_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:customTabBarVC animated:true]; -} -- (void)showThemeViewController:(PSSpecifier *)specifier { - // I create my own Color Theme ViewController for two main reasons: - // 1- Twitter use swift to build their view controller, so I can't hook anything on it. - // 2- Twitter knows you do not actually subscribe with Twitter Blue, so it keeps resting the changes and resting 'T1ColorSettingsPrimaryColorOptionKey' key, so I had to create another key to track the original one and keep sure no changes, but it still not enough to keep the new theme after relaunching app, so i had to force the changes again with new lunch. - BHColorThemeViewController *themeVC = [[BHColorThemeViewController alloc] init]; - if (self.twAccount != nil) { - [themeVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"THEME_SETTINGS_NAVIGATION_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:themeVC animated:true]; -} -- (void)showBHAppIconViewController:(PSSpecifier *)specifier { - BHAppIconViewController *appIconVC = [[BHAppIconViewController alloc] init]; - if (self.twAccount != nil) { - [appIconVC.navigationItem setTitleView:[objc_getClass("TFNTitleView") titleViewWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"APP_ICON_NAV_TITLE"] subtitle:self.twAccount.displayUsername]]; - } - [self.navigationController pushViewController:appIconVC animated:true]; -} -- (void)showURLHostSelectionViewController:(PSSpecifier *)specifier { - UITableViewCell *specifierCell = [specifier propertyForKey:PSTableCellKey]; - PSSpecifier *selectionSpecifier = [self specifierForID:@"Select URL host"]; - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"NeoFreeBird" message:@"URL" preferredStyle:UIAlertControllerStyleActionSheet]; - - if (alert.popoverPresentationController != nil) { - CGFloat midX = CGRectGetMidX(specifierCell.frame); - CGFloat midY = CGRectGetMidY(specifierCell.frame); - - alert.popoverPresentationController.sourceRect = CGRectMake(midX, midY, 0, 0); - alert.popoverPresentationController.sourceView = specifierCell; - } - - UIAlertAction *xHostAction = [UIAlertAction actionWithTitle:@"x.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"x.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"x.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *twitterHostAction = [UIAlertAction actionWithTitle:@"twitter.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"twitter.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"twitter.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *fxHostAction = [UIAlertAction actionWithTitle:@"fxtwitter.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"fxtwitter.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"fxtwitter.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *vxHostAction = [UIAlertAction actionWithTitle:@"vxtwitter.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"vxtwitter.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"vxtwitter.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - UIAlertAction *fixvxHostAction = [UIAlertAction actionWithTitle:@"fixvx.com" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setObject:@"fixvx.com" forKey:@"tweet_url_host"]; - [selectionSpecifier setProperty:@"fixvx.com" forKey:@"subtitle"]; - [self reloadSpecifiers]; - }]; - - UIAlertAction *cancel = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]; - - [alert addAction:xHostAction]; - [alert addAction:twitterHostAction]; - [alert addAction:fxHostAction]; - [alert addAction:vxHostAction]; - [alert addAction:fixvxHostAction]; - [alert addAction:cancel]; - - [self presentViewController:alert animated:true completion:nil]; -} -- (void)showCustomBackgroundViewViewController:(PSSpecifier *)specifier { - UITableViewCell *specifierCell = [specifier propertyForKey:PSTableCellKey]; - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"NeoFreeBird" message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - - if (alert.popoverPresentationController != nil) { - CGFloat midX = CGRectGetMidX(specifierCell.frame); - CGFloat midY = CGRectGetMidY(specifierCell.frame); - - alert.popoverPresentationController.sourceRect = CGRectMake(midX, midY, 0, 0); - alert.popoverPresentationController.sourceView = specifierCell; - } - - UIAlertAction *imageAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_1"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - UIImagePickerController *imagePicker = [[UIImagePickerController alloc] init]; - imagePicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary; - imagePicker.delegate = self; - [self presentViewController:imagePicker animated:YES completion:nil]; - }]; - - UIAlertAction *colorAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_2"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - UIColorPickerViewController *colorPicker = [[UIColorPickerViewController alloc] init]; - colorPicker.delegate = self; - [self presentViewController:colorPicker animated:true completion:nil]; - }]; - - UIAlertAction *resetAction = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_DIRECT_BACKGROUND_ALERT_OPTION_3"] style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setBool:false forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - }]; - - UIAlertAction *cancel = [UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]; - - [alert addAction:imageAction]; - [alert addAction:colorAction]; - [alert addAction:resetAction]; - [alert addAction:cancel]; - - [self presentViewController:alert animated:true completion:nil]; -} - -- (void)FLEXAction:(UISwitch *)sender { - if (sender.isOn) { - [[objc_getClass("FLEXManager") sharedManager] showExplorer]; - } else { - [[objc_getClass("FLEXManager") sharedManager] hideExplorer]; - } -} - -- (void)modernLayoutAction:(UISwitch *)sender { - BOOL enabled = sender.isOn; - NSString *key = @"enable_modern_layout"; - BOOL previousValue = !enabled; - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_REQUIRED_ALERT_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"MODERN_LAYOUT_RESTART_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_NOW_BUTTON_TITLE"] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setBool:enabled forKey:key]; - [[NSUserDefaults standardUserDefaults] synchronize]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - exit(0); - }); - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { - // Revert switch state if cancelled - [sender setOn:previousValue animated:YES]; - }]]; - - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)clearSourceLabelCacheAction:(PSSpecifier *)specifier { - [BHTManager clearSourceLabelCache]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CACHE_CLEARED_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"CACHE_CLEARED_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] - style:UIAlertActionStyleDefault - handler:nil]]; - - [self presentViewController:alert animated:YES completion:nil]; -} - -// Translate configuration input methods -- (void)showTranslateEndpointInput:(PSSpecifier *)specifier { - NSString *currentValue = [[NSUserDefaults standardUserDefaults] stringForKey:@"translate_endpoint"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_ENDPOINT_OPTION_TITLE"] - message:@"Enter the API endpoint URL for translation" - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { - textField.placeholder = @"https://generativelanguage.googleapis.com/v1beta/models"; - textField.text = currentValue; - textField.keyboardType = UIKeyboardTypeURL; - }]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - NSString *inputText = alert.textFields.firstObject.text; - if (inputText.length > 0) { - [[NSUserDefaults standardUserDefaults] setObject:inputText forKey:@"translate_endpoint"]; - [specifier setProperty:inputText forKey:@"subtitle"]; - } else { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"translate_endpoint"]; - [specifier setProperty:@"Default Gemini API" forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)showTranslateAPIKeyInput:(PSSpecifier *)specifier { - NSString *currentValue = [[NSUserDefaults standardUserDefaults] stringForKey:@"translate_api_key"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_API_KEY_OPTION_TITLE"] - message:@"Enter your API key for the translation service" - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { - textField.placeholder = @"API Key"; - textField.text = currentValue; - textField.secureTextEntry = YES; - }]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - NSString *inputText = alert.textFields.firstObject.text; - if (inputText.length > 0) { - [[NSUserDefaults standardUserDefaults] setObject:inputText forKey:@"translate_api_key"]; - [specifier setProperty:@"••••••••••••••••" forKey:@"subtitle"]; - } else { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"translate_api_key"]; - [specifier setProperty:@"Not Set" forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)showTranslateModelInput:(PSSpecifier *)specifier { - NSString *currentValue = [[NSUserDefaults standardUserDefaults] stringForKey:@"translate_model"]; - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"TRANSLATE_MODEL_OPTION_TITLE"] - message:@"Enter the model name to use for translation" - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addTextFieldWithConfigurationHandler:^(UITextField *textField) { - textField.placeholder = @"gemini-1.5-flash"; - textField.text = currentValue; - }]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"OK_BUTTON_TITLE"] style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { - NSString *inputText = alert.textFields.firstObject.text; - if (inputText.length > 0) { - [[NSUserDefaults standardUserDefaults] setObject:inputText forKey:@"translate_model"]; - [specifier setProperty:inputText forKey:@"subtitle"]; - } else { - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"translate_model"]; - [specifier setProperty:@"gemini-1.5-flash" forKey:@"subtitle"]; - } - [self reloadSpecifiers]; - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:nil]]; - [self presentViewController:alert animated:YES completion:nil]; -} - -- (void)colorPickerViewControllerDidSelectColor:(UIColorPickerViewController *)viewController { - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_image"]; - - - UIColor *selectedColor = viewController.selectedColor; - [[NSUserDefaults standardUserDefaults] setObject:selectedColor.hexString forKey:@"background_color"]; -} -- (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info { - NSFileManager *manager = [NSFileManager defaultManager]; - NSString *DocPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstObject; - - NSURL *oldImgPath = info[UIImagePickerControllerImageURL]; - NSURL *newImgPath = [[NSURL fileURLWithPath:DocPath] URLByAppendingPathComponent:@"msg_background.png"]; - - if ([manager fileExistsAtPath:newImgPath.path]) { - [manager removeItemAtURL:newImgPath error:nil]; - } - - [manager copyItemAtURL:oldImgPath toURL:newImgPath error:nil]; - - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"change_msg_background"]; - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"background_image"]; - [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"background_color"]; - - [picker dismissViewControllerAnimated:true completion:nil]; -} -- (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker { - [picker dismissViewControllerAnimated:true completion:nil]; -} - -- (void)tabBarThemingAction:(UISwitch *)sender { - // Save the setting immediately - [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:@"tab_bar_theming"]; - [[NSUserDefaults standardUserDefaults] synchronize]; - - // Trigger immediate refresh of all tab views with theming - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshAllTabViewsWithTheming]; - }); -} - -- (void)refreshAllTabViewsWithTheming { - // Find all T1TabView instances and refresh them with theming - for (UIWindow *window in [UIApplication sharedApplication].windows) { - if (window.isKeyWindow && window.rootViewController) { - [self refreshTabViewsWithThemingInView:window.rootViewController.view]; - } - } -} - -- (void)refreshTabViewsWithThemingInView:(UIView *)view { - // Check if this view is a T1TabView - if ([view isKindOfClass:NSClassFromString(@"T1TabView")]) { - // Use Twitter's specific internal methods to refresh the tab view - if ([view respondsToSelector:@selector(_t1_updateImageViewAnimated:)]) { - [view performSelector:@selector(_t1_updateImageViewAnimated:) withObject:@(NO)]; - } - if ([view respondsToSelector:@selector(_t1_updateTitleLabel)]) { - [view performSelector:@selector(_t1_updateTitleLabel)]; - } - if ([view respondsToSelector:@selector(_t1_layoutForTabBar)]) { - [view performSelector:@selector(_t1_layoutForTabBar)]; - } - // Fix badge positioning when theming changes - if ([view respondsToSelector:@selector(_t1_layoutBadgeViewMaximized)]) { - [view performSelector:@selector(_t1_layoutBadgeViewMaximized)]; - } - - // Force label color reset when theming is OFF - if (![[[NSUserDefaults standardUserDefaults] objectForKey:@"tab_bar_theming"] boolValue]) { - UILabel *titleLabel = [view valueForKey:@"titleLabel"]; - if (titleLabel) { - titleLabel.textColor = nil; - } - } - } - - // Recursively search subviews - for (UIView *subview in view.subviews) { - [self refreshTabViewsWithThemingInView:subview]; - } -} - -- (void)squareAvatarsAction:(UISwitch *)sender { - BOOL enabled = sender.isOn; - NSString *key = @"square_avatars"; - BOOL previousValue = !enabled; // The value before the switch was flipped - - UIAlertController *alert = [UIAlertController alertControllerWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_REQUIRED_ALERT_TITLE"] - message:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_REQUIRED_ALERT_MESSAGE"] - preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"RESTART_NOW_BUTTON_TITLE"] style:UIAlertActionStyleDestructive handler:^(UIAlertAction * _Nonnull action) { - [[NSUserDefaults standardUserDefaults] setBool:enabled forKey:key]; - [[NSUserDefaults standardUserDefaults] synchronize]; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - exit(0); - }); - }]]; - - [alert addAction:[UIAlertAction actionWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"CANCEL_BUTTON_TITLE"] style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { - // Flip the switch back visually if cancelled - [sender setOn:previousValue animated:YES]; - }]]; - - [self presentViewController:alert animated:YES completion:nil]; -} - -// Need helper to find specifier by key for switch actions -- (PSSpecifier *)specifierForID:(NSString *)identifier { - for (PSSpecifier *specifier in [self specifiers]) { - if ([[specifier propertyForKey:@"key"] isEqualToString:identifier]) { - return specifier; - } - } - return nil; -} - -// Custom Twitter opening methods -- (void)openAridanTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1351218086649720837"] options:@{} completionHandler:nil]; -} - -- (void)openTimiTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1671731225424195584"] options:@{} completionHandler:nil]; -} - -- (void)openNyatheaTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1541742676009226241"] options:@{} completionHandler:nil]; -} - -- (void)openBandarTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=827842200708853762"] options:@{} completionHandler:nil]; -} - -- (void)openNeoFreeBirdTwitter:(PSSpecifier *)specifier { - [[UIApplication sharedApplication] openURL:[NSURL URLWithString:@"twitter://user?id=1878595268255297537"] options:@{} completionHandler:nil]; -} - -- (void)restoreTabLabelsAction:(UISwitch *)sender { - // Save the setting immediately - [[NSUserDefaults standardUserDefaults] setBool:sender.isOn forKey:@"restore_tab_labels"]; - [[NSUserDefaults standardUserDefaults] synchronize]; - - // Trigger immediate refresh of all tab views - dispatch_async(dispatch_get_main_queue(), ^{ - [self refreshAllTabViews]; - }); -} - -- (void)refreshAllTabViews { - // Find all T1TabView instances and refresh them using Twitter's internal methods - for (UIWindow *window in [UIApplication sharedApplication].windows) { - if (window.isKeyWindow && window.rootViewController) { - [self refreshTabViewsInView:window.rootViewController.view]; - } - } -} - -- (void)refreshTabViewsInView:(UIView *)view { - // Check if this view is a T1TabView - if ([view isKindOfClass:NSClassFromString(@"T1TabView")]) { - // Use Twitter's specific internal methods to refresh the tab view - if ([view respondsToSelector:@selector(_t1_updateTitleLabel)]) { - [view performSelector:@selector(_t1_updateTitleLabel)]; - } - // Use Twitter's specific layout methods instead of layoutSubviews - if ([view respondsToSelector:@selector(_t1_layoutForTabBar)]) { - [view performSelector:@selector(_t1_layoutForTabBar)]; - } - // Fix badge positioning when labels change - if ([view respondsToSelector:@selector(_t1_layoutBadgeViewMaximized)]) { - [view performSelector:@selector(_t1_layoutBadgeViewMaximized)]; - } - - // Force label color reset when theming is OFF - if (![[[NSUserDefaults standardUserDefaults] objectForKey:@"tab_bar_theming"] boolValue]) { - UILabel *titleLabel = [view valueForKey:@"titleLabel"]; - if (titleLabel) { - titleLabel.textColor = nil; - } - } - } - - // Recursively search subviews - for (UIView *subview in view.subviews) { - [self refreshTabViewsInView:subview]; - } -} - -@end - -@implementation BHButtonTableViewCell -- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier specifier:(PSSpecifier *)specifier { - self = [super initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuseIdentifier specifier:specifier]; - if (self) { - NSString *subTitle = [specifier.properties[@"subtitle"] copy]; - BOOL isBig = specifier.properties[@"big"] ? ((NSNumber *)specifier.properties[@"big"]).boolValue : NO; - - // Set the font to semibold and apply accent color - self.textLabel.font = TwitterChirpFont(TwitterFontStyleSemibold); - self.textLabel.textColor = BHTCurrentAccentColor(); - - // Keep subtitle style exactly as before - self.detailTextLabel.text = subTitle; - self.detailTextLabel.numberOfLines = isBig ? 0 : 1; - self.detailTextLabel.textColor = [UIColor secondaryLabelColor]; - self.detailTextLabel.font = TwitterChirpFont(TwitterFontStyleRegular); - self.selectionStyle = UITableViewCellSelectionStyleDefault; - - // Apply theme color to cell - self.tintColor = BHTCurrentAccentColor(); - } - return self; -} - -- (void)tintColorDidChange { - [super tintColorDidChange]; - self.textLabel.textColor = BHTCurrentAccentColor(); - self.tintColor = BHTCurrentAccentColor(); -} -@end - -@implementation BHSwitchTableCell -- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier specifier:(PSSpecifier *)specifier { - if ((self = [super initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuseIdentifier specifier:specifier])) { - NSString *subTitle = [specifier.properties[@"subtitle"] copy]; - BOOL isBig = specifier.properties[@"big"] ? ((NSNumber *)specifier.properties[@"big"]).boolValue : NO; - - // Set the font to semibold - self.textLabel.font = TwitterChirpFont(TwitterFontStyleSemibold); - - // Keep subtitle style exactly as before - self.detailTextLabel.text = subTitle; - self.detailTextLabel.numberOfLines = isBig ? 0 : 1; - self.detailTextLabel.textColor = [UIColor secondaryLabelColor]; - self.detailTextLabel.font = TwitterChirpFont(TwitterFontStyleRegular); - self.selectionStyle = UITableViewCellSelectionStyleDefault; - - // Theme the switch - UISwitch *switchControl = (UISwitch *)[self control]; - switchControl.onTintColor = BHTCurrentAccentColor(); - - if (specifier.properties[@"switchAction"]) { - UISwitch *targetSwitch = ((UISwitch *)[self control]); - NSString *strAction = [specifier.properties[@"switchAction"] copy]; - [targetSwitch addTarget:[self cellTarget] action:NSSelectorFromString(strAction) forControlEvents:UIControlEventValueChanged]; - } - } - return self; -} - -- (void)tintColorDidChange { - [super tintColorDidChange]; - - // Update switch color when theme changes - UISwitch *switchControl = (UISwitch *)[self control]; - switchControl.onTintColor = BHTCurrentAccentColor(); -} -@end diff --git a/TWHeaders.h b/TWHeaders.h index 6a5a6f6e..b7d2772b 100644 --- a/TWHeaders.h +++ b/TWHeaders.h @@ -248,6 +248,14 @@ static NSString *_lastCopiedURL; @property(nonatomic) __weak id delegate; @end +@interface TTAStatusInlineReplyButton : UIView +@property(nonatomic) __weak id delegate; +@end + +@interface T1PersistentComposeViewController : UIViewController +@property(readonly, nonatomic) id statusViewModel; +@end + @protocol TTACoreStatusViewEventHandler @end @@ -408,6 +416,42 @@ static NSString *_lastCopiedURL; - (id)init; @end +@interface TFNTwitter : NSObject ++ (instancetype)sharedTwitter; +@property(readonly, nonatomic) NSArray *accounts; +@end + +@interface T1HostViewController : UIViewController ++ (instancetype)sharedHostViewController; +- (id)currentAccount; +@end + +@interface T1BaseWebViewController : UIViewController +- (instancetype)initWithURL:(NSURL *)url; +- (instancetype)initWithAccount:(id)account; +- (void)setRootURL:(NSURL *)url; +- (void)setCurrentURL:(NSURL *)url; +@property(nonatomic, readonly) NSURL *currentURL; +- (WKWebView *)webView; +@end + +@interface T1WebViewController : T1BaseWebViewController +- (instancetype)initWithRootURL:(NSURL *)rootURL + account:(id)account + shouldAuthenticate:(BOOL)shouldAuthenticate + shouldPresentAsNativePage:(BOOL)shouldPresentAsNativePage + sourceStatus:(id)sourceStatus + scribeComponent:(id)scribeComponent + scribeParameters:(id)scribeParameters; +@property(nonatomic, strong) id account; +- (BOOL)doesURLResultTypeOpenInWebview:(long long)resultType; +@end + +@interface UIViewController (TFNPresentation) +- (void)tfn_dismissAnimated:(id)sender; +- (void)tfn_presentFromViewController:(UIViewController *)viewController animated:(BOOL)animated; +@end + @interface TFSTwitterEntityURL : NSObject @property(readonly, copy, nonatomic) NSString *expandedURL; @end @@ -532,7 +576,7 @@ typedef FLEXAlertAction * _Nonnull (^FLEXAlertActionHandler)(void(^handler)(NSAr static void BH_changeTwitterColor(NSInteger colorID) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; TAEColorSettings *colorSettings = [objc_getClass("TAEColorSettings") sharedSettings]; - + [defaults setObject:@(colorID) forKey:@"T1ColorSettingsPrimaryColorOptionKey"]; [colorSettings setPrimaryColorOption:colorID]; } @@ -543,7 +587,7 @@ static UIImage *BH_imageFromView(UIView *view) { [view drawViewHierarchyInRect:view.bounds afterScreenUpdates:false]; UIImage *img = UIGraphicsGetImageFromCurrentImageContext(); UIGraphicsEndImageContext(); - + return img; } @@ -715,4 +759,4 @@ extern UIColor *BHTCurrentAccentColor(void); @interface TFNFlexibleLayoutView : UIView @property(nonatomic) CGRect frame; -@end \ No newline at end of file +@end diff --git a/Tweak.x b/Tweak.x index 9a84bb02..8e33f3ff 100644 --- a/Tweak.x +++ b/Tweak.x @@ -20,15 +20,27 @@ #import "BHDimPalette.h" #import #import "BHTBundle/BHTBundle.h" +#import "LegacyLogin/BHTLegacyLoginViewController.h" #import "TWHeaders.h" #import "SAMKeychain/SAMKeychain.h" #import "CustomTabBar/BHCustomTabBarUtility.h" #import #import #import "ModernSettingsViewController.h" +#import "BHDownloadInlineButton.h" @class T1SettingsViewController; +@interface DCAppAttestService : NSObject ++ (instancetype)sharedService; +- (BOOL)isSupported; +@end + +@interface ASWebAuthenticationSession : NSObject +- (instancetype)initWithURL:(NSURL *)URL callbackURLScheme:(NSString *)callbackURLScheme completionHandler:(void(^)(NSURL *, NSError *))completionHandler; +@property(nonatomic) BOOL prefersEphemeralWebBrowserSession; +@end + // Forward declarations static void BHT_UpdateAllTabBarIcons(void); static void BHT_applyThemeToWindow(UIWindow *window); @@ -460,6 +472,11 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I %end // MARK: App Delegate hooks + +// Defined with the native CreateTweet -> web rewrite section further down. +static void BHT_prewarmWebCookiesIfNeeded(void); +static void BHT_maybeHandleHarvestWebView(__unsafe_unretained id webViewController); + %hook T1AppDelegate - (_Bool)application:(UIApplication *)application didFinishLaunchingWithOptions:(id)arg2 { _Bool orig = %orig; @@ -472,6 +489,7 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"voice"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"undo_tweet"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"TrustedFriends"]; + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"bypass_age_verification"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"disableSensitiveTweetWarnings"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"disable_immersive_player"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"custom_voice_upload"]; @@ -482,12 +500,11 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_view_count"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_grok_analyze"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"restore_reply_context"]; - [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"disable_xchat"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_topics"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_topics_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"hide_who_to_follow"]; [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"no_tab_bar_hiding"]; - + [[NSUserDefaults standardUserDefaults] setBool:true forKey:@"attestation_bypass_enabled"]; } [BHTManager cleanCache]; if ([BHTManager FLEX]) { @@ -525,6 +542,8 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I }); } + BHT_prewarmWebCookiesIfNeeded(); + if ([BHTManager Padlock]) { if (BHT_isAuthenticated()) { BHT_removePadlockOverlay(); @@ -620,40 +639,16 @@ static void batchSwizzlingOnClass(Class cls, NSArray*origSelectors, I - (void)loadView { %orig; NSArray *hiddenBars = [BHCustomTabBarUtility getHiddenTabBars]; + BOOL hideGrokByDefault = ![[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_grok"]; for (T1TabView *tabView in self.tabViews) { - if ([hiddenBars containsObject:tabView.scribePage]) { + if ([hiddenBars containsObject:tabView.scribePage] || + (hideGrokByDefault && [tabView.scribePage isEqualToString:@"grok"])) { [tabView setHidden:true]; } } } %end -%hook T1DirectMessageConversationEntriesViewController -- (void)viewDidLoad { - %orig; - if ([BHTManager changeBackground]) { - if ([BHTManager backgroundImage]) { // set the backgeound as image - NSFileManager *manager = [NSFileManager defaultManager]; - NSString *DocPath = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, true).firstObject; - NSURL *imagePath = [[NSURL fileURLWithPath:DocPath] URLByAppendingPathComponent:@"msg_background.png"]; - - if ([manager fileExistsAtPath:imagePath.path]) { - UIImageView *backgroundImage = [[UIImageView alloc] initWithFrame:UIScreen.mainScreen.bounds]; - backgroundImage.image = [UIImage imageNamed:imagePath.path]; - [backgroundImage setContentMode:UIViewContentModeScaleAspectFill]; - [self.view insertSubview:backgroundImage atIndex:0]; - } - } - - if ([[NSUserDefaults standardUserDefaults] objectForKey:@"background_color"]) { // set the backgeound as color - NSString *hexCode = [[NSUserDefaults standardUserDefaults] objectForKey:@"background_color"]; - UIColor *selectedColor = [UIColor colorFromHexString:hexCode]; - self.view.backgroundColor = selectedColor; - } - } -} -%end - // Declare Twitter's vector loader. @interface UIImage (TwitterVectors) + (UIImage *)tfn_vectorImageNamed:(NSString *)name @@ -690,8 +685,10 @@ typedef NS_ENUM(NSInteger, BHTTwitterThemeVariant) { static BHTTwitterThemeVariant BHTCurrentTwitterThemeVariant(T1ProfileHeaderView *headerView) { UIUserInterfaceStyle style = UIUserInterfaceStyleLight; - if (headerView && @available(iOS 13.0, *)) { - style = headerView.traitCollection.userInterfaceStyle; + if (headerView) { + if (@available(iOS 13.0, *)) { + style = headerView.traitCollection.userInterfaceStyle; + } } // System / Twitter light theme @@ -1256,7 +1253,7 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } %new - (void)DownloadHandler { NSAttributedString *AttString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_MENU_TITLE"] attributes:@{ - NSFontAttributeName: [[%c(TAEStandardFontGroup) sharedFontGroup] headline2BoldFont], + NSFontAttributeName: [BHTManager menuTitleFont], NSForegroundColorAttributeName: UIColor.labelColor }]; TFNActiveTextItem *title = [[%c(TFNActiveTextItem) alloc] initWithTextModel:[[%c(TFNAttributedTextModel) alloc] initWithAttributedString:AttString] activeRanges:nil]; @@ -1393,322 +1390,1883 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } %end -// MARK: Save tweet as an image - -%hook TTAStatusInlineShareButton -- (void)didLongPressActionButton:(UILongPressGestureRecognizer *)gestureRecognizer { - if ([BHTManager tweetToImage]) { - if (gestureRecognizer.state == UIGestureRecognizerStateBegan) { - id delegate = self.delegate; - if (![delegate isKindOfClass:%c(TTAStatusInlineActionsView)]) { - return %orig; - } - TTAStatusInlineActionsView *actionsView = (TTAStatusInlineActionsView *)delegate; - T1StatusCell *tweetView; - - if ([actionsView.superview isKindOfClass:%c(T1StandardStatusView)]) { // normal tweet in the time line - tweetView = (T1StatusCell *)[(T1StandardStatusView *)actionsView.superview eventHandler]; - } else if ([actionsView.superview isKindOfClass:%c(T1TweetDetailsFocalStatusView)]) { // Focus tweet - tweetView = (T1StatusCell *)[(T1TweetDetailsFocalStatusView *)actionsView.superview eventHandler]; - } else if ([actionsView.superview isKindOfClass:%c(T1ConversationFocalStatusView)]) { // Focus tweet - tweetView = (T1StatusCell *)[(T1ConversationFocalStatusView *)actionsView.superview eventHandler]; - } else { - return %orig; - } +// MARK: Open reply in webview - UIImage *tweetImage = BH_imageFromView(tweetView); - UIActivityViewController *acVC = [[UIActivityViewController alloc] initWithActivityItems:@[tweetImage] applicationActivities:nil]; - if (is_iPad()) { - acVC.popoverPresentationController.sourceView = self; - acVC.popoverPresentationController.sourceRect = self.frame; +static id BHT_accountForAuthenticatedWebView(void) { + Class hostClass = %c(T1HostViewController); + if ([hostClass respondsToSelector:@selector(sharedHostViewController)]) { + id host = [hostClass sharedHostViewController]; + if ([host respondsToSelector:@selector(currentAccount)]) { + id account = [host currentAccount]; + if (account) { + return account; } - [topMostController() presentViewController:acVC animated:true completion:nil]; - return; } } - return %orig; -} -%end - -// MARK: Hide Blue verified checkmark -%hook T1CompositionStatusViewModel -- (BOOL)isFromUserVerified { - return [BHTManager hideBlueVerified] ? NO : %orig; -} -- (BOOL)isFromUserBlueVerified { - return [BHTManager hideBlueVerified] ? NO : %orig; + return nil; } -%end -%hook TFNTwitterStatus -- (BOOL)isFromUserVerified { - return [BHTManager hideBlueVerified] ? NO : %orig; -} -- (BOOL)isFromUserBlueVerified { - return [BHTManager hideBlueVerified] ? NO : %orig; -} -%end +static TFNTwitterStatus *BHT_statusFromObject(id object) { + if (!object) { + return nil; + } -%hook T1StandardUserViewModel -- (BOOL)verified { - return [BHTManager hideBlueVerified] ? NO : %orig; -} -- (BOOL)isBlueVerified { - return [BHTManager hideBlueVerified] ? NO : %orig; -} -%end + if ([object isKindOfClass:%c(TFNTwitterStatus)]) { + return (TFNTwitterStatus *)object; + } -%hook T1ProfileUserViewModel -- (BOOL)isVerifiedAccount { - return [BHTManager hideBlueVerified] ? NO : %orig; -} -%end + @try { + id tweet = [object valueForKey:@"tweet"]; + if ([tweet isKindOfClass:%c(TFNTwitterStatus)]) { + return (TFNTwitterStatus *)tweet; + } + } @catch (__unused NSException *exception) {} -%hook T1TwitterCoreStatusViewModelAdapter -- (BOOL)isFromUserVerified { - return [BHTManager hideBlueVerified] ? NO : %orig; -} -- (BOOL)isFromUserBlueVerified { - return [BHTManager hideBlueVerified] ? NO : %orig; + @try { + id status = [object valueForKey:@"status"]; + if ([status isKindOfClass:%c(TFNTwitterStatus)]) { + return (TFNTwitterStatus *)status; + } + } @catch (__unused NSException *exception) {} + + return nil; } -%end -// MARK: Timeline download +static TFNTwitterStatus *BHT_statusFromTweetView(T1StatusCell *tweetView) { + @try { + return BHT_statusFromObject([tweetView valueForKey:@"viewModel"]); + } @catch (__unused NSException *exception) {} -%hook TTAStatusInlineActionsView -+ (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { - NSArray *_orig = %orig; - NSMutableArray *newOrig = [_orig mutableCopy]; + return nil; +} - if ([BHTManager isVideoCell:arg1] && [BHTManager DownloadingVideos]) { - [newOrig addObject:%c(BHDownloadInlineButton)]; +static const void *BHTKeepReplyInWebViewKey = &BHTKeepReplyInWebViewKey; +static const void *BHTReplyWebViewDismissingKey = &BHTReplyWebViewDismissingKey; + +// Injected into the reply webview via -evaluateJavaScript +// Grabs the ID of the new post +static NSString *const BHTReplyCaptureScript = + @"(function(){" + "if(window.__bhtReplyHook)return;window.__bhtReplyHook=true;" + "var save=function(j){try{if(j&&j.data){" + "var r=(j.data.create_tweet&&j.data.create_tweet.tweet_results&&j.data.create_tweet.tweet_results.result)||" + "(j.data.notetweet_create&&j.data.notetweet_create.tweet_results&&j.data.notetweet_create.tweet_results.result);" + "if(r&&r.rest_id)sessionStorage.setItem('__bhtNewReply',String(r.rest_id));}}catch(e){}};" + "var isCreate=function(u){return typeof u==='string'&&u.indexOf('CreateTweet')!==-1;};" + "var of=window.fetch;" + "if(of){window.fetch=function(){var a=arguments;var u=(a[0]&&a[0].url)||a[0];" + "return of.apply(this,a).then(function(res){try{if(isCreate(u))res.clone().json().then(save).catch(function(){});}catch(e){}return res;});};}" + "var oo=XMLHttpRequest.prototype.open;var os=XMLHttpRequest.prototype.send;" + "XMLHttpRequest.prototype.open=function(m,u){this.__bhtURL=u;return oo.apply(this,arguments);};" + "XMLHttpRequest.prototype.send=function(){var x=this;try{if(isCreate(x.__bhtURL)){" + "x.addEventListener('load',function(){try{save(JSON.parse(x.responseText));}catch(e){}});}}catch(e){}return os.apply(this,arguments);};" + "})();"; + +static NSString *const BHTReplyReadScript = + @"(function(){var v=sessionStorage.getItem('__bhtNewReply')||'';sessionStorage.removeItem('__bhtNewReply');return v;})();"; + +static void BHT_openStatusNatively(NSString *statusID) { + if (statusID.length == 0) { + return; } - if ([newOrig containsObject:%c(TTAStatusInlineAnalyticsButton)] && [BHTManager hideViewCount]) { - [newOrig removeObject:%c(TTAStatusInlineAnalyticsButton)]; + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"twitter://status?id=%@", statusID]]; + if (!url) { + return; } - if ([newOrig containsObject:%c(TTAStatusInlineBookmarkButton)] && [BHTManager hideBookmarkButton]) { - [newOrig removeObject:%c(TTAStatusInlineBookmarkButton)]; + id delegate = [UIApplication sharedApplication].delegate; + if ([delegate respondsToSelector:@selector(openURL:options:)]) { + ((void (*)(id, SEL, id, id))objc_msgSend)(delegate, @selector(openURL:options:), url, @{}); } +} - return [newOrig copy]; +static void BHT_showPostSentAlert(NSString *statusID) { + dispatch_async(dispatch_get_main_queue(), ^{ + UIViewController *top = topMostController(); + if (!top) { + return; + } + UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Post sent" + message:nil + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:@"Open" style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { + BHT_openStatusNatively(statusID); + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"Dismiss" style:UIAlertActionStyleCancel handler:nil]]; + [top presentViewController:alert animated:YES completion:nil]; + }); } -%end -// MARK: Always open in Safari +static BOOL BHT_openAuthenticatedTweetWebView(NSString *statusID) { + if (statusID.length == 0) { + return NO; + } -%hook SFSafariViewController -- (void)viewWillAppear:(BOOL)animated { - if (![BHTManager alwaysOpenSafari]) { - return %orig; + NSString *urlString = [NSString stringWithFormat:@"https://x.com/intent/tweet?in_reply_to=%@", statusID]; + NSURL *url = [NSURL URLWithString:urlString]; + if (!url) { + return NO; } - NSURL *url = [self initialURL]; - NSString *urlStr = [url absoluteString]; + Class webViewControllerClass = %c(T1WebViewController); + SEL initSel = @selector(initWithRootURL:account:shouldAuthenticate:shouldPresentAsNativePage:sourceStatus:scribeComponent:scribeParameters:); + if (!webViewControllerClass || ![webViewControllerClass instancesRespondToSelector:initSel]) { + return NO; + } - // In-app browser is used for two-factor authentication with security key, - // login will not complete successfully if it's redirected to Safari - if ([urlStr containsString:@"twitter.com/account/"] || [urlStr containsString:@"twitter.com/i/flow/"]) { - return %orig; + id account = BHT_accountForAuthenticatedWebView(); + if (!account) { + return NO; } - [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; - [self dismissViewControllerAnimated:NO completion:nil]; + UIViewController *presentingController = topMostController(); + if (!presentingController) { + return NO; + } + + T1WebViewController *webViewController = + [[webViewControllerClass alloc] initWithRootURL:url + account:account + shouldAuthenticate:YES + shouldPresentAsNativePage:NO + sourceStatus:nil + scribeComponent:nil + scribeParameters:nil]; + if (!webViewController) { + return NO; + } + + // Mark this instance so our -doesURLResultTypeOpenInWebview: and -setCurrentURL: + // hooks know to keep the reply in-webview and auto-close it on /home. + objc_setAssociatedObject(webViewController, BHTKeepReplyInWebViewKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + Class navigationControllerClass = NSClassFromString(@"T1WebNavigationController") + ?: %c(TFNNavigationController) + ?: UINavigationController.class; + UINavigationController *modalNavigationController = [[navigationControllerClass alloc] initWithRootViewController:webViewController]; + + [presentingController presentViewController:modalNavigationController animated:YES completion:nil]; + + return YES; } -- (instancetype)initWithURL:(NSURL *)URL configuration:(SFSafariViewControllerConfiguration *)configuration { - if (![BHTManager alwaysOpenSafari]) { - return %orig; +static T1StatusCell *BHT_tweetViewFromInlineActionsView(TTAStatusInlineActionsView *actionsView) { + if ([actionsView.superview isKindOfClass:%c(T1StandardStatusView)]) { + return (T1StatusCell *)[(T1StandardStatusView *)actionsView.superview eventHandler]; } - NSString *urlStr = [URL absoluteString]; + if ([actionsView.superview isKindOfClass:%c(T1TweetDetailsFocalStatusView)]) { + return (T1StatusCell *)[(T1TweetDetailsFocalStatusView *)actionsView.superview eventHandler]; + } - // In-app browser is used for two-factor authentication with security key, - // login will not complete successfully if it's redirected to Safari - if ([urlStr containsString:@"twitter.com/account/"] || [urlStr containsString:@"twitter.com/i/flow/"]) { - return %orig; + if ([actionsView.superview isKindOfClass:%c(T1ConversationFocalStatusView)]) { + return (T1StatusCell *)[(T1ConversationFocalStatusView *)actionsView.superview eventHandler]; } - // Open in Safari instead and return nil to prevent SFSafariViewController creation - [[UIApplication sharedApplication] openURL:URL options:@{} completionHandler:nil]; return nil; } -- (instancetype)initWithURL:(NSURL *)URL { - if (![BHTManager alwaysOpenSafari]) { +%hook TTAStatusInlineReplyButton +- (void)didTap { + if (![BHTManager replyInWebView]) { return %orig; } - NSString *urlStr = [URL absoluteString]; - - // In-app browser is used for two-factor authentication with security key, - // login will not complete successfully if it's redirected to Safari - if ([urlStr containsString:@"twitter.com/account/"] || [urlStr containsString:@"twitter.com/i/flow/"]) { + id delegate = self.delegate; + if (![delegate isKindOfClass:%c(TTAStatusInlineActionsView)]) { return %orig; } - // Open in Safari instead and return nil to prevent SFSafariViewController creation - [[UIApplication sharedApplication] openURL:URL options:@{} completionHandler:nil]; - return nil; -} -%end + TTAStatusInlineActionsView *actionsView = (TTAStatusInlineActionsView *)delegate; + TFNTwitterStatus *status = BHT_statusFromTweetView(BHT_tweetViewFromInlineActionsView(actionsView)); + if (!status) { + status = BHT_statusFromObject(actionsView.viewModel); + } -%hook SFInteractiveDismissController -- (void)animateTransition:(id)transitionContext { - if (![BHTManager alwaysOpenSafari]) { + NSInteger statusID = status.statusID; + if (statusID <= 0) { return %orig; } - [transitionContext completeTransition:NO]; -} -%end -%hook TFSTwitterEntityURL -- (NSString *)url { - // https://github.com/haoict/twitter-no-ads/blob/master/Tweak.xm#L195 - return self.expandedURL; -} -%end - -// MARK: Disable RTL -%hook NSParagraphStyle -+ (NSWritingDirection)defaultWritingDirectionForLanguage:(id)lang { - return [BHTManager disableRTL] ? NSWritingDirectionLeftToRight : %orig; -} -+ (NSWritingDirection)_defaultWritingDirection { - return [BHTManager disableRTL] ? NSWritingDirectionLeftToRight : %orig; + NSString *statusIDString = @(statusID).stringValue; + if (!BHT_openAuthenticatedTweetWebView(statusIDString)) { + return %orig; + } } %end -// MARK: Bio Translate -%hook TFNTwitterCanonicalUser -- (_Bool)isProfileBioTranslatable { - return [BHTManager BioTranslate] ? true : %orig; -} -%end +%hook T1PersistentComposeViewController +- (void)persistentComposeViewDidTap:(id)composeView { + if (![BHTManager replyInWebView]) { + return %orig; + } -// MARK: No search history -%hook T1SearchTypeaheadViewController // for old Twitter versions -- (void)viewDidLoad { - if ([BHTManager NoHistory]) { // thanks @CrazyMind90 - if ([self respondsToSelector:@selector(clearActionControlWantsClear:)]) { - [self performSelector:@selector(clearActionControlWantsClear:)]; - } + TFNTwitterStatus *status = BHT_statusFromObject(self.statusViewModel); + NSInteger statusID = status.statusID; + if (statusID <= 0) { + return %orig; } - %orig; -} -%end -%hook TTSSearchTypeaheadViewController -- (void)viewDidLoad { - if ([BHTManager NoHistory]) { // thanks @CrazyMind90 - if ([self respondsToSelector:@selector(clearActionControlWantsClear:)]) { - [self performSelector:@selector(clearActionControlWantsClear:)]; - } + NSString *statusIDString = @(statusID).stringValue; + if (!BHT_openAuthenticatedTweetWebView(statusIDString)) { + return %orig; } - %orig; } %end -// MARK: Voice, SensitiveTweetWarnings, autoHighestLoad, VideoZoom, VODCaptions, disableSpacesBar feature -%hook TPSTwitterFeatureSwitches -// Twitter save all the features and keys in side JSON file in bundle of application fs_embedded_defaults_production.json, and use it in TFNTwitterAccount class but with DM voice maybe developers forget to add boolean variable in the class, so i had to change it from the file. -// also, you can find every key for every feature i used in this tweak, i can remove all the codes below and find every key for it but I'm lazy to do that, :) -- (BOOL)boolForKey:(NSString *)key { - if ([key isEqualToString:@"edit_tweet_enabled"] || [key isEqualToString:@"edit_tweet_ga_composition_enabled"] || [key isEqualToString:@"edit_tweet_pdp_dialog_enabled"] || [key isEqualToString:@"edit_tweet_upsell_enabled"]) { - return true; - } +%hook T1WebViewController +- (void)didFinishLoadingWithError:(id)error { + %orig; - if ([key isEqualToString:@"grok_ios_profile_summary_enabled"] || [key isEqualToString:@"creator_monetization_dashboard_enabled"] || [key isEqualToString:@"creator_monetization_profile_subscription_tweets_tab_enabled"] || [key isEqualToString:@"creator_purchases_dashboard_enabled"]) { - return false; - } + BHT_maybeHandleHarvestWebView(self); - if ([key isEqualToString:@"grok_translations_bio_inline_translation_is_enabled"] || [key isEqualToString:@"grok_translations_bio_translation_is_enabled"] || [key isEqualToString:@"grok_translations_post_inline_translation_is_enabled"] || [key isEqualToString:@"grok_translations_post_translation_is_enabled"]) { - return true; + if (!objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey)) { + return; } - if ([key isEqualToString:@"subscriptions_upsells_get_verified_profile"] || [key isEqualToString:@"ios_profile_analytics_upsell_possible_enabled"] || [key isEqualToString:@"ios_profile_analytics_upsell_enabled"]) { - return false; + WKWebView *webView = [self webView]; + if ([webView isKindOfClass:%c(WKWebView)]) { + [webView evaluateJavaScript:BHTReplyCaptureScript completionHandler:nil]; } +} - if ([key isEqualToString:@"subscriptions_verification_info_is_identity_verified"] || [key isEqualToString:@"subscriptions_verification_info_reason_enabled"] || [key isEqualToString:@"subscriptions_verification_info_verified_since_enabled"]) { - return false; +- (BOOL)doesURLResultTypeOpenInWebview:(long long)resultType { + if (objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey)) { + return YES; } + return %orig; +} - if ([key isEqualToString:@"articles_timeline_profile_tab_enabled"]) { - return ![BHTManager disableArticles]; - } +- (void)setCurrentURL:(NSURL *)url { + %orig; - if ([key isEqualToString:@"ios_dm_dash_enabled"]) { - return ![BHTManager disableXChat]; + if (!objc_getAssociatedObject(self, BHTKeepReplyInWebViewKey) || ![url.path isEqualToString:@"/home"]) { + return; } - if ([key isEqualToString:@"highlights_tweets_tab_ui_enabled"]) { - return ![BHTManager disableHighlights]; + // setCurrentURL: can fire more than once for the same navigation; only act once. + if (objc_getAssociatedObject(self, BHTReplyWebViewDismissingKey)) { + return; } + objc_setAssociatedObject(self, BHTReplyWebViewDismissingKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - if ([key isEqualToString:@"media_tab_profile_videos_tab_enabled"] || [key isEqualToString:@"media_tab_profile_photos_tab_enabled"]) { - return ![BHTManager disableMediaTab]; - } + __weak T1WebViewController *weakSelf = self; - if ([key isEqualToString:@"communities_enable_explore_tab"] || [key isEqualToString:@"subscriptions_settings_item_enabled"]) { - return false; - } + void (^finish)(NSString *) = ^(NSString *newReplyID) { + [weakSelf dismissViewControllerAnimated:YES completion:^{ + if (newReplyID.length > 0) { + BHT_showPostSentAlert(newReplyID); + } + }]; + }; - if ([key isEqualToString:@"dash_items_download_grok_enabled"]) { - return false; + WKWebView *webView = [self webView]; + if ([webView isKindOfClass:%c(WKWebView)]) { + [webView evaluateJavaScript:BHTReplyReadScript completionHandler:^(id result, NSError *jsError) { + NSString *newReplyID = [result isKindOfClass:[NSString class]] ? (NSString *)result : nil; + finish(newReplyID); + }]; + } else { + finish(nil); } +} +%end - if ([key isEqualToString:@"conversational_replies_ios_minimal_detail_enabled"]) { - return ![BHTManager OldStyle]; - } +// MARK: Web authentication for tweeting - if ([key isEqualToString:@"dm_compose_bar_v2_enabled"]) { - return ![BHTManager dmComposeBarV2]; - } +static NSString *const BHTWebBearer = + @"Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA"; +static NSString *const BHTWebQueryIDDefaultsKey = @"nfb_createtweet_queryid"; +static NSString *BHTWebCreateTweetQueryID = @"vwzfnq1lLOa1Nfx7htM2mw"; - if ([key isEqualToString:@"reply_sorting_enabled"]) { - return ![BHTManager replySorting]; - } +static NSString *BHTWebCT0 = nil; +static NSString *BHTWebAuthToken = nil; +static NSString *BHTWebTwid = nil; +static NSString *BHTWebAuthMulti = nil; - if ([key isEqualToString:@"dm_voice_creation_enabled"]) { - return ![BHTManager dmVoiceCreation]; - } +static NSMutableDictionary *BHTWebAccountCookies = nil; +static const void *BHTWebPostingUIDKey = &BHTWebPostingUIDKey; +static const void *BHTCreateTweetWatcherKey = &BHTCreateTweetWatcherKey; - if ([key isEqualToString:@"ios_tweet_detail_overflow_in_navigation_enabled"]) { - return false; +static dispatch_queue_t BHT_accountCacheQueue(void) { + static dispatch_queue_t queue; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + queue = dispatch_queue_create("com.nfb.webaccountcache", DISPATCH_QUEUE_SERIAL); + }); + return queue; +} + +static NSDictionary *BHT_cachedPair(NSString *userID) { + if (userID.length == 0) { + return nil; + } + __block NSDictionary *pair = nil; + dispatch_sync(BHT_accountCacheQueue(), ^{ + pair = BHTWebAccountCookies[userID]; + }); + return pair; +} + +static void BHT_setCachedPair(NSString *userID, NSDictionary *pair) { + if (userID.length == 0) { + return; + } + dispatch_sync(BHT_accountCacheQueue(), ^{ + if (!BHTWebAccountCookies) { + BHTWebAccountCookies = [NSMutableDictionary dictionary]; + } + if (pair) { + BHTWebAccountCookies[userID] = pair; + } else { + [BHTWebAccountCookies removeObjectForKey:userID]; + } + }); +} +static BOOL BHTWebCookieHarvestInFlight = NO; +static UIWindow *BHTWebHarvestWindow = nil; +static BOOL BHTWebBootstrapInFlight = NO; + +// The authenticated web helper webview is kept alive so we can generate a fresh +// x-client-transaction-id per send to avoid rate limiting +static WKWebView *BHTWebHelperWebView = nil; +static BOOL BHTWebHelperReady = NO; +static NSString *BHTWebXTID = nil; +static BOOL BHTWebXTIDInFlight = NO; + +static const void *BHTWebHarvestWebViewKey = &BHTWebHarvestWebViewKey; + +static void BHT_teardownWebHarvestWindow(void); +static void BHT_refreshXTID(void); +static void BHT_refreshWebCookiesViaWebView(void); + +@interface WKWebView (BHTAsyncJavaScript) +- (void)callAsyncJavaScript:(NSString *)functionBody + arguments:(NSDictionary *)arguments + inFrame:(WKFrameInfo *)frame + inContentWorld:(WKContentWorld *)contentWorld + completionHandler:(void (^)(id result, NSError *error))completionHandler; +@end + +static BOOL BHT_nativeCreateTweetInterceptEnabled(void) { + return ![BHTManager replyInWebView]; +} + +#pragma mark - Web session cookie harvesting + +static void BHT_storeWebCookies(NSArray *cookies) { + if (![cookies isKindOfClass:[NSArray class]]) { + return; + } + for (NSHTTPCookie *cookie in cookies) { + NSString *domain = cookie.domain ?: @""; + if (![domain containsString:@"x.com"] && ![domain containsString:@"twitter.com"]) { + continue; + } + if ([cookie.name isEqualToString:@"ct0"] && cookie.value.length) { + BHTWebCT0 = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"auth_token"] && cookie.value.length) { + BHTWebAuthToken = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"twid"] && cookie.value.length) { + BHTWebTwid = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"auth_multi"] && cookie.value.length) { + BHTWebAuthMulti = [cookie.value copy]; + } + } + + NSString *liveUserID = nil; + if (BHTWebTwid.length) { + NSString *decoded = [BHTWebTwid stringByRemovingPercentEncoding] ?: BHTWebTwid; + NSRange eq = [decoded rangeOfString:@"="]; + NSString *idPart = eq.location != NSNotFound ? [decoded substringFromIndex:NSMaxRange(eq)] : decoded; + NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet]; + NSString *digits = [[idPart componentsSeparatedByCharactersInSet:nonDigits] componentsJoinedByString:@""]; + liveUserID = digits.length ? digits : nil; + } + if (liveUserID.length && BHTWebAuthToken.length && BHTWebCT0.length) { + BHT_setCachedPair(liveUserID, @{ + @"auth_token": BHTWebAuthToken, + @"ct0": BHTWebCT0, + @"twid": BHTWebTwid, + }); + } +} + +static void BHT_harvestWebCookiesFromSharedStorage(void) { + NSMutableArray *all = [NSMutableArray array]; + for (NSString *domain in @[@"https://api.twitter.com", @"https://twitter.com", @"https://x.com"]) { + NSArray *cookies = [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:[NSURL URLWithString:domain]]; + if (cookies) { + [all addObjectsFromArray:cookies]; + } + } + BHT_storeWebCookies(all); +} + +static void BHT_onHelperWebViewLoaded(WKWebView *webView); + +@interface BHTWebHelperDelegate : NSObject +@end +@implementation BHTWebHelperDelegate +- (void)webView:(WKWebView *)webView didFinishNavigation:(__unused WKNavigation *)navigation { + BHT_onHelperWebViewLoaded(webView); +} +- (void)webView:(__unused WKWebView *)webView didFailProvisionalNavigation:(__unused WKNavigation *)navigation withError:(__unused NSError *)error { + BHTWebHelperWebView = nil; + BHTWebHelperReady = NO; + BHTWebCookieHarvestInFlight = NO; +} +@end + +static BHTWebHelperDelegate *BHTWebHelperDelegateInstance = nil; + +// Seed the helper webview's cookie store with the harvested web-session cookies so it +// loads authenticated +static void BHT_seedHelperCookies(WKWebView *webView, void (^done)(void)) { + if (@available(iOS 11.0, *)) { + NSMutableArray *cookies = [NSMutableArray array]; + NSDictionary *pairs = @{ @"auth_token": BHTWebAuthToken ?: @"", + @"ct0": BHTWebCT0 ?: @"", + @"twid": BHTWebTwid ?: @"" }; + for (NSString *name in pairs) { + NSString *value = pairs[name]; + if (value.length == 0) continue; + NSHTTPCookie *cookie = [NSHTTPCookie cookieWithProperties:@{ + NSHTTPCookieName: name, + NSHTTPCookieValue: value, + NSHTTPCookieDomain: @".x.com", + NSHTTPCookiePath: @"/", + }]; + if (cookie) [cookies addObject:cookie]; + } + if (cookies.count == 0) { + done(); + return; + } + WKHTTPCookieStore *store = webView.configuration.websiteDataStore.httpCookieStore; + __block NSUInteger remaining = cookies.count; + for (NSHTTPCookie *cookie in cookies) { + [store setCookie:cookie completionHandler:^{ + if (--remaining == 0) done(); + }]; + } + } else { + done(); + } +} + +// Stand up an offscreen raw WKWebView on x.com +static void BHT_refreshWebCookiesViaWebView(void) { + if (BHTWebHelperWebView) { + BHT_refreshXTID(); + return; + } + if (BHTWebCookieHarvestInFlight) { + return; + } + BHTWebCookieHarvestInFlight = YES; + + dispatch_async(dispatch_get_main_queue(), ^{ + BHT_harvestWebCookiesFromSharedStorage(); + + WKWebViewConfiguration *configuration = [[WKWebViewConfiguration alloc] init]; + configuration.allowsInlineMediaPlayback = YES; + configuration.allowsPictureInPictureMediaPlayback = NO; + configuration.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeAll; + WKWebView *webView = [[WKWebView alloc] initWithFrame:CGRectMake(0, 0, 390, 844) + configuration:configuration]; + BHTWebHelperDelegateInstance = [[BHTWebHelperDelegate alloc] init]; + webView.navigationDelegate = BHTWebHelperDelegateInstance; + webView.customUserAgent = @"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"; + webView.userInteractionEnabled = NO; + BHTWebHelperWebView = webView; + BHTWebHelperReady = NO; + + UIWindow *keyWindow = nil; + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { keyWindow = w; break; } + } + if (!keyWindow) { + keyWindow = [UIApplication sharedApplication].windows.firstObject; + } + if (keyWindow) { + webView.frame = CGRectMake(-3000, -3000, 390, 844); + webView.alpha = 0.01; + [keyWindow addSubview:webView]; + } + + BHT_seedHelperCookies(webView, ^{ + [webView loadRequest:[NSURLRequest requestWithURL:[NSURL URLWithString:@"https://x.com/settings/account"]]]; + }); + }); +} + +static void BHT_onHelperWebViewLoaded(WKWebView *webView) { + BHTWebCookieHarvestInFlight = NO; + + [webView.configuration.websiteDataStore.httpCookieStore getAllCookies:^(NSArray *cookies) { + BHT_storeWebCookies(cookies); + }]; + + NSString *script = nil; + NSURL *scriptURL = [[BHTBundle sharedBundle] pathForFile:@"BHTWebXTID.js"]; + if (scriptURL) { + script = [NSString stringWithContentsOfURL:scriptURL encoding:NSUTF8StringEncoding error:nil]; + } + if (script.length) { + [webView evaluateJavaScript:script completionHandler:^(__unused id result, __unused NSError *error) { + BHTWebHelperReady = YES; + BHT_refreshXTID(); + }]; + } +} + +static void BHT_teardownWebHarvestWindow(void) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (BHTWebHarvestWindow) { + BHTWebHarvestWindow.hidden = YES; + BHTWebHarvestWindow.rootViewController = nil; + BHTWebHarvestWindow = nil; + } + BHTWebBootstrapInFlight = NO; + }); +} + +static UIWindowScene *BHT_activeWindowScene(void) { + if (@available(iOS 13.0, *)) { + UIWindowScene *fallback = nil; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) { + continue; + } + if (scene.activationState == UISceneActivationStateForegroundActive) { + return (UIWindowScene *)scene; + } + if (!fallback) { + fallback = (UIWindowScene *)scene; + } + } + return fallback; + } + return nil; +} + +static NSString *BHT_userIDStringForAccount(id account) { + if (!account || ![account respondsToSelector:@selector(userID)]) { + return nil; + } + long long uid = ((long long (*)(id, SEL))objc_msgSend)(account, @selector(userID)); + return uid ? [@(uid) stringValue] : nil; +} + +static id BHT_accountForUserID(NSString *userID) { + if (userID.length == 0) { + return nil; + } + @try { + Class twitterClass = %c(TFNTwitter); + if (![twitterClass respondsToSelector:@selector(sharedTwitter)]) { + return nil; + } + id twitter = ((id (*)(id, SEL))objc_msgSend)((id)twitterClass, @selector(sharedTwitter)); + if (![twitter respondsToSelector:@selector(accounts)]) { + return nil; + } + NSArray *accounts = ((id (*)(id, SEL))objc_msgSend)(twitter, @selector(accounts)); + for (id account in accounts) { + if ([BHT_userIDStringForAccount(account) isEqualToString:userID]) { + return account; + } + } + } @catch (__unused NSException *exception) {} + return nil; +} + +static void BHT_bootstrapAccount(id account, NSString *userID) { + if (!account || userID.length == 0) { + return; + } + + if (BHTWebBootstrapInFlight) { + return; + } + + BHTWebBootstrapInFlight = YES; + + dispatch_async(dispatch_get_main_queue(), ^{ + if (BHTWebHarvestWindow) { + BHTWebHarvestWindow.hidden = YES; + BHTWebHarvestWindow.rootViewController = nil; + BHTWebHarvestWindow = nil; + } + + Class webViewControllerClass = %c(T1WebViewController); + SEL initSel = @selector(initWithRootURL:account:shouldAuthenticate:shouldPresentAsNativePage:sourceStatus:scribeComponent:scribeParameters:); + UIWindowScene *scene = BHT_activeWindowScene(); + if (!webViewControllerClass || !scene || + ![webViewControllerClass instancesRespondToSelector:initSel]) { + BHTWebBootstrapInFlight = NO; + return; + } + + NSURL *url = [NSURL URLWithString:@"https://x.com/settings/account"]; + T1WebViewController *webViewController = + [[webViewControllerClass alloc] initWithRootURL:url + account:account + shouldAuthenticate:YES + shouldPresentAsNativePage:NO + sourceStatus:nil + scribeComponent:nil + scribeParameters:nil]; + if (!webViewController) { + BHTWebBootstrapInFlight = NO; + return; + } + + objc_setAssociatedObject(webViewController, BHTWebHarvestWebViewKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + UIWindow *window = [[UIWindow alloc] initWithWindowScene:scene]; + window.frame = CGRectMake(-3000, -3000, 390, 844); + window.windowLevel = UIWindowLevelNormal - 1000; + window.userInteractionEnabled = NO; + window.rootViewController = webViewController; + window.hidden = NO; + BHTWebHarvestWindow = window; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(25 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + BHT_teardownWebHarvestWindow(); + }); + }); +} + +static void BHT_refreshXTID(void) { + if (BHTWebXTIDInFlight) { + return; + } + WKWebView *webView = BHTWebHelperWebView; + if (![webView isKindOfClass:[WKWebView class]]) { + return; + } + if (@available(iOS 14.0, *)) { + BHTWebXTIDInFlight = YES; + NSString *path = [NSString stringWithFormat:@"/graphql/%@/CreateTweet", BHTWebCreateTweetQueryID]; + dispatch_async(dispatch_get_main_queue(), ^{ + [webView callAsyncJavaScript:@"return await window.__bhtTransactionId(path, method);" + arguments:@{ @"method": @"POST", @"path": path } + inFrame:nil + inContentWorld:WKContentWorld.pageWorld + completionHandler:^(id result, NSError *error) { + BHTWebXTIDInFlight = NO; + BOOL ok = [result isKindOfClass:[NSString class]] && + [(NSString *)result length] > 10 && + ![(NSString *)result hasPrefix:@"BHTERR:"]; + if (ok) { + BHTWebXTID = [result copy]; + } + }]; + }); + } +} + +static void BHT_prewarmWebCookiesIfNeeded(void) { + if (!BHT_nativeCreateTweetInterceptEnabled()) { + return; + } + + NSString *savedQueryID = [[NSUserDefaults standardUserDefaults] stringForKey:BHTWebQueryIDDefaultsKey]; + if (savedQueryID.length) { + BHTWebCreateTweetQueryID = [savedQueryID copy]; + } + + if (BHTWebHelperWebView) { + BHT_refreshXTID(); + } else { + BHT_refreshWebCookiesViaWebView(); + } + + BHT_harvestWebCookiesFromSharedStorage(); + + id current = BHT_accountForAuthenticatedWebView(); + NSString *currentUserID = BHT_userIDStringForAccount(current); + if (current && currentUserID.length && !BHT_cachedPair(currentUserID)) { + BHT_bootstrapAccount(current, currentUserID); + } +} + +#pragma mark - Request / response transforms + +static void BHT_applyWebAuth(NSMutableURLRequest *request, NSString *authToken, NSString *ct0, NSString *userID) { + request.HTTPShouldHandleCookies = NO; + + for (NSString *header in @[@"Authorization", @"X-Twitter-Client-DeviceID", @"X-Twitter-Client-Version", + @"X-Twitter-Client", @"X-Twitter-API-Version", @"X-Twitter-Client-Limit-Ad-Tracking", + @"X-B3-TraceId", @"Timezone", @"kdt", @"X-Client-UUID"]) { + [request setValue:nil forHTTPHeaderField:header]; + } + + [request setValue:BHTWebBearer forHTTPHeaderField:@"authorization"]; + [request setValue:@"OAuth2Session" forHTTPHeaderField:@"x-twitter-auth-type"]; + [request setValue:@"yes" forHTTPHeaderField:@"x-twitter-active-user"]; + if (ct0.length) { + [request setValue:ct0 forHTTPHeaderField:@"x-csrf-token"]; + } + + NSMutableArray *cookiePairs = [NSMutableArray array]; + if (authToken.length) { + [cookiePairs addObject:[NSString stringWithFormat:@"auth_token=%@", authToken]]; + } + if (ct0.length) { + [cookiePairs addObject:[NSString stringWithFormat:@"ct0=%@", ct0]]; + } + if (userID.length) { + [cookiePairs addObject:[NSString stringWithFormat:@"twid=u%%3D%@", userID]]; + } + [request setValue:[cookiePairs componentsJoinedByString:@"; "] forHTTPHeaderField:@"Cookie"]; +} + +#pragma mark - Hooks + +static void BHT_maybeHandleHarvestWebView(__unsafe_unretained id webViewController) { + if (!webViewController || !objc_getAssociatedObject(webViewController, BHTWebHarvestWebViewKey)) { + return; + } + + WKWebView *webView = nil; + @try { + if ([webViewController respondsToSelector:@selector(webView)]) { + webView = ((WKWebView *(*)(id, SEL))objc_msgSend)(webViewController, @selector(webView)); + } + } @catch (__unused NSException *exception) {} + + void (^finish)(void) = ^{ + BHT_harvestWebCookiesFromSharedStorage(); + BHT_refreshWebCookiesViaWebView(); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + BHT_teardownWebHarvestWindow(); + }); + }; + + if ([webView isKindOfClass:%c(WKWebView)]) { + [webView.configuration.websiteDataStore.httpCookieStore getAllCookies:^(NSArray *cookies) { + BHT_storeWebCookies(cookies); + finish(); + }]; + } else { + finish(); + } +} + +static BOOL BHT_isCreateTweetURL(NSURL *url) { + return url && [url.path hasSuffix:@"/CreateTweet"]; +} + +// The queryId sits in the request path: .../graphql//CreateTweet +static NSString *BHT_queryIDFromCreateTweetURL(NSURL *url) { + NSArray *components = url.path.pathComponents; + if (components.count >= 2 && [components.lastObject isEqualToString:@"CreateTweet"]) { + return components[components.count - 2]; + } + return nil; +} + +static NSString *BHT_postingUserIDFromRequest(NSURLRequest *request) { + NSString *auth = [request valueForHTTPHeaderField:@"Authorization"]; + if (![auth isKindOfClass:[NSString class]]) { + return nil; + } + NSRange marker = [auth rangeOfString:@"oauth_token=\""]; + if (marker.location == NSNotFound) { + return nil; + } + NSString *rest = [auth substringFromIndex:NSMaxRange(marker)]; + NSRange endQuote = [rest rangeOfString:@"\""]; + if (endQuote.location == NSNotFound) { + return nil; + } + NSString *token = [rest substringToIndex:endQuote.location]; // "-" + NSRange dash = [token rangeOfString:@"-"]; + return dash.location != NSNotFound ? [token substringToIndex:dash.location] : nil; +} + +static NSString *BHT_harvestedUserID(void) { + if (BHTWebTwid.length == 0) { + return nil; + } + NSString *decoded = [BHTWebTwid stringByRemovingPercentEncoding] ?: BHTWebTwid; + NSRange eq = [decoded rangeOfString:@"="]; + NSString *idPart = eq.location != NSNotFound ? [decoded substringFromIndex:NSMaxRange(eq)] : decoded; + NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet]; + NSString *digits = [[idPart componentsSeparatedByCharactersInSet:nonDigits] componentsJoinedByString:@""]; + return digits.length ? digits : nil; +} + +static NSString *BHT_authTokenForUserID(NSString *userID) { + if (userID.length == 0) { + return nil; + } + + NSString *primaryUID = BHT_harvestedUserID(); + if (primaryUID.length && [primaryUID isEqualToString:userID] && BHTWebAuthToken.length) { + return BHTWebAuthToken; + } + + if (BHTWebAuthMulti.length == 0) { + return nil; + } + NSString *decoded = [BHTWebAuthMulti stringByRemovingPercentEncoding] ?: BHTWebAuthMulti; + decoded = [decoded stringByTrimmingCharactersInSet:[NSCharacterSet characterSetWithCharactersInString:@"\""]]; + NSCharacterSet *separators = [NSCharacterSet characterSetWithCharactersInString:@"|,"]; + for (NSString *entry in [decoded componentsSeparatedByCharactersInSet:separators]) { + NSRange colon = [entry rangeOfString:@":"]; + if (colon.location == NSNotFound) { + continue; + } + NSString *uid = [entry substringToIndex:colon.location]; + NSString *token = [entry substringFromIndex:NSMaxRange(colon)]; + if ([uid isEqualToString:userID] && token.length) { + return token; + } + } + return nil; +} + +static void BHT_waitUntilReady(BOOL (^ready)(void), void (^kick)(void)) { + if (ready() || [NSThread isMainThread]) { + return; + } + NSUInteger tick = 0; + while (![NSThread isMainThread] && !ready()) { + if (kick && (tick % 60 == 0)) { // ~every 3s + dispatch_async(dispatch_get_main_queue(), kick); + } + [NSThread sleepForTimeInterval:0.05]; + tick++; + } +} + +static BOOL BHT_waitUntilReadyBounded(BOOL (^ready)(void), void (^kick)(void), NSTimeInterval maxSeconds) { + if (ready()) { + return YES; + } + if ([NSThread isMainThread]) { + return NO; + } + NSUInteger tick = 0; + NSUInteger maxTicks = (NSUInteger)(maxSeconds / 0.05); + while (![NSThread isMainThread] && !ready() && tick < maxTicks) { + if (kick && (tick % 60 == 0)) { // ~every 3s + dispatch_async(dispatch_get_main_queue(), kick); + } + [NSThread sleepForTimeInterval:0.05]; + tick++; + } + return ready(); +} + +static BOOL BHT_resolveWebCreds(NSString *userID, NSString **outAuthToken, NSString **outCt0) { + NSDictionary *cached = BHT_cachedPair(userID); + NSString *authToken = cached[@"auth_token"]; + NSString *ct0 = cached[@"ct0"]; + if (outAuthToken) { + *outAuthToken = authToken; + } + if (outCt0) { + *outCt0 = ct0; + } + return userID.length > 0 && authToken.length > 0 && ct0.length > 0; +} + +@interface BHTCt0Fetcher : NSObject +@property (nonatomic, copy) NSString *ct0; +@property (nonatomic, copy) NSString *twid; +@property (nonatomic, assign) BOOL loggedOut; +- (void)captureFromResponse:(NSURLResponse *)response; +@end + +@implementation BHTCt0Fetcher +- (void)captureFromResponse:(NSURLResponse *)response { + if (![response isKindOfClass:[NSHTTPURLResponse class]]) { + return; + } + NSHTTPURLResponse *http = (NSHTTPURLResponse *)response; + NSArray *cookies = [NSHTTPCookie cookiesWithResponseHeaderFields:http.allHeaderFields + forURL:http.URL ?: response.URL]; + for (NSHTTPCookie *cookie in cookies) { + if ([cookie.name isEqualToString:@"ct0"] && cookie.value.length) { + self.ct0 = [cookie.value copy]; + } else if ([cookie.name isEqualToString:@"twid"] && cookie.value.length) { + self.twid = [cookie.value copy]; + } + } +} +- (void)noteRedirectTarget:(NSURL *)url { + NSString *path = url.absoluteString.lowercaseString ?: @""; + if ([path containsString:@"login"] || [path containsString:@"logout"] || + [path containsString:@"/i/flow/"] || [path containsString:@"account/access"]) { + self.loggedOut = YES; + } +} +- (void)URLSession:(__unused NSURLSession *)session + task:(__unused NSURLSessionTask *)task +willPerformHTTPRedirection:(NSHTTPURLResponse *)response + newRequest:(NSURLRequest *)request + completionHandler:(void (^)(NSURLRequest *))completionHandler { + [self captureFromResponse:response]; + [self noteRedirectTarget:request.URL]; + completionHandler(request); +} +@end + +static NSString *BHT_userIDDigitsFromTwid(NSString *twid) { + if (twid.length == 0) { + return nil; + } + NSString *decoded = [twid stringByRemovingPercentEncoding] ?: twid; + NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet]; + NSString *digits = [[decoded componentsSeparatedByCharactersInSet:nonDigits] componentsJoinedByString:@""]; + return digits.length ? digits : nil; +} + +static NSString *BHT_fetchCt0Sync(NSString *authToken, NSString *expectedUserID) { + if (authToken.length == 0 || [NSThread isMainThread]) { + return nil; + } + + BHTCt0Fetcher *fetcher = [BHTCt0Fetcher new]; + NSURLSessionConfiguration *config = [NSURLSessionConfiguration ephemeralSessionConfiguration]; + config.HTTPCookieStorage = nil; + config.HTTPShouldSetCookies = NO; + NSURLSession *session = [NSURLSession sessionWithConfiguration:config delegate:fetcher delegateQueue:nil]; + + NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:@"https://x.com/"]]; + request.HTTPShouldHandleCookies = NO; + [request setValue:[NSString stringWithFormat:@"auth_token=%@", authToken] forHTTPHeaderField:@"Cookie"]; + [request setValue:@"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" + forHTTPHeaderField:@"User-Agent"]; + + dispatch_semaphore_t done = dispatch_semaphore_create(0); + [[session dataTaskWithRequest:request completionHandler:^(__unused NSData *data, + NSURLResponse *response, + __unused NSError *error) { + [fetcher captureFromResponse:response]; + dispatch_semaphore_signal(done); + }] resume]; + dispatch_semaphore_wait(done, dispatch_time(DISPATCH_TIME_NOW, (int64_t)(15 * NSEC_PER_SEC))); + [session finishTasksAndInvalidate]; + + if (fetcher.loggedOut) { + return nil; + } + + NSString *responseUserID = BHT_userIDDigitsFromTwid(fetcher.twid); + if (expectedUserID.length && responseUserID.length && ![responseUserID isEqualToString:expectedUserID]) { + return nil; + } + return fetcher.ct0; +} + +static NSMutableURLRequest *BHT_webRequestFromNativeSend(NSURLRequest *request) { + if (!BHT_isCreateTweetURL(request.URL)) { + return nil; + } + + if (!BHT_nativeCreateTweetInterceptEnabled()) { + return nil; + } + + NSString *queryID = BHT_queryIDFromCreateTweetURL(request.URL); + if (queryID.length && ![queryID isEqualToString:BHTWebCreateTweetQueryID]) { + BHTWebCreateTweetQueryID = [queryID copy]; + [[NSUserDefaults standardUserDefaults] setObject:queryID forKey:BHTWebQueryIDDefaultsKey]; + } + + if (BHTWebXTID.length == 0) { + BHT_waitUntilReady(^BOOL{ return BHTWebXTID.length > 0; }, ^{ + if (!BHTWebHelperWebView) { + BHT_refreshWebCookiesViaWebView(); + } else if (BHTWebHelperReady) { + BHT_refreshXTID(); + } + }); + if (BHTWebXTID.length == 0) { + return nil; + } + } + + BHT_harvestWebCookiesFromSharedStorage(); + + NSString *postingUserID = BHT_postingUserIDFromRequest(request); + if (postingUserID.length == 0) { + return nil; + } + + NSString *authToken = nil, *ct0 = nil; + + if (!BHT_resolveWebCreds(postingUserID, &authToken, &ct0)) { + NSString *token = BHT_authTokenForUserID(postingUserID); + + for (int attempt = 0; attempt < 2 && ct0.length == 0; attempt++) { + if (token.length == 0) { + id account = BHT_accountForUserID(postingUserID); + if (!account) { + break; + } + NSString *waitUserID = postingUserID; + __block id waitAccount = account; + BHT_waitUntilReadyBounded(^BOOL{ + BHT_harvestWebCookiesFromSharedStorage(); + return BHT_authTokenForUserID(waitUserID).length > 0; + }, ^{ + BHT_bootstrapAccount(waitAccount, waitUserID); + }, 30.0); + token = BHT_authTokenForUserID(postingUserID); + if (token.length == 0) { + break; + } + } + + NSString *fresh = BHT_fetchCt0Sync(token, postingUserID); + if (fresh.length) { + authToken = token; + ct0 = fresh; + BHT_setCachedPair(postingUserID, @{ + @"auth_token": token, + @"ct0": fresh, + @"twid": [NSString stringWithFormat:@"u=%@", postingUserID], + }); + } else { + BHT_setCachedPair(postingUserID, nil); + token = nil; + } + } + + if (authToken.length == 0 || ct0.length == 0) { + return nil; + } + } + + NSMutableURLRequest *outgoing = [request mutableCopy]; + BHT_applyWebAuth(outgoing, authToken, ct0, postingUserID); + [outgoing setValue:BHTWebXTID forHTTPHeaderField:@"x-client-transaction-id"]; + BHT_refreshXTID(); + // Tag the request with the account it's posting as so the task observer can invalidate the + // right account's cached ct0 if the send comes back 4xx. + objc_setAssociatedObject(outgoing, BHTWebPostingUIDKey, postingUserID, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + return outgoing; +} + +// Watches a rewritten CreateTweet task and, if it finishes with a 4xx, it drops the ct0 +// from the cache +@interface BHTCreateTweetWatcher : NSObject +@property (nonatomic, copy) NSString *userID; +@end + +@implementation BHTCreateTweetWatcher +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(__unused NSDictionary *)change + context:(__unused void *)context { + NSURLSessionTask *task = object; + if (![keyPath isEqualToString:@"state"] || task.state != NSURLSessionTaskStateCompleted) { + return; + } + + BHTCreateTweetWatcher *keepAlive = self; // survive detaching our own retainer below + @try { + [task removeObserver:self forKeyPath:@"state"]; + } @catch (__unused NSException *exception) {} + objc_setAssociatedObject(task, BHTCreateTweetWatcherKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + + NSInteger code = [task.response isKindOfClass:[NSHTTPURLResponse class]] + ? [(NSHTTPURLResponse *)task.response statusCode] : 0; + NSString *userID = keepAlive.userID; + if (code >= 400 && code < 500 && userID.length) { + BHT_setCachedPair(userID, nil); + } + (void)keepAlive; +} +@end + +static void BHT_watchCreateTweetTask(id task, NSString *userID) { + if (![task isKindOfClass:[NSURLSessionTask class]] || userID.length == 0) { + return; + } + BHTCreateTweetWatcher *watcher = [BHTCreateTweetWatcher new]; + watcher.userID = userID; + objc_setAssociatedObject(task, BHTCreateTweetWatcherKey, watcher, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + @try { + [task addObserver:watcher forKeyPath:@"state" options:NSKeyValueObservingOptionNew context:NULL]; + } @catch (__unused NSException *exception) {} +} + +%hook NSURLSession + +- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionDataTask *task = %orig(outgoing); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request + completionHandler:(id)completionHandler { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionDataTask *task = %orig(outgoing, completionHandler); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromData:(NSData *)bodyData { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionUploadTask *task = %orig(outgoing, bodyData); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromFile:(NSURL *)fileURL { + NSMutableURLRequest *outgoing = BHT_webRequestFromNativeSend(request); + if (outgoing) { + NSURLSessionUploadTask *task = %orig(outgoing, fileURL); + BHT_watchCreateTweetTask(task, objc_getAssociatedObject(outgoing, BHTWebPostingUIDKey)); + return task; + } + return %orig; +} + +%end + +// MARK: Save tweet as an image + +%hook TTAStatusInlineShareButton +- (void)didLongPressActionButton:(UILongPressGestureRecognizer *)gestureRecognizer { + if ([BHTManager tweetToImage]) { + if (gestureRecognizer.state == UIGestureRecognizerStateBegan) { + id delegate = self.delegate; + if (![delegate isKindOfClass:%c(TTAStatusInlineActionsView)]) { + return %orig; + } + TTAStatusInlineActionsView *actionsView = (TTAStatusInlineActionsView *)delegate; + T1StatusCell *tweetView; + + if ([actionsView.superview isKindOfClass:%c(T1StandardStatusView)]) { // normal tweet in the time line + tweetView = (T1StatusCell *)[(T1StandardStatusView *)actionsView.superview eventHandler]; + } else if ([actionsView.superview isKindOfClass:%c(T1TweetDetailsFocalStatusView)]) { // Focus tweet + tweetView = (T1StatusCell *)[(T1TweetDetailsFocalStatusView *)actionsView.superview eventHandler]; + } else if ([actionsView.superview isKindOfClass:%c(T1ConversationFocalStatusView)]) { // Focus tweet + tweetView = (T1StatusCell *)[(T1ConversationFocalStatusView *)actionsView.superview eventHandler]; + } else { + return %orig; + } + + UIImage *tweetImage = BH_imageFromView(tweetView); + NSData *pngData = UIImagePNGRepresentation(tweetImage); + NSURL *pngURL = [[NSURL fileURLWithPath:NSTemporaryDirectory()] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.png", [[NSUUID UUID] UUIDString]]]; + [pngData writeToURL:pngURL atomically:YES]; + UIActivityViewController *acVC = [[UIActivityViewController alloc] initWithActivityItems:@[pngURL] applicationActivities:nil]; + if (is_iPad()) { + acVC.popoverPresentationController.sourceView = self; + acVC.popoverPresentationController.sourceRect = self.frame; + } + [topMostController() presentViewController:acVC animated:true completion:nil]; + return; + } + } + return %orig; +} +%end + +// MARK: Hide Blue verified checkmark + +%hook T1CompositionStatusViewModel +- (BOOL)isFromUserVerified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +- (BOOL)isFromUserBlueVerified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +%end + +%hook TFNTwitterStatus +- (BOOL)isFromUserVerified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +- (BOOL)isFromUserBlueVerified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +%end + +%hook T1StandardUserViewModel +- (BOOL)verified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +- (BOOL)isBlueVerified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +%end + +%hook T1ProfileUserViewModel +- (BOOL)isVerifiedAccount { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +%end + +%hook T1TwitterCoreStatusViewModelAdapter +- (BOOL)isFromUserVerified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +- (BOOL)isFromUserBlueVerified { + return [BHTManager hideBlueVerified] ? NO : %orig; +} +%end + +// MARK: Timeline download + +static NSArray *BHT_inlineActionViewClassesForViewModel(NSArray *classes, id viewModel) { + if (![classes isKindOfClass:NSArray.class]) { + return classes; + } + + NSMutableArray *newClasses = [classes mutableCopy]; + + Class analyticsButtonClass = %c(TTAStatusInlineAnalyticsButton); + if (analyticsButtonClass && + [newClasses containsObject:analyticsButtonClass] && + [BHTManager hideViewCount]) { + [newClasses removeObject:analyticsButtonClass]; + } + + Class bookmarkButtonClass = %c(TTAStatusInlineBookmarkButton); + if (bookmarkButtonClass && + [newClasses containsObject:bookmarkButtonClass] && + [BHTManager hideBookmarkButton]) { + [newClasses removeObject:bookmarkButtonClass]; + } + + Class downvoteButtonClass = %c(TTAStatusInlineDownvoteButton); + if (downvoteButtonClass && + [newClasses containsObject:downvoteButtonClass] && + [BHTManager hideDownvoteButton]) { + [newClasses removeObject:downvoteButtonClass]; + } + + return [newClasses copy]; +} + +%hook T1StatusInlineActionsView ++ (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { + NSArray *origClasses = %orig; + return BHT_inlineActionViewClassesForViewModel(origClasses, arg1); +} +%end + +%hook TTAStatusInlineActionsView ++ (NSArray *)_t1_inlineActionViewClassesForViewModel:(id)arg1 options:(NSUInteger)arg2 displayType:(NSUInteger)arg3 account:(id)arg4 { + NSArray *origClasses = %orig; + return BHT_inlineActionViewClassesForViewModel(origClasses, arg1); +} +// The downvote (dislike) button shown on replies/comments is gated by this +// method rather than being unconditionally present in the class list above. +// The array filter alone misses it, so intercept the gate directly: forcing +// NO prevents the button being built in every context (timeline and comments). ++ (BOOL)t1_shouldShowDownvoteButtonForViewModel:(id)arg1 options:(NSUInteger)arg2 anatomyFeatures:(id)arg3 displayType:(NSUInteger)arg4 account:(id)arg5 { + if ([BHTManager hideDownvoteButton]) { + return NO; + } + return %orig; +} +%end + +// The reply/comment downvote button is created unconditionally and its actual +// display is driven by -visibility: the actions view lays out only buttons whose +// visibility is non-zero and calls setHidden:YES on the rest. Filtering the class +// list doesn't remove it from the hierarchy, so force its visibility to 0 — this +// excludes it from layout (no gap) and gets it hidden like any collapsed action. +%hook TTAStatusInlineDownvoteButton +- (NSUInteger)visibility { + if ([BHTManager hideDownvoteButton]) { + return 0; + } + return %orig; +} +%end + +// Add a "Download media" item to the tweet overflow (3-dot) menu +%hook UIViewController +- (NSArray *)_t1_actionItemsForStatus:(__unsafe_unretained id)status account:(__unsafe_unretained id)account shareableEntity:(__unsafe_unretained id)shareableEntity entityURL:(__unsafe_unretained id)entityURL source:(__unsafe_unretained id)source options:(NSUInteger)options scribeComponent:(__unsafe_unretained id)scribeComponent doneBlock:(__unsafe_unretained id)doneBlock { + NSArray *origItems = %orig; + + if (![BHTManager DownloadingVideos] || ![status respondsToSelector:@selector(entities)]) { + return origItems; + } + + NSArray *mediaEntities = [[status entities] media]; + BOOL hasVideo = NO; + for (TFSTwitterEntityMedia *media in mediaEntities) { + if ([media isKindOfClass:%c(TFSTwitterEntityMedia)] && (media.mediaType == 2 || media.mediaType == 3)) { + hasVideo = YES; + break; + } + } + if (!hasVideo) { + return origItems; + } + + static char downloaderKey; + BHDownloadInlineButton *downloader = objc_getAssociatedObject(self, &downloaderKey); + if (!downloader) { + downloader = [%c(BHDownloadInlineButton) new]; + objc_setAssociatedObject(self, &downloaderKey, downloader, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + + TFNActionItem *downloadItem = [%c(TFNActionItem) actionItemWithTitle:[[BHTBundle sharedBundle] localizedStringForKey:@"DOWNLOAD_VIDEOS_OPTION_TITLE"] + imageName:@"arrow_down_circle_stroke" action:^{ + [downloader presentDownloadOptionsForMediaEntities:mediaEntities]; + }]; + + NSMutableArray *newItems = origItems ? [origItems mutableCopy] : [NSMutableArray array]; + NSUInteger insertIndex = newItems.count > 0 ? newItems.count - 1 : 0; + [newItems insertObject:downloadItem atIndex:insertIndex]; + return newItems; +} +%end + +// MARK: Always open in Safari + +%hook SFSafariViewController +- (void)viewWillAppear:(BOOL)animated { + if (![BHTManager alwaysOpenSafari]) { + return %orig; + } + + NSURL *url = [self initialURL]; + NSString *urlStr = [url absoluteString]; + + // In-app browser is used for two-factor authentication with security key, + // login will not complete successfully if it's redirected to Safari + if ([urlStr containsString:@"twitter.com/account/"] || [urlStr containsString:@"twitter.com/i/flow/"]) { + return %orig; + } + + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + [self dismissViewControllerAnimated:NO completion:nil]; +} + +- (instancetype)initWithURL:(NSURL *)URL configuration:(SFSafariViewControllerConfiguration *)configuration { + if (![BHTManager alwaysOpenSafari]) { + return %orig; + } + + NSString *urlStr = [URL absoluteString]; + + // In-app browser is used for two-factor authentication with security key, + // login will not complete successfully if it's redirected to Safari + if ([urlStr containsString:@"twitter.com/account/"] || [urlStr containsString:@"twitter.com/i/flow/"]) { + return %orig; + } + + // Open in Safari instead and return nil to prevent SFSafariViewController creation + [[UIApplication sharedApplication] openURL:URL options:@{} completionHandler:nil]; + return nil; +} + +- (instancetype)initWithURL:(NSURL *)URL { + if (![BHTManager alwaysOpenSafari]) { + return %orig; + } + + NSString *urlStr = [URL absoluteString]; + + // In-app browser is used for two-factor authentication with security key, + // login will not complete successfully if it's redirected to Safari + if ([urlStr containsString:@"twitter.com/account/"] || [urlStr containsString:@"twitter.com/i/flow/"]) { + return %orig; + } + + // Open in Safari instead and return nil to prevent SFSafariViewController creation + [[UIApplication sharedApplication] openURL:URL options:@{} completionHandler:nil]; + return nil; +} +%end + +%hook SFInteractiveDismissController +- (void)animateTransition:(id)transitionContext { + if (![BHTManager alwaysOpenSafari]) { + return %orig; + } + [transitionContext completeTransition:NO]; +} +%end + +%hook TFSTwitterEntityURL +- (NSString *)url { + // https://github.com/haoict/twitter-no-ads/blob/master/Tweak.xm#L195 + return self.expandedURL; +} +%end + +// MARK: Disable RTL +%hook NSParagraphStyle ++ (NSWritingDirection)defaultWritingDirectionForLanguage:(id)lang { + return [BHTManager disableRTL] ? NSWritingDirectionLeftToRight : %orig; +} ++ (NSWritingDirection)_defaultWritingDirection { + return [BHTManager disableRTL] ? NSWritingDirectionLeftToRight : %orig; +} +%end + +// MARK: Bio Translate +%hook TFNTwitterCanonicalUser +- (_Bool)isProfileBioTranslatable { + return [BHTManager BioTranslate] ? true : %orig; +} +%end + +// MARK: No search history +%hook T1SearchTypeaheadViewController // for old Twitter versions +- (void)viewDidLoad { + if ([BHTManager NoHistory]) { // thanks @CrazyMind90 + if ([self respondsToSelector:@selector(clearActionControlWantsClear:)]) { + [self performSelector:@selector(clearActionControlWantsClear:)]; + } + } + %orig; +} +%end + +%hook TTSSearchTypeaheadViewController +- (void)viewDidLoad { + if ([BHTManager NoHistory]) { // thanks @CrazyMind90 + if ([self respondsToSelector:@selector(clearActionControlWantsClear:)]) { + [self performSelector:@selector(clearActionControlWantsClear:)]; + } + } + %orig; +} +%end + +static NSString *BHTFeatureSwitchKeyForFeature(id feature) { + if (!feature || ![feature respondsToSelector:@selector(key)]) { + return nil; + } + + NSString *(*keyMessage)(id, SEL) = (NSString *(*)(id, SEL))objc_msgSend; + NSString *key = keyMessage(feature, @selector(key)); + return [key isKindOfClass:[NSString class]] ? key : nil; +} + +static NSNumber *BHTFeatureSwitchOverrideValueForKey(NSString *key) { + if (![key isKindOfClass:[NSString class]]) { + return nil; + } + + // Custom timelines overrides + BOOL hideCustomTimelines = [BHTManager hideCustomTimelines]; + if ([key isEqualToString:@"hometimeline_pinned_tabs_topics_enabled"] || + [key isEqualToString:@"hometimeline_pinned_tabs_generic_timelines_enabled"] || + [key isEqualToString:@"hometimeline_pinned_tabs_sticky_warm_start_enabled"] || + [key isEqualToString:@"home_timeline_sticky_pinned_tab_enabled"] || + [key isEqualToString:@"super_follow_subscriptions_home_timeline_tab_sticky_enabled"]) { + return hideCustomTimelines ? @NO : @YES; + } + + if ([key isEqualToString:@"home_timeline_non_sticky_tab_on_new_session_enabled"]) { + return @NO; + } + + if ([key isEqualToString:@"hometimeline_pinned_tabs_limit"] || + [key isEqualToString:@"hometimeline_pinned_tabs_management_pinnedsection_inline_limit"] || + [key isEqualToString:@"hometimeline_pinned_tabs_management_topics_inline_limit"]) { + return hideCustomTimelines ? @0 : @100; + } + + // Edit tweet + if ([key isEqualToString:@"edit_tweet_enabled"] || + [key isEqualToString:@"edit_tweet_ga_composition_enabled"] || + [key isEqualToString:@"edit_tweet_pdp_dialog_enabled"] || + [key isEqualToString:@"edit_tweet_upsell_enabled"]) { + return @YES; + } + + // Grok translations + if ([key isEqualToString:@"grok_translations_bio_inline_translation_is_enabled"] || + [key isEqualToString:@"grok_translations_bio_translation_is_enabled"]) { + return @([BHTManager BioTranslate]); + } + + if ([key isEqualToString:@"grok_translations_post_inline_translation_is_enabled"] || + [key isEqualToString:@"grok_translations_post_translation_is_enabled"]) { + return @YES; + } + + // Profile tabs + if ([key isEqualToString:@"articles_timeline_profile_tab_enabled"]) { + return @(![BHTManager disableArticles]); + } + + if ([key isEqualToString:@"highlights_tweets_tab_ui_enabled"]) { + return @(![BHTManager disableHighlights]); + } + + if ([key isEqualToString:@"media_tab_profile_videos_tab_enabled"] || + [key isEqualToString:@"media_tab_profile_photos_tab_enabled"] || + [key isEqualToString:@"media_tab_enabled"] || + [key isEqualToString:@"media_tab_profile_videos_tab_new_design_enabled"]) { + return @(![BHTManager disableMediaTab]); + } + + // Age verification bypass + if ([key hasPrefix:@"ios_age_assurance"] || [key isEqualToString:@"grok_settings_age_restriction_enabled"]) { + if ([BHTManager bypassAgeVerification]) { + return @NO; + } + } + + // Conversation / tweet detail + if ([key isEqualToString:@"conversational_replies_ios_minimal_detail_enabled"]) { + return @(![BHTManager OldStyle]); } - if ([key isEqualToString:@"ios_subscription_journey_enabled"]) { - return false; + if ([key isEqualToString:@"reply_sorting_enabled"]) { + return @(![BHTManager replySorting]); + } + + if ([key isEqualToString:@"ios_tweet_detail_overflow_in_navigation_enabled"]) { + return @NO; } if ([key isEqualToString:@"ios_tweet_detail_conversation_context_removal_enabled"]) { - return ![BHTManager restoreReplyContext]; + return @(![BHTManager restoreReplyContext]); + } + + // Tab bar configuration + if ([key isEqualToString:@"ios_tab_bar_default_show_grok"]) { + return @([[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_grok"]); + } + + if ([key isEqualToString:@"ios_tab_bar_default_show_profile"]) { + return @([[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_profile"]); + } + + if ([key isEqualToString:@"ios_tab_bar_default_show_communities"]) { + return @([[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_communities"]); + } + + // In-app article webview + if ([key isEqualToString:@"ios_in_app_article_webview_enabled"]) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + if ([d objectForKey:key] != nil) { + return @([d boolForKey:key]); + } + return @YES; + } + + // Premium / subscription upsell disables + if ([key isEqualToString:@"creator_purchases_dashboard_enabled"] || + [key isEqualToString:@"subscriptions_settings_item_enabled"] || + [key isEqualToString:@"grok_ios_profile_summary_enabled"] || + [key isEqualToString:@"creator_monetization_dashboard_enabled"] || + [key isEqualToString:@"creator_monetization_profile_subscription_tweets_tab_enabled"] || + [key isEqualToString:@"ios_subscription_journey_enabled"] || + [key isEqualToString:@"subscriptions_upsells_get_verified_profile"] || + [key isEqualToString:@"ios_profile_analytics_upsell_possible_enabled"] || + [key isEqualToString:@"ios_profile_analytics_upsell_enabled"] || + [key isEqualToString:@"subscriptions_verification_info_is_identity_verified"] || + [key isEqualToString:@"subscriptions_verification_info_reason_enabled"] || + [key isEqualToString:@"subscriptions_verification_info_verified_since_enabled"] || + [key isEqualToString:@"communities_enable_explore_tab"] || + [key isEqualToString:@"dash_items_download_grok_enabled"]) { + return @NO; + } + + return nil; +} + +// MARK: Voice, SensitiveTweetWarnings, autoHighestLoad, VideoZoom, VODCaptions, disableSpacesBar feature +%hook TPSTwitterFeatureSwitches +// Twitter save all the features and keys in side JSON file in bundle of application fs_embedded_defaults_production.json, and use it in TFNTwitterAccount class but with DM voice maybe developers forget to add boolean variable in the class, so i had to change it from the file. +// also, you can find every key for every feature i used in this tweak, i can remove all the codes below and find every key for it but I'm lazy to do that, :) +- (BOOL)boolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (id)featureSwitchValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; } - if ([key isEqualToString:@"ios_tab_bar_default_show_grok"]) { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_grok"]; + return %orig; +} + +- (NSInteger)integerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; + } + + return %orig; +} + +- (NSNumber *)numberForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (id)rawValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (BOOL)unsafePeekBoolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (NSInteger)unsafePeekIntegerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; + } + + return %orig; +} +%end + +%hook TFSAccountFeatureSwitches +- (BOOL)boolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (id)featureSwitchValueForFeature:(id)feature { + NSString *key = BHTFeatureSwitchKeyForFeature(feature); + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (NSNumber *)numberValueForFeature:(id)feature { + NSString *key = BHTFeatureSwitchKeyForFeature(feature); + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} +%end + +%hook TFSFeatureSwitches +- (BOOL)boolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (id)featureSwitchValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (NSInteger)integerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; + } + + return %orig; +} + +- (NSNumber *)numberForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (id)rawValueForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override; + } + + return %orig; +} + +- (BOOL)unsafePeekBoolForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.boolValue; + } + + return %orig; +} + +- (NSInteger)unsafePeekIntegerForKey:(NSString *)key { + NSNumber *override = BHTFeatureSwitchOverrideValueForKey(key); + if (override) { + return override.integerValue; + } + + return %orig; +} +%end + +// MARK: Override the login screens +%hook T1AccountsViewController +- (void)private_startLoginFlowWithSender:(id)sender { + [BHTLegacyLoginViewController presentLoginFrom:(UIViewController *)self]; +} +%end + +%hook T1HostViewController +- (void)makeOnboardingViewControllerWithCompletion:(void (^)(id))completion { + if (completion == nil) { + %orig; + return; + } + completion([BHTLegacyLoginViewController loginRootNavigationController]); +} +%end + + +static NSTimeInterval BHTPinnedTabsLaunchUptime = 0; + +static id BHTPinnedTabsPersistenceCoordinator(void) { + static id coordinator = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + coordinator = [NSObject new]; + }); + return coordinator; +} + +static NSArray *BHTPinnedTimelinesSnapshot(id repository) { + if (!repository || ![repository respondsToSelector:@selector(pinnedTimelines)]) { + return nil; + } + id value = ((id (*)(id, SEL))objc_msgSend)(repository, @selector(pinnedTimelines)); + return [value isKindOfClass:[NSArray class]] ? value : nil; +} + +static void BHTRecordPinnedTimelineUnpin(void) { + @synchronized (BHTPinnedTabsPersistenceCoordinator()) { + [[NSUserDefaults standardUserDefaults] setDouble:CFAbsoluteTimeGetCurrent() forKey:@"BHTCustomTimelinesUnpinTime"]; + } +} + +%hook _TtC32TwitterHomeFeatureImplementation31CachedPinnedTimelinesRepository +- (void)unpinTimelineWithTimeline:(id)timeline completion:(id)completion { + BHTRecordPinnedTimelineUnpin(); + %orig; +} + +- (void)unpinTimelineWithTimelineInput:(id)input completion:(id)completion { + BHTRecordPinnedTimelineUnpin(); + %orig; +} + +- (void)updatePinnedTimelines:(id)timelines { + if ([BHTManager hideCustomTimelines]) { + %orig; + return; + } + + BOOL block = NO; + @synchronized (BHTPinnedTabsPersistenceCoordinator()) { + BOOL isArray = [timelines isKindOfClass:[NSArray class]]; + NSUInteger incomingCount = isArray ? [timelines count] : 0; + NSTimeInterval now = CFAbsoluteTimeGetCurrent(); + NSTimeInterval lastUnpin = [[NSUserDefaults standardUserDefaults] doubleForKey:@"BHTCustomTimelinesUnpinTime"]; + + if ((now - lastUnpin < 120.0) || (isArray && incomingCount != 0)) { + block = NO; + } else { + NSArray *snapshot = BHTPinnedTimelinesSnapshot(self); + if (snapshot.count == 0) { + block = NO; + } else { + NSTimeInterval uptime = [[NSProcessInfo processInfo] systemUptime]; + BOOL withinStartupWindow = (BHTPinnedTabsLaunchUptime > 0) && ((uptime - BHTPinnedTabsLaunchUptime) < 20.0); + block = withinStartupWindow; + } + } + } + + if (!block) { + %orig; + } +} +%end + +static void BHTHideHomeAddTabButton(id container) { + if (![BHTManager hideCustomTimelines]) { + return; } + @try { + id button = [container valueForKey:@"addTabButton"]; + if ([button isKindOfClass:[UIView class]]) { + ((UIView *)button).hidden = YES; + } + } @catch (__unused NSException *exception) { - if ([key isEqualToString:@"ios_tab_bar_default_show_profile"]) { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_profile"]; - } + } +} - if ([key isEqualToString:@"ios_tab_bar_default_show_communities"]) { - return [[NSUserDefaults standardUserDefaults] boolForKey:@"ios_tab_bar_default_show_communities"]; - } +%hook _TtC32TwitterHomeFeatureImplementation35HomeTimelineContainerViewController +- (id)tfn_navigationBarAccessoryView { + id accessory = %orig; + BHTHideHomeAddTabButton(self); + return accessory; +} - if ([key isEqualToString:@"ios_in_app_article_webview_enabled"]) { - NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; - if ([d objectForKey:key] != nil) { - return [d boolForKey:key]; // respect the Settings toggle - } - return YES; // default off when unset - } - - return %orig; +- (void)viewDidAppear:(BOOL)animated { + %orig; + BHTHideHomeAddTabButton(self); } %end @@ -1796,9 +3354,38 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h %end %hook TFNTwitterAccount -- (_Bool)isXChatEnabled { - return [BHTManager disableXChat] ? false : %orig; +- (BOOL)_isSubscriptionsGatingBypassEnabled { + return YES; +} + +- (BOOL)canAccessXPayments { + return YES; +} + +- (BOOL)isGrokAskGrokButtonUnderPostFocalEnabled { + return YES; +} + +- (BOOL)isGrokAskGrokButtonUnderPostPreviewEnabled { + return YES; +} + +- (BOOL)isGrokEditWithGrokButtonUnderPostFocalEnabled { + return YES; +} + +- (BOOL)isGrokEditWithGrokButtonUnderPostPreviewEnabled { + return YES; +} + +- (BOOL)isPremiumTierUser { + return YES; +} + +- (BOOL)isXPaymentsEnrolled { + return YES; } + - (_Bool)isEditProfileUsernameEnabled { return true; } @@ -1811,6 +3398,9 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h - (_Bool)isSensitiveTweetWarningsConsumeEnabled { return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; } +- (BOOL)isAgeAssuranceAgeVerificationFlowEnabled { + return [BHTManager bypassAgeVerification] ? NO : %orig; +} - (_Bool)isVideoDynamicAdEnabled { return [BHTManager HidePromoted] ? false : %orig; } @@ -1829,6 +3419,36 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } %end +%hook _TtCV4Grok12GrokRootView9ViewModel +- (BOOL)_isPremiumUser { + return YES; +} +%end + +%hook TFNTwitterStatus +- (BOOL)hasImageInterstitial { + return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; +} + +- (id)imageInterstitial { + return [BHTManager disableSensitiveTweetWarnings] ? nil : %orig; +} + +- (id)innerImageInterstitial { + return [BHTManager disableSensitiveTweetWarnings] ? nil : %orig; +} + +- (BOOL)isPossiblySensitiveViewModelForAccount:(id)account { + return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; +} +%end + +%hook HFHealthSafetyFeature ++ (BOOL)isTweetMedialInterstitialEnabled:(id)featureSwitches { + return [BHTManager disableSensitiveTweetWarnings] ? false : %orig; +} +%end + // MARK: Tweet confirm %hook T1TweetComposeViewController - (void)_t1_didTapSendButton:(UIButton *)tweetButton { @@ -1909,6 +3529,49 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h } %end +// MARK: - Hide the trending/explore content on the Explore tab (keep the search bar) +static void BHT_hideExploreTabBar(UIView *view) { + if (!view) return; + if ([view isKindOfClass:NSClassFromString(@"TFNScrollingHorizontalLabelView")]) { + view.hidden = YES; + return; + } + for (UIView *subview in view.subviews) { + BHT_hideExploreTabBar(subview); + } +} + +%hook T1GuideNavigationController +- (void)viewDidLayoutSubviews { + %orig; + if (![BHTManager hideTrends]) return; + @try { + UINavigationController *nav = (UINavigationController *)self; + + UIViewController *guideVC = nav.viewControllers.firstObject; + if (!guideVC) return; + + Class chromeClass = NSClassFromString(@"T1TwitterSwift.URTChromeViewController"); + UIViewController *chrome = nil; + for (UIViewController *child in guideVC.childViewControllers) { + BOOL isChrome = chromeClass ? [child isKindOfClass:chromeClass] + : [child respondsToSelector:@selector(tfn_navigationBarAccessoryView)]; + if (isChrome) { chrome = child; break; } + } + if (!chrome) return; + + if (chrome.isViewLoaded) chrome.view.hidden = YES; + + if ([chrome respondsToSelector:@selector(tfn_navigationBarAccessoryView)]) { + UIView *accessory = ((UIView *(*)(id, SEL))objc_msgSend)(chrome, @selector(tfn_navigationBarAccessoryView)); + BHT_hideExploreTabBar(accessory); + } + } @catch (NSException *exception) { + NSLog(@"[BHTwitter] hideTrends exception: %@", exception); + } +} +%end + %hook T1ImmersiveExploreCardView - (void)handleDoubleTap:(id)arg1 { if ([BHTManager LikeConfirm]) { @@ -2062,7 +3725,7 @@ static void BHTApplyCopyButtonStyle(UIButton *copyButton, T1ProfileHeaderView *h %new - (void)customFontsHandler { if ([[NSFileManager defaultManager] fileExistsAtPath:@"/var/mobile/Library/Fonts/AddedFontCache.plist"]) { NSAttributedString *AttString = [[NSAttributedString alloc] initWithString:[[BHTBundle sharedBundle] localizedStringForKey:@"CUSTOM_FONTS_MENU_TITLE"] attributes:@{ - NSFontAttributeName: [[%c(TAEStandardFontGroup) sharedFontGroup] headline2BoldFont], + NSFontAttributeName: [BHTManager menuTitleFont], NSForegroundColorAttributeName: UIColor.labelColor }]; TFNActiveTextItem *title = [[%c(TFNActiveTextItem) alloc] initWithTextModel:[[%c(TFNAttributedTextModel) alloc] initWithAttributedString:AttString] activeRanges:nil]; @@ -2933,18 +4596,201 @@ objc_setAssociatedObject(footerView, %end -// Helper for the "Replace 'post' with 'Tweet' in notifications" setting -static BOOL BHNotifReplacePostWithTweetEnabled(void) { - NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; +// MARK: Restore Twitter terminology, controlled by "restore_twitter_names" +// Two layers, both driven by locale files in the tweak bundle: +// 1. RenameOverrides.strings — Twitter localization key -> exact replacement, +// a missing key falls through to the generic replacement +// 2. RenameWords.strings — generic word replacements ("X" -> "Twitter", +// "Post" -> "Tweet", etc.) applied to localized and server-side strings +// Both are strictly per-language: a language without its own copy of a file gets no +// renaming from that layer, rather than English rules applied to non-English text. + +static NSDictionary *BHTRenameTable(NSString *name) { + NSBundle *bundle = [BHTBundle sharedBundle].mainBundle; + NSString *appLanguage = [[NSBundle mainBundle] preferredLocalizations].firstObject ?: @"en"; + NSString *localization = [NSBundle preferredLocalizationsFromArray:bundle.localizations + forPreferences:@[appLanguage]].firstObject; + + // preferredLocalizationsFromArray: if there's no match, it silently + // returns the development region (en) instead, and rejects that so unsupported + // languages skip renaming rather than getting English rules + NSString *appCode = [appLanguage componentsSeparatedByString:@"-"].firstObject; + NSString *lprojCode = [[localization stringByReplacingOccurrencesOfString:@"_" withString:@"-"] + componentsSeparatedByString:@"-"].firstObject; + if (![appCode isEqualToString:lprojCode]) { + return @{}; + } + + NSString *path = [bundle pathForResource:name ofType:@"strings" inDirectory:nil forLocalization:localization]; + NSString *contents = path ? [NSString stringWithContentsOfFile:path encoding:NSUTF8StringEncoding error:nil] : nil; + NSDictionary *table = [contents propertyListFromStringsFileFormat]; + return [table isKindOfClass:[NSDictionary class]] ? table : @{}; +} + +static NSDictionary *BHTRenameKeyOverrides(void) { + static NSDictionary *overrides = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ overrides = BHTRenameTable(@"RenameOverrides"); }); + return overrides; +} - // Fall back to BrandingSettings default (@YES) if the key is missing - if ([defaults objectForKey:@"notif_replace_post_with_tweet"] == nil) { - return YES; +static NSDictionary *BHTwitterWordMap(void) { + static NSDictionary *map = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ map = BHTRenameTable(@"RenameWords"); }); + return map; +} + +// Builds \b(word|word…)\b from the map keys of one sensitivity class, longest word +// first so inflections win over their stems ("reposts" before "repost"). +static NSRegularExpression *BHTRenameRegex(BOOL caseSensitive) { + NSMutableArray *words = [NSMutableArray array]; + for (NSString *word in BHTwitterWordMap()) { + BOOL hasUpper = [word rangeOfCharacterFromSet:[NSCharacterSet uppercaseLetterCharacterSet]].location != NSNotFound; + if (hasUpper == caseSensitive) { + [words addObject:[NSRegularExpression escapedPatternForString:word]]; + } + } + if (words.count == 0) { + return nil; + } + + [words sortUsingComparator:^NSComparisonResult(NSString *a, NSString *b) { + if (a.length > b.length) return NSOrderedAscending; + if (a.length < b.length) return NSOrderedDescending; + return [a compare:b]; + }]; + NSString *pattern = [NSString stringWithFormat:@"\\b(%@)\\b", [words componentsJoinedByString:@"|"]]; + return [NSRegularExpression regularExpressionWithPattern:pattern + options:(caseSensitive ? 0 : NSRegularExpressionCaseInsensitive) + error:nil]; +} + +// Applies the capitalisation style of `token` (all-caps or leading-capital) to `base`. +static NSString *BHMatchCapitalisation(NSString *token, NSString *base) { + if (token.length == 0 || base.length == 0) { + return base; + } + + NSString *lower = token.lowercaseString; + if (token.length > 1 && [token isEqualToString:token.uppercaseString] && ![token isEqualToString:lower]) { + return base.uppercaseString; + } + + unichar first = [token characterAtIndex:0]; + if ([[NSCharacterSet uppercaseLetterCharacterSet] characterIsMember:first]) { + return [base stringByReplacingCharactersInRange:NSMakeRange(0, 1) + withString:[base substringToIndex:1].uppercaseString]; + } + return base; +} + +// Returns the ordered list of edits (@"range" -> NSValue, @"repl" -> NSString) to apply +// to `input`, sorted last-match-first so applying them never invalidates a later range. +// Returns nil when there is nothing to change. +static NSArray *BHRenameEdits(NSString *input) { + if (input.length == 0) { + return nil; + } + + static NSRegularExpression *insensitiveRegex = nil; + static NSRegularExpression *sensitiveRegex = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + insensitiveRegex = BHTRenameRegex(NO); + sensitiveRegex = BHTRenameRegex(YES); + }); + + NSDictionary *wordMap = BHTwitterWordMap(); + NSRange full = NSMakeRange(0, input.length); + NSMutableArray *edits = [NSMutableArray array]; + + for (NSTextCheckingResult *match in [sensitiveRegex matchesInString:input options:0 range:full]) { + NSString *repl = wordMap[[input substringWithRange:match.range]]; + if (repl) { + [edits addObject:@{@"range": [NSValue valueWithRange:match.range], @"repl": repl}]; + } + } + + for (NSTextCheckingResult *match in [insensitiveRegex matchesInString:input options:0 range:full]) { + NSString *token = [input substringWithRange:match.range]; + NSString *base = wordMap[token.lowercaseString]; + if (base) { + [edits addObject:@{@"range": [NSValue valueWithRange:match.range], + @"repl": BHMatchCapitalisation(token, base)}]; + } + } + + if (edits.count == 0) { + return nil; + } + + [edits sortUsingComparator:^NSComparisonResult(NSDictionary *a, NSDictionary *b) { + NSUInteger la = [a[@"range"] rangeValue].location; + NSUInteger lb = [b[@"range"] rangeValue].location; + if (la > lb) return NSOrderedAscending; + if (la < lb) return NSOrderedDescending; + return NSOrderedSame; + }]; + return edits; +} + +static NSString *BHRestoreTwitterTerminology(NSString *input) { + // Memoise: labels re-set the same handful of strings over and over. + static NSCache *cache = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ cache = [NSCache new]; }); + + NSString *cached = [cache objectForKey:input]; + if (cached) { + return cached; + } + + NSArray *edits = BHRenameEdits(input); + NSString *output = input; + if (edits) { + NSMutableString *result = [input mutableCopy]; + for (NSDictionary *edit in edits) { + [result replaceCharactersInRange:[edit[@"range"] rangeValue] withString:edit[@"repl"]]; + } + output = [result copy]; } - return [defaults boolForKey:@"notif_replace_post_with_tweet"]; + [cache setObject:output forKey:input]; + return output; } +static NSAttributedString *BHRestoreTwitterAttributed(NSAttributedString *input) { + NSArray *edits = BHRenameEdits(input.string); + if (!edits) { + return input; + } + + NSMutableAttributedString *result = [input mutableCopy]; + for (NSDictionary *edit in edits) { + NSRange range = [edit[@"range"] rangeValue]; + NSDictionary *attrs = [result attributesAtIndex:range.location effectiveRange:NULL]; + NSAttributedString *piece = [[NSAttributedString alloc] initWithString:edit[@"repl"] attributes:attrs]; + [result replaceCharactersInRange:range withAttributedString:piece]; + } + return result; +} + +%hook NSBundle +- (NSString *)localizedStringForKey:(NSString *)key value:(NSString *)value table:(NSString *)tableName { + NSString *result = %orig; + if (![BHTManager restoreTwitterNames] || self == [BHTBundle sharedBundle].mainBundle) { + return result; + } + + NSString *override = key ? BHTRenameKeyOverrides()[key] : nil; + if (override) { + return override; + } + return result.length > 0 ? BHRestoreTwitterTerminology(result) : result; +} +%end + %hook TFNAttributedTextView - (void)setTextModel:(TFNAttributedTextModel *)model { if (!model || !model.attributedString) { @@ -2989,124 +4835,19 @@ static BOOL BHNotifReplacePostWithTweetEnabled(void) { } } - // --- Notification text replacements --- - BOOL isNotificationView = NO; - { - UIView *view = self; - while (view && !isNotificationView) { - NSString *className = NSStringFromClass([view class]); - if ([className containsString:@"Notification"] || - [className containsString:@"T1NotificationsTimeline"]) { - isNotificationView = YES; - } - view = view.superview; - } - } - - if (isNotificationView && BHNotifReplacePostWithTweetEnabled()) { - if (!newString) { - newString = [[NSMutableAttributedString alloc] initWithAttributedString:model.attributedString]; - } - - NSArray *replacements = @[ - // Full phrase replacements first - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_reposted_your_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweeted_your_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_reposted_your_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweeted_your_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Reposted_your_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweeted_your_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Reposted_your_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweeted_your_Tweet_new"]}, - - // Standalone "post" -> "Tweet" - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_a_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_new_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_New_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_recent_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Recent_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_pinned_Post_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_pinned_Tweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Posts_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_your_Tweets_new"]}, - - // Standalone "reposted" -> "retweeted" - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_reposted_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweeted_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Reposted_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweeted_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_repost_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_retweet_new"]}, - - @{@"old": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Repost_old"], - @"new": [[BHTBundle sharedBundle] localizedStringForKey:@"notif_Retweet_new"]} - ]; - - for (NSDictionary *rep in replacements) { - NSString *oldStr = rep[@"old"]; - NSString *newStr = rep[@"new"]; - if (oldStr.length == 0 || newStr.length == 0) { - continue; - } - - NSRange searchRange = [[newString string] rangeOfString:oldStr]; - while (searchRange.location != NSNotFound) { - NSRange runRange = {0, 0}; - NSDictionary *attrs = [newString attributesAtIndex:searchRange.location - effectiveRange:&runRange]; - - NSAttributedString *replacement = - [[NSAttributedString alloc] initWithString:newStr attributes:attrs]; - - [newString replaceCharactersInRange:searchRange withAttributedString:replacement]; - modified = YES; - textChanged = YES; - - NSUInteger nextLocation = searchRange.location + replacement.length; - if (nextLocation >= newString.length) { - break; - } - - NSRange remainder = NSMakeRange(nextLocation, newString.length - nextLocation); - searchRange = [[newString string] rangeOfString:oldStr options:0 range:remainder]; - } + // --- Restore Twitter terminology --- + // TFNAttributedTextView renders interface chrome (notification headers, footers, + // timestamps, counts…). Tweet bodies use T1StatusBodyTextView / + // TTAStatusBodySelectableContentTextView, so they never reach this hook and are + // left untouched. The rewrite is driven by the shared word-boundary transform, so + // it changes the actual rendered text rather than a hardcoded list of phrases. + if ([BHTManager restoreTwitterNames]) { + NSAttributedString *source = newString ?: model.attributedString; + NSAttributedString *renamed = BHRestoreTwitterAttributed(source); + if (renamed != source) { + newString = [renamed mutableCopy]; + modified = YES; + textChanged = YES; } } @@ -3136,9 +4877,8 @@ static BOOL BHNotifReplacePostWithTweetEnabled(void) { static BOOL BHColorTwitterIconEnabled(void) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - // Fall back to the BrandingSettings default (@YES) if the key is missing if ([defaults objectForKey:@"color_twitter_icon_in_top_bar"] == nil) { - return YES; + return [BHTManager isTwitterBranded]; } return [defaults boolForKey:@"color_twitter_icon_in_top_bar"]; @@ -3174,37 +4914,35 @@ static BOOL BHColorTwitterIconEnabled(void) { %end -// MARK: - Hide Grok Analyze Button (TTAStatusAuthorView) - -@interface TTAStatusAuthorView : UIView -- (id)grokAnalyzeButton; -@end - -%hook TTAStatusAuthorView - -- (id)grokAnalyzeButton { - UIView *button = %orig; - if (button && [BHTManager hideGrokAnalyze]) { - button.hidden = YES; - } - return button; +// MARK: - Hide Grok Analyze Button +// The analyze button (timeline author view and post detail nav bar) is gated by a per-tweet +// boolean the API returns via the includeGrokAnalysisButton request field. Both +// shouldShowGrokAnalyzeButtonForAuthorView and shouldShowGrokAnalyzeButtonForPostDetailNavBar +// ultimately return this flag, so reporting it as absent at the model level suppresses the +// button on every surface without any view-level hiding or navigation-context guessing. +%hook TFNTwitterCanonicalStatus +- (BOOL)grokAnalysisButton { + if ([BHTManager hideGrokAnalyze]) return NO; + return %orig; } +%end +%hook TFSTwitterStatus +- (BOOL)grokAnalysisButton { + if ([BHTManager hideGrokAnalyze]) return NO; + return %orig; +} %end -// MARK: - Hide Grok Analyze & Subscribe Buttons on Detail View +// MARK: - Hide Subscribe Button on Detail View // Minimal interface for TFNButton, used by UIControl hook and FollowButton logic @class TFNButton; %hook UIControl -// Grok Analyze and Subscribe button +// Subscribe button - (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents { - if (action == @selector(didTapGrokAnalyze)) { - if ([self isKindOfClass:NSClassFromString(@"TFNButton")] && [BHTManager hideGrokAnalyze]) { - self.hidden = YES; - } - } else if (action == @selector(_didTapSubscribe)) { + if (action == @selector(_didTapSubscribe)) { if ([self isKindOfClass:NSClassFromString(@"TFNButton")] && [BHTManager restoreFollowButton]) { self.alpha = 0.0; self.userInteractionEnabled = NO; @@ -3316,13 +5054,10 @@ static BOOL findAndHideButtonWithAccessibilityId(UIView *viewToSearch, NSString } } -// This hook makes the control ALWAYS REPORT its variant as 32 -- (NSUInteger)variant { - if ([BHTManager restoreFollowButton]) { - return 32; - } - return %orig; -} +// NOTE: We intentionally do NOT override -variant. Forcing it to a constant 32 +// made every TUIFollowControl report "Follow" regardless of the real account +// relationship, which hid the Follow button on every tweet (NeoFreeBird#2). +// The setVariant: remap above already converts Subscribe (1) -> Follow (32). %end @@ -3985,7 +5720,58 @@ static char kManualRefreshInProgressKey; %end +// MARK: - Bypass attestation +// Spoof DCAppAttestService.isSupported as NO so Twitter falls back to a non-attested path, +// avoiding key exchange failures on jailbroken/sideloaded devices. +%hook DCAppAttestService + +- (BOOL)isSupported { + if ([BHTManager isAttestationBypassEnabled]) { + return NO; + } + return %orig; +} + +%end + +// Strip attestation headers from Twitter/X requests so the server doesn't reject +// the key exchange when the client cannot produce a valid attestation token. +%hook NSMutableURLRequest + +- (void)setValue:(NSString *)value forHTTPHeaderField:(NSString *)field { + if ([BHTManager isAttestationBypassEnabled]) { + NSString *host = self.URL.host; + if (host && ([host containsString:@"twitter.com"] || [host containsString:@"x.com"])) { + if ([field isEqualToString:@"X-Twitter-Client-Attest"] || [field isEqualToString:@"X-Client-UUID"]) { + return; + } + } + } + %orig; +} + +%end + +// Force ephemeral sessions so web flows doesn't share cookies with +// a persistent browser session that might expose attestation state. +%hook ASWebAuthenticationSession + +- (instancetype)initWithURL:(NSURL *)URL callbackURLScheme:(NSString *)callbackURLScheme completionHandler:(void(^)(NSURL *, NSError *))completionHandler { + id result = %orig; + if (result && [BHTManager isAttestationBypassEnabled]) { + __weak ASWebAuthenticationSession *weakSession = result; + dispatch_async(dispatch_get_main_queue(), ^{ + weakSession.prefersEphemeralWebBrowserSession = YES; + }); + } + return result; +} + +%end + %ctor { + BHTPinnedTabsLaunchUptime = [[NSProcessInfo processInfo] systemUptime]; + // Import AudioServices framework dlopen("/System/Library/Frameworks/AudioToolbox.framework/AudioToolbox", RTLD_LAZY); @@ -4090,31 +5876,6 @@ static char kManualRefreshInProgressKey; }); } -// MARK: - DM Avatar Images -%hook T1DirectMessageEntryViewModel -- (BOOL)shouldShowAvatarImage { - if (![BHTManager dmAvatars]) { - return %orig; - } - - if (self.isOutgoingMessage) { - return NO; // Don't show avatar for your own messages - } - // For incoming messages, only show avatar if it's the last message in a group from that sender - return [[self valueForKey:@"lastEntryInGroup"] boolValue]; -} - -- (BOOL)isAvatarImageEnabled { - if (![BHTManager dmAvatars]) { - return %orig; - } - - // Always return YES so that space is allocated for the avatar, - // allowing shouldShowAvatarImage to control actual visibility. - return YES; -} -%end - // MARK: - Classic Tab Bar Icon Theming %hook T1TabView @@ -4475,6 +6236,37 @@ static UIView *findPlayerControlsInHierarchy(UIView *startView) { } %end +// MARK: Remove the X-shaped reveal mask from the animated launch screen +// The animated launch screen masks its container layer with an X-shaped hole +// and grows it to reveal the app through an X-shaped portal. Detach that mask +// so the logo zoom is kept but the splash simply fades out instead. + +%hook T1AnimatedLaunchScreenView + +- (void)layoutSubviews { + %orig; + + for (UIView *sub in ((UIView *)self).subviews) { + sub.layer.mask = nil; + } +} + +- (void)animateRevealWithCompletion:(id)completion { + for (UIView *sub in ((UIView *)self).subviews) { + sub.layer.mask = nil; + } + + [UIView animateWithDuration:0.5 animations:^{ + for (UIView *sub in ((UIView *)self).subviews) { + sub.backgroundColor = [UIColor clearColor]; + } + }]; + + %orig; +} + +%end + // MARK: Source Label using T1ConversationFooterTextView %hook T1ConversationFooterTextView @@ -4541,21 +6333,31 @@ static UIView *findPlayerControlsInHierarchy(UIView *startView) { static BOOL BHPillLabelOverrideEnabled(void) { NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults]; - // Fall back to the BrandingSettings default (@YES) if the key is missing if ([defaults objectForKey:@"refresh_pill_label"] == nil) { - return YES; + return [BHTManager isTwitterBranded]; } return [defaults boolForKey:@"refresh_pill_label"]; } +// Only the "new posts/Tweets" refresh pill should be relabelled. TFNPillControl +// is used for other pills too ("Back to top", counts, …); the old code forced +// every pill's text to "Tweeted" and corrupted their reads. Gate on the pill's +// own text mentioning posts/tweets. +static BOOL BHPillTextIsNewContent(id text) { + if (![text isKindOfClass:[NSString class]]) return NO; + NSString *s = [(NSString *)text lowercaseString]; + return [s containsString:@"post"] || [s containsString:@"tweet"]; +} + // MARK: Change Pill text, controlled by "refresh_pill_label" %hook TFNPillControl - (id)text { - if (!BHPillLabelOverrideEnabled()) { - // Setting is off, keep original behavior - return %orig; + id origText = %orig; + if (!BHPillLabelOverrideEnabled() || !BHPillTextIsNewContent(origText)) { + // Setting off, or not the new-content pill: keep original behavior + return origText; } NSString *localizedText = [[BHTBundle sharedBundle] localizedStringForKey:@"REFRESH_PILL_TEXT"]; @@ -4564,8 +6366,8 @@ static BOOL BHPillLabelOverrideEnabled(void) { } - (void)setText:(id)arg1 { - if (!BHPillLabelOverrideEnabled()) { - // Setting is off, pass through original argument + if (!BHPillLabelOverrideEnabled() || !BHPillTextIsNewContent(arg1)) { + // Setting off, or not the new-content pill: pass through original argument return %orig(arg1); } diff --git a/branding/build_merged_car.py b/branding/build_merged_car.py new file mode 100644 index 00000000..75662904 --- /dev/null +++ b/branding/build_merged_car.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Rebuild an app's Assets.car with selected bitmap images replaced. + +Usage: build_merged_car.py [actool_partial_plist] + +Pipeline (see ipa-branding.sh: replace_app_images): + 1. `assetutil --info` on the app's Assets.car is the authoritative list of + renditions (Name / RenditionName / Idiom / Scale / pixel size / type). + 2. holds the app's bitmap pixels dumped by car_extract, with a + manifest.json keyed by (Name, idiom, scale, width, height). + 3. holds the user's loose replacement PNGs, each named after the + rendition it replaces (i.e. the app's RenditionName, discoverable via + `assetutil --info`). + +For every bitmap rendition we pick a pixel source: the overlay file if one is +named after this rendition (REPLACE), otherwise the extracted original +(PRESERVE). Non-bitmap assets (colors/vectors/data), appearance variants (dark) +and wide-gamut (P3) renditions are out of scope and reported as DROPPED. Overlay +files that match no existing rendition are reported and ignored (we never add +icons that did not already exist). + +We reconstruct a source .xcassets from those choices and compile it with actool. +""" + +import json +import os +import shutil +import subprocess +import sys + + +def find_actool(): + tool = shutil.which("actool") + if tool: + return tool + try: + return subprocess.run(["xcrun", "-f", "actool"], capture_output=True, + text=True, check=True).stdout.strip() + except (subprocess.CalledProcessError, OSError): + return "actool" + + +_resize_cache = {} + + +def resize_to(master, w, h, workdir): + """Scale `master` to exactly w x h (px), cached per (master,w,h). + + Prefers the aspect-preserving pad_image helper (NFB_PAD_TOOL) so a square + master isn't stretched into a non-square slot — it's centered on a + transparent canvas instead. Falls back to a plain sips stretch.""" + key = (master, w, h) + if key in _resize_cache: + return _resize_cache[key] + out = os.path.join(workdir, "resized_%d_%dx%d.png" % (len(_resize_cache), w, h)) + pad_tool = os.environ.get("NFB_PAD_TOOL") + if pad_tool and os.path.exists(pad_tool): + subprocess.run([pad_tool, master, str(w), str(h), out], + capture_output=True, text=True, check=True) + else: + subprocess.run(["sips", "-z", str(h), str(w), master, "--out", out], + capture_output=True, text=True, check=True) + _resize_cache[key] = out + return out + +BITMAP_TYPES = {"Image", "Icon Image"} +IDIOM_INT_TO_STR = {0: "universal", 1: "phone", 2: "pad", 3: "tv", 4: "car", 5: "watch", 6: "marketing"} +# assetutil idiom string -> actool Contents.json idiom value +IDIOM_ASSETUTIL_TO_ACTOOL = { + "universal": "universal", "phone": "iphone", "pad": "ipad", + "tv": "tv", "car": "car", "watch": "watch", "mac": "mac", + "marketing": "ios-marketing", +} + + +def fmt_scale(scale): + return "%dx" % int(round(float(scale))) + + +def fmt_size(px, scale): + pts = float(px) / float(scale) + s = ("%g" % pts) + return "%sx%s" % (s, s) + + +def is_wide_gamut(entry): + cs = (entry.get("Colorspace") or "").lower() + return "p3" in cs or "display" in cs + + +def has_nondefault_appearance(entry): + ap = entry.get("Appearance") + return bool(ap) and "any" not in str(ap).lower() + + +def main(): + if len(sys.argv) not in (5, 6): + sys.stderr.write(__doc__) + return 2 + app_car, extract_dir, overlay_dir, out_car = sys.argv[1:5] + partial_plist = sys.argv[5] if len(sys.argv) == 6 else None + + # 1. Authoritative rendition list. + info = json.loads(subprocess.run( + ["assetutil", "--info", app_car], capture_output=True, text=True, check=True).stdout) + rows = [e for e in info if isinstance(e, dict) and e.get("RenditionName")] + + # 2. Extracted pixels, indexed by (Name, idiom_str, scale, w, h). + with open(os.path.join(extract_dir, "manifest.json")) as fh: + manifest = json.load(fh) + extracted = {} + for m in manifest: + key = (m["renditionName"], IDIOM_INT_TO_STR.get(int(m["idiom"]), "universal"), + int(round(float(m["scale"]))), int(m["width"]), int(m["height"])) + extracted[key] = os.path.join(extract_dir, m["file"]) + + # 3. Overlay files by basename (exact rendition overrides) and by stem + # (single "master" images that get auto-resized to every size). + overlays = {} # exact filename -> path + masters = {} # stem (asset name / "AppIcon") -> path, lowercased + if os.path.isdir(overlay_dir): + for root, dirs, files in os.walk(overlay_dir): + dirs[:] = [d for d in dirs if d != "__MACOSX"] # skip macOS zip cruft + for f in files: + if f.startswith("._"): # AppleDouble resource-fork sidecars + continue + if not f.lower().endswith((".png", ".jpg", ".jpeg")): + continue + path = os.path.join(root, f) + overlays.setdefault(f, path) + masters.setdefault(os.path.splitext(f)[0].lower(), path) + used_files = set() + resize_dir = os.path.join(os.path.dirname(out_car) or ".", "_resized") + os.makedirs(resize_dir, exist_ok=True) + + # Which asset names are app icons (so an "AppIcon" master can target them all). + icon_names = {e.get("Name") for e in rows if e.get("AssetType") == "Icon Image"} + + # Settings-picker thumbnails (-settings) reuse their base icon's image. + SETTINGS_SPECIAL = {"icon-production-settings": "ProductionAppIcon"} + + def settings_base(name): + low = (name or "").lower() + if low in SETTINGS_SPECIAL: + return SETTINGS_SPECIAL[low] + if low.endswith("-settings"): + return name[:-len("-settings")] + return None + + def master_for(name): + """Master image chosen for an asset: its own, its base icon's (for + -settings thumbnails), or the catch-all AppIcon master.""" + m = masters.get((name or "").lower()) + if m: + return m + base = settings_base(name) + if base: + m = masters.get(base.lower()) + if m: + return m + if base in icon_names and "appicon" in masters: + return masters["appicon"] + if name in icon_names and "appicon" in masters: + return masters["appicon"] + return None + + # Group renditions into assets and choose a pixel source for each. + assets = {} # name -> {"icon": bool, "entries": [ {idiom,scale,size,src,replaced} ]} + dropped, missing = [], [] + for e in rows: + name = e.get("Name") + rname = e.get("RenditionName") + atype = e.get("AssetType") + if atype not in BITMAP_TYPES: + dropped.append((name, rname, atype)); continue + if is_wide_gamut(e): + dropped.append((name, rname, "wide-gamut")); continue + if has_nondefault_appearance(e): + dropped.append((name, rname, "appearance-variant")); continue + + idiom = (e.get("Idiom") or "universal").lower() + scale = int(round(float(e.get("Scale", 1)))) + w, h = int(e.get("PixelWidth", 0)), int(e.get("PixelHeight", 0)) + # Size classes distinguish otherwise-identical renditions (e.g. the + # LaunchStoryboardBackgroundImage A/B/D launch variants); without them + # actool would collapse the set to one image per scale. + wclass = e.get("SizeClass Horizontal") + hclass = e.get("SizeClass Vertical") + + # Source priority: exact rendition file > per-asset master (incl. a + # -settings thumbnail inheriting its base icon) > preserved original. + master = master_for(name) + if rname in overlays: + src, replaced = overlays[rname], True + used_files.add(overlays[rname]) + elif master: + src, replaced = resize_to(master, w, h, resize_dir), True + used_files.add(master) + else: + src = extracted.get((name, idiom, scale, w, h)) + replaced = False + if not src: + missing.append((name, rname, idiom, scale, "%dx%d" % (w, h))) + continue + + a = assets.setdefault(name, {"icon": False, "entries": []}) + a["icon"] = a["icon"] or (atype == "Icon Image") + a["entries"].append({"idiom": idiom, "scale": scale, "w": w, "h": h, + "wclass": wclass, "hclass": hclass, + "src": src, "replaced": replaced}) + + # Drop stock alternate app icons that were not overridden: keep the primary + # icon plus any alternate that actually received replacement art. A dropped + # icon's -settings picker thumbnail is dropped too (unless overridden). + def overridden(n): + return any(e["replaced"] for e in assets[n]["entries"]) + + icon_asset_names = {n for n, a in assets.items() if a["icon"]} + primary = None + for n in icon_asset_names: + if primary is None or "production" in n.lower(): + primary = n + drop = {n for n in icon_asset_names if n != primary and not overridden(n)} + for n in list(assets): + if not assets[n]["icon"] and settings_base(n) in drop and not overridden(n): + drop.add(n) + for n in drop: + del assets[n] + dropped_icons = sorted(n for n in drop if n in icon_asset_names) + + # Build a source .xcassets. + xcassets = os.path.join(os.path.dirname(out_car) or ".", "_merge.xcassets") + if os.path.isdir(xcassets): + shutil.rmtree(xcassets) + os.makedirs(xcassets) + with open(os.path.join(xcassets, "Contents.json"), "w") as fh: + json.dump({"info": {"version": 1, "author": "xcode"}}, fh) + + icon_name = None + alt_icons = [] + for name, a in assets.items(): + is_icon = a["icon"] + setdir = os.path.join(xcassets, "%s.%s" % (name, "appiconset" if is_icon else "imageset")) + os.makedirs(setdir, exist_ok=True) + if is_icon: + alt_icons.append(name) + if icon_name is None or "production" in name.lower(): + icon_name = name # prefer the production icon as the primary + entries = a["entries"] + # Single-size app icon (one 1024x1024 @1x, e.g. modern alternate icons): + # emit the universal single-size format actool expects, not per-idiom. + if is_icon and {fmt_size(e["w"], e["scale"]) for e in entries} == {"1024x1024"}: + ent = entries[0] + fn = "%s_0.png" % name + shutil.copyfile(ent["src"], os.path.join(setdir, fn)) + images = [{"idiom": "universal", "platform": "ios", + "size": "1024x1024", "filename": fn}] + else: + images = [] + for i, ent in enumerate(entries): + fn = "%s_%d.png" % (name, i) + shutil.copyfile(ent["src"], os.path.join(setdir, fn)) + img = {"idiom": IDIOM_ASSETUTIL_TO_ACTOOL.get(ent["idiom"], ent["idiom"]), + "scale": fmt_scale(ent["scale"]), "filename": fn} + if is_icon: + img["size"] = fmt_size(ent["w"], ent["scale"]) + if ent.get("wclass"): + img["width-class"] = ent["wclass"] + if ent.get("hclass"): + img["height-class"] = ent["hclass"] + images.append(img) + with open(os.path.join(setdir, "Contents.json"), "w") as fh: + json.dump({"images": images, "info": {"version": 1, "author": "xcode"}}, fh) + + # Compile. + out_dir = os.path.dirname(out_car) or "." + cmd = [find_actool(), xcassets, "--compile", out_dir, + "--platform", "iphoneos", "--minimum-deployment-target", "14.0"] + if icon_name: + cmd += ["--app-icon", icon_name] + # Every other app-icon set is kept as an alternate icon; the app's + # existing CFBundleIcons.CFBundleAlternateIcons already references them. + for alt in alt_icons: + if alt != icon_name: + cmd += ["--alternate-app-icon", alt] + if partial_plist: + cmd += ["--output-partial-info-plist", partial_plist] + else: + cmd += ["--output-partial-info-plist", os.path.join(out_dir, "_merge_partial.plist")] + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + sys.stderr.write("actool failed:\n%s\n%s\n" % (res.stdout, res.stderr)) + return 1 + + produced = os.path.join(out_dir, "Assets.car") + if produced != out_car: + shutil.move(produced, out_car) + shutil.rmtree(xcassets, ignore_errors=True) + shutil.rmtree(resize_dir, ignore_errors=True) + + replaced = sum(1 for a in assets.values() for e in a["entries"] if e["replaced"]) + preserved = sum(1 for a in assets.values() for e in a["entries"] if not e["replaced"]) + unused = sorted(p for p in set(overlays.values()) if p not in used_files) + print("merge: %d replaced, %d preserved, %d dropped, %d overlay files unused" % ( + replaced, preserved, len(dropped), len(unused))) + if dropped_icons: + print(" dropped %d un-overridden stock icon set(s): %s" % ( + len(dropped_icons), ", ".join(dropped_icons))) + for name, rname, why in dropped: + print(" dropped: %s (%s) [%s]" % (name, rname, why)) + for p in unused: + print(" overlay-unmatched: %s (no rendition or asset with that name)" % os.path.basename(p)) + for name, rname, idiom, scale, dims in missing: + print(" no-pixels: %s (%s) %s@%dx %s" % (name, rname, idiom, scale, dims)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/branding/car_extract.m b/branding/car_extract.m new file mode 100644 index 00000000..cd6f201a --- /dev/null +++ b/branding/car_extract.m @@ -0,0 +1,85 @@ +// car_extract: dump bitmap renditions of a compiled .car to PNGs + JSON manifest. +// Uses private CoreUI. Manifest lets build_merged_car.py match pixels to +// assetutil's rendition list by (renditionName, scale, width, height). +#import +#import +#import + +@interface CUIRenditionKey : NSObject +- (void *)keyList; +- (long long)themeScale; +- (long long)themeIdiom; +- (long long)themeDisplayGamut; +- (long long)themeAppearance; +@end + +@interface CUIThemeRendition : NSObject +- (CGImageRef)unslicedImage; +@end + +@interface CUIStructuredThemeStore : NSObject +- (CUIThemeRendition *)renditionWithKey:(void *)key; +- (NSString *)renditionNameForKeyList:(void *)keyList; +@end + +@interface CUICommonAssetStorage : NSObject +- (instancetype)initWithPath:(NSString *)path; +- (NSArray *)allAssetKeys; +- (NSString *)renditionNameForKeyList:(void *)keyList; +@end + +@interface CUICatalog : NSObject +- (instancetype)initWithURL:(NSURL *)url error:(NSError **)error; +- (id)_themeStore; +@end + +static BOOL writePNG(CGImageRef img, NSString *path) { + CFURLRef url = (__bridge CFURLRef)[NSURL fileURLWithPath:path]; + CGImageDestinationRef dst = CGImageDestinationCreateWithURL(url, CFSTR("public.png"), 1, NULL); + if (!dst) return NO; + CGImageDestinationAddImage(dst, img, NULL); + BOOL ok = CGImageDestinationFinalize(dst); + CFRelease(dst); + return ok; +} + +int main(int argc, const char **argv) { + @autoreleasepool { + if (argc != 3) { fprintf(stderr, "usage: car_extract \n"); return 2; } + NSString *carPath = @(argv[1]); + NSString *outDir = @(argv[2]); + [[NSFileManager defaultManager] createDirectoryAtPath:outDir withIntermediateDirectories:YES attributes:nil error:nil]; + + NSError *err = nil; + CUICatalog *cat = [[CUICatalog alloc] initWithURL:[NSURL fileURLWithPath:carPath] error:&err]; + if (!cat) { fprintf(stderr, "open failed: %s\n", err.description.UTF8String); return 1; } + CUIStructuredThemeStore *store = [cat _themeStore]; + CUICommonAssetStorage *storage = [[CUICommonAssetStorage alloc] initWithPath:carPath]; + + NSMutableArray *manifest = [NSMutableArray array]; + int idx = 0; + for (CUIRenditionKey *key in [storage allAssetKeys]) { + CUIThemeRendition *rend = [store renditionWithKey:[key keyList]]; + if (!rend) continue; + CGImageRef img = [rend unslicedImage]; + if (!img) continue; // non-bitmap (color/vector/data) -> skip + NSString *rname = [storage renditionNameForKeyList:[key keyList]]; + NSString *file = [NSString stringWithFormat:@"r%04d.png", idx++]; + if (!writePNG(img, [outDir stringByAppendingPathComponent:file])) continue; + [manifest addObject:@{ + @"file": file, + @"renditionName": rname ?: @"", + @"scale": @([key themeScale]), + @"idiom": @([key themeIdiom]), + @"gamut": @([key themeDisplayGamut]), + @"appearance": @([key themeAppearance]), + @"width": @(CGImageGetWidth(img)), + @"height": @(CGImageGetHeight(img)), + }]; + } + NSData *j = [NSJSONSerialization dataWithJSONObject:manifest options:NSJSONWritingPrettyPrinted error:nil]; + [j writeToFile:[outDir stringByAppendingPathComponent:@"manifest.json"] atomically:YES]; + fprintf(stderr, "extracted %lu bitmap renditions\n", (unsigned long)manifest.count); + } + return 0; +} diff --git a/branding/ipa_branding.py b/branding/ipa_branding.py new file mode 100644 index 00000000..ac8cdb15 --- /dev/null +++ b/branding/ipa_branding.py @@ -0,0 +1,321 @@ +#!/usr/bin/env python3 +"""IPA branding modifications applied to a freshly built .ipa/.tipa. + +This is a standalone port of the former ipa-branding.sh. build.sh invokes it +as a subprocess after an IPA/TIPA has been produced: + + RESOURCE_PACK=... TWITTER_BRANDING=1 python3 ipa_branding.py + +It unpacks the IPA once, applies every enabled step — the theme pack in +RESOURCE_PACK and, when TWITTER_BRANDING=1, the "Twitter" display name — then +repackages once. When no branding is enabled it exits 0 without touching the +IPA. A non-zero exit means a requested step failed; build.sh treats that as +fatal. + +The sibling helpers (car_extract.m, the .py steps) live alongside this file in +branding/, so they are resolved relative to this file rather than the caller. +""" + +import os +import plistlib +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +BRANDING_DIR = Path(__file__).resolve().parent + +# --- console output, matching build.sh's say()/err()/die() styling ---------- + +_IS_TTY = sys.stdout.isatty() +_BOLD_GREEN = "\033[1;32m" if _IS_TTY else "" +_RESET = "\033[0m" if _IS_TTY else "" + + +def say(message): + print(f"{_BOLD_GREEN}{message}{_RESET}") + + +def err(message): + print(f"Error: {message}", file=sys.stderr) + + +class BrandingError(Exception): + """Raised for a failed branding step; maps to a fatal non-zero exit.""" + + +def _have(cmd): + return shutil.which(cmd) is not None + + +def _run(args, **kwargs): + """Run a command, returning True on success (exit 0).""" + return subprocess.run(args, **kwargs).returncode == 0 + + +def _find(root, predicate): + """True if any file under root satisfies predicate(Path).""" + root = Path(root) + if not root.exists(): + return False + for path in root.rglob("*"): + if path.is_file() and predicate(path): + return True + return False + + +def _is_apple_double(path): + return path.name.startswith("._") + + +# --- display name ----------------------------------------------------------- + +def _set_display_name_in_app(appdir): + """Force the on-device app name back to "Twitter".""" + plist = appdir / "Info.plist" + if not plist.is_file(): + raise BrandingError("Branding: could not locate app Info.plist") + + # plistlib reads/writes both binary and XML plists, so no macOS-only plist + # tools are needed. IPA Info.plists are binary, so we write binary back. + with open(plist, "rb") as f: + data = plistlib.load(f) + data["CFBundleDisplayName"] = "Twitter" + with open(plist, "wb") as f: + plistlib.dump(data, f, fmt=plistlib.FMT_BINARY) + + say('Set CFBundleDisplayName to "Twitter".') + + +# --- resource pack ---------------------------------------------------------- + +def _apply_resource_pack_to_app(appdir, workdir, zip_path): + """Overlay replacement images/glyphs from a zip onto the app. + + The pack is a .zip with two optional subfolders plus optional root files: + icons/ loose images merged into the app's Assets.car (see + build_merged_car.py). A flat zip (images at the root, no icons/ + folder) is still treated as icons. + svgs/ vector glyphs copied over matching TwitterAppearance files (see + override_appearance_svgs.py). + non-image files at the zip root (e.g. LaunchScreen.nib) overwrite + the same-named file in the app root. + """ + zip_path = Path(zip_path) + if not zip_path.is_file(): + raise BrandingError(f"Branding: image pack not found: {zip_path}") + if not _have("unzip"): + raise BrandingError("Branding: 'unzip' is required for --resource-pack") + + zip_path = zip_path.resolve() + plist = appdir / "Info.plist" + car = appdir / "Assets.car" + + pack = workdir / "pack" + if not _run(["unzip", "-q", "-o", str(zip_path), "-d", str(pack)]): + raise BrandingError(f"Branding: failed to unpack image pack {zip_path}") + + icons_dir = pack / "icons" + if not icons_dir.is_dir(): + icons_dir = pack # back-compat: flat zip + svgs_dir = pack / "svgs" + + have_icons = _find( + icons_dir, + lambda p: p.suffix.lower() in (".png", ".jpg", ".jpeg") and not _is_apple_double(p), + ) + have_svgs = svgs_dir.is_dir() and _find( + svgs_dir, lambda p: p.suffix.lower() == ".svg" and not _is_apple_double(p) + ) + # Non-image files at the pack root overwrite the same-named file in the app + # root. (Root images belong to the flat-zip icons back-compat path.) + root_files = [ + p for p in pack.iterdir() + if p.is_file() + and p.suffix.lower() not in (".png", ".jpg", ".jpeg", ".svg") + and not _is_apple_double(p) + ] + have_root = bool(root_files) + + if not (have_icons or have_svgs or have_root): + raise BrandingError( + "Branding: image pack has no icons/ images, svgs/ glyphs, or root files" + ) + + # --- icons/: merge into Assets.car --- + if have_icons: + if not _have("assetutil"): + raise BrandingError("Branding: 'assetutil' is required for icons/") + clang_bin = shutil.which("clang") or _xcrun_find("clang") + actool_bin = shutil.which("actool") or _xcrun_find("actool") + if not clang_bin: + raise BrandingError("Branding: 'clang' (Xcode) is required for icons/") + if not actool_bin: + raise BrandingError("Branding: 'actool' (Xcode) is required for icons/") + if not car.is_file(): + raise BrandingError("Branding: app has no Assets.car to merge into") + + clang_log = workdir / "clang.log" + car_extract = workdir / "car_extract" + with open(clang_log, "w") as log: + built = _run( + [ + clang_bin, "-fobjc-arc", "-O2", + "-framework", "Foundation", + "-framework", "CoreGraphics", + "-framework", "ImageIO", + "-F", "/System/Library/PrivateFrameworks", "-framework", "CoreUI", + str(BRANDING_DIR / "car_extract.m"), "-o", str(car_extract), + ], + stderr=log, + ) + if not built: + err("Branding: failed to build car_extract:") + sys.stderr.write(clang_log.read_text()) + raise BrandingError("Branding: failed to build car_extract") + + # Aspect-preserving pad helper for master resizes (build_merged_car reads + # it via NFB_PAD_TOOL); non-fatal if it fails to build (falls back to sips). + pad_image = workdir / "pad_image" + with open(clang_log, "a") as log: + padded = _run( + [ + clang_bin, "-fobjc-arc", "-O2", + "-framework", "Foundation", + "-framework", "CoreGraphics", + "-framework", "ImageIO", + str(BRANDING_DIR / "pad_image.m"), "-o", str(pad_image), + ], + stderr=log, + ) + if padded: + os.environ["NFB_PAD_TOOL"] = str(pad_image) + + extract = workdir / "extract" + if not _run([str(car_extract), str(car), str(extract)]): + raise BrandingError(f"Branding: failed to extract {car}") + + new_car = workdir / "new.car" + if not _run([ + sys.executable, str(BRANDING_DIR / "build_merged_car.py"), + str(car), str(extract), str(icons_dir), str(new_car), + ]): + raise BrandingError("Branding: failed to rebuild Assets.car") + shutil.copyfile(new_car, car) + + if plist.is_file(): + if not _run([ + sys.executable, str(BRANDING_DIR / "update_bundle_icons.py"), + str(plist), str(car), + ]): + raise BrandingError("Branding: failed to update CFBundleIcons") + + # Sync the loose fallback icons in the app root (used by SpringBoard) to + # the rebuilt catalog, else the home-screen icon stays stale. + new_extract = workdir / "newextract" + if _run([str(car_extract), str(car), str(new_extract)], + stderr=subprocess.DEVNULL): + _run([ + sys.executable, str(BRANDING_DIR / "overwrite_loose_icons.py"), + str(appdir), str(new_extract), + ]) + + # --- svgs/: override TwitterAppearance vector glyphs --- + if have_svgs: + if not _run([ + sys.executable, str(BRANDING_DIR / "override_appearance_svgs.py"), + str(appdir), str(svgs_dir), + ]): + raise BrandingError("Branding: failed to override TwitterAppearance glyphs") + + # --- root files (e.g. LaunchScreen.nib): overwrite the same file in app root --- + if have_root: + for rf in root_files: + dest = appdir / rf.name + if dest.exists(): + if dest.is_dir(): + shutil.rmtree(dest) + else: + dest.unlink() + shutil.copyfile(rf, dest) + say(f"Replaced {rf.name} in the app root.") + else: + err(f"Branding: '{rf.name}' is not present in the app root; skipped.") + + say(f"Applied image pack to {appdir.name}.") + + +def _xcrun_find(tool): + try: + out = subprocess.run( + ["xcrun", "-f", tool], capture_output=True, text=True + ) + except FileNotFoundError: + return None + return out.stdout.strip() if out.returncode == 0 else None + + +# --- entry point ------------------------------------------------------------ + +def apply_ipa_branding(ipa): + """Unpack the IPA once, apply every enabled step, repackage once.""" + resource_pack = os.environ.get("RESOURCE_PACK", "") + twitter_branding = os.environ.get("TWITTER_BRANDING", "0") == "1" + if not resource_pack and not twitter_branding: + return + + ipa = Path(ipa) + if not ipa.is_file(): + raise BrandingError(f"Branding: IPA not found: {ipa}") + if not _have("unzip"): + raise BrandingError("Branding: 'unzip' is required") + if not _have("zip"): + raise BrandingError("Branding: 'zip' is required") + + workdir = Path(tempfile.mkdtemp()) + try: + ipa_root = workdir / "ipa" + if not _run(["unzip", "-q", str(ipa), "-d", str(ipa_root)]): + raise BrandingError(f"Branding: failed to unpack {ipa}") + + payload = ipa_root / "Payload" + apps = sorted(payload.glob("*.app")) if payload.is_dir() else [] + appdir = next((a for a in apps if a.is_dir()), None) + if appdir is None: + raise BrandingError(f"Branding: could not locate .app inside {ipa}") + + if resource_pack: + _apply_resource_pack_to_app(appdir, workdir, resource_pack) + if twitter_branding: + _set_display_name_in_app(appdir) + + # Repackage once. Use the zip binary (not zipfile) to preserve symlinks + # and permissions exactly as the original build produced them. + ipa = ipa.resolve() + tmp_ipa = ipa.with_name(ipa.name + ".branding.tmp") + if tmp_ipa.exists(): + tmp_ipa.unlink() + if not _run(["zip", "-qr", str(tmp_ipa), "Payload"], cwd=str(ipa_root)): + if tmp_ipa.exists(): + tmp_ipa.unlink() + raise BrandingError(f"Branding: failed to repackage {ipa}") + os.replace(tmp_ipa, ipa) + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +def main(argv): + if len(argv) != 2: + err("usage: ipa_branding.py ") + return 2 + try: + apply_ipa_branding(argv[1]) + except BrandingError as exc: + err(str(exc)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/branding/override_appearance_svgs.py b/branding/override_appearance_svgs.py new file mode 100644 index 00000000..f5c7e637 --- /dev/null +++ b/branding/override_appearance_svgs.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Override vector glyphs inside the app's TwitterAppearance bundles. + +Usage: override_appearance_svgs.py + +The app ships several copies of TwitterAppearance_TwitterAppearance.bundle (one +in the main app plus one per PlugIns/*.appex), each holding VectorImages/main/ +(UI glyphs) and VectorImages/twemoji/ (emoji). We target VectorImages/main only. +For each *.svg in we replace every existing main glyph of the same +name across all those bundles, so every surface (app, widgets, notifications, +share sheet) stays consistent. + +Only existing glyphs are replaced; a provided svg matching nothing is reported +and ignored (we never add new glyphs). Invoked from ipa-branding.sh. +""" + +import os +import shutil +import sys + + +def main(): + if len(sys.argv) != 3: + sys.stderr.write("usage: override_appearance_svgs.py \n") + return 2 + app_dir, svg_dir = sys.argv[1], sys.argv[2] + + # Index the VectorImages/main glyphs of every TwitterAppearance bundle by + # basename (twemoji and other subfolders are intentionally left alone). Each + # entry also records the bundle root so we can drop its stale seal later. + index = {} # basename -> [(path, bundle_root)] + bundle_roots = [] + for root, dirs, _files in os.walk(app_dir): + dirs[:] = [d for d in dirs if d != "__MACOSX"] + for d in dirs: + if "TwitterAppearance" in d and d.endswith(".bundle"): + broot = os.path.join(root, d) + bundle_roots.append(broot) + maindir = os.path.join(broot, "VectorImages", "main") + if not os.path.isdir(maindir): + continue + for f in os.listdir(maindir): + if f.lower().endswith(".svg"): + index.setdefault(f, []).append((os.path.join(maindir, f), broot)) + + # Apply each provided svg to all matching targets. + replaced_files = 0 + applied, unmatched = [], [] + modified_bundles = set() + for sroot, sdirs, sfiles in os.walk(svg_dir): + sdirs[:] = [d for d in sdirs if d != "__MACOSX"] + for f in sfiles: + if f.startswith("._") or not f.lower().endswith(".svg"): + continue + targets = index.get(f) + if not targets: + unmatched.append(f) + continue + for path, broot in targets: + shutil.copyfile(os.path.join(sroot, f), path) + modified_bundles.add(broot) + replaced_files += len(targets) + applied.append(f) + + # A resource bundle's own _CodeSignature seals its files by hash; once we + # replace glyphs that seal is stale. Drop it so the containing app/appex + # re-seal (by cyan and the installer) is authoritative and nothing chokes on + # a mismatch. The bundle stays valid, sealed by its parent's CodeResources. + for broot in sorted(modified_bundles): + shutil.rmtree(os.path.join(broot, "_CodeSignature"), ignore_errors=True) + + print("svg override: %d glyph(s) applied across %d location(s) in %d bundle(s); " + "stripped %d stale bundle seal(s)" % ( + len(applied), replaced_files, len(bundle_roots), len(modified_bundles))) + for f in sorted(unmatched): + print(" svg-unmatched: %s (no such glyph in TwitterAppearance bundles)" % f) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/branding/overwrite_loose_icons.py b/branding/overwrite_loose_icons.py new file mode 100644 index 00000000..20e4e1aa --- /dev/null +++ b/branding/overwrite_loose_icons.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +"""Sync loose fallback icon PNGs in the app root to the merged catalog. + +Usage: overwrite_loose_icons.py + +Twitter.app ships loose primary-icon files at its root (e.g. +ProductionAppIcon60x60@2x.png, ProductionAppIcon76x76@2x~ipad.png) that +SpringBoard uses for the home-screen icon. Replacing icons only inside +Assets.car leaves these stale, so the home screen keeps the old art. Here we +overwrite each loose root icon with the matching rendition from the freshly +rebuilt catalog (car_extract output in ), matched by the icon +asset name (filename prefix) and pixel dimensions. +""" + +import json +import os +import shutil +import struct +import sys + + +def png_dims(path): + # Scan chunks for IHDR. iOS "CgBI" PNGs prepend a CgBI chunk before IHDR, + # so we can't assume IHDR is first. + with open(path, "rb") as f: + if f.read(8) != b"\x89PNG\r\n\x1a\n": + return None + while True: + head = f.read(8) + if len(head) < 8: + return None + length = struct.unpack(">I", head[:4])[0] + if head[4:8] == b"IHDR": + return struct.unpack(">II", f.read(8)) + f.seek(length + 4, 1) # skip chunk data + CRC + + +def main(): + if len(sys.argv) != 3: + sys.stderr.write("usage: overwrite_loose_icons.py \n") + return 2 + app_dir, extract_dir = sys.argv[1], sys.argv[2] + + with open(os.path.join(extract_dir, "manifest.json")) as fh: + manifest = json.load(fh) + # (asset name, w, h) -> extracted png; and the set of asset names. + by_key = {} + names = set() + for m in manifest: + names.add(m["renditionName"]) + by_key[(m["renditionName"], int(m["width"]), int(m["height"]))] = \ + os.path.join(extract_dir, m["file"]) + + synced, skipped = [], [] + for f in os.listdir(app_dir): + if f.startswith("._") or not f.lower().endswith((".png", ".jpg", ".jpeg")): + continue + fp = os.path.join(app_dir, f) + if not os.path.isfile(fp): + continue + dims = png_dims(fp) + if not dims: + continue + # Longest asset name that prefixes this loose filename (e.g. + # "ProductionAppIcon60x60@2x.png" -> "ProductionAppIcon"). + cands = [n for n in names if f.startswith(n)] + if not cands: + continue + name = max(cands, key=len) + src = by_key.get((name, dims[0], dims[1])) + if src: + shutil.copyfile(src, fp) + synced.append((f, "%dx%d" % dims)) + else: + skipped.append((f, "%dx%d" % dims)) + + print("loose icons: %d synced to catalog" % len(synced)) + for f, d in synced: + print(" synced: %s (%s)" % (f, d)) + for f, d in skipped: + print(" no-match: %s (%s) has no catalog rendition of that size" % (f, d)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/branding/pad_image.m b/branding/pad_image.m new file mode 100644 index 00000000..b5645544 --- /dev/null +++ b/branding/pad_image.m @@ -0,0 +1,46 @@ +// pad_image: resize to exactly x preserving aspect ratio, centering +// the image on a transparent canvas (letterbox/pillarbox) rather than stretching. +// Used by build_merged_car.py's master resize so a square logo dropped in as a +// master (e.g. a launch image) is padded, not distorted, into non-square slots. +// +// usage: pad_image +#import +#import +#import + +int main(int argc, const char **argv) { + @autoreleasepool { + if (argc != 5) { fprintf(stderr, "usage: pad_image \n"); return 2; } + int W = atoi(argv[2]), H = atoi(argv[3]); + if (W <= 0 || H <= 0) { fprintf(stderr, "bad dimensions\n"); return 2; } + + CGImageSourceRef src = CGImageSourceCreateWithURL( + (__bridge CFURLRef)[NSURL fileURLWithPath:@(argv[1])], NULL); + if (!src) { fprintf(stderr, "cannot open %s\n", argv[1]); return 1; } + CGImageRef img = CGImageSourceCreateImageAtIndex(src, 0, NULL); + CFRelease(src); + if (!img) { fprintf(stderr, "cannot decode %s\n", argv[1]); return 1; } + + size_t iw = CGImageGetWidth(img), ih = CGImageGetHeight(img); + double scale = fmin((double)W / iw, (double)H / ih); // fit inside, keep aspect + double dw = iw * scale, dh = ih * scale; + CGRect dst = CGRectMake((W - dw) / 2.0, (H - dh) / 2.0, dw, dh); + + CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB(); + CGContextRef ctx = CGBitmapContextCreate(NULL, W, H, 8, 0, cs, + kCGImageAlphaPremultipliedLast); + CGColorSpaceRelease(cs); + if (!ctx) { CGImageRelease(img); fprintf(stderr, "context failed\n"); return 1; } + CGContextClearRect(ctx, CGRectMake(0, 0, W, H)); // transparent background + CGContextSetInterpolationQuality(ctx, kCGInterpolationHigh); + CGContextDrawImage(ctx, dst, img); + + CGImageRef out = CGBitmapContextCreateImage(ctx); + CGImageDestinationRef dstImg = CGImageDestinationCreateWithURL( + (__bridge CFURLRef)[NSURL fileURLWithPath:@(argv[4])], CFSTR("public.png"), 1, NULL); + CGImageDestinationAddImage(dstImg, out, NULL); + bool ok = CGImageDestinationFinalize(dstImg); + CFRelease(dstImg); CGImageRelease(out); CGContextRelease(ctx); CGImageRelease(img); + return ok ? 0 : 1; + } +} diff --git a/branding/update_bundle_icons.py b/branding/update_bundle_icons.py new file mode 100644 index 00000000..f33da777 --- /dev/null +++ b/branding/update_bundle_icons.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Rewrite an app's CFBundleIcons to match a compiled asset catalog. + +Usage: update_bundle_icons.py + +Enumerates the app-icon renditions inside (via `assetutil`), +groups them by idiom, and rewrites CFBundleIcons / CFBundleIcons~ipad in the +given Info.plist so they reference the icons actually present in the catalog. + +Kept as a sibling of ipa-branding.sh; invoked from replace_app_images(). +""" + +import collections +import json +import plistlib +import subprocess +import sys + + +def point_size(entry): + """Icon size in points, e.g. 120px @2x -> "60x60".""" + scale = float(entry.get("Scale", 1)) or 1.0 + w = float(entry.get("PixelWidth", 0)) / scale + h = float(entry.get("PixelHeight", 0)) / scale + return "%gx%g" % (w, h) + + +def main() -> int: + if len(sys.argv) != 3: + sys.stderr.write("usage: update_bundle_icons.py \n") + return 2 + plist_path, car_path = sys.argv[1], sys.argv[2] + + try: + raw = subprocess.run( + ["assetutil", "--info", car_path], + capture_output=True, text=True, check=True, + ).stdout + entries = json.loads(raw) + except subprocess.CalledProcessError as exc: + sys.stderr.write("assetutil failed: %s\n" % (exc.stderr or exc)) + return 1 + except (ValueError, OSError) as exc: + sys.stderr.write("could not read asset catalog: %s\n" % exc) + return 1 + + with open(plist_path, "rb") as fh: + info = plistlib.load(fh) + + # App-icon renditions are typed "Icon Image"; a catalog may hold several sets + # (the primary plus alternate icons). We only rewrite the PRIMARY set and + # leave CFBundleAlternateIcons untouched, so pick the primary's name. + icon_rows = [e for e in entries if isinstance(e, dict) + and e.get("AssetType") == "Icon Image" + and (e.get("Idiom") or "").lower() != "marketing"] + icon_names = [e.get("Name") for e in icon_rows if e.get("Name")] + if not icon_names: + sys.stderr.write("no app-icon renditions found in %s\n" % car_path) + return 3 + + def existing_primary_name(key): + d = info.get(key) + if isinstance(d, dict) and isinstance(d.get("CFBundlePrimaryIcon"), dict): + return d["CFBundlePrimaryIcon"].get("CFBundleIconName") + return None + + primary_name = existing_primary_name("CFBundleIcons") \ + or existing_primary_name("CFBundleIcons~ipad") + if primary_name not in set(icon_names): + # No usable hint from the plist: prefer a "production" set, else the one + # with the most renditions (the real home-screen icon). + prod = [n for n in icon_names if "production" in n.lower()] + primary_name = prod[0] if prod else \ + collections.Counter(icon_names).most_common(1)[0][0] + + # idiom bucket -> [base, ...] (unique, ordered) for the primary set only. + buckets = {"phone": [], "pad": []} + + def add(key, base): + if base not in buckets[key]: + buckets[key].append(base) + + for entry in icon_rows: + if entry.get("Name") != primary_name: + continue + # CFBundleIconFiles base name, e.g. "AppIcon" + "60x60" -> "AppIcon60x60". + base = "%s%s" % (primary_name, point_size(entry)) + idiom = (entry.get("Idiom") or "").lower() + if idiom == "phone": + add("phone", base) + elif idiom in ("pad", "ipad"): + add("pad", base) + elif idiom in ("universal", ""): + add("phone", base) + add("pad", base) + + def make_primary(files): + return {"CFBundleIconFiles": files, "CFBundleIconName": primary_name} if files else None + + phone_primary = make_primary(buckets["phone"]) + pad_primary = make_primary(buckets["pad"]) + + present = set(icon_names) + + def set_primary(key, primary): + # Replace only CFBundlePrimaryIcon; keep other sub-keys. Prune + # CFBundleAlternateIcons to icon sets still present in the catalog, so + # dropped stock icons are not left dangling in the picker. + icons = info.get(key) + if not isinstance(icons, dict): + icons = {} + icons["CFBundlePrimaryIcon"] = primary + alts = icons.get("CFBundleAlternateIcons") + if isinstance(alts, dict): + for k in [k for k in alts if k not in present]: + del alts[k] + if not alts: + del icons["CFBundleAlternateIcons"] + info[key] = icons + + if phone_primary is None and pad_primary is None: + sys.stderr.write("no app-icon renditions found in %s\n" % car_path) + return 3 + + if phone_primary is not None: + set_primary("CFBundleIcons", phone_primary) + # Modern asset-catalog apps also carry a top-level icon name. + info["CFBundleIconName"] = primary_name + if pad_primary is not None: + set_primary("CFBundleIcons~ipad", pad_primary) + + with open(plist_path, "wb") as fh: + plistlib.dump(info, fh, fmt=plistlib.FMT_BINARY) + + print("icons: primary=%s | phone files=%s | pad files=%s" % ( + primary_name, buckets["phone"], buckets["pad"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build.sh b/build.sh index 5449706d..87a83ee2 100755 --- a/build.sh +++ b/build.sh @@ -23,17 +23,19 @@ die() { err "$1"; exit 1; } usage() { cat </dev/null 2>&1; then CYAN_BIN="cyan"; fi +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +apply_ipa_branding() { + require_cmd python3 + TWITTER_BRANDING="$TWITTER_BRANDING" RESOURCE_PACK="$RESOURCE_PACK" \ + python3 "$SCRIPT_DIR/branding/ipa_branding.py" "$1" +} + MODE="" +TWITTER_BRANDING=0 +RESOURCE_PACK="" while [[ $# -gt 0 ]]; do case "$1" in @@ -64,6 +76,16 @@ while [[ $# -gt 0 ]]; do [[ -n "$MODE" ]] && die "Multiple flags provided. Choose one." MODE="rootfull"; shift ;; + -t|--twitter-branding) + TWITTER_BRANDING=1; shift + ;; + --resource-pack) + [[ $# -ge 2 ]] || die "--resource-pack requires a path argument." + RESOURCE_PACK="$2"; shift 2 + ;; + --resource-pack=*) + RESOURCE_PACK="${1#*=}"; shift + ;; -h|--help) usage; exit 0 ;; @@ -85,6 +107,21 @@ if [[ -z "$MODE" ]]; then exit 2 fi +if [[ "$MODE" != "sideloaded" && "$MODE" != "trollstore" ]]; then + if [[ "$TWITTER_BRANDING" -eq 1 ]]; then + say "Skipping --twitter-branding: branding only applies to IPA builds (--sideloaded/--trollstore)." + TWITTER_BRANDING=0 + fi + if [[ -n "$RESOURCE_PACK" ]]; then + say "Skipping --resource-pack: branding only applies to IPA builds (--sideloaded/--trollstore)." + RESOURCE_PACK="" + fi +fi + +if [[ -n "$RESOURCE_PACK" && ! -f "$RESOURCE_PACK" ]]; then + die "--resource-pack file not found: $RESOURCE_PACK" +fi + clean_tree() { if [[ -d .theos ]]; then rm -rf .theos; fi if [[ -f Makefile ]]; then make clean || true; fi @@ -102,8 +139,9 @@ case "$MODE" in say "Building the IPA." if command -v cyan >/dev/null 2>&1; then cyan -i packages/com.atebits.Tweetie2.ipa -o packages/NeoFreeBird-sideloaded --ignore-encrypted \ - -uwf .theos/obj/debug/keychainfix.dylib .theos/obj/debug/libbhFLEX.dylib \ + -uwf .theos/obj/debug/zxPluginsInject.dylib .theos/obj/debug/libbhFLEX.dylib \ .theos/obj/debug/BHTwitter.dylib layout/Library/Application\ Support/BHT/BHTwitter.bundle + apply_ipa_branding "$(ls -t packages/*.ipa 2>/dev/null | head -n1)" else say "Skipping cyan step because it is not installed." fi @@ -131,6 +169,7 @@ case "$MODE" in if command -v cyan >/dev/null 2>&1; then cyan -i packages/com.atebits.Tweetie2.ipa -o packages/NeoFreeBird-trollstore.tipa --ignore-encrypted \ -uwf .theos/obj/debug/BHTwitter.dylib .theos/obj/debug/libbhFLEX.dylib layout/Library/Application\ Support/BHT/BHTwitter.bundle + apply_ipa_branding "$(ls -t packages/*.tipa 2>/dev/null | head -n1)" else say "Skipping cyan step because it is not installed." fi @@ -149,4 +188,4 @@ case "$MODE" in *) die "Unknown mode: $MODE" ;; -esac \ No newline at end of file +esac diff --git a/control b/control index 022dbf6e..f7cb6d90 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.bandarhl.bhtwitter Name: NFB-BHTwitter -Version: 5.2 +Version: 5.3.0 Architecture: iphoneos-arm Description: Awesome tweak for Twitter Maintainer: NeoFreeBird diff --git a/keychainfix/Makefile b/keychainfix/Makefile deleted file mode 100644 index 96229e83..00000000 --- a/keychainfix/Makefile +++ /dev/null @@ -1,12 +0,0 @@ -TARGET := iphone:clang:latest:14.0 - - -include $(THEOS)/makefiles/common.mk - -TWEAK_NAME = keychainfix - -keychainfix_FILES = Tweak.x -keychainfix_FRAMEWORKS = UIKit Foundation Security -keychainfix_CFLAGS = -fobjc-arc - -include $(THEOS_MAKE_PATH)/tweak.mk diff --git a/keychainfix/Tweak.x b/keychainfix/Tweak.x deleted file mode 100644 index 979bdb53..00000000 --- a/keychainfix/Tweak.x +++ /dev/null @@ -1,39 +0,0 @@ -#import -#import -#import - -static NSString * _Nonnull accessGroupID() { - NSDictionary *query = [NSDictionary dictionaryWithObjectsAndKeys: - (__bridge NSString *)kSecClassGenericPassword, (__bridge NSString *)kSecClass, - @"bundleSeedID", kSecAttrAccount, - @"", kSecAttrService, - (id)kCFBooleanTrue, kSecReturnAttributes, - nil]; - CFDictionaryRef result = nil; - OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&result); - if (status == errSecItemNotFound) - status = SecItemAdd((__bridge CFDictionaryRef)query, (CFTypeRef *)&result); - if (status != errSecSuccess) - return nil; - NSString *accessGroup = [(__bridge NSDictionary *)result objectForKey:(__bridge NSString *)kSecAttrAccessGroup]; - - return accessGroup; -} - -%hook TFSKeychain -- (NSString *)providerDefaultAccessGroup { - return accessGroupID(); -} -- (NSString *)providerSharedAccessGroup { - return accessGroupID(); -} -%end - -%hook TFSKeychainDefaultTwitterConfiguration -- (NSString *)defaultAccessGroup { - return accessGroupID(); -} -- (NSString *)sharedAccessGroup { - return accessGroupID(); -} -%end \ No newline at end of file diff --git a/keychainfix/keychainfix.plist b/keychainfix/keychainfix.plist deleted file mode 100644 index 4bc6b7c6..00000000 --- a/keychainfix/keychainfix.plist +++ /dev/null @@ -1 +0,0 @@ -{ Filter = { Bundles = ( "com.atebits.Tweetie2" ); }; } diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/BHTWebXTID.js b/layout/Library/Application Support/BHT/BHTwitter.bundle/BHTWebXTID.js new file mode 100644 index 00000000..d2935647 --- /dev/null +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/BHTWebXTID.js @@ -0,0 +1,90 @@ +// BHTWebXTID.js — injected into NeoFreeBird's authenticated offscreen x.com webview. +// +// Purpose: get a valid `x-client-transaction-id` for the CreateTweet rewrite (X +// rate-limits requests without one) by reusing the web client's OWN generator, so +// there are no reimplemented algorithm constants to go stale. +// +// The only hardcoded strings here are structural anchors used to LOCATE the client's +// code (the webpack global name and the transaction module's stable feature-flag + +// header strings) — not algorithm secrets. +// +// Exposes on window: +// __bhtTransactionId(path, method) -> calls the client's own transaction-id +// generator. `path` is the client-internal form: /graphql//. +(function(){ + if(window.__bhtTransactionId)return; + + function withTimeout(p,ms){return Promise.race([p,new Promise(function(_,rej){setTimeout(function(){rej(new Error("timeout"));},ms);})]);} + + // Acquire webpack's require via the chunk-array push trick. The chunk array may not + // exist yet when we run (bundles still loading), so we pre-create it: webpack's + // bootstrap does `self[name]=self[name]||[]` then forEach()s existing entries, so our + // pushed entry is processed (and our callback invoked) once the runtime installs. + async function getWreq(){ + if(window.__bhtWreq)return window.__bhtWreq; + var name="webpackChunk_twitter_responsive_web"; + window[name]=window[name]||[]; + var arr=window[name]; + var req=await new Promise(function(resolve,reject){ + var done=false; + var finish=function(r){ if(!done){ done=true; resolve(r); } }; + try{ arr.push([["__bht_"+Date.now()],{},finish]); } + catch(e){ reject(e); return; } + setTimeout(function(){ if(!done){ done=true; reject(new Error("webpack not ready (timeout)")); } },15000); + }); + window.__bhtWreq=req; + return req; + } + + // Find and call the client's transaction-id generator. The transaction module is + // located by a stable source signature (not its per-build numeric id). Rather than + // guess which minified export is the generator, we call EVERY function export with + // (host, path, method) and keep whichever returns a valid token. The winning function + // is cached on window.__bhtGen for subsequent calls. + async function realGen(host,path,method){ + if(window.__bhtGen){ return await window.__bhtGen(host,path,method); } + var req=await getWreq(); + if(!req||!req.m)throw new Error("no req.m"); + var keys=Object.keys(req.m); + var cands=[]; + for(var i=0;i10){ + var dec="";try{dec=atob(out);}catch(e){} + if(dec.slice(0,2)!=="e:"){ window.__bhtGen=f; return out; } + lastErr="client:"+dec.slice(0,80); + } + }catch(e){} + } + } + throw new Error(lastErr||("generator export not found (cands="+cands.length+")")); + } + + window.__bhtTransactionId=async function(path,method){ + try{ + var t=await realGen("https://x.com",path,method); + if(typeof t==="string"&&t.length>10){ + var dec="";try{dec=atob(t);}catch(e){} + if(dec.slice(0,2)!=="e:")return t; // the client returns btoa("e:"+err) on failure + return "BHTERR:client-returned-error"; + } + return "BHTERR:gen-returned:"+String(t).slice(0,60); + }catch(e){ + return "BHTERR:"+(((e&&e.message)?e.message:String(e))||"unknown").slice(0,120); + } + }; +})(); diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings index f52a6a59..8fd34a95 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ar.lproj/Localizable.strings @@ -11,7 +11,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "مطلوب إعادة التشغيل"; "RESTART_REQUIRED_ALERT_MESSAGE" = "يجب إعادة تشغيل تويتر لتفعيل هذا التغيير."; "RESTART_NOW_BUTTON_TITLE" = "أعد التشغيل الآن"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "للتبديل بين التخطيط الحديث والكلاسيكي، يجب إعادة تشغيل تويتر."; "CONFIRM_ALERT_MESSAGE" = "هل أنت متأكد أنك تقصد ذلك؟"; "YES_BUTTON_TITLE" = "نعم"; @@ -74,6 +73,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "إخفاء الإشارات المرجعية"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "إزالة زر الإشارة المرجعية."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "إخفاء التصويت السلبي"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "إزالة زر التصويت السلبي من التغريدات."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "بدون تحذيرات محتوى حساس"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings index 3dc627bc..6e48303b 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/de.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Neustart erforderlich"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Twitter muss neu gestartet werden, damit diese Änderung wirksam wird."; "RESTART_NOW_BUTTON_TITLE" = "Jetzt neu starten"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Um zwischen modernem und klassischem Layout zu wechseln, muss Twitter neu gestartet werden."; "CONFIRM_ALERT_MESSAGE" = "Bist du dir sicher, dass du das tun wolltest?"; "YES_BUTTON_TITLE" = "Ja"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Lesezeichen ausblenden"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Entferne den Lesezeichen-Button aus Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Downvotes ausblenden"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Entferne den Downvote-Button aus Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Keine Warnungen wegen sensibler Inhalte"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings index 69242d39..0176edde 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "Restart required"; "RESTART_REQUIRED_ALERT_MESSAGE" = "You need to restart Twitter for this change to take effect."; -"MODERN_LAYOUT_RESTART_MESSAGE" = "To switch between the modern and classic layouts, you need to restart Twitter"; "RESET_COMPLETE_TITLE" = "Reset successful"; "BACKGROUND_RESET_MESSAGE" = "The Messages background was reset to default."; @@ -126,9 +125,14 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Hide bookmarks"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Remove the bookmark button from Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Hide downvotes"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Remove the downvote button from Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "No sensitive content warnings"; +"BYPASS_AGE_VERIFICATION_OPTION_TITLE" = "Bypass age verification"; +"BYPASS_AGE_VERIFICATION_OPTION_DETAIL_TITLE" = "Skips the age verification flow for sensitive content."; + "HIDE_GROK_ANALYZE_BUTTON_TITLE" = "Hide Grok analyze buttons"; "HIDE_GROK_ANALYZE_BUTTON_DETAIL_TITLE" = "Hides the Grok Analyze button in Tweets."; @@ -231,7 +235,7 @@ "MEDIA_UPLOAD_4K_ENABLED_OPTION_DETAIL_TITLE" = "Upload photos and videos in 4K."; "DOWNLOAD_VIDEOS_OPTION_TITLE" = "Download media"; -"DOWNLOAD_VIDEOS_OPTION_DETAIL_TITLE" = "Save videos or GIFs right from Tweets."; +"DOWNLOAD_VIDEOS_OPTION_DETAIL_TITLE" = "Save videos or GIFs right from the Tweet overflow menu."; "DIRECT_SAVE_OPTION_TITLE" = "Save to Photos"; "DIRECT_SAVE_OPTION_DETAIL_TITLE" = "Videos you save will go straight to your Photos app."; @@ -260,6 +264,12 @@ "STOP_HIDING_TAB_BAR_TITLE" = "Keep the navigation bar visible"; "STOP_HIDING_TAB_BAR_DETAIL_TITLE" = "Don't fade the navigation bar when you scroll."; +"HIDE_CUSTOM_TIMELINES_OPTION_TITLE" = "Hide custom timelines"; +"HIDE_CUSTOM_TIMELINES_OPTION_DETAIL_TITLE" = "Remove the Add button from the home timeline tabs. Required relaunching."; + +"HIDE_TRENDS_OPTION_TITLE" = "Hide trending content"; +"HIDE_TRENDS_OPTION_DETAIL_TITLE" = "Show only the search bar on the Explore tab, hiding all trends, news and the page tabs."; + "CLASSIC_TAB_BAR_SETTINGS_TITLE" = "Classic tab bar"; "CLASSIC_TAB_BAR_SETTINGS_DETAIL" = "Applies your accent color to the currently selected tab item."; @@ -384,15 +394,15 @@ /*Refresh pill text*/ "REFRESH_PILL_TEXT" = "Tweeted"; -"NOTIF_REPLACE_POST_WITH_TWEET_OPTION_TITLE" = "Post to Tweet replacer in notifications"; -"NOTIF_REPLACE_POST_WITH_TWEET_DETAIL_TITLE" = "Replaces 'Post' with 'Tweet' in the notifications pane. This affects *all* text in notifications, so if a Tweet contains the word 'post', it will be replaced too."; - "COLOR_TWITTER_ICON_OPTION_TITLE" = "Color Twitter icon"; "COLOR_TWITTER_ICON_DETAIL_TITLE" = "Applies the accent color to the Twitter icon in the top bar."; "REFRESH_PILL_OPTION_TITLE" = "Add 'Tweeted' to refresh pill"; "REFRESH_PILL_DETAIL_TITLE" = "Adds back the 'Tweeted' label to the refresh pill when someone Tweets. This may (incorrectly) appear in some other areas too."; +"RESTORE_TWITTER_NAMES_OPTION_TITLE" = "Restore Twitter terminology"; +"RESTORE_TWITTER_NAMES_OPTION_DETAIL_TITLE" = "Replaces 'X' with 'Twitter', 'Post' with 'Tweet', 'Repost' with 'Retweet' and so on throughout the app's interface."; + /*Presets*/ "PRESETS_IMPORT_SUCCESS_TITLE" = "Import successful"; @@ -491,4 +501,10 @@ "notif_retweet_new" = "retweet"; "notif_Repost_old" = "Repost"; -"notif_Retweet_new" = "Retweet"; \ No newline at end of file +"notif_Retweet_new" = "Retweet"; + +"ATTESTATION_BYPASS_OPTION_TITLE" = "Strip attestation headers"; +"ATTESTATION_BYPASS_OPTION_DETAIL_TITLE" = "May or may not impact any feature."; + +"REPLY_IN_WEBVIEW_OPTION_TITLE" = "Reply in web view"; +"REPLY_IN_WEBVIEW_OPTION_DETAIL_TITLE" = "Open the reply box in a web view to avoid attestation."; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameOverrides.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameOverrides.strings new file mode 100644 index 00000000..462421cb --- /dev/null +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameOverrides.strings @@ -0,0 +1,16 @@ +/* +Exact overrides for the "Restore Twitter names" feature, keyed by Twitter's own +localization keys (see Localization_Localization.bundle/en.lproj/Localizable.strings +in the app). When a key listed here is looked up, its value is returned as-is and +the generic word replacements from RenameWords.strings are skipped — use this when +the generic rules get a specific string wrong. + +Strictly per-language: keys not listed here, and languages without their own copy of +this file, fall through to the generic replacement. +*/ + +/* +e.g. The payments provider is genuinely named X, so don't rename it to Twitter. + +"PAYMENTS_PROVIDER_X" = "X"; +*/ diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameWords.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameWords.strings new file mode 100644 index 00000000..fb5efb3c --- /dev/null +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/en.lproj/RenameWords.strings @@ -0,0 +1,24 @@ +/* +Generic terminology replacements for the "Restore Twitter names" feature, applied +to every localized string and to server-composed text (notification rows etc.). + +Lowercase keys match whole words case-insensitively and the replacement copies the +matched word's capitalisation ("Posts" -> "Tweets", "POST" -> "TWEET"). Keys that +contain an uppercase letter match exactly and are replaced verbatim ("X" -> "Twitter"). +List each word in only one of the two forms, and never use a replacement value that +matches another key — replacements must be stable if the text is processed twice. + +Strictly per-language: a language without its own copy of this file gets no generic +renaming at all. +*/ + +"repost" = "retweet"; +"reposts" = "retweets"; +"reposted" = "retweeted"; +"reposting" = "retweeting"; +"post" = "tweet"; +"posts" = "tweets"; +"posted" = "tweeted"; +"posting" = "tweeting"; +"premium" = "blue"; +"X" = "Twitter"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings index 369d39f5..d3b76325 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/es.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Se requiere reinicio"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Debes reiniciar Twitter para que este cambio surta efecto."; "RESTART_NOW_BUTTON_TITLE" = "Reiniciar ahora"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Para cambiar entre el diseño moderno y clásico, debes reiniciar Twitter."; "CONFIRM_ALERT_MESSAGE" = "¿Estás seguro de que querías hacer eso?"; "YES_BUTTON_TITLE" = "Sí"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Ocultar marcadores"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Quita el botón de marcadores de los Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Ocultar votos negativos"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Quita el botón de voto negativo de los Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Sin advertencias de contenido sensible"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings index dbe131a5..268999c4 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/fr.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Redémarrage requis"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Vous devez redémarrer Twitter pour que ce changement prenne effet."; "RESTART_NOW_BUTTON_TITLE" = "Redémarrer maintenant"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Pour passer de la mise en page moderne à classique, vous devez redémarrer Twitter."; "CONFIRM_ALERT_MESSAGE" = "Tu es sûr de vouloir faire ça ?"; "YES_BUTTON_TITLE" = "Oui"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Masquer les favoris"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Supprime le bouton de favoris."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Masquer les votes négatifs"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Supprime le bouton de vote négatif des Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Pas d'avertissements de contenu sensible"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings index 11e990c8..6b2d3a67 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/hr.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Potrebno ponovno pokretanje"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Morate ponovno pokrenuti Twitter da bi promjena stupila na snagu."; "RESTART_NOW_BUTTON_TITLE" = "Ponovno pokreni"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Za prelazak između modernog i klasičnog izgleda morate ponovno pokrenuti Twitter."; "CONFIRM_ALERT_MESSAGE" = "Jesi li siguran da to želiš?"; "YES_BUTTON_TITLE" = "Da"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Sakrij oznake"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Ukloni gumb za spremanje tweetova."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Sakrij negativne glasove"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Ukloni gumb za negativan glas iz tweetova."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Bez upozorenja na osjetljiv sadržaj"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings index fe956323..042d54df 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/id.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Perlu Mulai Ulang"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Anda perlu memulai ulang Twitter agar perubahan ini berlaku."; "RESTART_NOW_BUTTON_TITLE" = "Mulai Ulang Sekarang"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Untuk beralih antara tata letak modern dan klasik, Anda perlu memulai ulang Twitter."; "CONFIRM_ALERT_MESSAGE" = "Yakin mau melakukan itu?"; "YES_BUTTON_TITLE" = "Ya"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Sembunyikan bookmark"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Hapus tombol bookmark dari Tweet."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Sembunyikan downvote"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Hapus tombol downvote dari Tweet."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Tanpa peringatan konten sensitif"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings index 92a89887..ef1edc23 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ja.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "再起動が必要です"; "RESTART_REQUIRED_ALERT_MESSAGE" = "この変更を反映するには、Twitterを再起動する必要があります。"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "古いレイアウトと新しいレイアウトを切り替えるには、Twitterを再起動する必要があります"; "RESET_COMPLETE_TITLE" = "リセットしました"; "BACKGROUND_RESET_MESSAGE" = "ダイレクトメッセージの背景がデフォルトにリセットされました。"; @@ -126,6 +125,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "ブックマークを非表示"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "ツイートからブックマークボタンを非表示にします。"; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "ダウンボートを非表示"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "ツイートからダウンボートボタンを非表示にします。"; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "センシティブ警告を非表示"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings index 1776a089..25119395 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ko.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "재시작 필요"; "RESTART_REQUIRED_ALERT_MESSAGE" = "변경 사항을 적용하기 위해서는 트위터를 재시작해야 합니다."; -"MODERN_LAYOUT_RESTART_MESSAGE" = "최신/클래식 레이아웃을 전환하기 위해서는 트위터를 재시작해야 합니다."; "RESET_COMPLETE_TITLE" = "재설정 완료"; "BACKGROUND_RESET_MESSAGE" = "쪽지 배경화면이 기본값으로 재설정되었습니다."; @@ -126,6 +125,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "북마크 추가 버튼 제거"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "트윗에서 북마크 추가 버튼을 제거합니다."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "다운보트 버튼 제거"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "트윗에서 다운보트 버튼을 제거합니다."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "민감한 콘텐츠 경고 비활성화"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings index b29e78ca..b98415ec 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/pl.lproj/Localizable.strings @@ -5,7 +5,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Wymagane ponowne uruchomienie"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Aby zmiana zaczęła działać, musisz ponownie uruchomić Twittera."; "RESTART_NOW_BUTTON_TITLE" = "Uruchom ponownie"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Aby przełączać się między nowym i klasycznym układem, musisz ponownie uruchomić Twittera."; "RESET_COMPLETE_TITLE" = "Przywracanie zakończone"; "BACKGROUND_RESET_MESSAGE" = "Tło Wiadomości zostało przywrócone do domyślnego."; @@ -77,6 +76,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Ukryj zakładki"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Usuń przycisk zakładek z Tweetów."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Ukryj głosy negatywne"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Usuń przycisk głosu negatywnego z Tweetów."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Bez ostrzeżeń o wrażliwych treściach"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings index d4506fb3..608a1552 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/ru.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Требуется перезапуск"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Необходимо перезапустить Twitter, чтобы изменения вступили в силу."; "RESTART_NOW_BUTTON_TITLE" = "Перезапустить"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Чтобы переключаться между современным и классическим макетом, перезапустите Twitter."; "CONFIRM_ALERT_MESSAGE" = "Ты точно этого хотел?"; "YES_BUTTON_TITLE" = "Да"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Скрыть закладки"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Убрать кнопку закладки с твитов."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Скрыть дизлайки"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Убрать кнопку дизлайка с твитов."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Без предупреждений о чувствительном контенте"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings index b9627c43..b4657446 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/sv.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "Omstart krävs"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Du måste starta om Twitter för att ändringen ska träda i kraft."; -"MODERN_LAYOUT_RESTART_MESSAGE" = "För att byta mellan modern och klassisk layout måste du starta om Twitter."; "CONFIRM_ALERT_MESSAGE" = "Säker på att du menade det?"; "YES_BUTTON_TITLE" = "Ja"; @@ -78,6 +77,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Dölj bokmärkesknapp"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Ta bort bokmärkesknappen från Tweets."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Dölj nedröstningsknapp"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Ta bort nedröstningsknappen från Tweets."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Inga varningar för känsligt innehåll"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings index 2f99537c..c2c38ae5 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/tr.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Yeniden başlatma gerekli"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Bu değişikliğin geçerli olması için Twitter'ı yeniden başlatmanız gerekiyor."; "RESTART_NOW_BUTTON_TITLE" = "Şimdi yeniden başlat"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Modern ve klasik düzen arasında geçiş yapmak için Twitter'ı yeniden başlatmanız gerekiyor."; "CONFIRM_ALERT_MESSAGE" = "Bunu yapmak istediğine emin misin?"; "YES_BUTTON_TITLE" = "Evet"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Yer imlerini gizle"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Tweet’lerden yer imi butonunu kaldır."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Olumsuz oyları gizle"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Tweet’lerden olumsuz oy butonunu kaldır."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Hassas içerik uyarısı yok"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings index 72f88839..2e25a9a2 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/uk.lproj/Localizable.strings @@ -12,7 +12,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "Потрібен перезапуск"; "RESTART_REQUIRED_ALERT_MESSAGE" = "Потрібно перезапустити Twitter, щоб зміни набули чинності."; "RESTART_NOW_BUTTON_TITLE" = "Перезапустити"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "Щоб перемикатися між сучасним і класичним макетами, потрібно перезапустити Twitter."; "CONFIRM_ALERT_MESSAGE" = "Ви точно хотіли це зробити?"; "YES_BUTTON_TITLE" = "Так"; @@ -75,6 +74,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "Приховати закладки"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "Прибрати кнопку закладки з твіту."; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "Приховати дизлайки"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "Прибрати кнопку дизлайка з твіту."; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "Прибрати попередження про чутливий контент"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings index 54601d0f..970d217a 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh-Hant.lproj/Localizable.strings @@ -9,7 +9,6 @@ /*Alerts and popups*/ "RESTART_REQUIRED_ALERT_TITLE" = "需要重新啟動"; "RESTART_REQUIRED_ALERT_MESSAGE" = "您需要重新啟動 Twitter 此變更才會生效。"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "切換現代或經典佈局,需重新啟動應用程式。"; "RESET_COMPLETE_TITLE" = "重置成功"; "BACKGROUND_RESET_MESSAGE" = "訊息背景已重置為預設值。"; @@ -126,6 +125,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "隱藏書籤按鈕"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的書籤功能。"; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "隱藏倒讚按鈕"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的倒讚按鈕。"; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "不顯示敏感內容警告"; diff --git a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings index b36726c9..1b2d62dd 100644 --- a/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings +++ b/layout/Library/Application Support/BHT/BHTwitter.bundle/zh_CN.lproj/Localizable.strings @@ -10,7 +10,6 @@ "RESTART_REQUIRED_ALERT_TITLE" = "需要重新启动"; "RESTART_REQUIRED_ALERT_MESSAGE" = "您需要重新启动 Twitter 以使更改生效。"; "RESTART_NOW_BUTTON_TITLE" = "立即重启"; -"MODERN_LAYOUT_RESTART_MESSAGE" = "要切换现代和经典布局,您需要重新启动 Twitter。"; "CONFIRM_ALERT_MESSAGE" = "你确定要这样做吗?"; "YES_BUTTON_TITLE" = "是"; @@ -73,6 +72,8 @@ "HIDE_MARKBOOK_BUTTON_OPTION_TITLE" = "隐藏书签按钮"; "HIDE_MARKBOOK_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的书签按钮。"; +"HIDE_DOWNVOTE_BUTTON_OPTION_TITLE" = "隐藏点踩按钮"; +"HIDE_DOWNVOTE_BUTTON_OPTION_DETAIL_TITLE" = "移除推文上的点踩按钮。"; "DISABLE_SENSITIVE_TWEET_WARNINGS_OPTION_TITLE" = "关闭敏感内容警告"; diff --git a/libflex/FLEX b/libflex/FLEX index 2bfba671..63a6f588 160000 --- a/libflex/FLEX +++ b/libflex/FLEX @@ -1 +1 @@ -Subproject commit 2bfba6715eff664ef84a02e8eb0ad9b5a609c684 +Subproject commit 63a6f588841e94e4c3adaa045ff16eb8163f0bb4 diff --git a/zxPluginsInject b/zxPluginsInject new file mode 160000 index 00000000..d849cd51 --- /dev/null +++ b/zxPluginsInject @@ -0,0 +1 @@ +Subproject commit d849cd510f9a925d692941b8d43c5efe1ca56649