mirror of
https://github.com/faroukbmiled/RyukGram.git
synced 2026-06-07 16:03:54 +02:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 57f05c8ccb | |||
| 2baffde0ee | |||
| db4a0b7e6f | |||
| 2e79bbf09a | |||
| d97b22ad5c | |||
| 2f9bf47566 | |||
| 2977873932 | |||
| 3fd1d8e138 | |||
| 6b23b6304c | |||
| 52f78110f6 | |||
| 64c3cb78cc | |||
| d1419bf1a5 | |||
| 0b9992ee30 |
@@ -4,7 +4,7 @@ title: 'bug: '
|
||||
labels:
|
||||
- bug
|
||||
assignees:
|
||||
- SoCuul
|
||||
- faroukbmiled
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -12,7 +12,7 @@ body:
|
||||
<br>
|
||||
|
||||
>[!TIP]
|
||||
> If you are looking for support with the tweak, make sure to visit the [SCInsta discussions page](https://github.com/SoCuul/SCInsta/discussions) to get help.
|
||||
> If you are looking for support with the tweak, make sure to visit the [RyukGram discussions page](https://github.com/faroukbmiled/RyukGram/discussions) to get help.
|
||||
- type: checkboxes
|
||||
id: before-start
|
||||
attributes:
|
||||
@@ -21,13 +21,13 @@ body:
|
||||
options:
|
||||
- label: >-
|
||||
I have read through the
|
||||
[FAQ](https://github.com/SoCuul/SCInsta/wiki/FAQ)
|
||||
[FAQ](https://github.com/faroukbmiled/RyukGram/wiki/FAQ)
|
||||
required: true
|
||||
- label: I have made sure this issue has not already been reported previously
|
||||
required: true
|
||||
- label: >-
|
||||
I have made sure this issue is present in the latest version of
|
||||
SCInsta
|
||||
RyukGram
|
||||
required: true
|
||||
- label: >-
|
||||
I am confident that this bug presents unintended behaviour within
|
||||
@@ -57,9 +57,9 @@ body:
|
||||
attributes:
|
||||
value: '---'
|
||||
- type: input
|
||||
id: info-scinsta-version
|
||||
id: info-ryukgram-version
|
||||
attributes:
|
||||
label: SCInsta Version
|
||||
label: RyukGram Version
|
||||
description: This can be found at the bottom of the tweak settings
|
||||
placeholder: e.g. v0.7.0
|
||||
validations:
|
||||
@@ -76,7 +76,7 @@ body:
|
||||
id: info-install-type
|
||||
attributes:
|
||||
label: Install Type
|
||||
description: The method used to use to install SCInsta
|
||||
description: The method used to use to install RyukGram
|
||||
options:
|
||||
- Sideloaded
|
||||
- TrollStore
|
||||
|
||||
@@ -4,7 +4,7 @@ title: 'feat: '
|
||||
labels:
|
||||
- enhancement
|
||||
assignees:
|
||||
- SoCuul
|
||||
- faroukbmiled
|
||||
body:
|
||||
- type: checkboxes
|
||||
id: before-start
|
||||
@@ -14,7 +14,7 @@ body:
|
||||
options:
|
||||
- label: >-
|
||||
I have read through the
|
||||
[FAQ](https://github.com/SoCuul/SCInsta/wiki/FAQ)
|
||||
[FAQ](https://github.com/faroukbmiled/RyukGram/wiki/FAQ)
|
||||
required: true
|
||||
- label: I have made sure this feature has not already been already suggested
|
||||
required: true
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: '💬 Browse Q&A'
|
||||
url: https://github.com/SoCuul/SCInsta/wiki/FAQ
|
||||
url: https://github.com/faroukbmiled/RyukGram/wiki/FAQ
|
||||
about: Find answers to the most commonly asked questions
|
||||
- name: '❓ Need Help?'
|
||||
url: https://github.com/SoCuul/SCInsta/discussions
|
||||
about: Visit the SCInsta discussions form to get support
|
||||
url: https://github.com/faroukbmiled/RyukGram/discussions
|
||||
about: Visit the RyukGram discussions form to get support
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: PR assignment
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
@@ -9,6 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: 'Auto-assign PR'
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
name: Build sideloaded IPA from Release Assets
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
decrypted_instagram_url:
|
||||
description: "Direct URL to the decrypted Instagram IPA"
|
||||
default: ""
|
||||
required: true
|
||||
type: string
|
||||
release_tag:
|
||||
description: "Release tag to pull assets from (leave empty for latest, e.g. v1.3.0)"
|
||||
default: ""
|
||||
required: false
|
||||
type: string
|
||||
upload_artifact:
|
||||
description: "Upload artifact"
|
||||
default: true
|
||||
required: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
UPSTREAM_REPO: faroukbmiled/RyukGram
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build RyukGram from Release
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Dependencies
|
||||
run: brew install dpkg
|
||||
|
||||
- name: Install IPA tooling (cyan + ipapatch)
|
||||
run: |
|
||||
pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
curl -fLo "$HOME/.local/bin/ipapatch" https://github.com/asdfzxcvbn/ipapatch/releases/download/v2.1.3/ipapatch.macos-arm64
|
||||
chmod +x "$HOME/.local/bin/ipapatch"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Resolve release tag
|
||||
id: tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
INPUT_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
TAG="$INPUT_TAG"
|
||||
else
|
||||
TAG="$(gh release view --repo "$UPSTREAM_REPO" --json tagName -q .tagName)"
|
||||
fi
|
||||
[ -n "$TAG" ] || { echo "::error::Could not resolve release tag."; exit 1; }
|
||||
VERSION="${TAG#v}"
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
echo "TAG=${TAG}" >> "$GITHUB_ENV"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "Using release tag: ${TAG} (version ${VERSION})"
|
||||
|
||||
- name: Download release assets (rootless deb + zxPluginsInject dylib)
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p packages release-assets
|
||||
gh release download "$TAG" \
|
||||
--repo "$UPSTREAM_REPO" \
|
||||
--dir release-assets \
|
||||
--pattern "RyukGram_*_rootless.deb" \
|
||||
--pattern "zxPluginsInject_v*.dylib"
|
||||
ls -la release-assets
|
||||
|
||||
DEB="$(ls -t release-assets/RyukGram_*_rootless.deb 2>/dev/null | head -n1 || true)"
|
||||
ZX_DYLIB="$(ls -t release-assets/zxPluginsInject_v*.dylib 2>/dev/null | head -n1 || true)"
|
||||
[ -n "$DEB" ] || { echo "::error::Rootless .deb not found in release $TAG."; exit 1; }
|
||||
[ -n "$ZX_DYLIB" ] || { echo "::error::zxPluginsInject dylib not found in release $TAG."; exit 1; }
|
||||
echo "DEB=${DEB}" >> "$GITHUB_ENV"
|
||||
echo "ZX_DYLIB=${ZX_DYLIB}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Extract dylib + bundle from rootless deb
|
||||
run: |
|
||||
set -euo pipefail
|
||||
STAGE="$(mktemp -d)"
|
||||
dpkg-deb -x "$DEB" "$STAGE"
|
||||
|
||||
DYLIB_SRC="$(find "$STAGE" -type f -name 'RyukGram.dylib' | head -1)"
|
||||
BUNDLE_SRC="$(find "$STAGE" -type d -name 'RyukGram.bundle' | head -1)"
|
||||
[ -n "$DYLIB_SRC" ] || { echo "::error::RyukGram.dylib not found in deb."; exit 1; }
|
||||
[ -n "$BUNDLE_SRC" ] || { echo "::error::RyukGram.bundle not found in deb."; exit 1; }
|
||||
|
||||
cp "$DYLIB_SRC" packages/RyukGram.dylib
|
||||
rm -rf packages/RyukGram.bundle
|
||||
cp -R "$BUNDLE_SRC" packages/RyukGram.bundle
|
||||
|
||||
# Match the @rpath LC that ipapatch writes into target binaries.
|
||||
cp "$ZX_DYLIB" packages/zxPluginsInject.dylib
|
||||
install_name_tool -id "@rpath/zxPluginsInject.dylib" packages/zxPluginsInject.dylib 2>/dev/null || true
|
||||
|
||||
rm -rf "$STAGE"
|
||||
ls -la packages
|
||||
ls -la packages/RyukGram.bundle | head -20
|
||||
|
||||
- name: Prepare Instagram IPA
|
||||
env:
|
||||
Instagram_URL: ${{ inputs.decrypted_instagram_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
wget "$Instagram_URL" --no-verbose -O packages/com.burbn.instagram.ipa
|
||||
ls -la packages
|
||||
|
||||
- name: Build sideloaded IPA (cyan + ipapatch with zxPluginsInject)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -f packages/RyukGram-sideloaded.ipa
|
||||
cyan \
|
||||
-i packages/com.burbn.instagram.ipa \
|
||||
-o packages/RyukGram-sideloaded.ipa \
|
||||
-f packages/RyukGram.dylib packages/RyukGram.bundle \
|
||||
-c 9 -m 15.0 -du
|
||||
|
||||
# Embed Safari "Open in Instagram" extension before ipapatch
|
||||
# re-signs, so instagram.com links open the app.
|
||||
APPEX_SRC="extensions/OpenInstagramSafariExtension.appex"
|
||||
if [ -d "$APPEX_SRC" ]; then
|
||||
echo "Embedding Safari extension"
|
||||
INJECT_TMP="$(mktemp -d)"
|
||||
unzip -q packages/RyukGram-sideloaded.ipa -d "$INJECT_TMP"
|
||||
APP_DIR="$(find "$INJECT_TMP/Payload" -maxdepth 1 -type d -name '*.app' | head -1)"
|
||||
if [ -n "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR/PlugIns"
|
||||
rm -rf "$APP_DIR/PlugIns/OpenInstagramSafariExtension.appex"
|
||||
cp -R "$APPEX_SRC" "$APP_DIR/PlugIns/"
|
||||
( cd "$INJECT_TMP" && zip -qr -9 ../repacked.ipa Payload )
|
||||
mv "$INJECT_TMP/../repacked.ipa" packages/RyukGram-sideloaded.ipa
|
||||
fi
|
||||
rm -rf "$INJECT_TMP"
|
||||
fi
|
||||
|
||||
echo "Running ipapatch (zxPluginsInject LC injection)"
|
||||
ipapatch \
|
||||
--input packages/RyukGram-sideloaded.ipa \
|
||||
--inplace --noconfirm \
|
||||
--dylib packages/zxPluginsInject.dylib
|
||||
|
||||
- name: Rename IPA
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mv packages/RyukGram-sideloaded.ipa "packages/RyukGram_sideloaded_v${VERSION}.ipa"
|
||||
ls -la packages
|
||||
|
||||
- name: Upload IPA artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_sideloaded_v${{ steps.tag.outputs.version }}
|
||||
path: packages/RyukGram_sideloaded_v*.ipa
|
||||
if-no-files-found: error
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
default: true
|
||||
required: false
|
||||
type: boolean
|
||||
build_tipa:
|
||||
description: "Build tipa"
|
||||
default: false
|
||||
required: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -106,12 +111,18 @@ jobs:
|
||||
IPA=$(ls -t *.ipa | grep -iv instagram | head -n1)
|
||||
[ -n "$IPA" ] && mv "$IPA" "RyukGram_sideloaded_v${VERSION}.ipa"
|
||||
|
||||
- name: Duplicate as .tipa
|
||||
if: ${{ inputs.build_tipa }}
|
||||
run: |
|
||||
cd main/packages
|
||||
cp "RyukGram_sideloaded_v${VERSION}.ipa" "RyukGram_trollstore_v${VERSION}.tipa"
|
||||
|
||||
- name: Pass package name to upload action
|
||||
id: package_name
|
||||
run: |
|
||||
echo "package=$(ls -t main/packages/RyukGram_sideloaded_v*.ipa | head -n1 | xargs basename)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload Artifact
|
||||
- name: Upload IPA Artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -119,11 +130,21 @@ jobs:
|
||||
path: ${{ github.workspace }}/main/packages/${{ steps.package_name.outputs.package }}
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload TIPA Artifact
|
||||
if: ${{ inputs.upload_artifact && inputs.build_tipa }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_trollstore_v${{ steps.version.outputs.version }}
|
||||
path: ${{ github.workspace }}/main/packages/RyukGram_trollstore_v*.tipa
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2.0.6
|
||||
with:
|
||||
name: RyukGram_sideloaded_v${{ steps.version.outputs.version }}
|
||||
files: ${{ github.workspace }}/main/packages/RyukGram_sideloaded_v*.ipa
|
||||
files: |
|
||||
${{ github.workspace }}/main/packages/RyukGram_sideloaded_v*.ipa
|
||||
${{ github.workspace }}/main/packages/RyukGram_trollstore_v*.tipa
|
||||
draft: true
|
||||
|
||||
- name: Output Release URL
|
||||
|
||||
@@ -13,7 +13,7 @@ on:
|
||||
delete_workflow_pattern:
|
||||
description: 'Name or filename of the workflow (if not set, all workflows are targeted)'
|
||||
required: false
|
||||
default: 'Build and Package SCInsta'
|
||||
default: 'Build and Package RyukGram'
|
||||
delete_workflow_by_state_pattern:
|
||||
description: 'Filter workflows by state: active, deleted, disabled_fork, disabled_inactivity, disabled_manually'
|
||||
required: true
|
||||
|
||||
+7
-2
@@ -39,8 +39,7 @@ upstream-scinsta
|
||||
*.ipa
|
||||
*.dylib
|
||||
deploy.sh
|
||||
PENDING_CHANGES.md
|
||||
PENDING_CHANGES.md.bk
|
||||
PENDING_CHANGES.*
|
||||
wrapper/
|
||||
scripts/*.py
|
||||
scripts/__pycache__/
|
||||
@@ -50,3 +49,9 @@ modules/ffmpegkit/
|
||||
|
||||
# External reference tweaks
|
||||
exp_flags/
|
||||
|
||||
# Source packaging
|
||||
zip-src.sh
|
||||
RyukGram-src-*.zip
|
||||
*.zip
|
||||
*_diff.txt
|
||||
|
||||
Vendored
+1
-1
@@ -22,7 +22,7 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Build SCInsta and deploy with IPA",
|
||||
"label": "Build RyukGram and deploy with IPA",
|
||||
"type": "shell",
|
||||
"command": "./build-dev.sh true",
|
||||
"group": {
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
# RyukGram
|
||||
A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com/SoCuul/SCInsta) with additional features and fixes.\
|
||||
`Version v1.2.0` | `Tested on Instagram 425.0.0`
|
||||
`Version v1.3.0` | `Tested on Instagram 430.0.0`
|
||||
|
||||
<a href="https://buymeacoffee.com/axryuk" target="_blank" rel="noopener noreferrer"><img src="https://cdn.buymeacoffee.com/buttons/v2/default-violet.png" alt="Buy me a coffee" height="50"></a>
|
||||
|
||||
---
|
||||
|
||||
> [!WARNING]
|
||||
> **Source pushes to this repository are paused for now, until further notice** — partly because a bit of the code has been finding its way into other paid projects, with no credit.
|
||||
> Releases and localization are still kept up to date here — new builds land on the [Releases](https://github.com/faroukbmiled/RyukGram/releases/latest) page, and translation pull requests are still welcome.
|
||||
|
||||
---
|
||||
|
||||
@@ -22,19 +30,24 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Hide ads
|
||||
- Hide Meta AI
|
||||
- Hide metrics (likes, comments, shares counts)
|
||||
- Hide the TestFlight beta update popup
|
||||
- Disable app haptics
|
||||
- Copy description
|
||||
- Copy comment text from long-press menu **\***
|
||||
- Download GIF comments **\***
|
||||
- Download / copy / expand GIF and image comments **\***
|
||||
- Custom GIF in comments — long-press the GIF button to paste any Giphy link **\***
|
||||
- Download audio from the reels audio page **\***
|
||||
- Profile copy button **\***
|
||||
- Replace domain in shared links — rewrite copied/shared links for embeds in Discord, Telegram, etc. with preset or custom domains **\***
|
||||
- Strip tracking params from shared links (igsh, utm) **\***
|
||||
- Replace domain in shared links for embeds (Discord, Telegram, etc.) **\***
|
||||
- Strip tracking params from shared links **\***
|
||||
- Open links in external browser **\***
|
||||
- Strip tracking from browser links **\***
|
||||
- Do not save recent searches
|
||||
- Open link from clipboard — long-press the search tab **\***
|
||||
- Use detailed (native) color picker
|
||||
- Enable liquid glass buttons
|
||||
- Enable liquid glass surfaces — floating tab bar, dynamic sizing, and other UI elements **\***
|
||||
- Enable liquid glass surfaces **\***
|
||||
- Liquid glass tab bar — Fixed (never shrink) / Hide on scroll
|
||||
- Enable teen app icons
|
||||
- IG Notes:
|
||||
- Hide notes tray
|
||||
@@ -46,106 +59,169 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- No suggested chats
|
||||
- Hide trending searches
|
||||
- Hide explore posts grid
|
||||
- Skip sensitive content covers **\***
|
||||
- Live
|
||||
- Anonymous live viewing **\***
|
||||
- Toggle live comments **\***
|
||||
- Privacy
|
||||
- Hide RyukGram UI on screenshots, screen recordings, and mirroring **\***
|
||||
|
||||
### Feed
|
||||
- Hide stories tray
|
||||
- Hide suggested stories — removes suggested accounts from the stories tray **\***
|
||||
- View profile picture from story tray long-press menu (HD via API) **\***
|
||||
- Hide suggested stories **\***
|
||||
- View profile picture from story tray long-press menu **\***
|
||||
- Hide entire feed
|
||||
- No suggested posts
|
||||
- No suggested for you (accounts)
|
||||
- No suggested reels
|
||||
- No suggested threads posts
|
||||
- Disable video autoplay
|
||||
- Media zoom — long press on media to expand in full-screen viewer **\***
|
||||
- Custom date format (moved to General > Date format, now supports feed, notes/comments/stories, and DMs) **\***
|
||||
- Media zoom — long press media to expand in full-screen viewer **\***
|
||||
- Start media muted — expanded videos open with sound off **\***
|
||||
- Custom date format — feed, notes/comments/stories, and DMs; optional relative threshold, compact "1h" style, and a Combine with date picker (`Jan 5, 2026 (2h)` or `2h – Jan 5, 2026`) **\***
|
||||
- Disable background refresh, home button refresh, and home button scroll **\***
|
||||
- Disable reels tab button refresh **\***
|
||||
- Hide repost button in feed **\***
|
||||
|
||||
### Reels
|
||||
- Modify tap controls
|
||||
- Auto-scroll reels mode **\***
|
||||
- Always show progress scrubber
|
||||
- Disable auto-unmuting reels (properly blocks mute switch, volume buttons, and announcer broadcasts) **\***
|
||||
- Disable auto-unmuting reels **\***
|
||||
- Confirm reel refresh
|
||||
- Unlock password-locked reels **\***
|
||||
- Hide reels header
|
||||
- Hide repost button in reels **\***
|
||||
- Hide friends avatars on the reels Friends tab **\***
|
||||
- Hide social context overlay on reels (reposted/commented bubbles) **\***
|
||||
- Hide "Made with Edits" / "Open in Edits" promo pills on reels **\***
|
||||
- Hide reels blend button
|
||||
- Swipe a reel left to open the author's profile
|
||||
- Disable scrolling reels
|
||||
- Prevent doom scrolling (limit maximum viewable reels)
|
||||
- Enhanced Pause/Play mode (when Pause/Play tap control is set): **\***
|
||||
- Mute toggle auto-hidden, only play/pause icon visible
|
||||
- Mute toggle auto-hidden
|
||||
- Audio forced on in reels tab
|
||||
- Play indicator properly hidden when video plays (fixes IG bug after hold/zoom)
|
||||
- Play indicator hidden during playback
|
||||
- Playback toggle synced with overlay during hold/zoom
|
||||
- Works across IG A/B test variants
|
||||
- Optional tap-to-mute on photo reels
|
||||
|
||||
### Action buttons **\***
|
||||
- Context-aware action menu on feed, reels, and stories (expand, repost, download, copy caption, etc.) **\***
|
||||
- Context-aware action menu on feed, reels, and stories **\***
|
||||
- Configurable default tap action per context **\***
|
||||
- Global icon picker — change the icon used across feed, stories, reels and DMs **\***
|
||||
- Optional date header at the top of the action menu **\***
|
||||
- Carousel and multi-story reel support with bulk download **\***
|
||||
- Repost via IG's native creation flow **\***
|
||||
- Full-screen media viewer with zoom and swipe **\***
|
||||
- Story playback pauses when menus are open **\***
|
||||
|
||||
### Profile **\***
|
||||
- Zoom profile photo — long press to view full-screen with user info **\***
|
||||
- Zoom profile photo — long press to view full-screen **\***
|
||||
- Save profile picture
|
||||
- View highlight cover from profile long-press menu **\***
|
||||
- Profile copy button **\***
|
||||
- Follow indicator — shows whether the user follows you **\***
|
||||
- Copy note on long press — long-press the note bubble to copy text **\***
|
||||
- Follow indicator — shows whether the user follows you (off / on / colored) **\***
|
||||
- Copy note on long press **\***
|
||||
- Fake profile stats — verified badge and follower/following/post counts **\***
|
||||
- Show full follower / post count on profile headers **\***
|
||||
- Profile card details — view count, like count and upload date overlay on each post / reel card, with optional short-number format and on-demand fetch for missing counts **\***
|
||||
- Filter & sort follower/following lists — reorder by mutuals, who follows you, verified or A–Z, filter to just those, plus load-more and jump to top/bottom **\***
|
||||
|
||||
### Profile Analyzer (beta) **\***
|
||||
- Follower and following scans with progress and cancel **\***
|
||||
- Mutuals and non-followbacks lists **\***
|
||||
- New and lost followers/following trackers across scans **\***
|
||||
- Profile change history — username, name, bio, pfp **\***
|
||||
- Searchable lists with inline and batch follow/unfollow **\***
|
||||
- Visited profiles tracker — log every profile you open with date / verified / private filters **\***
|
||||
- Pull-to-refresh on the visited list re-syncs identity and pictures from IG **\***
|
||||
|
||||
### Saving
|
||||
- Enhanced HD downloads — up to 1080p via DASH + FFmpegKit **\***
|
||||
- Enhanced HD downloads up to 1080p **\***
|
||||
- Quality picker with preview playback **\***
|
||||
- Audio-only and raw photo download options **\***
|
||||
- Fallback to 720p without FFmpegKit **\***
|
||||
- Download pill with frosted glass, progress bar, bulk counter, success/error states **\***
|
||||
- Save to RyukGram album — routes downloads into a dedicated album in Photos **\***
|
||||
- Download confirmation — optional dialog before downloading **\***
|
||||
- Legacy long-press gesture — deprecated, off by default. Finger count + hold time customizable **\***
|
||||
- Live progress through both download and encode **\***
|
||||
- Download manager — parallel downloads with a configurable limit, one combined progress pill, and a live queue with cancel / retry / redownload, long-press share / save, and bulk select; opens from the pill, a home shortcut, or settings **\***
|
||||
- Auto-retry dropped downloads — parks offline downloads and resumes them when the connection returns **\***
|
||||
- Save to RyukGram album — every download + share-sheet "Save to Photos" routes into a dedicated album with a customizable name **\***
|
||||
- Enhanced media resolution — IG's CDN ships higher-quality images **\***
|
||||
- Advanced encoding panel — codec (HW / libx264), preset, tune, H.264 profile + level, CRF / bitrate, pixel format, max resolution, frame rate, audio codec / bitrate / channels / sample rate, faststart, strip metadata **\***
|
||||
- Download confirmation dialog **\***
|
||||
- Output filenames formatted as `@username_context_timestamp` across every save surface (feed / reels / DMs / notes / comments / disappearing media) **\***
|
||||
- Legacy long-press gesture (deprecated, customizable finger count + hold time) **\***
|
||||
|
||||
### Gallery **\***
|
||||
- On-device gallery — every download can mirror into an in-app library **\***
|
||||
- Stores images, videos, audio (m4a/aac/mp3/ogg/opus/...) and animated GIFs **\***
|
||||
- Filter chips by type, source (feed / reels / stories / DMs / profile / notes / comments) and uploader; favorites; folders **\***
|
||||
- Group by user — sections or virtual folders alongside your real folders **\***
|
||||
- Folder cells show a thumbnail collage + item count and last-activity date **\***
|
||||
- In-app preview carousel — image / video / audio scrubber / GIF playback **\***
|
||||
- Audio + GIF picker — DM Upload Audio and (in future surfaces) Comment GIF can pull straight from the gallery **\***
|
||||
- "Download to Gallery" submenu on every download surface when the gallery is enabled, with Photos / Gallery / Share options for audio **\***
|
||||
|
||||
### Stories and messages
|
||||
- Keep deleted messages (preserves unsent messages with visual indicator and notification pill) **\***
|
||||
- Keep deleted messages **\***
|
||||
- Deleted messages log — dedicated UI for every unsent message type, per-sender groups, search, filter, bulk Save / Share / Delete, edit history per message **\***
|
||||
- Hide trailing action buttons on preserved messages
|
||||
- Warn before clearing on refresh — optional confirmation when pulling to refresh the DMs tab if preserved messages would be cleared **\***
|
||||
- Warn before pull-to-refresh clears preserved messages **\***
|
||||
- Manually mark messages as seen (button or toggle mode) **\***
|
||||
- Long-press the seen button for quick actions **\***
|
||||
- Auto mark seen on send (marks messages as read when you send any message) **\***
|
||||
- Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\***
|
||||
- Auto mark seen on send **\***
|
||||
- Auto mark seen on typing **\***
|
||||
- Mark seen on story like **\***
|
||||
- Mark seen on story reply — also covers text replies and emoji reactions **\***
|
||||
- Advance to next story when marking as seen — tapping the eye button auto-skips to the next story **\***
|
||||
- Advance on story like — liking a story auto-skips to the next one **\***
|
||||
- Advance on story reply — sending a reply or emoji reaction auto-skips to the next story **\***
|
||||
- Per-chat read-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Long-press any DM chat to add/remove. Settings page with search, sort, multi-select, and per-entry keep-deleted override **\***
|
||||
- Send audio as file — send audio files as voice messages from the DM plus menu **\***
|
||||
- Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\***
|
||||
- Mark seen on story reply **\***
|
||||
- Advance to next story when marking as seen **\***
|
||||
- Advance on story like **\***
|
||||
- Advance on story reply **\***
|
||||
- Per-chat read-receipt exclusion list with Block all / Block selected mode **\***
|
||||
- Send audio as file — DM plus menu, or long-press the camera button while replying **\***
|
||||
- Download voice messages **\***
|
||||
- Disable typing status
|
||||
- Disable disappearing messages mode — blocks the swipe-to-enable gesture in DMs **\***
|
||||
- Hide voice/video call buttons — independent toggles for each, remaining nav items reflow dynamically **\***
|
||||
- Unlimited replay of direct stories (toggle in eye button menu) **\***
|
||||
- Full last active date — show full date instead of relative time **\***
|
||||
- Send files in DMs (experimental) — send select file types via the plus menu **\***
|
||||
- Notes actions — copy text, download GIF/audio from notes long-press menu **\***
|
||||
- Disable vanish mode swipe **\***
|
||||
- Hide voice/video call buttons (independent toggles) **\***
|
||||
- Hide send to group chat in the share sheet **\***
|
||||
- Bypass DM character limit **\***
|
||||
- Pin recipients on long-press in the share sheet — pinned chats render at the top **\***
|
||||
- Custom chat backgrounds — per-chat images injected into IG's native theme picker, library upload with built-in cropper, per-image opacity / blur / dim, optional global default, per-account list of chats with backgrounds **\***
|
||||
- Unlimited replay of direct stories **\***
|
||||
- Full last active date **\***
|
||||
- Send files in DMs (experimental) **\***
|
||||
- Hold the DM tab button to open the on-device gallery **\***
|
||||
- Notes actions — copy text, download GIF / audio (Photos or Gallery) **\***
|
||||
- Copy note text on long press **\***
|
||||
- Disable view-once limitations
|
||||
- Disable screenshot detection
|
||||
- Disable story seen receipt (blocks network upload, toggleable at runtime without restart) **\***
|
||||
- Keep stories visually unseen — keeps the colorful ring in the tray after viewing **\***
|
||||
- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\***
|
||||
- Disable story seen receipt **\***
|
||||
- Keep stories visually seen locally **\***
|
||||
- Manual mark story as seen (button or toggle mode) **\***
|
||||
- Long-press the story seen button for quick actions **\***
|
||||
- Per-user story seen-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Manage via 3-dot menu, eye button long-press, or settings list **\***
|
||||
- Story audio mute/unmute toggle — button on overlay and in action menu to toggle audio **\***
|
||||
- View story mentions — bottom sheet with profile pic, follow/unfollow, tap-to-open profile **\***
|
||||
- Stop story auto-advance — stories won't auto-skip when the timer ends **\***
|
||||
- Download disappearing DM media (photos + videos) — expand, share, or save from action menu **\***
|
||||
- Mark disappearing messages as viewed button **\***
|
||||
- Upload audio as voice message — send audio files, extract audio from videos, with built-in trim editor **\***
|
||||
- Per-user story seen-receipt exclusion list with Block all / Block selected mode **\***
|
||||
- Story audio mute/unmute toggle **\***
|
||||
- View story mentions, with optional quick-access overlay button and count badge **\***
|
||||
- Hide stories midcards (Trending / Music) **\***
|
||||
- Stop story auto-advance **\***
|
||||
- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting **\***
|
||||
- Force legacy Quiz and Reveal stickers back into the story composer tray **\***
|
||||
- Bypass Reveal sticker — view stories blurred behind a Reveal sticker without DMing the author **\***
|
||||
- Allow video in photo sticker — story photo sticker picker accepts videos too **\***
|
||||
- Custom solid or gradient color for music and lyric stickers **\***
|
||||
- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle **\***
|
||||
- Download disappearing DM media **\***
|
||||
- Upload audio as voice message with built-in trim editor **\***
|
||||
- Disable instants creation
|
||||
- Send Instants from your photo album — gallery button on the Instants camera with a built-in square cropper, posts through IG's native capture flow; pick from the in-app gallery or Photos library when the gallery is enabled
|
||||
- Allow screenshots on Instants — bypasses the screenshot/screen-record block, scoped to the Instants viewer only
|
||||
- Instants action button — Expand / Save (Photos / Gallery) / Share / Save all, fully configurable through the standard action-menu config
|
||||
- Confirm Instants emoji reaction — optional confirmation before a quick-reaction sends
|
||||
- Confirm Instants capture + Confirm switching Instant
|
||||
- Save to Gallery from the expanded media viewer — share button surfaces a Save / Share menu when the gallery is enabled, with username / source attribution carried through
|
||||
|
||||
### Navigation
|
||||
### Interface **\***
|
||||
- Tab bar shortcuts — Home shortcut button + Action button icon picker, grouped at the top of the page
|
||||
- Notifications — universal in-app pill (Minimal / Colorful / Glow / Island), per-action routing (custom pill / IG-native / off), top or bottom position, master kill switch, swipe-to-dismiss, multi-pill stacking
|
||||
- Modify tab bar icon order
|
||||
- Modify swiping between tabs
|
||||
- Hiding tabs
|
||||
@@ -154,70 +230,106 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Hide reels tab
|
||||
- Hide create tab
|
||||
- Hide messages tab
|
||||
- Messages-only mode — keep DM inbox + profile, hide everything else, force launch into inbox **\***
|
||||
- Launch tab — pick which tab the app opens to (ignored in Messages-only mode) **\***
|
||||
- Messages-only mode — inbox + profile only, launch straight into inbox **\***
|
||||
- Hide tab bar sub-toggle — floating settings gear replaces it **\***
|
||||
- Launch tab — pick which tab the app opens to **\***
|
||||
- Home shortcut button — extra button on the home top bar with a configurable multi-action menu (Gallery / Settings / Security & Privacy / Hidden chats / Locked chats / Profile Analyzer / Deleted messages / Fake location / Clear cache / Changelog) **\***
|
||||
- Experimental flags
|
||||
|
||||
### Confirm actions
|
||||
- Confirm like: Posts/Stories
|
||||
- Confirm story emoji reaction **\***
|
||||
- Confirm note like + emoji reaction **\***
|
||||
- Confirm like: Reels
|
||||
- Confirm follow
|
||||
- Confirm unfollow **\***
|
||||
- Confirm repost
|
||||
- Confirm call
|
||||
- Confirm voice call **\***
|
||||
- Confirm video call **\***
|
||||
- Confirm voice messages
|
||||
- Confirm follow requests
|
||||
- Confirm shh mode (disappearing messages)
|
||||
- Confirm vanish mode
|
||||
- Confirm posting comment
|
||||
- Confirm send to group chat **\***
|
||||
- Confirm changing direct message theme
|
||||
- Confirm sticker interaction
|
||||
- Confirm sticker interaction (stories / highlights, per-surface: disabled / all / reaction stickers only) **\***
|
||||
|
||||
### Fake location **\***
|
||||
- Overrides CoreLocation app-wide so any IG feature reading a coord (Friends Map, posts, etc.) gets your chosen location
|
||||
- Override location app-wide for any IG feature reading coordinates
|
||||
- MapKit picker with search + reverse-geocoded names
|
||||
- Saved presets — tap to apply
|
||||
- Quick toggle button injected into the Friends Map: enable/disable, swap presets, change location, open settings
|
||||
- Saved presets
|
||||
- Quick toggle button on the Friends Map
|
||||
|
||||
### Theme **\***
|
||||
- Theme picker — Off / Light / Dark / OLED, with optional Force theme to override the iOS appearance
|
||||
- OLED chat theme — pure black DM thread and incoming bubbles
|
||||
- Keyboard theme — dark or OLED, sticks through search keyboard activations
|
||||
- Apply & restart, plus Reset theme to revert every theme option
|
||||
|
||||
### Security & Privacy **\***
|
||||
- Passcode + biometric lock — gates tweak settings, gallery, deleted-messages log, Profile Analyzer, DM inbox, individual chats, and Instagram itself
|
||||
- Per-target idle timeout, re-lock on background, lock every time, independent session
|
||||
- Hidden chats — long-press a DM to hide it; managed under S&P
|
||||
- Per-account lists (excluded chats, hidden chats, locked chats, share-sheet pins) — switching IG accounts shows that account's own data
|
||||
- App-switcher snapshot shroud — covers IG content when a locked surface is visible
|
||||
- Hide message preview for locked chats in the inbox
|
||||
|
||||
### Tweak settings **\***
|
||||
- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location
|
||||
- Pause playback when opening settings (toggleable) **\***
|
||||
- Search bar with breadcrumbs across nested pages
|
||||
- What's new indicator — a dot marks newly added settings and clears once viewed **\***
|
||||
- Pause playback when opening settings **\***
|
||||
- Quick-access via long-press on feed tab **\***
|
||||
- Native Instagram icons throughout settings and in-app menus **\***
|
||||
|
||||
### Advanced experimental features **\***
|
||||
- Toggle hidden Instagram experiments: QuickSnap (Instants), Direct Notes reply types, Friend Map, Homecoming, Prism
|
||||
- Batched changes with an Apply & restart button
|
||||
- Auto-reset after 3 consecutive launch crashes
|
||||
|
||||
### Backup & Restore **\***
|
||||
- Export RyukGram settings as a JSON file
|
||||
- Import settings from a JSON file
|
||||
- Searchable, collapsible, editable preview before saving or applying
|
||||
- Export RyukGram settings as JSON
|
||||
- Import settings from JSON
|
||||
- Preview before saving or applying
|
||||
|
||||
### Localization **\***
|
||||
- Multi-language UI — every user-facing string in RyukGram flows through a central translation layer **\***
|
||||
- Built-in language picker — globe icon in the top-right of Settings; pick System default or any shipped language **\***
|
||||
- Falls back to English when a translation is missing, so nothing ever breaks **\***
|
||||
- Currently shipping: **English only** — other languages land as translators submit them (see below).
|
||||
- Multi-language UI with fallback to English **\***
|
||||
- Built-in language picker in Settings **\***
|
||||
- Currently shipping: **English**, **Spanish**, **Russian**, **Korean**, **Arabic**, **Chinese (Traditional)**, **Chinese (Simplified)**, **Portuguese (Brazil)**, **Turkish**
|
||||
|
||||
### Optimization
|
||||
- Automatically clears unneeded cache folders, reducing the size of your Instagram installation
|
||||
- Clear Instagram cache on demand with optional auto-clear interval, with a toggle to preserve DMs, drafts, and Notes **\***
|
||||
|
||||
# Translating RyukGram
|
||||
Want to see RyukGram in your language? Open a PR — it takes about 30 minutes of copy-paste.
|
||||
Want to see RyukGram in your language? Two ways:
|
||||
|
||||
1. Copy `src/Localization/Resources/en.lproj/Localizable.strings` into a new folder named after the language code, e.g. `ar.lproj` (Arabic), `es.lproj` (Spanish), `fr.lproj` (French), `pt.lproj` (Portuguese), `de.lproj` (German), `tr.lproj` (Turkish)…
|
||||
2. Translate the **right-hand side** of every `"key" = "value";` line. Never touch the left-hand side — it's the lookup key and must match English.
|
||||
3. Keep format specifiers (`%@`, `%lu`, `%d`, `%1$@`…) exactly as they appear, in the same order. Use positional specifiers (`%1$@ %2$lu`) if your language needs different word order.
|
||||
4. Keep the same section banners and structure — it makes the diff easy to review.
|
||||
5. Open a pull request at <https://github.com/faroukbmiled/RyukGram/pulls>. Title it e.g. `l10n: Add Arabic translation`.
|
||||
### Option A: In-app (fastest)
|
||||
1. Open **Settings → Debug → Localization → Export strings** — pick a language (English for a fresh start, or an existing one to fix) and share its `.strings` file to yourself.
|
||||
2. Translate the **right-hand side** of every `"key" = "value";` line. Never touch the left-hand side.
|
||||
3. Go to **Debug → Localization → Update → + Add new language** — enter your language code (e.g. `fr`), pick the translated file, restart.
|
||||
4. Your language now appears in the globe menu. Test it, tweak it, re-import as needed.
|
||||
5. When ready, open a pull request with the file at `src/Localization/Resources/<code>.lproj/Localizable.strings`.
|
||||
|
||||
Partial translations are welcome — untranslated keys automatically fall back to English at runtime. Ship what you've got, iterate from there.
|
||||
### Option B: PR directly
|
||||
1. Copy `src/Localization/Resources/en.lproj/Localizable.strings` into a new folder: `<code>.lproj/Localizable.strings`
|
||||
2. Translate the right-hand side of every line.
|
||||
3. Keep format specifiers (`%@`, `%lu`, `%d`, `%1$@`…) exactly as-is. Use positional specifiers if your language needs different word order.
|
||||
4. Keep section banners and structure — makes the diff easy to review.
|
||||
5. Open a PR at <https://github.com/faroukbmiled/RyukGram/pulls>. Title it e.g. `l10n: Add French translation`.
|
||||
|
||||
If you find a string in the app that still renders in English on a translated build, open an issue with a screenshot and we'll add the key.
|
||||
Partial translations are welcome — untranslated keys fall back to English at runtime.
|
||||
|
||||
If you find a string that still renders in English on a translated build, open an issue with a screenshot.
|
||||
|
||||
## Known Issues
|
||||
- Preserved unsent messages cannot be removed using "Delete for you". Pull to refresh in the DMs tab clears all preserved messages (with optional confirmation if "Warn before clearing on refresh" is enabled).
|
||||
- Preserved unsent messages cannot be removed using "Delete for you". Pull to refresh in the DMs tab clears the active account's preserved messages (with optional confirmation if "Warn before clearing on refresh" is enabled).
|
||||
- "Delete for you" detection uses a ~2 second window after the local action. If a real other-party unsend happens to land in the same window, it may not be preserved. Rare in practice and limited to that specific overlap.
|
||||
- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures — IG draws that backdrop, not the tweak, so it's outside our redaction.
|
||||
|
||||
# Opening Tweak Settings
|
||||
|
||||
| | |
|
||||
|:-------------------------------------------:|:-------------------------------------------:|
|
||||
| <img src="https://i.imgur.com/uPMcugZ.png"> | <img src="https://i.imgur.com/ctIiL7i.png"> |
|
||||
| <img src="https://i.imgur.com/uPMcugZ.png"> | <img src="https://i.imgur.com/RUlsg4k.jpeg"> |
|
||||
|
||||
# Building from source
|
||||
### Prerequisites
|
||||
@@ -239,13 +351,30 @@ If you find a string in the app that still renders in English on a translated bu
|
||||
### Run build script
|
||||
```sh
|
||||
$ chmod +x build.sh
|
||||
$ ./build.sh <sideload/rootless/rootful>
|
||||
$ ./build.sh <sideload/sidestore/rootless/rootful>
|
||||
```
|
||||
|
||||
# Credits
|
||||
- [SCInsta](https://github.com/SoCuul/SCInsta) by [@SoCuul](https://github.com/SoCuul) — original tweak this fork is based on
|
||||
- [@BandarHL](https://github.com/BandarHL) — creator of the original BHInstagram project
|
||||
- [@faroukbmiled](https://github.com/faroukbmiled) — RyukGram modifications and additional features
|
||||
- [Instaoled](https://t.me/ciesIPAs) by @VAXMG — OLED theme inspiration
|
||||
- [@euoradan](https://t.me/euoradan) (Radan) — experimental Instagram feature flag research
|
||||
- [@Mikasa-san](https://github.com/Mikasa-san) — code contributions
|
||||
- [@n3d1117](https://github.com/n3d1117) (Edoardo) — Following feed mode (ported from [InstaSane](https://github.com/n3d1117/InstaSane))
|
||||
- [@erupts0](https://github.com/erupts0) (John) — testing and feature suggestions
|
||||
- [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension) — base for the bundled Safari extension
|
||||
- [@asdfzxcvbn](https://github.com/asdfzxcvbn) — [ipapatch](https://github.com/asdfzxcvbn/ipapatch) and [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject)
|
||||
- Furamako — Spanish translation
|
||||
- [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) — Korean translation
|
||||
- [ZomkaDEV](https://github.com/ZomkaDEV) — Russian translation
|
||||
- [@bruuhim](https://github.com/bruuhim) — Arabic translation
|
||||
- [@jaydenjcpy](https://github.com/jaydenjcpy) — Chinese (Traditional and Simplified) translation
|
||||
- Bruno ([@brunorainha](https://github.com/brunorainha)) — Portuguese (Brazil) translation
|
||||
- [@yesnt10](https://github.com/yesnt10) — Turkish translation
|
||||
|
||||
# Support
|
||||
|
||||
RyukGram is free and open source. If you'd like to support development:
|
||||
|
||||
- [☕ Donate to Ryuk (RyukGram)](https://buymeacoffee.com/axryuk)
|
||||
- [☕ Donate to SoCuul (original SCInsta)](https://ko-fi.com/SoCuul) — RyukGram wouldn't exist without SoCuul's original SCInsta, so showing them some love is always welcome
|
||||
|
||||
+1
-1
@@ -22,7 +22,7 @@ else
|
||||
make DEV=1
|
||||
|
||||
# Change framework locations to @rpath
|
||||
install_name_tool -change "/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate" "@rpath/CydiaSubstrate.framework/CydiaSubstrate" ".theos/obj/debug/SCInsta.dylib" 2>/dev/null || true
|
||||
install_name_tool -change "/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate" "@rpath/CydiaSubstrate.framework/CydiaSubstrate" ".theos/obj/debug/RyukGram.dylib" 2>/dev/null || true
|
||||
|
||||
_scinsta_devquick_after
|
||||
fi
|
||||
@@ -28,6 +28,18 @@ copy_localization_into_bundle() {
|
||||
done
|
||||
}
|
||||
|
||||
# Copy generic static assets (PNGs, etc.) into a RyukGram.bundle. Used for
|
||||
# bundled images the tweak loads via SCILocalizationBundle().
|
||||
# Arg 1: destination bundle directory (created if missing).
|
||||
copy_bundle_assets() {
|
||||
local DEST="$1"
|
||||
local SRC="src/BundleAssets"
|
||||
[ -d "$SRC" ] || return 0
|
||||
mkdir -p "$DEST"
|
||||
find "$SRC" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.pdf' \) \
|
||||
-exec cp {} "$DEST/" \;
|
||||
}
|
||||
|
||||
# Collect all FFmpegKit frameworks for injection
|
||||
ffmpegkit_frameworks() {
|
||||
local fws=""
|
||||
@@ -57,7 +69,7 @@ inject_bundle_into_deb() {
|
||||
|
||||
local BUNDLE_DIR="$TMPDIR/${PREFIX}Library/Application Support/RyukGram.bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
( cd .. && copy_localization_into_bundle "$BUNDLE_DIR" )
|
||||
( cd .. && copy_localization_into_bundle "$BUNDLE_DIR" && copy_bundle_assets "$BUNDLE_DIR" )
|
||||
|
||||
if [ -d "../modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
for fw in ../modules/ffmpegkit/*.framework; do
|
||||
@@ -89,6 +101,47 @@ inject_bundle_into_deb() {
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
|
||||
# Build zxPluginsInject.dylib -> packages/zxPluginsInject.dylib
|
||||
build_zxpi_dylib() {
|
||||
local MOD_DIR="modules/zxPluginsInject"
|
||||
local DYLIB_OUT="$MOD_DIR/.theos/obj/zxPluginsInject.dylib"
|
||||
|
||||
if [ -z "${THEOS:-}" ]; then
|
||||
if [ -d "$HOME/theos" ]; then
|
||||
export THEOS="$HOME/theos"
|
||||
else
|
||||
echo -e '\033[1m\033[0;31mTHEOS not set and ~/theos not found\033[0m' >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
( cd "$MOD_DIR" && make FINALPACKAGE=1 >/dev/null )
|
||||
|
||||
[ -f "$DYLIB_OUT" ] || {
|
||||
echo -e '\033[1m\033[0;31mzxPluginsInject.dylib build failed\033[0m' >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
mkdir -p packages
|
||||
cp "$DYLIB_OUT" packages/zxPluginsInject.dylib
|
||||
# Match the @rpath LC that ipapatch writes into target binaries.
|
||||
install_name_tool -id "@rpath/zxPluginsInject.dylib" \
|
||||
packages/zxPluginsInject.dylib 2>/dev/null || true
|
||||
}
|
||||
|
||||
# LC-inject zxPluginsInject.dylib into main exec + every .appex in the IPA.
|
||||
# Arg 1: path to the IPA
|
||||
run_ipapatch() {
|
||||
local IPA="$1"
|
||||
if ! command -v ipapatch &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m'
|
||||
echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest'
|
||||
exit 1
|
||||
fi
|
||||
echo -e '\033[1m\033[32mRunning ipapatch (zxPluginsInject LC injection)\033[0m'
|
||||
ipapatch --input "$IPA" --inplace --noconfirm --dylib packages/zxPluginsInject.dylib
|
||||
}
|
||||
|
||||
# Build just the dylib (for Feather/manual injection)
|
||||
if [ "$1" == "dylib" ];
|
||||
then
|
||||
@@ -108,6 +161,7 @@ then
|
||||
|
||||
# Ship localization bundle next to the dylib so Feather/manual installs work.
|
||||
copy_localization_into_bundle "packages/RyukGram.bundle"
|
||||
copy_bundle_assets "packages/RyukGram.bundle"
|
||||
|
||||
echo -e "\033[1m\033[32mDone!\033[0m\n\nDylib at: $(pwd)/packages/RyukGram.dylib\nBundle at: $(pwd)/packages/RyukGram.bundle"
|
||||
|
||||
@@ -197,9 +251,6 @@ then
|
||||
if ! command -v ipapatch &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m'
|
||||
echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest'
|
||||
echo
|
||||
echo -e '\033[0;33mUse ./build.sh sideload --buildonly to just compile without creating the IPA.\033[0m'
|
||||
echo -e '\033[0;33mOr use ./build.sh dylib to build the dylib for Feather injection.\033[0m'
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
@@ -208,6 +259,10 @@ then
|
||||
|
||||
make $MAKEARGS
|
||||
|
||||
# Build zxPluginsInject.dylib so ipapatch can inject it after cyan
|
||||
echo -e '\033[1m\033[32mBuilding zxPluginsInject.dylib\033[0m'
|
||||
build_zxpi_dylib
|
||||
|
||||
# Copy dylib to packages
|
||||
mkdir -p packages
|
||||
cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib
|
||||
@@ -223,6 +278,7 @@ then
|
||||
rm -rf "$BUNDLE_PATH"
|
||||
mkdir -p "$BUNDLE_PATH"
|
||||
copy_localization_into_bundle "$BUNDLE_PATH"
|
||||
copy_bundle_assets "$BUNDLE_PATH"
|
||||
if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
echo -e '\033[1m\033[32mBuilding RyukGram.bundle\033[0m'
|
||||
for fw in modules/ffmpegkit/*.framework; do
|
||||
@@ -278,8 +334,7 @@ then
|
||||
rm -rf "$INJECT_TMP"
|
||||
fi
|
||||
|
||||
# Patch IPA for sideloading
|
||||
ipapatch --input "packages/RyukGram-sideloaded.ipa" --inplace --noconfirm
|
||||
run_ipapatch packages/RyukGram-sideloaded.ipa
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the ipa file at: $(pwd)/packages"
|
||||
|
||||
@@ -333,16 +388,130 @@ then
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the deb file at: $(pwd)/packages"
|
||||
|
||||
# TrollStore build — .tipa is a renamed .ipa. Skip sideload re-sign; TS signs on-device.
|
||||
elif [ "$1" == "trollstore" ];
|
||||
then
|
||||
|
||||
HAS_FLEX=1
|
||||
if [ -z "$(ls -A modules/FLEXing 2>/dev/null)" ]; then
|
||||
HAS_FLEX=0
|
||||
fi
|
||||
|
||||
if [ "$HAS_FLEX" == "1" ]; then
|
||||
MAKEARGS='SIDELOAD=1'
|
||||
FLEXPATH='.theos/obj/debug/FLEXing.dylib .theos/obj/debug/libflex.dylib'
|
||||
else
|
||||
MAKEARGS=''
|
||||
FLEXPATH=''
|
||||
fi
|
||||
COMPRESSION=9
|
||||
|
||||
make clean 2>/dev/null || true
|
||||
rm -rf .theos
|
||||
|
||||
mkdir -p packages
|
||||
ipaFile="$(find ./packages/ -maxdepth 1 -type f \( -iname '*com.burbn.instagram*.ipa' -o -iname 'Instagram*.ipa' -o -iname '[0-9]*.ipa' \) ! -iname 'RyukGram*.ipa' -exec basename {} \; 2>/dev/null | head -1)"
|
||||
if [ -z "${ipaFile}" ]; then
|
||||
cwdIpa="$(find . -maxdepth 1 -type f \( -iname '*com.burbn.instagram*.ipa' -o -iname 'Instagram*.ipa' -o -iname '[0-9]*.ipa' \) 2>/dev/null | head -1)"
|
||||
if [ -n "$cwdIpa" ]; then
|
||||
mv "$cwdIpa" packages/
|
||||
ipaFile="$(basename "$cwdIpa")"
|
||||
fi
|
||||
fi
|
||||
if [ -z "${ipaFile}" ]; then
|
||||
echo -e '\033[1m\033[0;31mDecrypted Instagram IPA not found.\033[0m'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v cyan &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mcyan not found. Install it with:\033[0m'
|
||||
echo ' pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip'
|
||||
exit 1
|
||||
fi
|
||||
if ! command -v ipapatch &> /dev/null; then
|
||||
echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m'
|
||||
echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo -e '\033[1m\033[32mBuilding RyukGram tweak for TrollStore (.tipa)\033[0m'
|
||||
make $MAKEARGS
|
||||
cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib
|
||||
|
||||
echo -e '\033[1m\033[32mBuilding zxPluginsInject.dylib\033[0m'
|
||||
build_zxpi_dylib
|
||||
|
||||
BUNDLE_PATH="packages/RyukGram.bundle"
|
||||
rm -rf "$BUNDLE_PATH"
|
||||
mkdir -p "$BUNDLE_PATH"
|
||||
copy_localization_into_bundle "$BUNDLE_PATH"
|
||||
copy_bundle_assets "$BUNDLE_PATH"
|
||||
if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
for fw in modules/ffmpegkit/*.framework; do
|
||||
cp -R "$fw" "$BUNDLE_PATH/"
|
||||
done
|
||||
LIBS="libavutil libavcodec libavformat libavfilter libavdevice libswresample libswscale"
|
||||
for lib in $LIBS; do
|
||||
mv "$BUNDLE_PATH/${lib}.framework" "$BUNDLE_PATH/${lib}_sci.framework"
|
||||
install_name_tool -id "@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$BUNDLE_PATH/${lib}_sci.framework/${lib}"
|
||||
done
|
||||
for target in "$BUNDLE_PATH/ffmpegkit.framework/ffmpegkit" \
|
||||
"$BUNDLE_PATH"/libav*_sci.framework/libav* \
|
||||
"$BUNDLE_PATH"/libsw*_sci.framework/libsw*; do
|
||||
[ -f "$target" ] || continue
|
||||
for lib in $LIBS; do
|
||||
install_name_tool -change \
|
||||
"@rpath/${lib}.framework/${lib}" \
|
||||
"@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$target" 2>/dev/null || true
|
||||
done
|
||||
done
|
||||
install_name_tool -add_rpath @loader_path/.. \
|
||||
"$BUNDLE_PATH/ffmpegkit.framework/ffmpegkit" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
TWEAKPATH=".theos/obj/debug/RyukGram.dylib"
|
||||
BUNDLE_ARG=""
|
||||
[ -d "$BUNDLE_PATH" ] && BUNDLE_ARG="$BUNDLE_PATH"
|
||||
|
||||
echo -e '\033[1m\033[32mCreating the TIPA file...\033[0m'
|
||||
rm -f packages/RyukGram-trollstore.tipa packages/RyukGram-trollstore.ipa
|
||||
cyan -i "packages/${ipaFile}" -o packages/RyukGram-trollstore.ipa -f $TWEAKPATH $FLEXPATH $BUNDLE_ARG -c $COMPRESSION -m 15.0 -du
|
||||
|
||||
# Embed Safari extension.
|
||||
APPEX_SRC="extensions/OpenInstagramSafariExtension.appex"
|
||||
if [ -d "$APPEX_SRC" ]; then
|
||||
echo -e '\033[1m\033[32mEmbedding Safari extension\033[0m'
|
||||
INJECT_TMP=$(mktemp -d)
|
||||
unzip -q packages/RyukGram-trollstore.ipa -d "$INJECT_TMP"
|
||||
APP_DIR="$(find "$INJECT_TMP/Payload" -maxdepth 1 -type d -name '*.app' | head -1)"
|
||||
if [ -n "$APP_DIR" ]; then
|
||||
mkdir -p "$APP_DIR/PlugIns"
|
||||
rm -rf "$APP_DIR/PlugIns/OpenInstagramSafariExtension.appex"
|
||||
cp -R "$APPEX_SRC" "$APP_DIR/PlugIns/"
|
||||
( cd "$INJECT_TMP" && zip -qr -${COMPRESSION} ../repacked.ipa Payload )
|
||||
mv "$INJECT_TMP/../repacked.ipa" packages/RyukGram-trollstore.ipa
|
||||
fi
|
||||
rm -rf "$INJECT_TMP"
|
||||
fi
|
||||
|
||||
run_ipapatch packages/RyukGram-trollstore.ipa
|
||||
|
||||
mv packages/RyukGram-trollstore.ipa packages/RyukGram-trollstore.tipa
|
||||
echo -e "\033[1m\033[32mDone!\033[0m\n\nTIPA at: $(pwd)/packages/RyukGram-trollstore.tipa"
|
||||
|
||||
else
|
||||
echo '+----------------------+'
|
||||
echo '|RyukGram Build Script |'
|
||||
echo '+----------------------+'
|
||||
echo
|
||||
echo 'Usage: ./build.sh <dylib/sideload/rootless/rootful>'
|
||||
echo 'Usage: ./build.sh <dylib/sideload/trollstore/rootless/rootful>'
|
||||
echo
|
||||
echo ' dylib - Build the dylib only (for Feather/manual injection)'
|
||||
echo ' sideload - Build a patched IPA (requires cyan + ipapatch + decrypted IPA)'
|
||||
echo ' rootless - Build a rootless .deb package (with FFmpegKit)'
|
||||
echo ' rootful - Build a rootful .deb package (with FFmpegKit)'
|
||||
echo ' dylib - Build the dylib only (for Feather/manual injection)'
|
||||
echo ' sideload - Build a patched IPA (requires cyan + decrypted IPA)'
|
||||
echo ' trollstore - Build a .tipa for TrollStore (requires cyan + decrypted IPA)'
|
||||
echo ' rootless - Build a rootless .deb package (with FFmpegKit)'
|
||||
echo ' rootful - Build a rootful .deb package (with FFmpegKit)'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Package: com.faroukbmiled.ryukgram
|
||||
Name: RyukGram
|
||||
Version: 1.2.0
|
||||
Version: 1.2.2
|
||||
Architecture: iphoneos-arm
|
||||
Description: A feature-rich tweak for Instagram on iOS, based on SCInsta
|
||||
Homepage: https://github.com/faroukbmiled/RyukGram
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
TARGET := iphone:clang:16.2:14.0
|
||||
ARCHS := arm64
|
||||
|
||||
include $(THEOS)/makefiles/common.mk
|
||||
|
||||
TWEAK_NAME := zxPluginsInject
|
||||
|
||||
$(TWEAK_NAME)_FILES := $(shell find src -type f -name "*.*m") ../fishhook/fishhook.c
|
||||
$(TWEAK_NAME)_CFLAGS := -fobjc-arc -Os
|
||||
$(TWEAK_NAME)_LOGOS_DEFAULT_GENERATOR := internal
|
||||
|
||||
include $(THEOS_MAKE_PATH)/tweak.mk
|
||||
@@ -0,0 +1,15 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
extern NSString *accessGroupId;
|
||||
extern NSString *bundleId;
|
||||
|
||||
extern void rebindSecFuncs();
|
||||
|
||||
extern BOOL createDirectoryIfNotExists(NSString *path);
|
||||
extern NSURL *getAppGroupPathIfExists();
|
||||
|
||||
@interface LSBundleProxy: NSObject
|
||||
@property(nonatomic, assign, readonly) NSDictionary *entitlements;
|
||||
@property(nonatomic, assign, readonly) NSDictionary *groupContainerURLs;
|
||||
+ (instancetype)bundleProxyForCurrentProcess;
|
||||
@end
|
||||
@@ -0,0 +1,38 @@
|
||||
#import <objc/runtime.h>
|
||||
|
||||
#import "Header.h"
|
||||
|
||||
BOOL createDirectoryIfNotExists(NSString *path) {
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
if ([fileManager fileExistsAtPath:path]) return YES;
|
||||
|
||||
NSError *error = nil;
|
||||
[fileManager createDirectoryAtPath:path
|
||||
withIntermediateDirectories:YES
|
||||
attributes:nil
|
||||
error:&error];
|
||||
return error == nil;
|
||||
}
|
||||
|
||||
NSURL *getAppGroupPathIfExists() {
|
||||
static NSURL *cachedAppGroupPath = nil;
|
||||
static dispatch_once_t onceToken;
|
||||
|
||||
dispatch_once(&onceToken, ^{
|
||||
LSBundleProxy *bundleProxy = [objc_getClass("LSBundleProxy") bundleProxyForCurrentProcess];
|
||||
if (!bundleProxy) return;
|
||||
|
||||
NSDictionary *entitlements = bundleProxy.entitlements;
|
||||
if (![entitlements isKindOfClass:[NSDictionary class]]) return;
|
||||
|
||||
NSArray *appGroups = entitlements[@"com.apple.security.application-groups"];
|
||||
if (appGroups.count == 0) return;
|
||||
|
||||
NSDictionary *appGroupsPaths = bundleProxy.groupContainerURLs;
|
||||
if (![appGroupsPaths isKindOfClass:[NSDictionary class]]) return;
|
||||
|
||||
cachedAppGroupPath = appGroupsPaths[[appGroups firstObject]];
|
||||
});
|
||||
|
||||
return cachedAppGroupPath;
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
#import <Security/Security.h>
|
||||
|
||||
#import "Header.h"
|
||||
#import "../../fishhook/fishhook.h"
|
||||
|
||||
static OSStatus (*origSecItemAdd)(CFDictionaryRef attributes, CFTypeRef *result);
|
||||
static OSStatus (*origSecItemCopyMatching)(CFDictionaryRef query, CFTypeRef *result);
|
||||
static OSStatus (*origSecItemUpdate)(CFDictionaryRef query, CFDictionaryRef attributesToUpdate);
|
||||
static OSStatus (*origSecItemDelete)(CFDictionaryRef query);
|
||||
|
||||
static OSStatus zxSecItemAdd(CFDictionaryRef attributes, CFTypeRef *result) {
|
||||
NSMutableDictionary *mutableAttributes = [(__bridge NSDictionary *)attributes mutableCopy];
|
||||
mutableAttributes[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemAdd((__bridge CFDictionaryRef)mutableAttributes, result);
|
||||
}
|
||||
|
||||
static OSStatus zxSecItemCopyMatching(CFDictionaryRef query, CFTypeRef *result) {
|
||||
NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy];
|
||||
mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemCopyMatching((__bridge CFDictionaryRef)mutableQuery, result);
|
||||
}
|
||||
|
||||
static OSStatus zxSecItemUpdate(CFDictionaryRef query, CFDictionaryRef attributesToUpdate) {
|
||||
NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy];
|
||||
mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemUpdate((__bridge CFDictionaryRef)mutableQuery, attributesToUpdate);
|
||||
}
|
||||
|
||||
static OSStatus zxSecItemDelete(CFDictionaryRef query) {
|
||||
NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy];
|
||||
mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId;
|
||||
return origSecItemDelete((__bridge CFDictionaryRef)mutableQuery);
|
||||
}
|
||||
|
||||
void rebindSecFuncs() {
|
||||
struct rebinding rebinds[4] = {
|
||||
{"SecItemAdd", (void *)zxSecItemAdd, (void **)&origSecItemAdd},
|
||||
{"SecItemCopyMatching", (void *)zxSecItemCopyMatching, (void **)&origSecItemCopyMatching},
|
||||
{"SecItemUpdate", (void *)zxSecItemUpdate, (void **)&origSecItemUpdate},
|
||||
{"SecItemDelete", (void *)zxSecItemDelete, (void **)&origSecItemDelete}
|
||||
};
|
||||
rebind_symbols(rebinds, 4);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
#import "Header.h"
|
||||
|
||||
%hook CKContainer
|
||||
- (id)_setupWithContainerID:(id)a options:(id)b { return nil; }
|
||||
- (id)_initWithContainerIdentifier:(id)a { return nil; }
|
||||
%end
|
||||
|
||||
%hook CKEntitlements
|
||||
- (id)initWithEntitlementsDict:(NSDictionary *)entitlements {
|
||||
NSMutableDictionary *mutEntitlements = [entitlements mutableCopy];
|
||||
[mutEntitlements removeObjectForKey:@"com.apple.developer.icloud-container-environment"];
|
||||
[mutEntitlements removeObjectForKey:@"com.apple.developer.icloud-services"];
|
||||
return %orig([mutEntitlements copy]);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook NSFileManager
|
||||
- (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier {
|
||||
if (NSURL *ourAppGroupURL = getAppGroupPathIfExists()) {
|
||||
NSURL *fakeAppGroupURL = [ourAppGroupURL URLByAppendingPathComponent:groupIdentifier];
|
||||
createDirectoryIfNotExists(fakeAppGroupURL.path);
|
||||
return fakeAppGroupURL;
|
||||
}
|
||||
|
||||
NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
|
||||
NSString *fakePath = [[paths lastObject] stringByAppendingPathComponent:groupIdentifier];
|
||||
createDirectoryIfNotExists(fakePath);
|
||||
return [NSURL fileURLWithPath:fakePath];
|
||||
}
|
||||
%end
|
||||
|
||||
// Scoped to app-extension processes only. Appex needs the suite redirect so
|
||||
// it reads the group.* defaults the main app wrote (rich push previews depend
|
||||
// on it). Applying it in the main process breaks UI-dismiss flag persistence
|
||||
// on IG 423+ (Friends Map "Not now" reappears every launch).
|
||||
static BOOL sciIsAppExtensionProcess(void) {
|
||||
static BOOL cached = NO;
|
||||
static dispatch_once_t token;
|
||||
dispatch_once(&token, ^{
|
||||
cached = ([[NSBundle mainBundle] infoDictionary][@"NSExtension"] != nil);
|
||||
});
|
||||
return cached;
|
||||
}
|
||||
|
||||
%hook NSUserDefaults
|
||||
- (id)_initWithSuiteName:(NSString *)suiteName container:(NSURL *)container {
|
||||
if (!sciIsAppExtensionProcess()) return %orig(suiteName, container);
|
||||
|
||||
NSURL *appGroupURL = getAppGroupPathIfExists();
|
||||
if (!appGroupURL || ![suiteName hasPrefix:@"group"]) return %orig(suiteName, container);
|
||||
|
||||
if (NSURL *customContainerURL = [appGroupURL URLByAppendingPathComponent:suiteName]) {
|
||||
return %orig(suiteName, customContainerURL);
|
||||
}
|
||||
return %orig(suiteName, container);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,29 @@
|
||||
#import "Header.h"
|
||||
|
||||
NSString *accessGroupId;
|
||||
NSString *bundleId;
|
||||
|
||||
static void setRequiredIDs() {
|
||||
NSDictionary *query = @{
|
||||
(__bridge NSString *)kSecClass: (__bridge NSString *)kSecClassGenericPassword,
|
||||
(__bridge NSString *)kSecAttrAccount: @"zxPluginsInjectGenericEntry",
|
||||
(__bridge NSString *)kSecAttrService: @"",
|
||||
(__bridge id)kSecReturnAttributes: (id)kCFBooleanTrue
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
bundleId = [[NSBundle mainBundle] bundleIdentifier];
|
||||
accessGroupId = [(__bridge NSDictionary *)result objectForKey:(__bridge NSString *)kSecAttrAccessGroup];
|
||||
if (result) CFRelease(result);
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void init() {
|
||||
setRequiredIDs();
|
||||
rebindSecFuncs();
|
||||
}
|
||||
@@ -118,11 +118,16 @@ const void *kSCIDismissKey = &kSCIDismissKey;
|
||||
id media = provider(sender);
|
||||
if (media == (id)kCFNull) return;
|
||||
|
||||
SCIActionContext tapCtx = (SCIActionContext)ctxNum.integerValue;
|
||||
NSString *tapCtxLabel = [SCIMediaActions contextLabelForContext:tapCtx];
|
||||
|
||||
if ([tap isEqualToString:@"expand"]) {
|
||||
[SCIMediaActions expandMedia:media fromView:sender caption:nil];
|
||||
} else if ([tap isEqualToString:@"download_share"]) {
|
||||
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
|
||||
[SCIMediaActions downloadAndShareMedia:media];
|
||||
} else if ([tap isEqualToString:@"download_photos"]) {
|
||||
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]];
|
||||
[SCIMediaActions downloadAndSaveMedia:media];
|
||||
} else if ([tap isEqualToString:@"copy_link"]) {
|
||||
[SCIMediaActions copyURLForMedia:media];
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "../InstagramHeaders.h"
|
||||
#import "../Downloader/Download.h"
|
||||
#import "SCIActionMenu.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
@@ -16,6 +17,18 @@ typedef NS_ENUM(NSInteger, SCIActionContext) {
|
||||
|
||||
@interface SCIMediaActions : NSObject
|
||||
|
||||
// MARK: - Filename naming
|
||||
|
||||
// `@username_context_yyyyMMdd_HHmmss` (sanitized). UUID fallback on failure.
|
||||
+ (NSString *)filenameStemForMedia:(nullable id)media contextLabel:(NSString *)ctxLabel;
|
||||
|
||||
// "feed" / "reels" / "stories".
|
||||
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx;
|
||||
|
||||
// Stem read by the download + mux write sites to name output files.
|
||||
+ (nullable NSString *)currentFilenameStem;
|
||||
+ (void)setCurrentFilenameStem:(nullable NSString *)stem;
|
||||
|
||||
// MARK: - Media extraction
|
||||
|
||||
/// Return the post's caption string. Tries selectors first, falls back to
|
||||
@@ -28,6 +41,16 @@ typedef NS_ENUM(NSInteger, SCIActionContext) {
|
||||
/// Ordered children of a carousel IGMedia. Empty array for non-carousels.
|
||||
+ (NSArray *)carouselChildrenForMedia:(id)media;
|
||||
|
||||
/// YES if the media has an audio track (`has_audio` fieldCache == 1).
|
||||
+ (BOOL)mediaHasAudio:(id)media;
|
||||
|
||||
/// Download the raw photo URL, skipping any video route.
|
||||
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action;
|
||||
|
||||
/// Extract the audio-only track from the DASH manifest via FFmpeg. Photos
|
||||
/// library can't hold audio, so both actions end at the share sheet.
|
||||
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action;
|
||||
|
||||
/// Best URL for a single (non-carousel) media item. Prefers video URL, falls
|
||||
/// back to photo URL. Returns nil if nothing extractable.
|
||||
+ (nullable NSURL *)bestURLForMedia:(id)media;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#import "../Utils.h"
|
||||
#import "../Downloader/Download.h"
|
||||
#import "../PhotoAlbum.h"
|
||||
#import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <Photos/Photos.h>
|
||||
@@ -20,12 +21,63 @@ static SCIDownloadDelegate *sciActiveDownloadDelegate = nil;
|
||||
extern void sciToggleStoryAudio(void);
|
||||
extern BOOL sciIsStoryAudioEnabled(void);
|
||||
|
||||
// MARK: - Filename naming
|
||||
|
||||
static NSString *sciCurrentFilenameStem = nil;
|
||||
|
||||
static NSString *sciSanitizeFilenameComponent(NSString *s) {
|
||||
if (!s.length) return @"";
|
||||
NSMutableCharacterSet *bad = [NSMutableCharacterSet alphanumericCharacterSet];
|
||||
[bad addCharactersInString:@"._-"];
|
||||
NSCharacterSet *drop = bad.invertedSet;
|
||||
NSArray *parts = [s componentsSeparatedByCharactersInSet:drop];
|
||||
NSString *out = [parts componentsJoinedByString:@""];
|
||||
if (out.length > 30) out = [out substringToIndex:30];
|
||||
return out;
|
||||
}
|
||||
|
||||
// IGAPIStorableObject's backing dict.
|
||||
static NSDictionary *sciMediaFieldCache(id obj) {
|
||||
if (!obj) return nil;
|
||||
static Ivar fcIvar = NULL;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
Class c = NSClassFromString(@"IGAPIStorableObject");
|
||||
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
|
||||
});
|
||||
if (!fcIvar) return nil;
|
||||
id v = object_getIvar(obj, fcIvar);
|
||||
return [v isKindOfClass:[NSDictionary class]] ? v : nil;
|
||||
}
|
||||
|
||||
static NSString *sciUsernameForMedia(id media) {
|
||||
if (!media) return nil;
|
||||
@try {
|
||||
id user = nil;
|
||||
@try { user = [media valueForKey:@"user"]; } @catch (__unused id e) {}
|
||||
if (!user) {
|
||||
NSDictionary *fc = sciMediaFieldCache(media);
|
||||
user = fc[@"user"];
|
||||
}
|
||||
if (!user) return nil;
|
||||
NSString *u = nil;
|
||||
@try { u = [user valueForKey:@"username"]; } @catch (__unused id e) {}
|
||||
if (![u isKindOfClass:[NSString class]] || !u.length) {
|
||||
NSDictionary *ufc = sciMediaFieldCache(user);
|
||||
id v = ufc[@"username"];
|
||||
if ([v isKindOfClass:[NSString class]]) u = v;
|
||||
else if ([user isKindOfClass:[NSDictionary class]]) u = ((NSDictionary *)user)[@"username"];
|
||||
}
|
||||
return [u isKindOfClass:[NSString class]] ? u : nil;
|
||||
} @catch (__unused id e) { return nil; }
|
||||
}
|
||||
|
||||
// Match keys used in the settings-entry title map for openSettingsForContext:
|
||||
static NSString *sciSettingsTitleForContext(SCIActionContext ctx) {
|
||||
switch (ctx) {
|
||||
case SCIActionContextFeed: return @"Feed";
|
||||
case SCIActionContextReels: return @"Reels";
|
||||
case SCIActionContextStories: return @"Stories";
|
||||
case SCIActionContextFeed: return SCILocalized(@"Feed");
|
||||
case SCIActionContextReels: return SCILocalized(@"Reels");
|
||||
case SCIActionContextStories: return SCILocalized(@"Stories");
|
||||
}
|
||||
return @"General";
|
||||
}
|
||||
@@ -73,6 +125,38 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
|
||||
|
||||
@implementation SCIMediaActions
|
||||
|
||||
+ (NSString *)contextLabelForContext:(SCIActionContext)ctx {
|
||||
switch (ctx) {
|
||||
case SCIActionContextFeed: return @"feed";
|
||||
case SCIActionContextReels: return @"reels";
|
||||
case SCIActionContextStories: return @"stories";
|
||||
}
|
||||
return @"media";
|
||||
}
|
||||
|
||||
+ (NSString *)filenameStemForMedia:(id)media contextLabel:(NSString *)ctxLabel {
|
||||
@try {
|
||||
NSString *user = sciSanitizeFilenameComponent(sciUsernameForMedia(media));
|
||||
NSString *userPart = user.length ? [@"@" stringByAppendingString:user] : @"media";
|
||||
NSString *ctxPart = sciSanitizeFilenameComponent(ctxLabel);
|
||||
if (!ctxPart.length) ctxPart = @"media";
|
||||
static NSDateFormatter *fmt = nil;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
fmt = [NSDateFormatter new];
|
||||
fmt.dateFormat = @"yyyyMMdd_HHmmss";
|
||||
fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"];
|
||||
});
|
||||
NSString *ts = [fmt stringFromDate:[NSDate date]];
|
||||
return [NSString stringWithFormat:@"%@_%@_%@", userPart, ctxPart, ts];
|
||||
} @catch (__unused id e) {
|
||||
return [[NSUUID UUID] UUIDString];
|
||||
}
|
||||
}
|
||||
|
||||
+ (NSString *)currentFilenameStem { return sciCurrentFilenameStem; }
|
||||
+ (void)setCurrentFilenameStem:(NSString *)stem { sciCurrentFilenameStem = [stem copy]; }
|
||||
|
||||
// MARK: - Media extraction
|
||||
|
||||
+ (NSString *)captionForMedia:(id)media {
|
||||
@@ -208,6 +292,96 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
|
||||
return @[];
|
||||
}
|
||||
|
||||
+ (BOOL)mediaHasAudio:(id)media {
|
||||
if (!media) return NO;
|
||||
// fieldCache on media (old IG path).
|
||||
id v = sciFieldCache(media, @"has_audio");
|
||||
if ([v respondsToSelector:@selector(boolValue)] && [v boolValue]) return YES;
|
||||
|
||||
// IGVideo.isAudioDetected — positive signal only; NO often means "IG
|
||||
// hasn't decoded the manifest yet" for stories, not actually silent.
|
||||
@try {
|
||||
id video = nil;
|
||||
if ([media respondsToSelector:@selector(video)])
|
||||
video = ((id(*)(id, SEL))objc_msgSend)(media, @selector(video));
|
||||
if (video && [video respondsToSelector:@selector(isAudioDetected)]) {
|
||||
if (((BOOL(*)(id, SEL))objc_msgSend)(video, @selector(isAudioDetected))) return YES;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
|
||||
// Stories often carry audio but don't surface it in fieldCache. If any
|
||||
// of these music/audio hints are present, treat as audio-bearing.
|
||||
for (NSString *key in @[@"music_metadata", @"story_music_stickers",
|
||||
@"is_story_image_with_music", @"story_sound_on",
|
||||
@"spotify_stickers", @"story_music_lyric_stickers"]) {
|
||||
id val = sciFieldCache(media, key);
|
||||
if (val && ![val isKindOfClass:[NSNull class]]) {
|
||||
if ([val respondsToSelector:@selector(boolValue)] && [val boolValue]) return YES;
|
||||
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return YES;
|
||||
if ([val isKindOfClass:[NSDictionary class]] && [(NSDictionary *)val count]) return YES;
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: if a DASH manifest exists, assume audio is present.
|
||||
return [SCIDashParser dashManifestForMedia:media].length > 0;
|
||||
}
|
||||
|
||||
+ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action {
|
||||
NSURL *url = [self hdPhotoURLForMedia:media];
|
||||
if (!url) url = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media];
|
||||
if (!url) url = [self fieldCachePhotoURLForMedia:media];
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo URL")]; return; }
|
||||
NSString *ext = [[url lastPathComponent] pathExtension];
|
||||
if (!ext.length) ext = @"jpg";
|
||||
sciActiveDownloadDelegate = sciMakeDownloader(action, NO);
|
||||
[sciActiveDownloadDelegate downloadFileWithURL:url fileExtension:ext hudLabel:nil];
|
||||
}
|
||||
|
||||
// Photos library can't hold audio — save action falls back to share sheet.
|
||||
+ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action {
|
||||
NSString *manifest = [SCIDashParser dashManifestForMedia:media];
|
||||
if (!manifest.length) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio stream available")];
|
||||
return;
|
||||
}
|
||||
NSArray *reps = [SCIDashParser parseManifest:manifest];
|
||||
SCIDashRepresentation *audio = [SCIDashParser bestAudioFromRepresentations:reps];
|
||||
if (!audio.url) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio track found")];
|
||||
return;
|
||||
}
|
||||
if (![SCIFFmpeg isAvailable]) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"FFmpeg not available")];
|
||||
return;
|
||||
}
|
||||
|
||||
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
|
||||
NSString *ticket = [pill beginTicketWithTitle:SCILocalized(@"Downloading audio...")
|
||||
onCancel:^{ [SCIFFmpeg cancelAll]; }];
|
||||
|
||||
NSString *audioStem = [self currentFilenameStem] ?: [[NSUUID UUID] UUIDString];
|
||||
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"%@.m4a", audioStem]];
|
||||
NSString *cmd = [NSString stringWithFormat:@"-i \"%@\" -vn -c:a copy -y \"%@\"",
|
||||
audio.url.absoluteString, outPath];
|
||||
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (!success) {
|
||||
[pill finishTicket:ticket errorMessage:SCILocalized(@"Audio extract failed")];
|
||||
return;
|
||||
}
|
||||
[pill finishTicket:ticket successMessage:SCILocalized(@"Audio ready")];
|
||||
NSURL *fileURL = [NSURL fileURLWithPath:outPath];
|
||||
switch (action) {
|
||||
case quickLook: [SCIUtils showQuickLookVC:@[fileURL]]; break;
|
||||
case share:
|
||||
case saveToPhotos:
|
||||
default: [SCIUtils showShareVC:fileURL]; break;
|
||||
}
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
+ (NSURL *)bestURLForMedia:(id)media {
|
||||
if (!media) return nil;
|
||||
|
||||
@@ -327,6 +501,7 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) {
|
||||
// Try enhanced HD path via reusable quality picker
|
||||
BOOL handled = [SCIQualityPicker pickQualityForMedia:media
|
||||
fromView:sourceView
|
||||
action:action
|
||||
picked:^(SCIDashRepresentation *video, SCIDashRepresentation *audio) {
|
||||
[self downloadDASHVideo:video audio:audio action:action];
|
||||
}
|
||||
@@ -620,13 +795,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
NSMutableArray<NSURL *> *files = [NSMutableArray array];
|
||||
NSLock *lock = [NSLock new];
|
||||
__block NSUInteger completed = 0;
|
||||
NSString *bulkStem = [self currentFilenameStem];
|
||||
|
||||
NSUInteger __idx = 0;
|
||||
for (NSURL *url in urls) {
|
||||
if (cancelled) break;
|
||||
dispatch_group_enter(group);
|
||||
NSString *ext = [[url lastPathComponent] pathExtension];
|
||||
NSString *name = bulkStem
|
||||
? [NSString stringWithFormat:@"%@_%lu", bulkStem, (unsigned long)(++__idx)]
|
||||
: [[NSUUID UUID] UUIDString];
|
||||
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString],
|
||||
[NSString stringWithFormat:@"%@.%@", name,
|
||||
ext.length ? ext : @"jpg"]];
|
||||
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
|
||||
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
|
||||
@@ -762,6 +942,12 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
fromView:(UIView *)sourceView {
|
||||
NSMutableArray<SCIAction *> *out = [NSMutableArray array];
|
||||
|
||||
NSString *ctxLabel = [self contextLabelForContext:ctx];
|
||||
// Stamp the filename stem before a download fires.
|
||||
void (^stampStemForMedia)(id) = ^(id m) {
|
||||
[SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:m contextLabel:ctxLabel]];
|
||||
};
|
||||
|
||||
// Resolve parent media for carousel detection + bulk actions.
|
||||
id parentMedia = media;
|
||||
if (media && ![self isCarouselMedia:media]) {
|
||||
@@ -888,6 +1074,36 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
}
|
||||
}
|
||||
|
||||
// Story user list management (add/remove from exclusion list).
|
||||
if (ctx == SCIActionContextStories && [SCIUtils getBoolPref:@"enable_story_user_exclusions"]) {
|
||||
extern NSDictionary *sciOwnerInfoForView(UIView *);
|
||||
extern void sciRefreshAllVisibleOverlays(UIViewController *);
|
||||
extern __weak UIViewController *sciActiveStoryViewerVC;
|
||||
NSDictionary *ownerInfo = sourceView ? sciOwnerInfoForView(sourceView) : nil;
|
||||
NSString *ownerPK = ownerInfo[@"pk"];
|
||||
if (ownerPK.length) {
|
||||
BOOL inList = [SCIExcludedStoryUsers isInList:ownerPK];
|
||||
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
NSString *addLabel = bs ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude from seen");
|
||||
NSString *removeLabel = bs ? SCILocalized(@"Remove from block list") : SCILocalized(@"Remove from exclude list");
|
||||
NSString *title = inList ? removeLabel : addLabel;
|
||||
NSString *icon = inList ? @"eye.fill" : @"eye.slash";
|
||||
NSString *capturedPK = [ownerPK copy];
|
||||
NSString *capturedUser = [ownerInfo[@"username"] ?: @"" copy];
|
||||
NSString *capturedName = [ownerInfo[@"fullName"] ?: @"" copy];
|
||||
[out addObject:[SCIAction actionWithTitle:title icon:icon handler:^{
|
||||
if (inList) {
|
||||
[SCIExcludedStoryUsers removePK:capturedPK];
|
||||
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Unblocked") : SCILocalized(@"Removed from list")];
|
||||
} else {
|
||||
[SCIExcludedStoryUsers addOrUpdateEntry:@{@"pk": capturedPK, @"username": capturedUser, @"fullName": capturedName}];
|
||||
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Added to block list") : SCILocalized(@"Added to exclude list")];
|
||||
}
|
||||
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}]];
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx != SCIActionContextStories) {
|
||||
// Caption lives on the parent media (not on carousel children).
|
||||
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Copy caption")
|
||||
@@ -915,9 +1131,11 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
[SCIMediaActions copyAllURLsForMedia:bulkMedia];
|
||||
}],
|
||||
[SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{
|
||||
stampStemForMedia(bulkMedia);
|
||||
[SCIMediaActions downloadAllAndShareMedia:bulkMedia];
|
||||
}],
|
||||
[SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{
|
||||
stampStemForMedia(bulkMedia);
|
||||
[SCIMediaActions downloadAllAndSaveMedia:bulkMedia];
|
||||
}],
|
||||
];
|
||||
@@ -1037,6 +1255,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
if (u) [urls addObject:u];
|
||||
}
|
||||
if (!urls.count) return;
|
||||
stampStemForMedia(capturedMedias.firstObject);
|
||||
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Download all stories and share?") done:^(NSArray<NSURL *> *files) {
|
||||
if (!files.count) return;
|
||||
UIViewController *top = topMostController();
|
||||
@@ -1052,6 +1271,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
if (u) [urls addObject:u];
|
||||
}
|
||||
if (!urls.count) return;
|
||||
stampStemForMedia(capturedMedias.firstObject);
|
||||
[SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Save all stories to Photos?") done:^(NSArray<NSURL *> *files) {
|
||||
[SCIMediaActions bulkSaveFiles:files];
|
||||
}];
|
||||
@@ -1074,11 +1294,13 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download and share")
|
||||
icon:@"square.and.arrow.up"
|
||||
handler:^{
|
||||
stampStemForMedia(media);
|
||||
[SCIMediaActions downloadAndShareMedia:media];
|
||||
}]];
|
||||
[out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download to Photos")
|
||||
icon:@"square.and.arrow.down"
|
||||
handler:^{
|
||||
stampStemForMedia(media);
|
||||
[SCIMediaActions downloadAndSaveMedia:media];
|
||||
}]];
|
||||
|
||||
@@ -1107,13 +1329,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD
|
||||
NSMutableArray<NSURL *> *files = [NSMutableArray array];
|
||||
NSLock *lock = [NSLock new];
|
||||
__block NSUInteger completed = 0;
|
||||
NSString *bulkStem2 = [self currentFilenameStem];
|
||||
|
||||
NSUInteger __idx2 = 0;
|
||||
for (NSURL *url in urls) {
|
||||
if (cancelled) break;
|
||||
dispatch_group_enter(group);
|
||||
NSString *ext = [[url lastPathComponent] pathExtension];
|
||||
NSString *name = bulkStem2
|
||||
? [NSString stringWithFormat:@"%@_%lu", bulkStem2, (unsigned long)(++__idx2)]
|
||||
: [[NSUUID UUID] UUIDString];
|
||||
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString],
|
||||
[NSString stringWithFormat:@"%@.%@", name,
|
||||
ext.length ? ext : @"jpg"]];
|
||||
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
|
||||
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#import "SCIMediaViewer.h"
|
||||
#import "../Utils.h"
|
||||
#import "../SCIImageCache.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <AVKit/AVKit.h>
|
||||
|
||||
@@ -57,15 +58,10 @@
|
||||
[self.view addSubview:self.spinner];
|
||||
[self.spinner startAnimating];
|
||||
|
||||
NSURL *url = [self.photoURL copy];
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSData *data = [NSData dataWithContentsOfURL:url];
|
||||
UIImage *img = data ? [UIImage imageWithData:data] : nil;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.spinner stopAnimating];
|
||||
if (img) self.imageView.image = img;
|
||||
});
|
||||
});
|
||||
[SCIImageCache loadImageFromURL:self.photoURL completion:^(UIImage *img) {
|
||||
[self.spinner stopAnimating];
|
||||
if (img) self.imageView.image = img;
|
||||
}];
|
||||
|
||||
// Double-tap to zoom
|
||||
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];
|
||||
@@ -131,7 +127,7 @@
|
||||
#pragma mark - Container VC (PageViewController-based)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIMediaViewerContainerVC : UIViewController <UIPageViewControllerDataSource, UIPageViewControllerDelegate>
|
||||
@interface _SCIMediaViewerContainerVC : UIViewController <UIPageViewControllerDataSource, UIPageViewControllerDelegate, UIGestureRecognizerDelegate>
|
||||
@property (nonatomic, strong) NSArray<SCIMediaViewerItem *> *items;
|
||||
@property (nonatomic, assign) NSUInteger currentIndex;
|
||||
@property (nonatomic, strong) UIPageViewController *pageVC;
|
||||
@@ -238,18 +234,16 @@
|
||||
[self.captionLabel.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-8],
|
||||
]];
|
||||
|
||||
// Swipe down to dismiss
|
||||
UIPanGestureRecognizer *pan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(handleDismissPan:)];
|
||||
pan.delegate = (id<UIGestureRecognizerDelegate>)self;
|
||||
[self.view addGestureRecognizer:pan];
|
||||
|
||||
// Single tap toggles chrome
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(toggleChrome)];
|
||||
tap.cancelsTouchesInView = NO;
|
||||
[self.pageVC.view addGestureRecognizer:tap];
|
||||
|
||||
// For photos, let double-tap zoom work without triggering single-tap
|
||||
for (UIGestureRecognizer *gr in self.pageVC.view.gestureRecognizers) {
|
||||
if ([gr isKindOfClass:[UITapGestureRecognizer class]] && ((UITapGestureRecognizer *)gr).numberOfTapsRequired == 1) {
|
||||
// Already have our tap
|
||||
}
|
||||
}
|
||||
|
||||
[self updateChrome];
|
||||
}
|
||||
|
||||
@@ -290,6 +284,45 @@
|
||||
}];
|
||||
}
|
||||
|
||||
- (BOOL)gestureRecognizerShouldBegin:(UIPanGestureRecognizer *)gr {
|
||||
if (![gr isKindOfClass:[UIPanGestureRecognizer class]]) return YES;
|
||||
CGPoint v = [gr velocityInView:self.view];
|
||||
return fabs(v.y) > fabs(v.x) && v.y > 0;
|
||||
}
|
||||
|
||||
- (void)handleDismissPan:(UIPanGestureRecognizer *)gr {
|
||||
CGFloat ty = [gr translationInView:self.view].y;
|
||||
CGFloat h = self.view.bounds.size.height;
|
||||
CGFloat progress = fmin(fmax(ty / h, 0), 1);
|
||||
|
||||
switch (gr.state) {
|
||||
case UIGestureRecognizerStateChanged: {
|
||||
self.view.transform = CGAffineTransformMakeTranslation(0, ty);
|
||||
self.view.alpha = 1.0 - progress * 0.5;
|
||||
break;
|
||||
}
|
||||
case UIGestureRecognizerStateEnded:
|
||||
case UIGestureRecognizerStateCancelled: {
|
||||
CGFloat vy = [gr velocityInView:self.view].y;
|
||||
if (progress > 0.25 || vy > 800) {
|
||||
[UIView animateWithDuration:0.2 animations:^{
|
||||
self.view.transform = CGAffineTransformMakeTranslation(0, h);
|
||||
self.view.alpha = 0;
|
||||
} completion:^(BOOL finished) {
|
||||
[self dismissViewControllerAnimated:NO completion:nil];
|
||||
}];
|
||||
} else {
|
||||
[UIView animateWithDuration:0.25 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0 options:0 animations:^{
|
||||
self.view.transform = CGAffineTransformIdentity;
|
||||
self.view.alpha = 1;
|
||||
} completion:nil];
|
||||
}
|
||||
break;
|
||||
}
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)closeTapped {
|
||||
// Pause any playing video
|
||||
UIViewController *current = self.pageVC.viewControllers.firstObject;
|
||||
@@ -424,7 +457,7 @@
|
||||
_SCIMediaViewerContainerVC *vc = [[_SCIMediaViewerContainerVC alloc] init];
|
||||
vc.items = items;
|
||||
vc.currentIndex = index;
|
||||
vc.modalPresentationStyle = UIModalPresentationFullScreen;
|
||||
vc.modalPresentationStyle = UIModalPresentationOverFullScreen;
|
||||
vc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
|
||||
[topMostController() presentViewController:vc animated:YES completion:nil];
|
||||
});
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 MiB |
@@ -1,4 +1,5 @@
|
||||
#import "Manager.h"
|
||||
#import "../ActionButton/SCIMediaActions.h"
|
||||
|
||||
@implementation SCIDownloadManager
|
||||
|
||||
@@ -31,8 +32,6 @@
|
||||
|
||||
// URLSession methods
|
||||
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
|
||||
NSLog(@"Task wrote %lld bytes of %lld bytes", bytesWritten, totalBytesExpectedToWrite);
|
||||
|
||||
float progress = (float)totalBytesWritten / (float)totalBytesExpectedToWrite;
|
||||
|
||||
[self.delegate downloadDidProgress:progress];
|
||||
@@ -46,8 +45,7 @@
|
||||
}
|
||||
|
||||
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
|
||||
NSLog(@"Task completed with error: %@", error);
|
||||
|
||||
if (error) NSLog(@"[SCInsta] Download error: %@", error);
|
||||
[self.delegate downloadDidFinishWithError:error];
|
||||
}
|
||||
|
||||
@@ -56,7 +54,8 @@
|
||||
NSFileManager *fileManager = [NSFileManager defaultManager];
|
||||
|
||||
NSString *cacheDirectoryPath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
|
||||
NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", NSUUID.UUID.UUIDString, self.fileExtension]];
|
||||
NSString *stem = [SCIMediaActions currentFilenameStem] ?: NSUUID.UUID.UUIDString;
|
||||
NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", stem, self.fileExtension]];
|
||||
|
||||
NSLog(@"[SCInsta] Download Handler: Moving file from: %@ to: %@", oldPath.absoluteString, newPath.absoluteString);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import <objc/runtime.h>
|
||||
@@ -212,16 +213,13 @@ static IGMedia *sciFeedMediaFromButton(UIView *button) {
|
||||
|
||||
if (![SCIUtils getBoolPref:@"feed_action_button"]) return;
|
||||
|
||||
UIButton *btn = (UIButton *)[self viewWithTag:kFeedActionBtnTag];
|
||||
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kFeedActionBtnTag];
|
||||
if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil;
|
||||
if (!btn) {
|
||||
btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:21 diameter:36];
|
||||
btn.tag = kFeedActionBtnTag;
|
||||
|
||||
UIImageSymbolConfiguration *cfg =
|
||||
[UIImageSymbolConfiguration configurationWithPointSize:21 weight:UIImageSymbolWeightRegular];
|
||||
[btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor labelColor];
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
btn.iconTint = [UIColor labelColor];
|
||||
btn.bubbleColor = [UIColor clearColor];
|
||||
[self addSubview:btn];
|
||||
|
||||
// Position: right side, left of bookmark. Shifted up 4pt to
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import <objc/runtime.h>
|
||||
@@ -119,17 +120,14 @@ static id sciReelsMediaProvider(UIView *sourceView) {
|
||||
if (![SCIUtils getBoolPref:@"reels_action_button"]) return;
|
||||
if (!self.superview) return;
|
||||
|
||||
UIButton *btn = (UIButton *)[self viewWithTag:kReelActionBtnTag];
|
||||
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kReelActionBtnTag];
|
||||
if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil;
|
||||
|
||||
if (!btn) {
|
||||
btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = kReelActionBtnTag;
|
||||
|
||||
UIImageSymbolConfiguration *symCfg =
|
||||
[UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold];
|
||||
UIImage *base = [UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:symCfg];
|
||||
// Bake the drop shadow into a single UIImage so no CALayer shadow is
|
||||
// applied to the button itself.
|
||||
// Bake the drop shadow into the image so no CALayer shadow is needed.
|
||||
CGFloat pad = 8;
|
||||
CGSize sz = CGSizeMake(base.size.width + pad * 2, base.size.height + pad * 2);
|
||||
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz];
|
||||
@@ -144,11 +142,20 @@ static id sciReelsMediaProvider(UIView *sourceView) {
|
||||
CGContextRestoreGState(c);
|
||||
}];
|
||||
|
||||
[btn setImage:icon forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor whiteColor];
|
||||
btn = [[SCIChromeButton alloc] initWithSymbol:@"" pointSize:0 diameter:40];
|
||||
btn.tag = kReelActionBtnTag;
|
||||
btn.bubbleColor = [UIColor clearColor];
|
||||
btn.iconView.image = icon;
|
||||
|
||||
// Capsule configuration gives us the native dark platter animation
|
||||
// when the menu opens/closes — behaviour parity with IG's own chrome.
|
||||
UIButtonConfiguration *cfg = [UIButtonConfiguration plainButtonConfiguration];
|
||||
cfg.cornerStyle = UIButtonConfigurationCornerStyleCapsule;
|
||||
cfg.background.backgroundColor = [UIColor clearColor];
|
||||
cfg.contentInsets = NSDirectionalEdgeInsetsZero;
|
||||
btn.configuration = cfg;
|
||||
|
||||
self.clipsToBounds = NO;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:btn];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
@@ -159,7 +166,6 @@ static id sciReelsMediaProvider(UIView *sourceView) {
|
||||
]];
|
||||
}
|
||||
|
||||
// Reconfigure with fresh media provider.
|
||||
[SCIActionButton configureButton:btn
|
||||
context:SCIActionContextReels
|
||||
prefKey:@"reels_action_default"
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook IGDirectThreadCallButtonsCoordinator
|
||||
// Voice Call
|
||||
- (void)_didTapAudioButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"call_confirm"]) {
|
||||
NSLog(@"[SCInsta] Call confirm triggered");
|
||||
|
||||
// 426+ dropped the sender arg
|
||||
- (void)_didTapAudioButton {
|
||||
if ([SCIUtils getBoolPref:@"voice_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)_didTapVideoButton {
|
||||
if ([SCIUtils getBoolPref:@"video_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-426 signatures
|
||||
- (void)_didTapAudioButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"voice_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
// Video Call
|
||||
- (void)_didTapVideoButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"call_confirm"]) {
|
||||
NSLog(@"[SCInsta] Call confirm triggered");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"video_call_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
|
||||
@@ -1,41 +1,81 @@
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
// Reels like tap goes through a Swift class method on
|
||||
// IGSundialViewerLikeButtonActionHandler since IG 426.
|
||||
typedef void (*SciHandleTapFn)(Class, SEL, id, id, BOOL);
|
||||
typedef void (*SciHandleTapCompFn)(Class, SEL, id, id, BOOL, id);
|
||||
static SciHandleTapFn orig_sciHandleTap = NULL;
|
||||
static SciHandleTapCompFn orig_sciHandleTapComp = NULL;
|
||||
|
||||
// Confirmation handlers
|
||||
static void new_sciHandleTap(Class cls, SEL _cmd, id ctx, id btn, BOOL anim) {
|
||||
if (![SCIUtils getBoolPref:@"like_confirm_reels"]) {
|
||||
orig_sciHandleTap(cls, _cmd, ctx, btn, anim);
|
||||
return;
|
||||
}
|
||||
__strong id sCtx = ctx;
|
||||
__strong id sBtn = btn;
|
||||
[SCIUtils showConfirmation:^{
|
||||
@try { orig_sciHandleTap(cls, _cmd, sCtx, sBtn, anim); }
|
||||
@catch (__unused id e) {}
|
||||
}];
|
||||
}
|
||||
|
||||
#define CONFIRMPOSTLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm"]) { \
|
||||
NSLog(@"[SCInsta] Confirm post like triggered"); \
|
||||
\
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
} \
|
||||
else { \
|
||||
return orig; \
|
||||
} \
|
||||
// Copy the completion block — it's a stack block and won't survive the alert.
|
||||
static void new_sciHandleTapComp(Class cls, SEL _cmd, id ctx, id btn, BOOL anim, id comp) {
|
||||
if (![SCIUtils getBoolPref:@"like_confirm_reels"]) {
|
||||
orig_sciHandleTapComp(cls, _cmd, ctx, btn, anim, comp);
|
||||
return;
|
||||
}
|
||||
__strong id sCtx = ctx;
|
||||
__strong id sBtn = btn;
|
||||
id sComp = comp ? [comp copy] : nil;
|
||||
[SCIUtils showConfirmation:^{
|
||||
@try { orig_sciHandleTapComp(cls, _cmd, sCtx, sBtn, anim, sComp); }
|
||||
@catch (__unused id e) {}
|
||||
}];
|
||||
}
|
||||
|
||||
#define CONFIRMREELSLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm_reels"]) { \
|
||||
NSLog(@"[SCInsta] Confirm reels like triggered"); \
|
||||
\
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
} \
|
||||
else { \
|
||||
return orig; \
|
||||
} \
|
||||
__attribute__((constructor)) static void _sciHookReelsLikeHandler(void) {
|
||||
Class c = NSClassFromString(@"_TtC30IGSundialOverlayActionHandlers38IGSundialViewerLikeButtonActionHandler");
|
||||
if (!c) return;
|
||||
Class meta = object_getClass(c);
|
||||
SEL s1 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:");
|
||||
SEL s2 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:completion:");
|
||||
if (class_getClassMethod(c, s1))
|
||||
MSHookMessageEx(meta, s1, (IMP)new_sciHandleTap, (IMP *)&orig_sciHandleTap);
|
||||
if (class_getClassMethod(c, s2))
|
||||
MSHookMessageEx(meta, s2, (IMP)new_sciHandleTapComp, (IMP *)&orig_sciHandleTapComp);
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////
|
||||
#define CONFIRMPOSTLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm"]) \
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
else return orig;
|
||||
|
||||
#define CONFIRMREELSLIKE(orig) \
|
||||
if ([SCIUtils getBoolPref:@"like_confirm_reels"]) \
|
||||
[SCIUtils showConfirmation:^(void) { orig; }]; \
|
||||
else return orig;
|
||||
|
||||
// Liking posts
|
||||
%hook IGUFIButtonBarView
|
||||
- (void)_onLikeButtonPressed:(id)arg1 {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)_onLikeButtonPressed {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
%hook IGFeedPhotoView
|
||||
- (void)_onDoubleTap:(id)arg1 {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)_onDoubleTap {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
%hook IGVideoPlayerOverlayContainerView
|
||||
- (void)_handleDoubleTapGesture:(id)arg1 {
|
||||
@@ -48,9 +88,6 @@
|
||||
- (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)controlsOverlayControllerDidLongPressLikeButton:(id)arg1 gestureRecognizer:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
@@ -62,6 +99,9 @@
|
||||
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)swift_photoCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
%hook IGSundialViewerCarouselCell
|
||||
- (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 {
|
||||
@@ -70,6 +110,9 @@
|
||||
- (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
- (void)carouselCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 {
|
||||
CONFIRMREELSLIKE(%orig);
|
||||
}
|
||||
%end
|
||||
|
||||
// Liking comments
|
||||
@@ -96,53 +139,9 @@
|
||||
}
|
||||
%end
|
||||
|
||||
// Liking stories
|
||||
%hook IGStoryFullscreenDefaultFooterView
|
||||
- (void)_handleLikeTapped {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)_likeTapped {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
- (void)inputView:(id)arg1 didTapLikeButton:(id)arg2 {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
}
|
||||
// Story like/emoji confirm handled by SCIStoryInteractionPipeline.
|
||||
|
||||
// For some stupid reason they removed the "liketapped" methods on newer Instagram versions
|
||||
// Now we have to do a shitty workaround instead :(
|
||||
// Works 99% of the time, but sometimes clicks get through directly to the like button (somehow)
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"like_confirm"]) return;
|
||||
|
||||
UIButton *likeButton = [self valueForKey:@"likeButton"];
|
||||
if (!likeButton) return;
|
||||
|
||||
// 129115 = L(12) I(9) K(11) E(5)
|
||||
static NSInteger kOverlayTag = 129115;
|
||||
if ([likeButton viewWithTag:kOverlayTag]) return;
|
||||
|
||||
UIButton *overlay = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
overlay.tag = kOverlayTag;
|
||||
overlay.frame = likeButton.bounds;
|
||||
overlay.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[overlay addTarget:self action:@selector(overlayTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[likeButton addSubview:overlay];
|
||||
}
|
||||
|
||||
%new - (void)overlayTapped:(UIButton *)overlay {
|
||||
UIButton *likeButton = (UIButton *)overlay.superview;
|
||||
|
||||
[SCIUtils showConfirmation:^{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[likeButton sendActionsForControlEvents:UIControlEventTouchUpInside];
|
||||
});
|
||||
}];
|
||||
}
|
||||
%end
|
||||
|
||||
// DM like button (seems to be hidden)
|
||||
// DM like button
|
||||
%hook IGDirectThreadViewController
|
||||
- (void)_didTapLikeButton {
|
||||
CONFIRMPOSTLIKE(%orig);
|
||||
|
||||
@@ -1,13 +1,24 @@
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// Split by _analyticsModule: "highlight" substring → highlights toggle, else stories toggle.
|
||||
|
||||
static BOOL sciTapIsHighlight(id target) {
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(target), "_analyticsModule");
|
||||
if (!iv) return NO;
|
||||
id v = nil;
|
||||
@try { v = object_getIvar(target, iv); } @catch (__unused id e) { return NO; }
|
||||
if (![v isKindOfClass:[NSString class]]) return NO;
|
||||
return [((NSString *)v).lowercaseString containsString:@"highlight"];
|
||||
}
|
||||
|
||||
%hook IGStoryViewerTapTarget
|
||||
- (void)_didTap:(id)arg1 forEvent:(id)arg2 {
|
||||
if ([SCIUtils getBoolPref:@"sticker_interact_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm sticker interact triggered");
|
||||
|
||||
NSString *key = sciTapIsHighlight(self) ? @"sticker_interact_confirm_highlights" : @"sticker_interact_confirm";
|
||||
if ([SCIUtils getBoolPref:key]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
%end
|
||||
%end
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// Hooks installed iff sci_exp_flags_enabled.
|
||||
// Override: MetaLocalExperiment group{,Peek}Name — substring-match _experimentName, return "test"/nil.
|
||||
// View-only: IGMobileConfigContextManager get{Bool,Int64,Double,String}[:withDefault:] — record, no override.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "SCIExpFlags.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// MetaLocalExperiment
|
||||
|
||||
static NSString *experimentNameOf(id obj) {
|
||||
if (!obj) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
|
||||
if (!iv) return nil;
|
||||
@try {
|
||||
id v = object_getIvar(obj, iv);
|
||||
if ([v isKindOfClass:[NSString class]]) return v;
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id overrideGroupFor(NSString *expName, id origGroup) {
|
||||
if (!expName.length) return origGroup;
|
||||
NSString *lower = expName.lowercaseString;
|
||||
for (NSString *key in [SCIExpFlags allOverriddenNames]) {
|
||||
if (![lower containsString:key.lowercaseString]) continue;
|
||||
SCIExpFlagOverride o = [SCIExpFlags overrideForName:key];
|
||||
if (o == SCIExpFlagOverrideTrue) return @"test";
|
||||
if (o == SCIExpFlagOverrideFalse) return nil;
|
||||
}
|
||||
return origGroup;
|
||||
}
|
||||
|
||||
static id (*orig_groupName)(id, SEL);
|
||||
static id new_groupName(id self, SEL _cmd) {
|
||||
id orig = orig_groupName ? orig_groupName(self, _cmd) : nil;
|
||||
NSString *name = experimentNameOf(self);
|
||||
[SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil];
|
||||
return overrideGroupFor(name, orig);
|
||||
}
|
||||
|
||||
static id (*orig_peekGroupName)(id, SEL);
|
||||
static id new_peekGroupName(id self, SEL _cmd) {
|
||||
id orig = orig_peekGroupName ? orig_peekGroupName(self, _cmd) : nil;
|
||||
NSString *name = experimentNameOf(self);
|
||||
[SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil];
|
||||
return overrideGroupFor(name, orig);
|
||||
}
|
||||
|
||||
// IGMobileConfigContextManager — view-only.
|
||||
// param arg is {uint64} struct, ABI-identical to unsigned long long on arm64.
|
||||
|
||||
static BOOL (*orig_mcBool)(id, SEL, unsigned long long);
|
||||
static BOOL new_mcBool(id self, SEL _cmd, unsigned long long pid) {
|
||||
BOOL v = orig_mcBool(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:v ? @"YES" : @"NO"];
|
||||
return v;
|
||||
}
|
||||
static BOOL (*orig_mcBool_def)(id, SEL, unsigned long long, BOOL);
|
||||
static BOOL new_mcBool_def(id self, SEL _cmd, unsigned long long pid, BOOL def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:def ? @"YES" : @"NO"];
|
||||
return orig_mcBool_def(self, _cmd, pid, def);
|
||||
}
|
||||
static long long (*orig_mcInt)(id, SEL, unsigned long long);
|
||||
static long long new_mcInt(id self, SEL _cmd, unsigned long long pid) {
|
||||
long long v = orig_mcInt(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", v]];
|
||||
return v;
|
||||
}
|
||||
static long long (*orig_mcInt_def)(id, SEL, unsigned long long, long long);
|
||||
static long long new_mcInt_def(id self, SEL _cmd, unsigned long long pid, long long def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", def]];
|
||||
return orig_mcInt_def(self, _cmd, pid, def);
|
||||
}
|
||||
static double (*orig_mcDouble)(id, SEL, unsigned long long);
|
||||
static double new_mcDouble(id self, SEL _cmd, unsigned long long pid) {
|
||||
double v = orig_mcDouble(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", v]];
|
||||
return v;
|
||||
}
|
||||
static double (*orig_mcDouble_def)(id, SEL, unsigned long long, double);
|
||||
static double new_mcDouble_def(id self, SEL _cmd, unsigned long long pid, double def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", def]];
|
||||
return orig_mcDouble_def(self, _cmd, pid, def);
|
||||
}
|
||||
static id (*orig_mcString)(id, SEL, unsigned long long);
|
||||
static id new_mcString(id self, SEL _cmd, unsigned long long pid) {
|
||||
id v = orig_mcString(self, _cmd, pid);
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[v description] ?: @""];
|
||||
return v;
|
||||
}
|
||||
static id (*orig_mcString_def)(id, SEL, unsigned long long, id);
|
||||
static id new_mcString_def(id self, SEL _cmd, unsigned long long pid, id def) {
|
||||
[SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[def description] ?: @""];
|
||||
return orig_mcString_def(self, _cmd, pid, def);
|
||||
}
|
||||
|
||||
// install
|
||||
|
||||
static void install(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
|
||||
if (!cls) return;
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if (!class_getInstanceMethod(cls, s)) return;
|
||||
MSHookMessageEx(cls, s, newImp, origOut);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"sci_exp_flags_enabled"]) return;
|
||||
|
||||
if ([SCIExpFlags checkAndHandleCrashLoop]) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[SCIUtils showToastForDuration:4.0 title:@"Exp flags reset after repeated crashes"];
|
||||
});
|
||||
}
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{ [SCIExpFlags markLaunchStable]; });
|
||||
|
||||
// Family inherits Meta — one install covers both
|
||||
Class meta = NSClassFromString(@"MetaLocalExperiment");
|
||||
install(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName);
|
||||
install(meta, @"peekGroupName", (IMP)new_peekGroupName, (IMP *)&orig_peekGroupName);
|
||||
|
||||
Class mc = NSClassFromString(@"IGMobileConfigContextManager");
|
||||
install(mc, @"getBool:", (IMP)new_mcBool, (IMP *)&orig_mcBool);
|
||||
install(mc, @"getBool:withDefault:", (IMP)new_mcBool_def, (IMP *)&orig_mcBool_def);
|
||||
install(mc, @"getInt64:", (IMP)new_mcInt, (IMP *)&orig_mcInt);
|
||||
install(mc, @"getInt64:withDefault:", (IMP)new_mcInt_def, (IMP *)&orig_mcInt_def);
|
||||
install(mc, @"getDouble:", (IMP)new_mcDouble, (IMP *)&orig_mcDouble);
|
||||
install(mc, @"getDouble:withDefault:", (IMP)new_mcDouble_def, (IMP *)&orig_mcDouble_def);
|
||||
install(mc, @"getString:", (IMP)new_mcString, (IMP *)&orig_mcString);
|
||||
install(mc, @"getString:withDefault:", (IMP)new_mcString_def, (IMP *)&orig_mcString_def);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Exp flag override store + observation logs.
|
||||
// Override works only for MetaLocalExperiment (name-substring match on _experimentName).
|
||||
// MC reads + scanned names are view-only — no reliable name→ID mapping.
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIExpFlagOverride) {
|
||||
SCIExpFlagOverrideOff = 0,
|
||||
SCIExpFlagOverrideTrue = 1,
|
||||
SCIExpFlagOverrideFalse = 2,
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIExpMCType) {
|
||||
SCIExpMCTypeBool,
|
||||
SCIExpMCTypeInt,
|
||||
SCIExpMCTypeDouble,
|
||||
SCIExpMCTypeString,
|
||||
};
|
||||
|
||||
@interface SCIExpObservation : NSObject
|
||||
@property (nonatomic, copy) NSString *experimentName;
|
||||
@property (nonatomic, copy) NSString *lastGroup;
|
||||
@property (nonatomic, assign) NSUInteger hitCount;
|
||||
@end
|
||||
|
||||
@interface SCIExpMCObservation : NSObject
|
||||
@property (nonatomic, assign) unsigned long long paramID;
|
||||
@property (nonatomic, assign) SCIExpMCType type;
|
||||
@property (nonatomic, copy) NSString *lastDefault;
|
||||
@property (nonatomic, assign) NSUInteger hitCount;
|
||||
@end
|
||||
|
||||
@interface SCIExpFlags : NSObject
|
||||
|
||||
// overrides (persisted)
|
||||
+ (SCIExpFlagOverride)overrideForName:(NSString *)name;
|
||||
+ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name;
|
||||
+ (NSArray<NSString *> *)allOverriddenNames;
|
||||
+ (void)resetAllOverrides;
|
||||
|
||||
// meta observations (live)
|
||||
+ (void)recordExperimentName:(NSString *)name group:(NSString *)group;
|
||||
+ (NSArray<SCIExpObservation *> *)allObservations;
|
||||
|
||||
// MC id observations (live, view-only)
|
||||
+ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def;
|
||||
+ (NSArray<SCIExpMCObservation *> *)allMCObservations;
|
||||
|
||||
// binary-scanned names (bg, cb on main)
|
||||
+ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray<NSString *> *names))completion;
|
||||
|
||||
// crash-loop guard — 3 bad launches wipe overrides
|
||||
+ (BOOL)checkAndHandleCrashLoop;
|
||||
+ (void)markLaunchStable;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,187 @@
|
||||
#import "SCIExpFlags.h"
|
||||
#import <sys/mman.h>
|
||||
#import <sys/stat.h>
|
||||
#import <fcntl.h>
|
||||
|
||||
static NSString *const kOverridesKey = @"sci_exp_overrides_by_name";
|
||||
static NSString *const kCrashCounterKey = @"sci_exp_flags_unstable_launches";
|
||||
static const NSInteger kCrashThreshold = 3;
|
||||
|
||||
@implementation SCIExpObservation
|
||||
@end
|
||||
@implementation SCIExpMCObservation
|
||||
@end
|
||||
|
||||
@implementation SCIExpFlags
|
||||
|
||||
// overrides
|
||||
|
||||
+ (NSMutableDictionary *)loadOverrides {
|
||||
NSDictionary *d = [[NSUserDefaults standardUserDefaults] dictionaryForKey:kOverridesKey];
|
||||
return d ? [d mutableCopy] : [NSMutableDictionary dictionary];
|
||||
}
|
||||
|
||||
+ (void)saveOverrides:(NSDictionary *)d {
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
if (d.count == 0) [ud removeObjectForKey:kOverridesKey];
|
||||
else [ud setObject:d forKey:kOverridesKey];
|
||||
}
|
||||
|
||||
+ (SCIExpFlagOverride)overrideForName:(NSString *)name {
|
||||
if (!name.length) return SCIExpFlagOverrideOff;
|
||||
NSNumber *n = [self loadOverrides][name];
|
||||
return n ? (SCIExpFlagOverride)n.integerValue : SCIExpFlagOverrideOff;
|
||||
}
|
||||
|
||||
+ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name {
|
||||
if (!name.length) return;
|
||||
NSMutableDictionary *d = [self loadOverrides];
|
||||
if (o == SCIExpFlagOverrideOff) [d removeObjectForKey:name];
|
||||
else d[name] = @(o);
|
||||
[self saveOverrides:d];
|
||||
}
|
||||
|
||||
+ (NSArray<NSString *> *)allOverriddenNames { return [[self loadOverrides] allKeys]; }
|
||||
+ (void)resetAllOverrides { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kOverridesKey]; }
|
||||
|
||||
// meta observations
|
||||
|
||||
static NSMutableDictionary<NSString *, SCIExpObservation *> *gMetaObs = nil;
|
||||
static dispatch_queue_t metaQueue(void) {
|
||||
static dispatch_queue_t q;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.meta", DISPATCH_QUEUE_CONCURRENT); });
|
||||
return q;
|
||||
}
|
||||
|
||||
+ (void)recordExperimentName:(NSString *)name group:(NSString *)group {
|
||||
if (!name.length) return;
|
||||
dispatch_barrier_async(metaQueue(), ^{
|
||||
if (!gMetaObs) gMetaObs = [NSMutableDictionary dictionary];
|
||||
SCIExpObservation *o = gMetaObs[name];
|
||||
if (!o) { o = [SCIExpObservation new]; o.experimentName = name; gMetaObs[name] = o; }
|
||||
o.lastGroup = group;
|
||||
o.hitCount++;
|
||||
});
|
||||
}
|
||||
|
||||
+ (NSArray<SCIExpObservation *> *)allObservations {
|
||||
__block NSArray *snap = @[];
|
||||
dispatch_sync(metaQueue(), ^{ snap = gMetaObs ? [gMetaObs.allValues copy] : @[]; });
|
||||
return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpObservation *a, SCIExpObservation *b) {
|
||||
return [a.experimentName caseInsensitiveCompare:b.experimentName];
|
||||
}];
|
||||
}
|
||||
|
||||
// MC observations (view-only)
|
||||
|
||||
static NSMutableDictionary<NSNumber *, SCIExpMCObservation *> *gMCObs = nil;
|
||||
static dispatch_queue_t mcQueue(void) {
|
||||
static dispatch_queue_t q;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.mc", DISPATCH_QUEUE_CONCURRENT); });
|
||||
return q;
|
||||
}
|
||||
|
||||
+ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def {
|
||||
dispatch_barrier_async(mcQueue(), ^{
|
||||
if (!gMCObs) gMCObs = [NSMutableDictionary dictionary];
|
||||
NSNumber *k = @(pid);
|
||||
SCIExpMCObservation *o = gMCObs[k];
|
||||
if (!o) { o = [SCIExpMCObservation new]; o.paramID = pid; o.type = t; gMCObs[k] = o; }
|
||||
o.lastDefault = def ?: @"";
|
||||
o.hitCount++;
|
||||
});
|
||||
}
|
||||
|
||||
+ (NSArray<SCIExpMCObservation *> *)allMCObservations {
|
||||
__block NSArray *snap = @[];
|
||||
dispatch_sync(mcQueue(), ^{ snap = gMCObs ? [gMCObs.allValues copy] : @[]; });
|
||||
// hot flags first
|
||||
return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpMCObservation *a, SCIExpMCObservation *b) {
|
||||
if (a.hitCount != b.hitCount) return a.hitCount > b.hitCount ? NSOrderedAscending : NSOrderedDescending;
|
||||
if (a.paramID < b.paramID) return NSOrderedAscending;
|
||||
if (a.paramID > b.paramID) return NSOrderedDescending;
|
||||
return NSOrderedSame;
|
||||
}];
|
||||
}
|
||||
|
||||
// crash-loop guard
|
||||
|
||||
+ (BOOL)checkAndHandleCrashLoop {
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
NSInteger c = [ud integerForKey:kCrashCounterKey] + 1;
|
||||
if (c >= kCrashThreshold && [self loadOverrides].count > 0) {
|
||||
[self resetAllOverrides];
|
||||
[ud removeObjectForKey:kCrashCounterKey];
|
||||
return YES;
|
||||
}
|
||||
[ud setInteger:c forKey:kCrashCounterKey];
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ (void)markLaunchStable { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCrashCounterKey]; }
|
||||
|
||||
// binary scan — mmap executable, grep for flag-prefix strings, dedupe/sort
|
||||
|
||||
+ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray<NSString *> *))completion {
|
||||
dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{
|
||||
NSArray *names = [self scanExecutable];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ completion(names ?: @[]); });
|
||||
});
|
||||
}
|
||||
|
||||
+ (NSArray<NSString *> *)scanExecutable {
|
||||
NSString *path = [[NSBundle mainBundle] executablePath];
|
||||
if (!path) return @[];
|
||||
int fd = open(path.UTF8String, O_RDONLY);
|
||||
if (fd < 0) return @[];
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0 || st.st_size <= 0) { close(fd); return @[]; }
|
||||
size_t size = (size_t)st.st_size;
|
||||
const char *base = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
|
||||
close(fd);
|
||||
if (base == MAP_FAILED) return @[];
|
||||
|
||||
// Meta flag/analytics name prefixes
|
||||
static const char *prefixes[] = {
|
||||
"ig_ios_", "ig_android_", "ig_direct_", "ig_feed_", "ig_reels_",
|
||||
"ig_stories_", "ig_explore_", "ig_camera_", "ig_growth_", "ig_privacy_",
|
||||
"fbios_", "fb_ios_"
|
||||
};
|
||||
const size_t pc = sizeof(prefixes) / sizeof(prefixes[0]);
|
||||
NSMutableSet *seen = [NSMutableSet set];
|
||||
|
||||
for (size_t i = 0; i < size; i++) {
|
||||
char c = base[i];
|
||||
if (c != 'i' && c != 'f') continue;
|
||||
if (i > 0) {
|
||||
char prev = base[i - 1];
|
||||
if ((prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '_' || prev == '.') continue;
|
||||
}
|
||||
size_t matched = 0;
|
||||
const char *rem = base + i;
|
||||
size_t left = size - i;
|
||||
for (size_t p = 0; p < pc; p++) {
|
||||
size_t L = strlen(prefixes[p]);
|
||||
if (left >= L && memcmp(rem, prefixes[p], L) == 0) { matched = L; break; }
|
||||
}
|
||||
if (!matched) continue;
|
||||
size_t j = i + matched;
|
||||
while (j < size) {
|
||||
char ch = base[j];
|
||||
if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.')) break;
|
||||
j++;
|
||||
}
|
||||
size_t nl = j - i;
|
||||
if (nl >= 16 && nl <= 160) {
|
||||
NSString *s = [[NSString alloc] initWithBytes:(base + i) length:nl encoding:NSASCIIStringEncoding];
|
||||
if (s) [seen addObject:s];
|
||||
}
|
||||
i = j;
|
||||
}
|
||||
munmap((void *)base, size);
|
||||
return [[seen allObjects] sortedArrayUsingSelector:@selector(compare:)];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,60 @@
|
||||
// Direct Notes experimental reply types + friend map. Gates: igt_directnotes_*.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
#include "../../../modules/fishhook/fishhook.h"
|
||||
|
||||
static inline BOOL prefFriendMap(void) { return [SCIUtils getBoolPref:@"igt_directnotes_friendmap"]; }
|
||||
static inline BOOL prefAudio(void) { return [SCIUtils getBoolPref:@"igt_directnotes_audio_reply"]; }
|
||||
static inline BOOL prefAvatar(void) { return [SCIUtils getBoolPref:@"igt_directnotes_avatar_reply"]; }
|
||||
static inline BOOL prefGifs(void) { return [SCIUtils getBoolPref:@"igt_directnotes_gifs_reply"]; }
|
||||
static inline BOOL prefPhoto(void) { return [SCIUtils getBoolPref:@"igt_directnotes_photo_reply"]; }
|
||||
|
||||
static BOOL rep_friendmap(void) { return prefFriendMap(); }
|
||||
static BOOL rep_audio(void) { return prefAudio(); }
|
||||
static BOOL rep_avatar(void) { return prefAvatar(); }
|
||||
static BOOL rep_gifs(void) { return prefGifs(); }
|
||||
static BOOL rep_photo(void) { return prefPhoto(); }
|
||||
|
||||
static inline BOOL containsAny(NSString *s, NSArray<NSString *> *needles) {
|
||||
if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO;
|
||||
NSString *lower = s.lowercaseString;
|
||||
for (NSString *n in needles) if ([lower containsString:n]) return YES;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL matchesDirectNotes(NSString *name) {
|
||||
if (prefFriendMap() && containsAny(name, @[@"friendmap", @"friends_map",
|
||||
@"ig_ios_friendmap_", @"friendmapenabled"])) return YES;
|
||||
if (prefAudio() && containsAny(name, @[@"audio"])) return YES;
|
||||
if (prefAvatar() && containsAny(name, @[@"avatar"])) return YES;
|
||||
if (prefGifs() && containsAny(name, @[@"gifs", @"sticker"])) return YES;
|
||||
if (prefPhoto() && containsAny(name, @[@"photo"])) return YES;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_isIn)(id, SEL, id) = NULL;
|
||||
static BOOL new_isIn(id self, SEL _cmd, id name) {
|
||||
if (matchesDirectNotes(name)) return YES;
|
||||
return orig_isIn ? orig_isIn(self, _cmd, name) : NO;
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (!(prefFriendMap() || prefAudio() || prefAvatar() || prefGifs() || prefPhoto())) return;
|
||||
|
||||
struct rebinding binds[] = {
|
||||
{"IGDirectNotesFriendMapEnabled", (void *)rep_friendmap, NULL},
|
||||
{"IGDirectNotesEnableAudioNoteReplyType", (void *)rep_audio, NULL},
|
||||
{"IGDirectNotesEnableAvatarReplyTypes", (void *)rep_avatar, NULL},
|
||||
{"IGDirectNotesEnableGifsStickersReplyTypes", (void *)rep_gifs, NULL},
|
||||
{"IGDirectNotesEnablePhotoNoteReplyType", (void *)rep_photo, NULL},
|
||||
};
|
||||
rebind_symbols(binds, sizeof(binds) / sizeof(binds[0]));
|
||||
|
||||
Class helper = NSClassFromString(@"IGDirectNotesExperimentHelper");
|
||||
SEL sel = NSSelectorFromString(@"isInExperiment:");
|
||||
if (helper && class_getInstanceMethod(helper, sel)) {
|
||||
MSHookMessageEx(helper, sel, (IMP)new_isIn, (IMP *)&orig_isIn);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
// Experiment-name substring override. Gates: igt_quicksnap, igt_directnotes_friendmap, igt_prism.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static inline BOOL containsAny(NSString *s, NSArray<NSString *> *needles) {
|
||||
if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO;
|
||||
NSString *lower = s.lowercaseString;
|
||||
for (NSString *n in needles) if ([lower containsString:n]) return YES;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL matchQuickSnap(NSString *name) {
|
||||
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return NO;
|
||||
return containsAny(name, @[@"quicksnap", @"quick_snap", @"instants", @"xma_quicksnap",
|
||||
@"_ig_ios_quicksnap_", @"_ig_ios_quick_snap_", @"_ig_ios_instants_"]);
|
||||
}
|
||||
|
||||
static BOOL matchFriendMap(NSString *name) {
|
||||
if (![SCIUtils getBoolPref:@"igt_directnotes_friendmap"]) return NO;
|
||||
return containsAny(name, @[@"friendmap", @"friends_map", @"direct_notes",
|
||||
@"ig_direct_notes_ios", @"_ig_ios_friendmap_", @"_ig_ios_friends_map_"]);
|
||||
}
|
||||
|
||||
static BOOL matchPrism(NSString *name) {
|
||||
if (![SCIUtils getBoolPref:@"igt_prism"]) return NO;
|
||||
return containsAny(name, @[@"prism"]);
|
||||
}
|
||||
|
||||
static inline BOOL shouldForceOn(NSString *name) {
|
||||
return matchQuickSnap(name) || matchFriendMap(name) || matchPrism(name);
|
||||
}
|
||||
|
||||
static NSString *expNameOf(id obj) {
|
||||
if (!obj) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName");
|
||||
if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
|
||||
if (!iv) return nil;
|
||||
@try {
|
||||
id v = object_getIvar(obj, iv);
|
||||
if ([v isKindOfClass:[NSString class]]) return v;
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL (*orig_meta_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_meta_isIn(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return YES;
|
||||
return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_family_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_family_isIn(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return YES;
|
||||
return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL;
|
||||
static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) {
|
||||
if (shouldForceOn(name)) return YES;
|
||||
return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO;
|
||||
}
|
||||
|
||||
static id (*orig_groupName)(id, SEL) = NULL;
|
||||
static id new_groupName(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return @"test";
|
||||
return orig_groupName ? orig_groupName(self, _cmd) : nil;
|
||||
}
|
||||
|
||||
static id (*orig_peekGroup)(id, SEL) = NULL;
|
||||
static id new_peekGroup(id self, SEL _cmd) {
|
||||
if (shouldForceOn(expNameOf(self))) return @"test";
|
||||
return orig_peekGroup ? orig_peekGroup(self, _cmd) : nil;
|
||||
}
|
||||
|
||||
static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
|
||||
if (!cls) return;
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if (!class_getInstanceMethod(cls, s)) return;
|
||||
MSHookMessageEx(cls, s, newImp, origOut);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (!([SCIUtils getBoolPref:@"igt_quicksnap"] ||
|
||||
[SCIUtils getBoolPref:@"igt_directnotes_friendmap"] ||
|
||||
[SCIUtils getBoolPref:@"igt_prism"])) return;
|
||||
|
||||
Class meta = NSClassFromString(@"MetaLocalExperiment");
|
||||
hook(meta, @"isInExperiment", (IMP)new_meta_isIn, (IMP *)&orig_meta_isIn);
|
||||
hook(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName);
|
||||
hook(meta, @"peekGroupName", (IMP)new_peekGroup, (IMP *)&orig_peekGroup);
|
||||
hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment",
|
||||
(IMP)new_family_isIn, (IMP *)&orig_family_isIn);
|
||||
hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:",
|
||||
(IMP)new_lid_enabled, (IMP *)&orig_lid_enabled);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Force-enable Homecoming nav experiment. Gate: igt_homecoming.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static NSString *expNameOf(id obj) {
|
||||
if (!obj) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName");
|
||||
if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName");
|
||||
if (!iv) return nil;
|
||||
@try {
|
||||
id v = object_getIvar(obj, iv);
|
||||
if ([v isKindOfClass:[NSString class]]) return v;
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static inline BOOL matchesHomecoming(NSString *s) {
|
||||
return [s isKindOfClass:[NSString class]] && s.length &&
|
||||
[s.lowercaseString containsString:@"homecoming"];
|
||||
}
|
||||
|
||||
static BOOL (*orig_meta_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_meta_isIn(id self, SEL _cmd) {
|
||||
if (matchesHomecoming(expNameOf(self))) return YES;
|
||||
return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_family_isIn)(id, SEL) = NULL;
|
||||
static BOOL new_family_isIn(id self, SEL _cmd) {
|
||||
if (matchesHomecoming(expNameOf(self))) return YES;
|
||||
return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL;
|
||||
static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) {
|
||||
if (matchesHomecoming(name)) return YES;
|
||||
return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_nav_isHC)(id, SEL) = NULL;
|
||||
static BOOL new_nav_isHC(id self, SEL _cmd) { return YES; }
|
||||
|
||||
static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) {
|
||||
if (!cls) return;
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if (!class_getInstanceMethod(cls, s)) return;
|
||||
MSHookMessageEx(cls, s, newImp, origOut);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"igt_homecoming"]) return;
|
||||
|
||||
hook(NSClassFromString(@"MetaLocalExperiment"), @"isInExperiment",
|
||||
(IMP)new_meta_isIn, (IMP *)&orig_meta_isIn);
|
||||
hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment",
|
||||
(IMP)new_family_isIn, (IMP *)&orig_family_isIn);
|
||||
hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:",
|
||||
(IMP)new_lid_enabled, (IMP *)&orig_lid_enabled);
|
||||
hook(NSClassFromString(@"_TtC18IGNavConfiguration18IGNavConfiguration"),
|
||||
@"isHomecomingEnabled", (IMP)new_nav_isHC, (IMP *)&orig_nav_isHC);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Force-enable QuickSnap (Instants) surfaces. Gate: igt_quicksnap.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
#define QS_BOOL1_RETURN_YES(fnName) \
|
||||
static BOOL (*orig_##fnName)(id, SEL, id) = NULL; \
|
||||
static BOOL new_##fnName(id self, SEL _cmd, id arg) { return YES; }
|
||||
|
||||
QS_BOOL1_RETURN_YES(qs_enabled)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_feed)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_inbox)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_stories)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_peek)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_tray)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_tray_peek)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_tray_pog)
|
||||
QS_BOOL1_RETURN_YES(qs_enabled_empty_pog)
|
||||
QS_BOOL1_RETURN_YES(qs_isqp)
|
||||
|
||||
static BOOL (*orig_qs_corner)(id, SEL) = NULL;
|
||||
static BOOL new_qs_corner(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_qs_dialog)(id, SEL) = NULL;
|
||||
static BOOL new_qs_dialog(id self, SEL _cmd) { return YES; }
|
||||
|
||||
static BOOL (*orig_peek)(id, SEL) = NULL;
|
||||
static BOOL new_peek(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_recap)(id, SEL) = NULL;
|
||||
static BOOL new_recap(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig__recap)(id, SEL) = NULL;
|
||||
static BOOL new__recap(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_recap_media)(id, SEL) = NULL;
|
||||
static BOOL new_recap_media(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_recap_video)(id, SEL) = NULL;
|
||||
static BOOL new_recap_video(id self, SEL _cmd) { return YES; }
|
||||
static BOOL (*orig_hidden)(id, SEL) = NULL;
|
||||
static BOOL new_hidden(id self, SEL _cmd) { return NO; }
|
||||
static BOOL (*orig__hidden)(id, SEL) = NULL;
|
||||
static BOOL new__hidden(id self, SEL _cmd) { return NO; }
|
||||
|
||||
static void hookClassMethod(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls) return;
|
||||
Class meta = object_getClass(cls);
|
||||
if (!meta) return;
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
if (!class_getInstanceMethod(meta, sel)) return;
|
||||
MSHookMessageEx(meta, sel, impl, orig);
|
||||
}
|
||||
|
||||
static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
if (!class_getInstanceMethod(cls, sel)) return;
|
||||
MSHookMessageEx(cls, sel, impl, orig);
|
||||
}
|
||||
|
||||
static void hookZeroArgAcross(NSArray<NSString *> *classes, NSString *sn, IMP impl, IMP *orig) {
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
for (NSString *cn in classes) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls || !class_getInstanceMethod(cls, sel)) continue;
|
||||
MSHookMessageEx(cls, sel, impl, orig);
|
||||
}
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return;
|
||||
|
||||
NSString *helper = @"_TtC26IGQuickSnapExperimentation32IGQuickSnapExperimentationHelper";
|
||||
hookClassMethod(helper, @"isQuicksnapEnabled:", (IMP)new_qs_enabled, (IMP *)&orig_qs_enabled);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInFeed:", (IMP)new_qs_enabled_feed, (IMP *)&orig_qs_enabled_feed);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInInbox:", (IMP)new_qs_enabled_inbox, (IMP *)&orig_qs_enabled_inbox);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInStories:", (IMP)new_qs_enabled_stories, (IMP *)&orig_qs_enabled_stories);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTray:", (IMP)new_qs_enabled_tray, (IMP *)&orig_qs_enabled_tray);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPeek:", (IMP)new_qs_enabled_tray_peek, (IMP *)&orig_qs_enabled_tray_peek);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPog:", (IMP)new_qs_enabled_tray_pog, (IMP *)&orig_qs_enabled_tray_pog);
|
||||
hookClassMethod(helper, @"isQuicksnapNotesTrayEmptyPogEnabled:", (IMP)new_qs_enabled_empty_pog, (IMP *)&orig_qs_enabled_empty_pog);
|
||||
hookClassMethod(helper, @"isQuicksnapEnabledAsPeek:", (IMP)new_qs_enabled_peek, (IMP *)&orig_qs_enabled_peek);
|
||||
|
||||
NSString *tray = @"_TtC21IGNotesTrayController21IGNotesTrayController";
|
||||
hookInstance(tray, @"_isEligibleForQuicksnapCornerStackTransitionDialog", (IMP)new_qs_corner, (IMP *)&orig_qs_corner);
|
||||
hookInstance(tray, @"_isEligibleForQuicksnapDialog", (IMP)new_qs_dialog, (IMP *)&orig_qs_dialog);
|
||||
hookInstance(tray, @"isQPEnabled:", (IMP)new_qs_isqp, (IMP *)&orig_qs_isqp);
|
||||
|
||||
hookInstance(@"IGDirectNotesTrayRowSectionController", @"isQPEnabled:", (IMP)new_qs_isqp, NULL);
|
||||
hookInstance(@"_TtC24IGDirectNotesTrayUISwift37IGDirectNotesTrayRowSectionController",
|
||||
@"isQPEnabled:", (IMP)new_qs_isqp, NULL);
|
||||
|
||||
NSArray *instantsClasses = @[
|
||||
@"IGInstantGestureRecognizer",
|
||||
@"IGAPIQuickSnapData",
|
||||
@"XDTQuickSnapData",
|
||||
@"IGAPIQuicksnapRecapMediaInfo",
|
||||
@"XDTQuicksnapRecapMediaInfo",
|
||||
];
|
||||
hookZeroArgAcross(instantsClasses, @"isEligibleForPeek", (IMP)new_peek, (IMP *)&orig_peek);
|
||||
hookZeroArgAcross(instantsClasses, @"isQuicksnapRecap", (IMP)new_recap, (IMP *)&orig_recap);
|
||||
hookZeroArgAcross(instantsClasses, @"_isQuicksnapRecap", (IMP)new__recap, (IMP *)&orig__recap);
|
||||
hookZeroArgAcross(instantsClasses, @"hasQuicksnapRecapMedia",(IMP)new_recap_media, (IMP *)&orig_recap_media);
|
||||
hookZeroArgAcross(instantsClasses, @"isInstantsRecapVideo", (IMP)new_recap_video, (IMP *)&orig_recap_video);
|
||||
|
||||
NSString *svc = @"_TtC18IGQuickSnapService18IGQuickSnapService";
|
||||
hookInstance(svc, @"isHiddenByServer", (IMP)new_hidden, (IMP *)&orig_hidden);
|
||||
hookInstance(svc, @"_isHiddenByServer", (IMP)new__hidden, (IMP *)&orig__hidden);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// MobileConfig override for any ig_boolForKey: naming QuickSnap. Gate: igt_quicksnap.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static inline BOOL keyMatchesQuickSnap(id key) {
|
||||
if (![key isKindOfClass:[NSString class]]) return NO;
|
||||
NSString *s = ((NSString *)key).lowercaseString;
|
||||
return [s containsString:@"quicksnap"] ||
|
||||
[s containsString:@"quick_snap"] ||
|
||||
[s containsString:@"instants"] ||
|
||||
[s containsString:@"xma_quicksnap"];
|
||||
}
|
||||
|
||||
static BOOL (*orig_bool_key)(id, SEL, id) = NULL;
|
||||
static BOOL new_bool_key(id self, SEL _cmd, id key) {
|
||||
if (keyMatchesQuickSnap(key)) return YES;
|
||||
return orig_bool_key ? orig_bool_key(self, _cmd, key) : NO;
|
||||
}
|
||||
|
||||
static BOOL (*orig_bool_key_def)(id, SEL, id, BOOL) = NULL;
|
||||
static BOOL new_bool_key_def(id self, SEL _cmd, id key, BOOL def) {
|
||||
if (keyMatchesQuickSnap(key)) return YES;
|
||||
return orig_bool_key_def ? orig_bool_key_def(self, _cmd, key, def) : def;
|
||||
}
|
||||
|
||||
static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) {
|
||||
Class cls = NSClassFromString(cn);
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(sn);
|
||||
if (!class_getInstanceMethod(cls, sel)) return;
|
||||
MSHookMessageEx(cls, sel, impl, orig);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return;
|
||||
|
||||
hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key);
|
||||
hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def);
|
||||
hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key);
|
||||
hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Crash-loop guard + pref registry for igt_* experimental flags.
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface SCIExperimentalGuard : NSObject
|
||||
|
||||
+ (NSArray<NSString *> *)allPrefKeys;
|
||||
+ (BOOL)anyEnabled;
|
||||
+ (void)resetAll;
|
||||
+ (BOOL)didResetThisLaunch;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,68 @@
|
||||
#import "SCIExperimentalGuard.h"
|
||||
#import "../../Utils.h"
|
||||
|
||||
static NSString *const kCounterKey = @"sci_exp_unstable_launches";
|
||||
static NSInteger const kThreshold = 3;
|
||||
static BOOL gDidReset = NO;
|
||||
|
||||
@implementation SCIExperimentalGuard
|
||||
|
||||
+ (NSArray<NSString *> *)allPrefKeys {
|
||||
static NSArray *keys;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
keys = @[
|
||||
@"igt_homecoming",
|
||||
@"igt_quicksnap",
|
||||
@"igt_prism",
|
||||
@"igt_directnotes_friendmap",
|
||||
@"igt_directnotes_audio_reply",
|
||||
@"igt_directnotes_avatar_reply",
|
||||
@"igt_directnotes_gifs_reply",
|
||||
@"igt_directnotes_photo_reply",
|
||||
];
|
||||
});
|
||||
return keys;
|
||||
}
|
||||
|
||||
+ (BOOL)anyEnabled {
|
||||
for (NSString *k in [self allPrefKeys]) {
|
||||
if ([SCIUtils getBoolPref:k]) return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
+ (void)resetAll {
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
for (NSString *k in [self allPrefKeys]) [ud setBool:NO forKey:k];
|
||||
}
|
||||
|
||||
+ (BOOL)didResetThisLaunch { return gDidReset; }
|
||||
|
||||
+ (void)load {
|
||||
if (![self anyEnabled]) return;
|
||||
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
NSInteger c = [ud integerForKey:kCounterKey] + 1;
|
||||
|
||||
if (c >= kThreshold) {
|
||||
[self resetAll];
|
||||
[ud removeObjectForKey:kCounterKey];
|
||||
gDidReset = YES;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[SCIUtils showToastForDuration:5.0
|
||||
title:SCILocalized(@"Experimental flags reset")
|
||||
subtitle:SCILocalized(@"Disabled after repeated crashes.")];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[ud setInteger:c forKey:kCounterKey];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:kCounterKey];
|
||||
});
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -1,32 +1,61 @@
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// IGFeedPlayback.IGFeedPlaybackStrategy gained new init parameters in IG 423+.
|
||||
// Both the 2-arg and 3-arg variants are hooked to force shouldDisableAutoplay=YES.
|
||||
// Hooked via MSHookMessageEx in %ctor since the class has a Swift-mangled name.
|
||||
// IGFeedPlayback.IGFeedPlaybackStrategy has a Swift-mangled name. Both init
|
||||
// variants force shouldDisableAutoplay=YES when the pref is on.
|
||||
|
||||
static id (*orig_initStrategy2)(id, SEL, BOOL, BOOL);
|
||||
static id new_initStrategy2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
|
||||
static id (*orig_feedInit2)(id, SEL, BOOL, BOOL);
|
||||
static id new_feedInit2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
|
||||
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
|
||||
return orig_initStrategy2(self, _cmd, shouldDisable, shouldClearStale);
|
||||
return orig_feedInit2(self, _cmd, shouldDisable, shouldClearStale);
|
||||
}
|
||||
|
||||
static id (*orig_initStrategy3)(id, SEL, BOOL, BOOL, BOOL);
|
||||
static id new_initStrategy3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) {
|
||||
static id (*orig_feedInit3)(id, SEL, BOOL, BOOL, BOOL);
|
||||
static id new_feedInit3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) {
|
||||
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
|
||||
return orig_initStrategy3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
|
||||
return orig_feedInit3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
|
||||
}
|
||||
|
||||
// Carousel tap-to-play. The modern feed video cell receives single-taps via
|
||||
// this delegate callback, but the Swift implementation skips resume when the
|
||||
// cell sits inside a carousel. Force retryStartPlayback after orig.
|
||||
static void (*orig_cellDidSingleTap)(id, SEL, id, id);
|
||||
static void new_cellDidSingleTap(id self, SEL _cmd, id overlay, id gr) {
|
||||
orig_cellDidSingleTap(self, _cmd, overlay, gr);
|
||||
if (![SCIUtils getBoolPref:@"disable_feed_autoplay"]) return;
|
||||
UIView *sv = [(UIView *)self superview];
|
||||
if (!sv || !strstr(class_getName([sv class]), "Carousel")) return;
|
||||
if ([self respondsToSelector:@selector(retryStartPlayback)])
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(retryStartPlayback));
|
||||
}
|
||||
|
||||
static void sciHookFeedStrategy(void) {
|
||||
Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy");
|
||||
if (!cls) return;
|
||||
SEL s2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:);
|
||||
if ([cls instancesRespondToSelector:s2])
|
||||
MSHookMessageEx(cls, s2, (IMP)new_feedInit2, (IMP *)&orig_feedInit2);
|
||||
SEL s3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:);
|
||||
if ([cls instancesRespondToSelector:s3])
|
||||
MSHookMessageEx(cls, s3, (IMP)new_feedInit3, (IMP *)&orig_feedInit3);
|
||||
}
|
||||
|
||||
static void sciHookVideoCell(void) {
|
||||
static BOOL hooked = NO;
|
||||
if (hooked) return;
|
||||
Class cls = objc_getClass("IGModernFeedVideoCell.IGModernFeedVideoCell");
|
||||
if (!cls) return;
|
||||
SEL s = @selector(videoPlayerOverlayControllerDidSingleTap:gestureRecognizer:);
|
||||
if (![cls instancesRespondToSelector:s]) return;
|
||||
MSHookMessageEx(cls, s, (IMP)new_cellDidSingleTap, (IMP *)&orig_cellDidSingleTap);
|
||||
hooked = YES;
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy");
|
||||
if (!cls) return;
|
||||
|
||||
SEL sel2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:);
|
||||
if ([cls instancesRespondToSelector:sel2])
|
||||
MSHookMessageEx(cls, sel2, (IMP)new_initStrategy2, (IMP *)&orig_initStrategy2);
|
||||
|
||||
SEL sel3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:);
|
||||
if ([cls instancesRespondToSelector:sel3])
|
||||
MSHookMessageEx(cls, sel3, (IMP)new_initStrategy3, (IMP *)&orig_initStrategy3);
|
||||
sciHookFeedStrategy();
|
||||
sciHookVideoCell();
|
||||
// Swift cell class can load after dylib init; retry on main runloop.
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ sciHookVideoCell(); });
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
// Quick fake-location toggle injected into IG's Friends Map (DMs > Maps).
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../Settings/SCIFakeLocationSettingsVC.h"
|
||||
#import "../../Settings/SCIFakeLocationPickerVC.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static const NSInteger kSciMapBtnTag = 0x5C1F4B;
|
||||
static const NSInteger kSciMapBtnTag = 0x5C1F4B;
|
||||
static const NSInteger kSciMapHitBtnTag = 0x5C1F4C;
|
||||
|
||||
static UIViewController *sciTopMost(void) {
|
||||
UIWindow *win = nil;
|
||||
@@ -179,6 +181,8 @@ static UIMenu *sciBuildMapMenu(void) {
|
||||
static void sciRemoveMapButton(UIView *mapView) {
|
||||
UIView *btn = [mapView viewWithTag:kSciMapBtnTag];
|
||||
if (btn) [btn removeFromSuperview];
|
||||
UIView *hit = [mapView viewWithTag:kSciMapHitBtnTag];
|
||||
if (hit) [hit removeFromSuperview];
|
||||
}
|
||||
|
||||
static void sciAddMapButton(UIView *mapView) {
|
||||
@@ -186,40 +190,55 @@ static void sciAddMapButton(UIView *mapView) {
|
||||
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) { sciRemoveMapButton(mapView); return; }
|
||||
if ([mapView viewWithTag:kSciMapBtnTag]) return;
|
||||
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = kSciMapBtnTag;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
btn.backgroundColor = [UIColor secondarySystemBackgroundColor];
|
||||
btn.layer.cornerRadius = 24;
|
||||
btn.layer.shadowColor = [UIColor blackColor].CGColor;
|
||||
btn.layer.shadowOpacity = 0.18;
|
||||
btn.layer.shadowRadius = 5;
|
||||
btn.layer.shadowOffset = CGSizeMake(0, 2);
|
||||
btn.showsMenuAsPrimaryAction = YES;
|
||||
btn.menu = sciBuildMapMenu();
|
||||
|
||||
// Refresh menu on each press so toggle/preset state is current.
|
||||
[btn addAction:[UIAction actionWithHandler:^(__unused UIAction *a) {
|
||||
btn.menu = sciBuildMapMenu();
|
||||
}] forControlEvents:UIControlEventMenuActionTriggered];
|
||||
|
||||
[mapView addSubview:btn];
|
||||
// Visible chrome — static, never absorbed into the menu platter animation.
|
||||
BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"];
|
||||
SCIChromeButton *chrome = [[SCIChromeButton alloc] initWithSymbol:on ? @"location.fill" : @"location.slash"
|
||||
pointSize:18
|
||||
diameter:48];
|
||||
chrome.tag = kSciMapBtnTag;
|
||||
chrome.bubbleColor = [UIColor secondarySystemBackgroundColor];
|
||||
chrome.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor];
|
||||
chrome.layer.shadowColor = [UIColor blackColor].CGColor;
|
||||
chrome.layer.shadowOpacity = 0.18;
|
||||
chrome.layer.shadowRadius = 5;
|
||||
chrome.layer.shadowOffset = CGSizeMake(0, 2);
|
||||
chrome.userInteractionEnabled = NO;
|
||||
[mapView addSubview:chrome];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16],
|
||||
[btn.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78],
|
||||
[btn.widthAnchor constraintEqualToConstant:48],
|
||||
[btn.heightAnchor constraintEqualToConstant:48],
|
||||
[chrome.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16],
|
||||
[chrome.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78],
|
||||
[chrome.widthAnchor constraintEqualToConstant:48],
|
||||
[chrome.heightAnchor constraintEqualToConstant:48],
|
||||
]];
|
||||
|
||||
// Invisible hit target owns the menu; visible chrome below stays put
|
||||
// when UIKit absorbs the hit into the menu platter on dismiss.
|
||||
UIButton *hit = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
hit.tag = kSciMapHitBtnTag;
|
||||
hit.backgroundColor = [UIColor clearColor];
|
||||
hit.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
hit.showsMenuAsPrimaryAction = YES;
|
||||
hit.menu = sciBuildMapMenu();
|
||||
[hit addAction:[UIAction actionWithHandler:^(__unused UIAction *a) {
|
||||
hit.menu = sciBuildMapMenu();
|
||||
}] forControlEvents:UIControlEventMenuActionTriggered];
|
||||
[mapView addSubview:hit];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[hit.leadingAnchor constraintEqualToAnchor:chrome.leadingAnchor],
|
||||
[hit.trailingAnchor constraintEqualToAnchor:chrome.trailingAnchor],
|
||||
[hit.topAnchor constraintEqualToAnchor:chrome.topAnchor],
|
||||
[hit.bottomAnchor constraintEqualToAnchor:chrome.bottomAnchor],
|
||||
]];
|
||||
}
|
||||
|
||||
static void sciRefreshMapButton(UIView *mapView) {
|
||||
UIButton *btn = (UIButton *)[mapView viewWithTag:kSciMapBtnTag];
|
||||
if (!btn) return;
|
||||
SCIChromeButton *btn = (SCIChromeButton *)[mapView viewWithTag:kSciMapBtnTag];
|
||||
if (![btn isKindOfClass:[SCIChromeButton class]]) return;
|
||||
BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"];
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
[btn setImage:[UIImage systemImageNamed:on ? @"location.fill" : @"location.slash" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
btn.tintColor = on ? [UIColor systemGreenColor] : [UIColor labelColor];
|
||||
btn.menu = sciBuildMapMenu();
|
||||
btn.symbolName = on ? @"location.fill" : @"location.slash";
|
||||
btn.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor];
|
||||
// Don't touch btn.menu here — reassigning mid-dismiss flickers the button.
|
||||
// UIControlEventMenuActionTriggered rebuilds on next open.
|
||||
}
|
||||
|
||||
static void (*orig_mapLayout)(UIView *, SEL);
|
||||
|
||||
@@ -1,31 +1,172 @@
|
||||
// Explore tab hide toggles.
|
||||
// hide_explore_grid → posts grid + shimmer loader
|
||||
// hide_trending_searches → category chip bar + algo button on the right
|
||||
//
|
||||
// Grid revealing rules: tapping a chip or focusing the search bar counts as
|
||||
// engagement and unhides the grid until the user leaves the Explore tab.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
%hook IGExploreGridViewController
|
||||
- (void)viewDidLoad {
|
||||
if ([SCIUtils getBoolPref:@"hide_explore_grid"]) {
|
||||
NSLog(@"[SCInsta] Hiding explore grid");
|
||||
static BOOL sciHideGrid(void) { return [SCIUtils getBoolPref:@"hide_explore_grid"]; }
|
||||
static BOOL sciHideSearch(void) { return [SCIUtils getBoolPref:@"hide_trending_searches"]; }
|
||||
|
||||
[[self view] removeFromSuperview];
|
||||
static __weak UIViewController *gActiveExploreVC = nil;
|
||||
static BOOL gSearchFocused = NO;
|
||||
static BOOL gUserEngaged = NO;
|
||||
|
||||
return;
|
||||
// MARK: - Hide helpers
|
||||
|
||||
// Alpha + userInteraction instead of .hidden keeps IG's data fetch and the
|
||||
// shimmer animation alive, so toggling the pref back on shows fresh content
|
||||
// instantly without a restart.
|
||||
static void sciSetViewVisuallyHidden(UIView *v, BOOL hidden) {
|
||||
if (!v) return;
|
||||
v.alpha = hidden ? 0.0 : 1.0;
|
||||
v.userInteractionEnabled = !hidden;
|
||||
}
|
||||
|
||||
static void sciSetIvarViewHidden(id host, const char *name, BOOL hidden) {
|
||||
Ivar iv = class_getInstanceVariable([host class], name);
|
||||
if (!iv) return;
|
||||
@try {
|
||||
UIView *v = object_getIvar(host, iv);
|
||||
if ([v isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(v, hidden);
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
static void sciApplyExploreHide(id vc) {
|
||||
// Chips stay visible while search is focused (they act as filters then).
|
||||
BOOL hideChips = sciHideSearch() && !gSearchFocused;
|
||||
sciSetIvarViewHidden(vc, "_nidoChipBar", hideChips);
|
||||
|
||||
// Force re-layout so pref flips reflect on re-entry.
|
||||
Ivar stvIvar = class_getInstanceVariable([vc class], "_searchTitleView");
|
||||
if (stvIvar) {
|
||||
@try {
|
||||
UIView *tv = object_getIvar(vc, stvIvar);
|
||||
if ([tv isKindOfClass:[UIView class]]) {
|
||||
[tv setNeedsLayout];
|
||||
[tv layoutIfNeeded];
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
return %orig;
|
||||
|
||||
// Grid reveals on chip tap or search focus.
|
||||
BOOL hideGrid = sciHideGrid() && !gUserEngaged && !gSearchFocused;
|
||||
sciSetIvarViewHidden(vc, "_shimmeringGridView", hideGrid);
|
||||
|
||||
Ivar gvcIvar = class_getInstanceVariable([vc class], "_gridViewController");
|
||||
if (!gvcIvar) return;
|
||||
@try {
|
||||
UIViewController *grid = object_getIvar(vc, gvcIvar);
|
||||
if (![grid isKindOfClass:[UIViewController class]] || !grid.isViewLoaded) return;
|
||||
sciSetViewVisuallyHidden(grid.view, hideGrid);
|
||||
Ivar cvIvar = class_getInstanceVariable([grid class], "_collectionView");
|
||||
if (cvIvar) {
|
||||
UIView *cv = object_getIvar(grid, cvIvar);
|
||||
if ([cv isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(cv, hideGrid);
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
// Algo button vs Cancel: both are IGTapButton siblings of the search bar.
|
||||
// Cancel has a UIButtonLabel (the "Cancel" text); the algo button is square
|
||||
// with just an icon child.
|
||||
static BOOL sciIsAlgoButton(UIView *btn) {
|
||||
if (btn.bounds.size.width != btn.bounds.size.height) return NO;
|
||||
for (UIView *sub in btn.subviews) {
|
||||
if ([sub isKindOfClass:[UILabel class]]) return NO;
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
// MARK: - VC hooks
|
||||
|
||||
%group HideExploreGroup
|
||||
|
||||
%hook IGExploreViewController
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
gActiveExploreVC = self;
|
||||
sciApplyExploreHide(self);
|
||||
}
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
%orig;
|
||||
gActiveExploreVC = self;
|
||||
sciApplyExploreHide(self);
|
||||
}
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
%orig;
|
||||
gUserEngaged = NO;
|
||||
gSearchFocused = NO;
|
||||
}
|
||||
- (void)exploreChipBarView:(id)bar didSelectChipAtIndex:(NSInteger)idx {
|
||||
%orig;
|
||||
gUserEngaged = YES;
|
||||
sciApplyExploreHide(self);
|
||||
[self.view setNeedsLayout];
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGExploreViewController
|
||||
- (void)viewDidLoad {
|
||||
%hook IGAnimatablePlaceholderTextField
|
||||
- (BOOL)becomeFirstResponder {
|
||||
BOOL r = %orig;
|
||||
gSearchFocused = YES;
|
||||
if (gActiveExploreVC) {
|
||||
sciApplyExploreHide(gActiveExploreVC);
|
||||
[gActiveExploreVC.view setNeedsLayout];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
- (BOOL)resignFirstResponder {
|
||||
BOOL r = %orig;
|
||||
gSearchFocused = NO;
|
||||
if (gActiveExploreVC) {
|
||||
sciApplyExploreHide(gActiveExploreVC);
|
||||
[gActiveExploreVC.view setNeedsLayout];
|
||||
}
|
||||
return r;
|
||||
}
|
||||
%end
|
||||
|
||||
// Hook the search title view's own layout — catches every relayout at the
|
||||
// source, so hiding the algo button + stretching the bar has no lagged frame.
|
||||
%hook IGExploreSearchTitleView
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
BOOL hide = sciHideSearch();
|
||||
Class tapBtnCls = NSClassFromString(@"IGTapButton");
|
||||
Class dotCls = NSClassFromString(@"IGDSDotView");
|
||||
Class searchCls = NSClassFromString(@"IGSearchBar");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"hide_explore_grid"]) {
|
||||
NSLog(@"[SCInsta] Hiding explore grid");
|
||||
|
||||
IGShimmeringGridView *shimmeringGridView = MSHookIvar<IGShimmeringGridView *>(self, "_shimmeringGridView");
|
||||
if (shimmeringGridView != nil) {
|
||||
[shimmeringGridView removeFromSuperview];
|
||||
UIView *searchBar = nil;
|
||||
for (UIView *sub in self.subviews) {
|
||||
if (searchCls && [sub isKindOfClass:searchCls]) {
|
||||
searchBar = sub;
|
||||
} else if (tapBtnCls && [sub isKindOfClass:tapBtnCls] && sciIsAlgoButton(sub)) {
|
||||
sub.hidden = hide;
|
||||
} else if (dotCls && [sub isKindOfClass:dotCls]) {
|
||||
sub.hidden = hide;
|
||||
}
|
||||
}
|
||||
if (searchBar && hide) {
|
||||
CGFloat target = self.bounds.size.width;
|
||||
if (searchBar.frame.size.width != target) {
|
||||
CGRect f = searchBar.frame;
|
||||
f.size.width = target;
|
||||
searchBar.frame = f;
|
||||
}
|
||||
}
|
||||
}
|
||||
%end
|
||||
%end
|
||||
|
||||
%end // HideExploreGroup
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"hide_explore_grid"] ||
|
||||
[SCIUtils getBoolPref:@"hide_trending_searches"]) {
|
||||
%init(HideExploreGroup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Hide suggested stories from the tray. Only filters when suggested items
|
||||
// are present — skips clean inputs to avoid IGListKit diff cascade.
|
||||
// Hide suggested stories from the feed tray. The adapter hook is shared
|
||||
// with profile highlights, so we key off diffIdentifier: only suggested
|
||||
// items use a 32-char hex UUID (real users use numeric PKs, highlights use
|
||||
// "highlight:<pk>"). Default-keep on anything ambiguous.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
@@ -7,15 +9,27 @@
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static BOOL sciIsSuggestedTrayItem(id obj) {
|
||||
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO;
|
||||
static BOOL sciIsHexUUIDString(NSString *s) {
|
||||
if (s.length != 32) return NO;
|
||||
static NSCharacterSet *nonHex;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdef"] invertedSet];
|
||||
});
|
||||
return [s rangeOfCharacterFromSet:nonHex].location == NSNotFound;
|
||||
}
|
||||
|
||||
static BOOL sciIsSuggestedTrayItem(id obj) {
|
||||
@try {
|
||||
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO;
|
||||
if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) return NO;
|
||||
|
||||
NSString *diffId = nil;
|
||||
@try { diffId = [[obj performSelector:@selector(diffIdentifier)] description]; } @catch (...) {}
|
||||
if (!sciIsHexUUIDString(diffId)) return NO;
|
||||
|
||||
id owner = [obj valueForKey:@"reelOwner"];
|
||||
if (!owner) return NO;
|
||||
|
||||
Ivar userIvar = class_getInstanceVariable([owner class], "_userReelOwner_user");
|
||||
if (!userIvar) return NO;
|
||||
id igUser = object_getIvar(owner, userIvar);
|
||||
@@ -25,14 +39,11 @@ static BOOL sciIsSuggestedTrayItem(id obj) {
|
||||
for (Class c = [igUser class]; c && !fcIvar; c = class_getSuperclass(c))
|
||||
fcIvar = class_getInstanceVariable(c, "_fieldCache");
|
||||
if (!fcIvar) return NO;
|
||||
|
||||
id fc = object_getIvar(igUser, fcIvar);
|
||||
if (![fc isKindOfClass:[NSDictionary class]]) return NO;
|
||||
if ([(NSDictionary *)fc count] == 0) return YES;
|
||||
|
||||
id fs = [(NSDictionary *)fc objectForKey:@"friendship_status"];
|
||||
if (!fs) return NO;
|
||||
|
||||
return ![[fs valueForKey:@"following"] boolValue];
|
||||
} @catch (__unused NSException *e) {
|
||||
return NO;
|
||||
@@ -42,15 +53,13 @@ static BOOL sciIsSuggestedTrayItem(id obj) {
|
||||
static NSArray *(*orig_objectsForListAdapter)(id, SEL, id);
|
||||
static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
|
||||
NSArray *objects = orig_objectsForListAdapter(self, _cmd, adapter);
|
||||
|
||||
if (![SCIUtils getBoolPref:@"hide_suggested_stories"]) return objects;
|
||||
|
||||
// Pass through unchanged when input has no suggestions (avoids cascade).
|
||||
BOOL hasSuggested = NO;
|
||||
BOOL anySuggested = NO;
|
||||
for (id obj in objects) {
|
||||
if (sciIsSuggestedTrayItem(obj)) { hasSuggested = YES; break; }
|
||||
if (sciIsSuggestedTrayItem(obj)) { anySuggested = YES; break; }
|
||||
}
|
||||
if (!hasSuggested) return objects;
|
||||
if (!anySuggested) return objects;
|
||||
|
||||
NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count];
|
||||
for (id obj in objects) {
|
||||
@@ -60,10 +69,9 @@ static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class dsCls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
|
||||
if (!dsCls) return;
|
||||
|
||||
Class cls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(@"objectsForListAdapter:");
|
||||
if (class_getInstanceMethod(dsCls, sel))
|
||||
MSHookMessageEx(dsCls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
|
||||
if (!class_getInstanceMethod(cls, sel)) return;
|
||||
MSHookMessageEx(cls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
// Hide the trending-searches pill bar under the explore search bar.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
|
||||
%group HideTrendingSearchesGroup
|
||||
%hook IGDSSegmentedPillBarView
|
||||
- (void)didMoveToWindow {
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return;
|
||||
self.hidden = YES;
|
||||
}
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return;
|
||||
self.hidden = YES;
|
||||
}
|
||||
%end
|
||||
%end
|
||||
|
||||
if ([[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) {
|
||||
if ([SCIUtils getBoolPref:@"hide_trending_searches"]) {
|
||||
NSLog(@"[SCInsta] Hiding trending searches");
|
||||
|
||||
[self removeFromSuperview];
|
||||
}
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"hide_trending_searches"]) {
|
||||
%init(HideTrendingSearchesGroup);
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
|
||||
static BOOL sciMsgOnlyHideTabBar(void) {
|
||||
return sciMsgOnly() && [SCIUtils getBoolPref:@"messages_only_hide_tabbar"];
|
||||
}
|
||||
|
||||
%hook IGTabBarController
|
||||
|
||||
@@ -30,6 +34,21 @@ static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
|
||||
}
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
if (!sciMsgOnlyHideTabBar()) return;
|
||||
Ivar tbIv = class_getInstanceVariable([self class], "_tabBar");
|
||||
UIView *tabBar = tbIv ? object_getIvar(self, tbIv) : nil;
|
||||
if (tabBar) {
|
||||
tabBar.hidden = YES;
|
||||
tabBar.alpha = 0.0;
|
||||
}
|
||||
UIViewController *selected = [self valueForKey:@"selectedViewController"];
|
||||
if (selected.isViewLoaded) {
|
||||
selected.view.frame = self.view.bounds;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface enum no longer maps cleanly to the trimmed _buttons array, so flip
|
||||
// the selected state ourselves and nudge the liquid-glass indicator.
|
||||
%new - (void)sciSyncTabBarSelection:(NSString *)which {
|
||||
@@ -63,3 +82,43 @@ static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Floating settings button — long-press on tab bar is gone when it's hidden.
|
||||
static const void *kSCIMsgOnlyBtnKey = &kSCIMsgOnlyBtnKey;
|
||||
|
||||
static void sciMsgOnlyInjectSettingsButton(UIViewController *vc) {
|
||||
if (!sciMsgOnlyHideTabBar() || !vc || !vc.isViewLoaded) return;
|
||||
if (objc_getAssociatedObject(vc, kSCIMsgOnlyBtnKey)) return;
|
||||
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"gearshape"
|
||||
pointSize:18
|
||||
diameter:36];
|
||||
btn.iconTint = [UIColor labelColor];
|
||||
btn.bubbleColor = [UIColor clearColor];
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:vc action:@selector(sciMsgOnlyOpenSettings)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
[vc.view addSubview:btn];
|
||||
|
||||
UILayoutGuide *sa = vc.view.safeAreaLayoutGuide;
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.leadingAnchor constraintEqualToAnchor:sa.leadingAnchor constant:12],
|
||||
[btn.topAnchor constraintEqualToAnchor:sa.topAnchor constant:6],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36],
|
||||
]];
|
||||
|
||||
objc_setAssociatedObject(vc, kSCIMsgOnlyBtnKey, btn, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
%hook IGDirectInboxViewController
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciMsgOnlyInjectSettingsButton((UIViewController *)self);
|
||||
}
|
||||
|
||||
%new - (void)sciMsgOnlyOpenSettings {
|
||||
UIViewController *vc = (UIViewController *)self;
|
||||
[SCIUtils showSettingsVC:vc.view.window];
|
||||
}
|
||||
%end
|
||||
|
||||
@@ -92,12 +92,11 @@ NSArray *filterSurfacesArray(NSArray *surfaces) {
|
||||
}
|
||||
|
||||
- (BOOL)isTabSwipingEnabled {
|
||||
|
||||
// Swipe lands on stripped tabs in messages-only.
|
||||
if ([SCIUtils getBoolPref:@"messages_only"]) return NO;
|
||||
if ([[SCIUtils getStringPref:@"swipe_nav_tabs"] isEqualToString:@"enabled"]) return YES;
|
||||
else if ([[SCIUtils getStringPref:@"swipe_nav_tabs"] isEqualToString:@"disabled"]) return NO;
|
||||
|
||||
return %orig;
|
||||
|
||||
}
|
||||
- (void)setIsTabSwipingEnabled:(BOOL)arg1 {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// Long-press the Explore/search tab to open an IG link from the clipboard.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
static const void *kPasteGestureKey = &kPasteGestureKey;
|
||||
|
||||
// Parse the clipboard string into a URL IG will recognize. Accepts bare
|
||||
// hostnames, canonical IG hosts, and fix-embed mirrors (any host with
|
||||
// "instagram" in it — ddinstagram, eeinstagram, vxinstagram, etc.) which
|
||||
// get rewritten to www.instagram.com.
|
||||
static NSURL *sciNormalizeIGURL(NSString *raw) {
|
||||
if (!raw.length) return nil;
|
||||
raw = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
if (![raw containsString:@"://"]) raw = [@"https://" stringByAppendingString:raw];
|
||||
|
||||
NSURL *url = [NSURL URLWithString:raw];
|
||||
NSString *scheme = url.scheme.lowercaseString;
|
||||
if ([scheme isEqualToString:@"instagram"]) return url;
|
||||
if (![scheme isEqualToString:@"http"] && ![scheme isEqualToString:@"https"]) return nil;
|
||||
|
||||
NSString *host = url.host.lowercaseString;
|
||||
if (!host.length) return nil;
|
||||
|
||||
if ([host isEqualToString:@"instagram.com"]
|
||||
|| [host hasSuffix:@".instagram.com"]
|
||||
|| [host isEqualToString:@"instagr.am"]
|
||||
|| [host isEqualToString:@"ig.me"]) {
|
||||
return url;
|
||||
}
|
||||
|
||||
if ([host containsString:@"instagram"]) {
|
||||
NSURLComponents *comps = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
|
||||
comps.scheme = @"https";
|
||||
comps.host = @"www.instagram.com";
|
||||
return comps.URL;
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
@interface SCIPasteLinkHandler : NSObject <UIGestureRecognizerDelegate>
|
||||
+ (instancetype)shared;
|
||||
- (void)longPressed:(UILongPressGestureRecognizer *)g;
|
||||
@end
|
||||
|
||||
@implementation SCIPasteLinkHandler
|
||||
+ (instancetype)shared {
|
||||
static SCIPasteLinkHandler *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [SCIPasteLinkHandler new]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
// Gate the gesture on the pref. When off, IG's default long-press falls through.
|
||||
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)g {
|
||||
return [SCIUtils getBoolPref:@"paste_link_from_search"];
|
||||
}
|
||||
|
||||
- (void)longPressed:(UILongPressGestureRecognizer *)g {
|
||||
if (g.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
NSURL *url = sciNormalizeIGURL([[UIPasteboard generalPasteboard] string]);
|
||||
if (!url) {
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Clipboard is not an Instagram URL")];
|
||||
return;
|
||||
}
|
||||
|
||||
// https URLs route through universal-link handling, not openURL:options:.
|
||||
UIApplication *app = [UIApplication sharedApplication];
|
||||
id<UIApplicationDelegate> delegate = app.delegate;
|
||||
|
||||
if ([url.scheme.lowercaseString isEqualToString:@"instagram"]) {
|
||||
if ([delegate respondsToSelector:@selector(application:openURL:options:)]) {
|
||||
[delegate application:app openURL:url options:@{}];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSUserActivity *activity = [[NSUserActivity alloc] initWithActivityType:NSUserActivityTypeBrowsingWeb];
|
||||
activity.webpageURL = url;
|
||||
SEL contSel = @selector(application:continueUserActivity:restorationHandler:);
|
||||
if ([delegate respondsToSelector:contSel]) {
|
||||
BOOL handled = [delegate application:app
|
||||
continueUserActivity:activity
|
||||
restorationHandler:^(NSArray<id<UIUserActivityRestoring>> *_Nullable _) {}];
|
||||
if (handled) return;
|
||||
}
|
||||
|
||||
if ([delegate respondsToSelector:@selector(application:openURL:options:)]) {
|
||||
[delegate application:app openURL:url options:@{}];
|
||||
}
|
||||
}
|
||||
@end
|
||||
|
||||
static void sciAttachPasteGesture(UIButton *btn) {
|
||||
if (!btn || objc_getAssociatedObject(btn, kPasteGestureKey)) return;
|
||||
SCIPasteLinkHandler *handler = [SCIPasteLinkHandler shared];
|
||||
UILongPressGestureRecognizer *g = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:handler action:@selector(longPressed:)];
|
||||
g.minimumPressDuration = 0.5;
|
||||
g.delegate = handler;
|
||||
// Cancel the tap so IG's tab-tap doesn't fire after and clobber our nav.
|
||||
g.cancelsTouchesInView = YES;
|
||||
[btn addGestureRecognizer:g];
|
||||
objc_setAssociatedObject(btn, kPasteGestureKey, g, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
%hook IGTabBarController
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
Ivar iv = class_getInstanceVariable([self class], "_exploreButton");
|
||||
if (!iv) return;
|
||||
id btn = object_getIvar(self, iv);
|
||||
if ([btn isKindOfClass:[UIButton class]]) sciAttachPasteGesture(btn);
|
||||
}
|
||||
%end
|
||||
@@ -1,5 +1,6 @@
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../../modules/JGProgressHUD/JGProgressHUD.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
@@ -103,9 +104,6 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
|
||||
NSString *fullName = [sci_valueForAnyKey(user, @[@"fullName", @"fullname", @"name"]) description];
|
||||
NSString *biography = [sci_valueForAnyKey(user, @[@"biography", @"bio", @"profileBiography"]) description];
|
||||
|
||||
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
|
||||
username, fullName, (unsigned long)biography.length);
|
||||
|
||||
UIAlertController *menu = [UIAlertController alertControllerWithTitle:SCILocalized(@"Copy from profile")
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
@@ -154,15 +152,14 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
|
||||
@end
|
||||
|
||||
static UIView *sci_buildCopyButton(void) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"doc.on.doc"
|
||||
pointSize:16
|
||||
diameter:24];
|
||||
btn.accessibilityIdentifier = @"sci-profile-copy-button";
|
||||
btn.accessibilityLabel = @"Copy profile info";
|
||||
UIImageSymbolConfiguration *cfg =
|
||||
[UIImageSymbolConfiguration configurationWithPointSize:16
|
||||
weight:UIImageSymbolWeightRegular];
|
||||
UIImage *icon = [[UIImage systemImageNamed:@"doc.on.doc"] imageByApplyingSymbolConfiguration:cfg];
|
||||
[btn setImage:icon forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor labelColor];
|
||||
btn.iconTint = [UIColor labelColor];
|
||||
btn.bubbleColor = [UIColor clearColor];
|
||||
btn.translatesAutoresizingMaskIntoConstraints = YES;
|
||||
btn.frame = CGRectMake(0, 0, 24, 44);
|
||||
[btn addTarget:[SCIProfileCopyTarget shared]
|
||||
action:@selector(handleTap:)
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Compute and clear Instagram's local caches (Library/Caches, Application
|
||||
// Support, tmp, NSURLCache).
|
||||
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
// Posted on main after a non-transient scan completes. Object is NSNumber.
|
||||
extern NSString *const SCICacheSizeDidUpdateNotification;
|
||||
|
||||
@interface SCICacheManager : NSObject
|
||||
|
||||
// Scan + update cachedSize + persist. Completion on main.
|
||||
+ (void)getCacheSizeWithCompletion:(void(^)(uint64_t bytes))completion;
|
||||
|
||||
// Scan without touching cachedSize / persistence / notification.
|
||||
+ (void)getCacheSizeTransientWithCompletion:(void(^)(uint64_t bytes))completion;
|
||||
|
||||
// Last computed value; lazy-loads from NSUserDefaults on first call.
|
||||
+ (uint64_t)cachedSize;
|
||||
|
||||
+ (void)refreshSizeInBackground;
|
||||
|
||||
// No-op when `cache_auto_check_size` is off.
|
||||
+ (void)refreshSizeInBackgroundIfEnabled;
|
||||
|
||||
// Completion reports bytes reclaimed, on main.
|
||||
+ (void)clearCacheWithCompletion:(void(^)(uint64_t bytesCleared))completion;
|
||||
|
||||
// Fires a silent clear if the configured interval has elapsed. Called from
|
||||
// applicationDidEnterBackground.
|
||||
+ (void)runAutoClearIfDue;
|
||||
|
||||
+ (NSString *)formattedSize:(uint64_t)bytes;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,202 @@
|
||||
#import "SCICacheManager.h"
|
||||
#import <stdatomic.h>
|
||||
#import <fts.h>
|
||||
#import <sys/stat.h>
|
||||
#import <dirent.h>
|
||||
#import <removefile.h>
|
||||
|
||||
static NSString *const kAutoClearModeKey = @"cache_auto_clear_mode";
|
||||
static NSString *const kLastAutoClearKey = @"cache_last_auto_clear_ts";
|
||||
static NSString *const kLastKnownSizeKey = @"cache_last_known_size";
|
||||
|
||||
NSString *const SCICacheSizeDidUpdateNotification = @"SCICacheSizeDidUpdateNotification";
|
||||
|
||||
static _Atomic uint64_t gCachedSize = 0;
|
||||
static dispatch_once_t gLoadPersistedOnce;
|
||||
|
||||
static void sciLoadPersistedSizeOnce(void) {
|
||||
dispatch_once(&gLoadPersistedOnce, ^{
|
||||
uint64_t stored = (uint64_t)[[NSUserDefaults standardUserDefaults] doubleForKey:kLastKnownSizeKey];
|
||||
atomic_store(&gCachedSize, stored);
|
||||
});
|
||||
}
|
||||
|
||||
static NSArray<NSString *> *sciCacheDirs(void) {
|
||||
NSMutableArray *dirs = [NSMutableArray array];
|
||||
NSArray *caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES);
|
||||
if (caches.firstObject) [dirs addObject:caches.firstObject];
|
||||
NSArray *appSupport = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES);
|
||||
if (appSupport.firstObject) [dirs addObject:appSupport.firstObject];
|
||||
NSString *tmp = NSTemporaryDirectory();
|
||||
if (tmp.length) [dirs addObject:tmp];
|
||||
return dirs;
|
||||
}
|
||||
|
||||
// Top-level entry names under any cache root that belong to RyukGram user
|
||||
// data (analyzer snapshots, header cache, future persistent state) and must
|
||||
// survive a cache wipe.
|
||||
static BOOL sciIsProtectedEntryName(const char *name) {
|
||||
return strcmp(name, "RyukGram") == 0;
|
||||
}
|
||||
|
||||
// POSIX fts — avoids the NSDirectoryEnumerator per-entry alloc overhead.
|
||||
static uint64_t sciDirectorySize(NSString *path) {
|
||||
const char *root = [path fileSystemRepresentation];
|
||||
if (!root) return 0;
|
||||
char * const paths[] = { (char *)root, NULL };
|
||||
FTS *fts = fts_open(paths, FTS_PHYSICAL | FTS_NOCHDIR | FTS_XDEV, NULL);
|
||||
if (!fts) return 0;
|
||||
uint64_t total = 0;
|
||||
FTSENT *ent;
|
||||
while ((ent = fts_read(fts))) {
|
||||
// Don't descend into RyukGram user-data subtrees.
|
||||
if (ent->fts_info == FTS_D && ent->fts_level == 1 &&
|
||||
sciIsProtectedEntryName(ent->fts_name)) {
|
||||
fts_set(fts, ent, FTS_SKIP);
|
||||
continue;
|
||||
}
|
||||
if (ent->fts_info == FTS_F && ent->fts_statp) {
|
||||
total += (uint64_t)ent->fts_statp->st_size;
|
||||
}
|
||||
}
|
||||
fts_close(fts);
|
||||
return total;
|
||||
}
|
||||
|
||||
// Recursive delete of directory contents — the top-level dir itself is
|
||||
// preserved so IG's file handles stay valid, and RyukGram subtrees are
|
||||
// skipped so our analyzer snapshots + header cache survive.
|
||||
static void sciDeleteDirectoryContents(NSString *path) {
|
||||
const char *root = [path fileSystemRepresentation];
|
||||
if (!root) return;
|
||||
DIR *dp = opendir(root);
|
||||
if (!dp) return;
|
||||
struct dirent *de;
|
||||
while ((de = readdir(dp))) {
|
||||
if (de->d_name[0] == '.' && (de->d_name[1] == 0 ||
|
||||
(de->d_name[1] == '.' && de->d_name[2] == 0))) continue;
|
||||
if (sciIsProtectedEntryName(de->d_name)) continue;
|
||||
char full[PATH_MAX];
|
||||
snprintf(full, sizeof(full), "%s/%s", root, de->d_name);
|
||||
removefile(full, NULL, REMOVEFILE_RECURSIVE);
|
||||
}
|
||||
closedir(dp);
|
||||
}
|
||||
|
||||
@implementation SCICacheManager
|
||||
|
||||
// Transient mode reports the size to the caller but skips persisting it
|
||||
// and firing the update notification — used by the "Show cache size" off
|
||||
// tap path to scan on demand without leaking state.
|
||||
+ (void)_scanWithQos:(qos_class_t)qos
|
||||
transient:(BOOL)transient
|
||||
completion:(void(^)(uint64_t))completion {
|
||||
dispatch_queue_t q = dispatch_get_global_queue(qos, 0);
|
||||
dispatch_async(q, ^{
|
||||
NSArray<NSString *> *dirs = sciCacheDirs();
|
||||
__block _Atomic uint64_t running = 0;
|
||||
dispatch_group_t group = dispatch_group_create();
|
||||
for (NSString *d in dirs) {
|
||||
dispatch_group_async(group, q, ^{
|
||||
atomic_fetch_add(&running, sciDirectorySize(d));
|
||||
});
|
||||
}
|
||||
dispatch_group_async(group, q, ^{
|
||||
atomic_fetch_add(&running, (uint64_t)[[NSURLCache sharedURLCache] currentDiskUsage]);
|
||||
});
|
||||
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
|
||||
|
||||
uint64_t total = atomic_load(&running);
|
||||
if (!transient) {
|
||||
atomic_store(&gCachedSize, total);
|
||||
[[NSUserDefaults standardUserDefaults] setDouble:(double)total forKey:kLastKnownSizeKey];
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (!transient) {
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:SCICacheSizeDidUpdateNotification
|
||||
object:@(total)];
|
||||
}
|
||||
if (completion) completion(total);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)getCacheSizeWithCompletion:(void(^)(uint64_t))completion {
|
||||
[self _scanWithQos:QOS_CLASS_USER_INITIATED transient:NO completion:completion];
|
||||
}
|
||||
|
||||
+ (void)getCacheSizeTransientWithCompletion:(void(^)(uint64_t))completion {
|
||||
[self _scanWithQos:QOS_CLASS_USER_INITIATED transient:YES completion:completion];
|
||||
}
|
||||
|
||||
+ (uint64_t)cachedSize {
|
||||
sciLoadPersistedSizeOnce();
|
||||
return atomic_load(&gCachedSize);
|
||||
}
|
||||
|
||||
+ (void)refreshSizeInBackground {
|
||||
[self _scanWithQos:QOS_CLASS_BACKGROUND transient:NO completion:nil];
|
||||
}
|
||||
|
||||
+ (void)refreshSizeInBackgroundIfEnabled {
|
||||
if (![[NSUserDefaults standardUserDefaults] boolForKey:@"cache_auto_check_size"]) return;
|
||||
[self refreshSizeInBackground];
|
||||
}
|
||||
|
||||
+ (void)clearCacheWithCompletion:(void(^)(uint64_t))completion {
|
||||
dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0);
|
||||
dispatch_async(q, ^{
|
||||
// Snapshot the known size; only re-scan if we never measured.
|
||||
uint64_t reclaimed = atomic_load(&gCachedSize);
|
||||
if (reclaimed == 0) {
|
||||
for (NSString *d in sciCacheDirs()) reclaimed += sciDirectorySize(d);
|
||||
reclaimed += (uint64_t)[[NSURLCache sharedURLCache] currentDiskUsage];
|
||||
}
|
||||
|
||||
NSArray<NSString *> *dirs = sciCacheDirs();
|
||||
dispatch_group_t group = dispatch_group_create();
|
||||
for (NSString *d in dirs) {
|
||||
dispatch_group_async(group, q, ^{ sciDeleteDirectoryContents(d); });
|
||||
}
|
||||
dispatch_group_async(group, q, ^{
|
||||
[[NSURLCache sharedURLCache] removeAllCachedResponses];
|
||||
});
|
||||
dispatch_group_wait(group, DISPATCH_TIME_FOREVER);
|
||||
|
||||
atomic_store(&gCachedSize, 0);
|
||||
[[NSUserDefaults standardUserDefaults] setDouble:0 forKey:kLastKnownSizeKey];
|
||||
[[NSUserDefaults standardUserDefaults] setDouble:[NSDate date].timeIntervalSince1970
|
||||
forKey:kLastAutoClearKey];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:SCICacheSizeDidUpdateNotification
|
||||
object:@(0)];
|
||||
if (completion) completion(reclaimed);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)runAutoClearIfDue {
|
||||
NSString *mode = [[NSUserDefaults standardUserDefaults] stringForKey:kAutoClearModeKey];
|
||||
if (!mode.length || [mode isEqualToString:@"off"]) { [self refreshSizeInBackgroundIfEnabled]; return; }
|
||||
|
||||
NSTimeInterval interval = 0;
|
||||
if ([mode isEqualToString:@"daily"]) interval = 24 * 60 * 60;
|
||||
else if ([mode isEqualToString:@"weekly"]) interval = 7 * 24 * 60 * 60;
|
||||
else if ([mode isEqualToString:@"monthly"]) interval = 30 * 24 * 60 * 60;
|
||||
else { [self refreshSizeInBackgroundIfEnabled]; return; }
|
||||
|
||||
NSTimeInterval last = [[NSUserDefaults standardUserDefaults] doubleForKey:kLastAutoClearKey];
|
||||
NSTimeInterval now = [NSDate date].timeIntervalSince1970;
|
||||
if (last > 0 && (now - last) < interval) { [self refreshSizeInBackgroundIfEnabled]; return; }
|
||||
|
||||
[self clearCacheWithCompletion:^(uint64_t bytes) {
|
||||
NSLog(@"[SCInsta] auto-clear cache mode=%@ reclaimed=%@", mode, [self formattedSize:bytes]);
|
||||
}];
|
||||
}
|
||||
|
||||
+ (NSString *)formattedSize:(uint64_t)bytes {
|
||||
return [NSByteCountFormatter stringFromByteCount:(long long)bytes
|
||||
countStyle:NSByteCountFormatterCountStyleFile];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,16 @@
|
||||
// SCIChangelog — fetches RyukGram release notes from GitHub and presents
|
||||
// them in a scrollable popup. Shows automatically on launch when the tweak
|
||||
// version changes; also available from the About page.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCIChangelog : NSObject
|
||||
|
||||
/// Present the latest release notes when this is a version the user hasn't
|
||||
/// seen yet. No-op otherwise. Safe to call on every launch.
|
||||
+ (void)presentIfNewFromWindow:(UIWindow *)window;
|
||||
|
||||
/// Present a browser of every release (tap a row → see its notes).
|
||||
+ (void)presentAllFromViewController:(UIViewController *)host;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,374 @@
|
||||
#import "SCIChangelog.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../Tweak.h"
|
||||
|
||||
static NSString *const kRepo = @"faroukbmiled/RyukGram";
|
||||
// Stores the SCIVersionString of the last tweak build whose popup was shown.
|
||||
// When the tweak updates, this mismatches and triggers a fresh check.
|
||||
static NSString *const kLastSeenVersionKey = @"sci_changelog_last_seen_version";
|
||||
// Debug pref: when YES, the popup fires every launch regardless of version.
|
||||
static NSString *const kForceShowKey = @"sci_changelog_force_show";
|
||||
|
||||
// MARK: - Cache
|
||||
|
||||
static NSString *sciChangelogCacheDir(void) {
|
||||
static NSString *dir = nil;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
NSString *base = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject;
|
||||
dir = [base stringByAppendingPathComponent:@"RyukGramChangelog"];
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
static NSString *sciCachedReleasePath(NSString *tag) {
|
||||
NSString *safe = [tag stringByReplacingOccurrencesOfString:@"/" withString:@"_"];
|
||||
return [sciChangelogCacheDir() stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.json", safe]];
|
||||
}
|
||||
|
||||
static NSDictionary *sciLoadCachedRelease(NSString *tag) {
|
||||
NSData *data = [NSData dataWithContentsOfFile:sciCachedReleasePath(tag)];
|
||||
if (!data) return nil;
|
||||
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
||||
return [obj isKindOfClass:[NSDictionary class]] ? obj : nil;
|
||||
}
|
||||
|
||||
static void sciSaveCachedRelease(NSString *tag, NSDictionary *json) {
|
||||
NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil];
|
||||
if (data) [data writeToFile:sciCachedReleasePath(tag) atomically:YES];
|
||||
}
|
||||
|
||||
// MARK: - Network
|
||||
|
||||
static void sciFetchJSON(NSString *url, void (^completion)(NSDictionary *)) {
|
||||
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
|
||||
[req setValue:@"application/vnd.github+json" forHTTPHeaderField:@"Accept"];
|
||||
[[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
|
||||
NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
|
||||
if (![json isKindOfClass:[NSDictionary class]] || !json[@"tag_name"]) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ completion(nil); });
|
||||
return;
|
||||
}
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ completion(json); });
|
||||
}] resume];
|
||||
}
|
||||
|
||||
// Fetch a specific tag, falling back to /releases/latest on 404 so the popup
|
||||
// works in the window between a local version bump and the release being
|
||||
// published on GitHub.
|
||||
static void sciFetchRelease(NSString *tag, void (^completion)(NSDictionary *)) {
|
||||
NSString *tagURL = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases/tags/%@", kRepo, tag];
|
||||
sciFetchJSON(tagURL, ^(NSDictionary *json) {
|
||||
if (json) {
|
||||
sciSaveCachedRelease(json[@"tag_name"], json);
|
||||
completion(json);
|
||||
return;
|
||||
}
|
||||
NSString *latestURL = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases/latest", kRepo];
|
||||
sciFetchJSON(latestURL, ^(NSDictionary *latest) {
|
||||
if (latest) sciSaveCachedRelease(latest[@"tag_name"], latest);
|
||||
completion(latest);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static void sciFetchReleaseList(void (^completion)(NSArray<NSDictionary *> *)) {
|
||||
NSString *url = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases?per_page=50", kRepo];
|
||||
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]];
|
||||
[req setValue:@"application/vnd.github+json" forHTTPHeaderField:@"Accept"];
|
||||
[[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) {
|
||||
NSArray *arr = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
completion([arr isKindOfClass:[NSArray class]] ? arr : nil);
|
||||
});
|
||||
}] resume];
|
||||
}
|
||||
|
||||
// MARK: - Markdown renderer
|
||||
|
||||
static NSAttributedString *sciRenderMarkdown(NSString *md) {
|
||||
NSMutableAttributedString *out = [[NSMutableAttributedString alloc] init];
|
||||
if (!md.length) return out;
|
||||
|
||||
UIFont *body = [UIFont systemFontOfSize:15];
|
||||
UIFont *h2 = [UIFont systemFontOfSize:20 weight:UIFontWeightBold];
|
||||
UIFont *h3 = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
|
||||
UIColor *fg = [UIColor labelColor];
|
||||
UIColor *muted = [UIColor secondaryLabelColor];
|
||||
|
||||
NSMutableParagraphStyle *bodyPS = [NSMutableParagraphStyle new];
|
||||
bodyPS.lineSpacing = 2;
|
||||
bodyPS.paragraphSpacing = 3;
|
||||
|
||||
NSMutableParagraphStyle *headingPS = [NSMutableParagraphStyle new];
|
||||
headingPS.lineSpacing = 2;
|
||||
headingPS.paragraphSpacing = 4;
|
||||
headingPS.paragraphSpacingBefore = 10;
|
||||
|
||||
NSArray<NSString *> *lines = [md componentsSeparatedByString:@"\n"];
|
||||
BOOL firstEmitted = NO;
|
||||
for (NSString *raw in lines) {
|
||||
// Skip blank lines — paragraph spacing already handles breathing room.
|
||||
if (raw.length == 0) continue;
|
||||
|
||||
NSString *line = raw;
|
||||
NSMutableDictionary *attrs = [@{
|
||||
NSFontAttributeName: body,
|
||||
NSForegroundColorAttributeName: fg,
|
||||
NSParagraphStyleAttributeName: bodyPS,
|
||||
} mutableCopy];
|
||||
NSString *prefix = nil;
|
||||
|
||||
if ([line hasPrefix:@"## "]) {
|
||||
attrs[NSFontAttributeName] = h2;
|
||||
attrs[NSParagraphStyleAttributeName] = firstEmitted ? headingPS : bodyPS;
|
||||
line = [line substringFromIndex:3];
|
||||
} else if ([line hasPrefix:@"### "]) {
|
||||
attrs[NSFontAttributeName] = h3;
|
||||
attrs[NSParagraphStyleAttributeName] = firstEmitted ? headingPS : bodyPS;
|
||||
line = [line substringFromIndex:4];
|
||||
} else if ([line hasPrefix:@"- "] || [line hasPrefix:@"* "]) {
|
||||
prefix = @" • ";
|
||||
line = [line substringFromIndex:2];
|
||||
} else if ([line hasPrefix:@"> "]) {
|
||||
attrs[NSForegroundColorAttributeName] = muted;
|
||||
line = [line substringFromIndex:2];
|
||||
}
|
||||
|
||||
if (firstEmitted) {
|
||||
[out appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n" attributes:attrs]];
|
||||
}
|
||||
if (prefix) {
|
||||
[out appendAttributedString:[[NSAttributedString alloc] initWithString:prefix attributes:attrs]];
|
||||
}
|
||||
|
||||
NSMutableAttributedString *seg = [[NSMutableAttributedString alloc] initWithString:line attributes:attrs];
|
||||
|
||||
// Inline **bold**
|
||||
NSRegularExpression *boldRx = [NSRegularExpression regularExpressionWithPattern:@"\\*\\*(.+?)\\*\\*" options:0 error:nil];
|
||||
NSArray *boldMatches = [boldRx matchesInString:seg.string options:0 range:NSMakeRange(0, seg.string.length)];
|
||||
for (NSTextCheckingResult *m in [boldMatches reverseObjectEnumerator]) {
|
||||
NSString *inner = [seg.string substringWithRange:[m rangeAtIndex:1]];
|
||||
UIFont *baseFont = attrs[NSFontAttributeName];
|
||||
UIFont *boldFont = [UIFont systemFontOfSize:baseFont.pointSize weight:UIFontWeightBold];
|
||||
NSMutableDictionary *boldAttrs = [attrs mutableCopy];
|
||||
boldAttrs[NSFontAttributeName] = boldFont;
|
||||
NSAttributedString *replacement = [[NSAttributedString alloc] initWithString:inner attributes:boldAttrs];
|
||||
[seg replaceCharactersInRange:m.range withAttributedString:replacement];
|
||||
}
|
||||
|
||||
// Inline [text](url) links
|
||||
NSRegularExpression *linkRx = [NSRegularExpression regularExpressionWithPattern:@"\\[([^\\]]+)\\]\\(([^)]+)\\)" options:0 error:nil];
|
||||
NSArray *linkMatches = [linkRx matchesInString:seg.string options:0 range:NSMakeRange(0, seg.string.length)];
|
||||
for (NSTextCheckingResult *m in [linkMatches reverseObjectEnumerator]) {
|
||||
NSString *text = [seg.string substringWithRange:[m rangeAtIndex:1]];
|
||||
NSString *url = [seg.string substringWithRange:[m rangeAtIndex:2]];
|
||||
NSMutableDictionary *linkAttrs = [attrs mutableCopy];
|
||||
linkAttrs[NSLinkAttributeName] = url;
|
||||
NSAttributedString *replacement = [[NSAttributedString alloc] initWithString:text attributes:linkAttrs];
|
||||
[seg replaceCharactersInRange:m.range withAttributedString:replacement];
|
||||
}
|
||||
|
||||
[out appendAttributedString:seg];
|
||||
firstEmitted = YES;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// MARK: - Detail view controller (renders one release)
|
||||
|
||||
@interface _SCIChangelogDetailVC : UIViewController
|
||||
@property (nonatomic, copy) NSDictionary *releaseJSON;
|
||||
@property (nonatomic, copy) void (^onDismiss)(void);
|
||||
@end
|
||||
|
||||
@implementation _SCIChangelogDetailVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
|
||||
|
||||
NSString *name = self.releaseJSON[@"name"] ?: self.releaseJSON[@"tag_name"] ?: @"?";
|
||||
NSString *body = self.releaseJSON[@"body"] ?: @"";
|
||||
NSString *htmlURL = self.releaseJSON[@"html_url"] ?: @"";
|
||||
self.title = SCILocalized(@"What's new in RyukGram");
|
||||
|
||||
self.navigationItem.rightBarButtonItem =
|
||||
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone
|
||||
target:self
|
||||
action:@selector(done)];
|
||||
|
||||
// Tap the release-name heading to open the GitHub page.
|
||||
NSString *header = htmlURL.length
|
||||
? [NSString stringWithFormat:@"## [%@](%@)\n", name, htmlURL]
|
||||
: [NSString stringWithFormat:@"## %@\n", name];
|
||||
NSAttributedString *attrBody = sciRenderMarkdown([header stringByAppendingString:body]);
|
||||
|
||||
UITextView *tv = [UITextView new];
|
||||
tv.editable = NO;
|
||||
tv.backgroundColor = [UIColor clearColor];
|
||||
tv.textContainerInset = UIEdgeInsetsMake(16, 16, 24, 16);
|
||||
tv.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
tv.attributedText = attrBody;
|
||||
tv.alwaysBounceVertical = YES;
|
||||
[self.view addSubview:tv];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[tv.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
|
||||
[tv.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[tv.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[tv.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
|
||||
]];
|
||||
}
|
||||
|
||||
- (void)done {
|
||||
[self dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// MARK: - Releases list view controller
|
||||
|
||||
@interface _SCIReleaseListVC : UITableViewController
|
||||
@property (nonatomic, copy) NSArray<NSDictionary *> *releases;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
|
||||
@end
|
||||
|
||||
@implementation _SCIReleaseListVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.title = SCILocalized(@"Release notes");
|
||||
self.navigationItem.rightBarButtonItem =
|
||||
[[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone
|
||||
target:self
|
||||
action:@selector(done)];
|
||||
self.tableView.rowHeight = 60;
|
||||
|
||||
UIActivityIndicatorView *spin = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge];
|
||||
spin.hidesWhenStopped = YES;
|
||||
[spin startAnimating];
|
||||
self.tableView.backgroundView = spin;
|
||||
self.spinner = spin;
|
||||
|
||||
[self loadReleases];
|
||||
}
|
||||
|
||||
- (void)loadReleases {
|
||||
sciFetchReleaseList(^(NSArray<NSDictionary *> *arr) {
|
||||
self.releases = arr ?: @[];
|
||||
[self.spinner stopAnimating];
|
||||
self.tableView.backgroundView = nil;
|
||||
if (self.releases.count == 0) {
|
||||
UILabel *empty = [UILabel new];
|
||||
empty.text = SCILocalized(@"No releases");
|
||||
empty.textAlignment = NSTextAlignmentCenter;
|
||||
empty.textColor = [UIColor secondaryLabelColor];
|
||||
empty.font = [UIFont systemFontOfSize:15];
|
||||
self.tableView.backgroundView = empty;
|
||||
}
|
||||
[self.tableView reloadData];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)done {
|
||||
[self dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
|
||||
return self.releases.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip {
|
||||
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"r"];
|
||||
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"r"];
|
||||
NSDictionary *rel = self.releases[ip.row];
|
||||
NSString *tag = rel[@"tag_name"];
|
||||
NSString *title = rel[@"name"] ?: tag;
|
||||
|
||||
NSMutableArray<NSString *> *tags = [NSMutableArray array];
|
||||
if (ip.row == 0) [tags addObject:SCILocalized(@"latest")];
|
||||
if ([tag isEqualToString:SCIVersionString]) [tags addObject:SCILocalized(@"installed")];
|
||||
if (tags.count) {
|
||||
title = [NSString stringWithFormat:@"%@ (%@)", title, [tags componentsJoinedByString:@", "]];
|
||||
}
|
||||
cell.textLabel.text = title;
|
||||
NSString *published = rel[@"published_at"];
|
||||
cell.detailTextLabel.text = published ? [published substringToIndex:MIN((NSUInteger)10, published.length)] : @"";
|
||||
cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip {
|
||||
[tv deselectRowAtIndexPath:ip animated:YES];
|
||||
NSDictionary *rel = self.releases[ip.row];
|
||||
_SCIChangelogDetailVC *vc = [_SCIChangelogDetailVC new];
|
||||
vc.releaseJSON = rel;
|
||||
[self.navigationController pushViewController:vc animated:YES];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
@implementation SCIChangelog
|
||||
|
||||
+ (UIViewController *)topVCInWindow:(UIWindow *)window {
|
||||
UIViewController *vc = window.rootViewController;
|
||||
while (vc.presentedViewController) vc = vc.presentedViewController;
|
||||
return vc;
|
||||
}
|
||||
|
||||
+ (void)presentReleaseJSON:(NSDictionary *)json onDismiss:(void(^)(void))onDismiss fromWindow:(UIWindow *)window {
|
||||
_SCIChangelogDetailVC *vc = [_SCIChangelogDetailVC new];
|
||||
vc.releaseJSON = json;
|
||||
vc.onDismiss = onDismiss;
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
|
||||
nav.modalPresentationStyle = UIModalPresentationPageSheet;
|
||||
if (@available(iOS 15.0, *)) {
|
||||
UISheetPresentationController *sheet = nav.sheetPresentationController;
|
||||
sheet.detents = @[
|
||||
UISheetPresentationControllerDetent.mediumDetent,
|
||||
UISheetPresentationControllerDetent.largeDetent,
|
||||
];
|
||||
sheet.prefersGrabberVisible = YES;
|
||||
}
|
||||
[[self topVCInWindow:window] presentViewController:nav animated:YES completion:nil];
|
||||
}
|
||||
|
||||
+ (void)presentIfNewFromWindow:(UIWindow *)window {
|
||||
if (!window) return;
|
||||
NSUserDefaults *ud = [NSUserDefaults standardUserDefaults];
|
||||
BOOL force = [ud boolForKey:kForceShowKey];
|
||||
|
||||
// Fast-path: already shown for this tweak version — skip all I/O.
|
||||
if (!force && [[ud stringForKey:kLastSeenVersionKey] isEqualToString:SCIVersionString]) return;
|
||||
|
||||
void (^show)(NSDictionary *) = ^(NSDictionary *json) {
|
||||
if (!json) return;
|
||||
// Mark seen on show so any dismissal path (Done, swipe) is covered.
|
||||
[[NSUserDefaults standardUserDefaults] setObject:SCIVersionString forKey:kLastSeenVersionKey];
|
||||
[self presentReleaseJSON:json onDismiss:nil fromWindow:window];
|
||||
};
|
||||
|
||||
NSDictionary *cached = sciLoadCachedRelease(SCIVersionString);
|
||||
if (cached) { show(cached); return; }
|
||||
sciFetchRelease(SCIVersionString, ^(NSDictionary *json) { show(json); });
|
||||
}
|
||||
|
||||
+ (void)presentAllFromViewController:(UIViewController *)host {
|
||||
if (!host) return;
|
||||
_SCIReleaseListVC *list = [_SCIReleaseListVC new];
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:list];
|
||||
nav.modalPresentationStyle = UIModalPresentationPageSheet;
|
||||
if (@available(iOS 15.0, *)) {
|
||||
UISheetPresentationController *sheet = nav.sheetPresentationController;
|
||||
sheet.detents = @[UISheetPresentationControllerDetent.largeDetent];
|
||||
sheet.prefersGrabberVisible = YES;
|
||||
}
|
||||
[host presentViewController:nav animated:YES completion:nil];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -37,7 +37,7 @@
|
||||
%orig;
|
||||
|
||||
BOOL msgOnly = [SCIUtils getBoolPref:@"messages_only"];
|
||||
NSString *target = msgOnly ? SCILocalized(@"direct-inbox-tab") : SCILocalized(@"mainfeed-tab");
|
||||
NSString *target = msgOnly ? @"direct-inbox-tab" : @"mainfeed-tab";
|
||||
if (![self.accessibilityIdentifier isEqualToString:target]) return;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"settings_shortcut"]) {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Live-stream tweaks — anonymous viewing + long-press heart to hide comments.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
// MARK: - Anonymous viewing
|
||||
|
||||
static void sciDisableViewerCountPuller(id feedbackController) {
|
||||
Ivar pullerIvar = class_getInstanceVariable([feedbackController class], "_viewCountPuller");
|
||||
if (!pullerIvar) return;
|
||||
id puller = object_getIvar(feedbackController, pullerIvar);
|
||||
if (!puller) return;
|
||||
|
||||
// Ivars live on the IGLiveIntervalPuller superclass.
|
||||
Ivar activeIvar = NULL;
|
||||
Ivar timerIvar = NULL;
|
||||
for (Class c = [puller class]; c && c != [NSObject class]; c = class_getSuperclass(c)) {
|
||||
if (!activeIvar) activeIvar = class_getInstanceVariable(c, "_isActive");
|
||||
if (!timerIvar) timerIvar = class_getInstanceVariable(c, "_nextFetchTimer");
|
||||
if (activeIvar && timerIvar) break;
|
||||
}
|
||||
if (activeIvar) {
|
||||
ptrdiff_t off = ivar_getOffset(activeIvar);
|
||||
*(BOOL *)((char *)(__bridge void *)puller + off) = NO;
|
||||
}
|
||||
if (timerIvar) {
|
||||
id timer = object_getIvar(puller, timerIvar);
|
||||
if (timer && [timer respondsToSelector:@selector(invalidate)]) {
|
||||
((void(*)(id, SEL))objc_msgSend)(timer, @selector(invalidate));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGLiveFeedbackController
|
||||
- (void)start {
|
||||
%orig;
|
||||
if ([SCIUtils getBoolPref:@"live_anonymous_view"]) {
|
||||
sciDisableViewerCountPuller(self);
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
// MARK: - Hide comments (session-only)
|
||||
|
||||
// Session-only — state resets on each new comments VC appearance.
|
||||
static __weak UIViewController *gActiveLiveCommentsVC = nil;
|
||||
static BOOL gCommentsHidden = NO;
|
||||
static const void *kSCIHeartAttachedKey = &kSCIHeartAttachedKey;
|
||||
|
||||
// Only hide the scrolling collection — keep input + like usable.
|
||||
static void sciHideCommentCollections(UIView *root, BOOL hide, int depth) {
|
||||
if (!root || depth > 8) return;
|
||||
for (UIView *sub in root.subviews) {
|
||||
if ([sub isKindOfClass:[UICollectionView class]]) {
|
||||
sub.alpha = hide ? 0.0 : 1.0;
|
||||
sub.userInteractionEnabled = !hide;
|
||||
continue;
|
||||
}
|
||||
sciHideCommentCollections(sub, hide, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
static void sciApplyCommentsStateTo(UIViewController *vc) {
|
||||
if (!vc || !vc.isViewLoaded) return;
|
||||
sciHideCommentCollections(vc.view, gCommentsHidden, 0);
|
||||
}
|
||||
|
||||
extern "C" void sciRefreshLiveCommentsHidden(void) {
|
||||
sciApplyCommentsStateTo(gActiveLiveCommentsVC);
|
||||
}
|
||||
|
||||
static void sciAttachLongPressToView(UIView *v);
|
||||
|
||||
// Heart lives in the footer's _likeButton ivar.
|
||||
%hook IGLiveFooterButtonsView
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
id obj = (id)self;
|
||||
Ivar iv = class_getInstanceVariable([obj class], "_likeButton");
|
||||
if (!iv) return;
|
||||
UIView *btn = object_getIvar(obj, iv);
|
||||
if (btn) sciAttachLongPressToView(btn);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGLiveCommentsContainerViewController
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
gActiveLiveCommentsVC = self;
|
||||
gCommentsHidden = NO;
|
||||
sciApplyCommentsStateTo(self);
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
%orig;
|
||||
if (gActiveLiveCommentsVC == self) gActiveLiveCommentsVC = nil;
|
||||
}
|
||||
%end
|
||||
|
||||
// MARK: - Long-press heart → toggle comments
|
||||
|
||||
@interface SCILiveLikeLongPress : NSObject
|
||||
+ (instancetype)shared;
|
||||
- (void)fired:(UILongPressGestureRecognizer *)g;
|
||||
@end
|
||||
|
||||
@implementation SCILiveLikeLongPress
|
||||
+ (instancetype)shared {
|
||||
static SCILiveLikeLongPress *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [SCILiveLikeLongPress new]; });
|
||||
return s;
|
||||
}
|
||||
- (void)fired:(UILongPressGestureRecognizer *)g {
|
||||
if (g.state != UIGestureRecognizerStateBegan) return;
|
||||
if (![SCIUtils getBoolPref:@"live_hide_comments"]) return;
|
||||
gCommentsHidden = !gCommentsHidden;
|
||||
sciRefreshLiveCommentsHidden();
|
||||
[SCIUtils showToastForDuration:1.0
|
||||
title:gCommentsHidden ? SCILocalized(@"Comments hidden")
|
||||
: SCILocalized(@"Comments shown")];
|
||||
}
|
||||
@end
|
||||
|
||||
static void sciAttachLongPressToView(UIView *v) {
|
||||
if (!v || objc_getAssociatedObject(v, kSCIHeartAttachedKey)) return;
|
||||
UILongPressGestureRecognizer *g = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:[SCILiveLikeLongPress shared] action:@selector(fired:)];
|
||||
g.minimumPressDuration = 0.5;
|
||||
// Swallow the tap so the reactions sheet doesn't open.
|
||||
g.cancelsTouchesInView = YES;
|
||||
[v addGestureRecognizer:g];
|
||||
objc_setAssociatedObject(v, kSCIHeartAttachedKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// Fake profile stats for own profile — follower/following/post counts
|
||||
// and verified badge. Counts rewrite IGStatButton labels; verified flips
|
||||
// is_verified at the JSON parse layer + swizzles IGUsernameModel to catch
|
||||
// cached-model renders.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static BOOL sciFakeOn(NSString *key) { return [SCIUtils getBoolPref:key]; }
|
||||
|
||||
// IG format — 1,192 / 12.3K / 1.2M / 1.2B. Raw digits only; passthrough otherwise.
|
||||
static NSString *sciFormatCount(NSString *raw) {
|
||||
raw = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
||||
if (!raw.length) return nil;
|
||||
NSCharacterSet *digits = [NSCharacterSet decimalDigitCharacterSet];
|
||||
for (NSUInteger i = 0; i < raw.length; i++) {
|
||||
if (![digits characterIsMember:[raw characterAtIndex:i]]) return raw;
|
||||
}
|
||||
long long n = raw.longLongValue;
|
||||
if (n < 10000) {
|
||||
NSNumberFormatter *f = [NSNumberFormatter new];
|
||||
f.numberStyle = NSNumberFormatterDecimalStyle;
|
||||
return [f stringFromNumber:@(n)];
|
||||
}
|
||||
double d; NSString *suf;
|
||||
if (n >= 1000000000LL) { d = n / 1000000000.0; suf = @"B"; }
|
||||
else if (n >= 1000000LL) { d = n / 1000000.0; suf = @"M"; }
|
||||
else { d = n / 1000.0; suf = @"K"; }
|
||||
NSString *s = [NSString stringWithFormat:@"%.1f", d];
|
||||
if ([s hasSuffix:@".0"]) s = [s substringToIndex:s.length - 2];
|
||||
return [s stringByAppendingString:suf];
|
||||
}
|
||||
|
||||
static NSString *sciFakeValue(NSString *valueKey) {
|
||||
return sciFormatCount([[NSUserDefaults standardUserDefaults] stringForKey:valueKey]);
|
||||
}
|
||||
|
||||
// ============ Fake counts — IGStatButton label rewrite ============
|
||||
|
||||
static BOOL sciButtonIsOnOwnProfile(UIView *btn) {
|
||||
Class selfCellCls = NSClassFromString(@"IGProfileSimpleAvatarStatsCell");
|
||||
if (!selfCellCls) return NO;
|
||||
UIView *cur = btn;
|
||||
while (cur && ![cur isKindOfClass:selfCellCls]) cur = cur.superview;
|
||||
if (!cur) return NO;
|
||||
Ivar iv = class_getInstanceVariable([cur class], "_isCurrentUser");
|
||||
if (!iv) return NO;
|
||||
return *(BOOL *)((char *)(__bridge void *)cur + ivar_getOffset(iv));
|
||||
}
|
||||
|
||||
static NSString *sciFakeTextForName(NSString *name) {
|
||||
if (!name) return nil;
|
||||
NSString *low = name.lowercaseString;
|
||||
if ([low containsString:@"follower"]) {
|
||||
if (sciFakeOn(@"fake_follower_count")) return sciFakeValue(@"fake_follower_count_value");
|
||||
} else if ([low containsString:@"following"]) {
|
||||
if (sciFakeOn(@"fake_following_count")) return sciFakeValue(@"fake_following_count_value");
|
||||
} else if ([low containsString:@"post"]) {
|
||||
if (sciFakeOn(@"fake_post_count")) return sciFakeValue(@"fake_post_count_value");
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciApplyFakeToButton(id btn) {
|
||||
if (!sciFakeOn(@"fake_follower_count")
|
||||
&& !sciFakeOn(@"fake_following_count")
|
||||
&& !sciFakeOn(@"fake_post_count")) return;
|
||||
Ivar nmIv = class_getInstanceVariable([btn class], "_name");
|
||||
NSString *name = nmIv ? object_getIvar(btn, nmIv) : nil;
|
||||
NSString *fake = sciFakeTextForName(name);
|
||||
if (!fake) return;
|
||||
if (!sciButtonIsOnOwnProfile(btn)) return;
|
||||
Ivar lblIv = class_getInstanceVariable([btn class], "_countLabel");
|
||||
UILabel *lbl = lblIv ? object_getIvar(btn, lblIv) : nil;
|
||||
if ([lbl isKindOfClass:[UILabel class]]) lbl.text = fake;
|
||||
}
|
||||
|
||||
static void (*orig_setName)(id, SEL, id);
|
||||
static void new_setName(id self, SEL _cmd, id name) {
|
||||
orig_setName(self, _cmd, name);
|
||||
sciApplyFakeToButton(self);
|
||||
}
|
||||
|
||||
static void (*orig_setCount)(id, SEL, id);
|
||||
static void new_setCount(id self, SEL _cmd, id cfg) {
|
||||
orig_setCount(self, _cmd, cfg);
|
||||
sciApplyFakeToButton(self);
|
||||
}
|
||||
|
||||
static void (*orig_layout)(id, SEL);
|
||||
static void new_layout(id self, SEL _cmd) {
|
||||
orig_layout(self, _cmd);
|
||||
sciApplyFakeToButton(self);
|
||||
}
|
||||
|
||||
// ============ Fake verified — JSON response rewrite ============
|
||||
// PK + pref cached — read on every JSON parse.
|
||||
static NSString *gSelfPK = nil;
|
||||
static BOOL gFakeVerifiedOn = NO;
|
||||
|
||||
static BOOL sciPKMatchesSelf(id pk) {
|
||||
if (!gSelfPK.length) return NO;
|
||||
if ([pk isKindOfClass:[NSString class]]) return [pk isEqualToString:gSelfPK];
|
||||
if ([pk isKindOfClass:[NSNumber class]]) return [[(NSNumber *)pk stringValue] isEqualToString:gSelfPK];
|
||||
return NO;
|
||||
}
|
||||
|
||||
static void sciFlipVerifiedInJSON(id obj, int depth) {
|
||||
if (depth > 16) return;
|
||||
if ([obj isKindOfClass:[NSMutableDictionary class]]) {
|
||||
NSMutableDictionary *d = obj;
|
||||
id pk = d[@"pk"] ?: d[@"strong_id__"] ?: d[@"user_id"] ?: d[@"id"];
|
||||
if (sciPKMatchesSelf(pk)) d[@"is_verified"] = @YES;
|
||||
for (id v in d.allValues) sciFlipVerifiedInJSON(v, depth + 1);
|
||||
} else if ([obj isKindOfClass:[NSMutableArray class]]) {
|
||||
for (id v in (NSMutableArray *)obj) sciFlipVerifiedInJSON(v, depth + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Belt-and-suspenders — profile header reads isVerified from a cached
|
||||
// IGUsernameModel without re-parsing JSON on every refresh.
|
||||
typedef BOOL (*SciIsVerifiedFn)(id, SEL);
|
||||
static SciIsVerifiedFn orig_UsernameModel_isVerified = NULL;
|
||||
static NSString *gSelfUsername = nil;
|
||||
|
||||
static BOOL new_UsernameModel_isVerified(id self, SEL _cmd) {
|
||||
BOOL o = orig_UsernameModel_isVerified ? orig_UsernameModel_isVerified(self, _cmd) : NO;
|
||||
if (o) return YES;
|
||||
if (!gSelfUsername.length) return NO;
|
||||
NSString *u = nil;
|
||||
@try { u = [self valueForKey:@"username"]; } @catch (__unused id e) {}
|
||||
if ([u isKindOfClass:[NSString class]] && [u isEqualToString:gSelfUsername]) return YES;
|
||||
return NO;
|
||||
}
|
||||
|
||||
static id (*orig_JSONObjectWithData)(Class, SEL, NSData *, NSJSONReadingOptions, NSError **);
|
||||
static id new_JSONObjectWithData(Class self, SEL _cmd, NSData *data, NSJSONReadingOptions opts, NSError **err) {
|
||||
if (!gFakeVerifiedOn) return orig_JSONObjectWithData(self, _cmd, data, opts, err);
|
||||
opts |= NSJSONReadingMutableContainers;
|
||||
id r = orig_JSONObjectWithData(self, _cmd, data, opts, err);
|
||||
if (r) sciFlipVerifiedInJSON(r, 0);
|
||||
return r;
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void _sciFakeStatsInit(void) {
|
||||
// Both feature sets gate install on launch pref + require restart —
|
||||
// off means no hook at all.
|
||||
BOOL anyCountOn = sciFakeOn(@"fake_follower_count")
|
||||
|| sciFakeOn(@"fake_following_count")
|
||||
|| sciFakeOn(@"fake_post_count");
|
||||
if (anyCountOn) {
|
||||
Class sb = NSClassFromString(@"IGStatButton");
|
||||
if (sb) {
|
||||
MSHookMessageEx(sb, @selector(setName:), (IMP)new_setName, (IMP *)&orig_setName);
|
||||
MSHookMessageEx(sb, @selector(setCount:), (IMP)new_setCount, (IMP *)&orig_setCount);
|
||||
MSHookMessageEx(sb, @selector(layoutSubviews), (IMP)new_layout, (IMP *)&orig_layout);
|
||||
}
|
||||
}
|
||||
|
||||
if (!sciFakeOn(@"fake_verified")) return;
|
||||
gFakeVerifiedOn = YES;
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
gSelfPK = [[SCIUtils currentUserPK] copy];
|
||||
id session = [SCIUtils activeUserSession];
|
||||
id user = nil;
|
||||
@try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {}
|
||||
@try { gSelfUsername = [[user valueForKey:@"username"] copy]; } @catch (__unused id e) {}
|
||||
});
|
||||
[[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification
|
||||
object:nil queue:nil
|
||||
usingBlock:^(__unused NSNotification *n) {
|
||||
if (!gSelfPK.length) gSelfPK = [[SCIUtils currentUserPK] copy];
|
||||
if (!gSelfUsername.length) {
|
||||
id session = [SCIUtils activeUserSession];
|
||||
id user = nil;
|
||||
@try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {}
|
||||
@try { gSelfUsername = [[user valueForKey:@"username"] copy]; } @catch (__unused id e) {}
|
||||
}
|
||||
}];
|
||||
|
||||
Class jc = object_getClass([NSJSONSerialization class]);
|
||||
MSHookMessageEx(jc, @selector(JSONObjectWithData:options:error:),
|
||||
(IMP)new_JSONObjectWithData, (IMP *)&orig_JSONObjectWithData);
|
||||
|
||||
Class um = NSClassFromString(@"IGUsernameModel");
|
||||
if (um) {
|
||||
Method m = class_getInstanceMethod(um, @selector(isVerified));
|
||||
if (m) {
|
||||
orig_UsernameModel_isVerified = (SciIsVerifiedFn)method_getImplementation(m);
|
||||
method_setImplementation(m, (IMP)new_UsernameModel_isVerified);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
@@ -11,30 +12,6 @@
|
||||
|
||||
static const NSInteger kFollowBadgeTag = 99788;
|
||||
|
||||
static NSString *sciPKFromUser(id igUser) {
|
||||
if (!igUser) return nil;
|
||||
Ivar pkIvar = NULL;
|
||||
for (Class c = [igUser class]; c && !pkIvar; c = class_getSuperclass(c))
|
||||
pkIvar = class_getInstanceVariable(c, "_pk");
|
||||
if (!pkIvar) return nil;
|
||||
return [object_getIvar(igUser, pkIvar) description];
|
||||
}
|
||||
|
||||
static NSString *sciCurrentUserPK(void) {
|
||||
@try {
|
||||
for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) {
|
||||
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
|
||||
for (UIWindow *window in scene.windows) {
|
||||
id session = [window valueForKey:@"userSession"];
|
||||
if (!session) continue;
|
||||
id su = [session valueForKey:@"user"];
|
||||
if (su) return sciPKFromUser(su);
|
||||
}
|
||||
}
|
||||
} @catch (NSException *e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Cache follow status on the VC to avoid re-fetching
|
||||
static const char kFollowStatusKey;
|
||||
static NSNumber *sciGetFollowStatus(id vc) {
|
||||
@@ -64,26 +41,20 @@ static void sciRenderBadge(UIViewController *vc) {
|
||||
UIView *old = [statContainer viewWithTag:kFollowBadgeTag];
|
||||
if (old) [old removeFromSuperview];
|
||||
|
||||
UILabel *badge = [[UILabel alloc] init];
|
||||
NSString *text = followedBy ? SCILocalized(@"Follows you") : SCILocalized(@"Doesn't follow you");
|
||||
SCIChromeLabel *badge = [[SCIChromeLabel alloc] initWithText:text];
|
||||
badge.tag = kFollowBadgeTag;
|
||||
badge.text = followedBy ? SCILocalized(@"Follows you") : SCILocalized(@"Doesn't follow you");
|
||||
badge.font = [UIFont systemFontOfSize:11 weight:UIFontWeightMedium];
|
||||
badge.textColor = followedBy
|
||||
? [UIColor colorWithRed:0.3 green:0.75 blue:0.4 alpha:1.0]
|
||||
: [UIColor colorWithRed:0.85 green:0.3 blue:0.3 alpha:1.0];
|
||||
[badge sizeToFit];
|
||||
|
||||
CGFloat x = 0;
|
||||
for (UIView *sub in statContainer.subviews) {
|
||||
if (!sub.isHidden && sub.frame.size.width > 0) {
|
||||
x = sub.frame.origin.x;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
badge.frame = CGRectMake(x, statContainer.bounds.size.height - badge.frame.size.height - 2,
|
||||
badge.frame.size.width, badge.frame.size.height);
|
||||
[statContainer addSubview:badge];
|
||||
|
||||
// Pinned to the leading edge so it sits flush-left on any device + RTL.
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[badge.leadingAnchor constraintEqualToAnchor:statContainer.leadingAnchor],
|
||||
[badge.bottomAnchor constraintEqualToAnchor:statContainer.bottomAnchor constant:-8],
|
||||
]];
|
||||
}
|
||||
|
||||
%hook IGProfileViewController
|
||||
@@ -103,8 +74,8 @@ static void sciRenderBadge(UIViewController *vc) {
|
||||
@try { igUser = [self valueForKey:@"user"]; } @catch (NSException *e) {}
|
||||
if (!igUser) return;
|
||||
|
||||
NSString *profilePK = sciPKFromUser(igUser);
|
||||
NSString *myPK = sciCurrentUserPK();
|
||||
NSString *profilePK = [SCIUtils pkFromIGUser:igUser];
|
||||
NSString *myPK = [SCIUtils currentUserPK];
|
||||
if (!profilePK || !myPK || [profilePK isEqualToString:myPK]) return;
|
||||
|
||||
__weak UIViewController *weakSelf = self;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "SCIProfileAnalyzerModels.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIPAListKind) {
|
||||
SCIPAListKindPlain, // no action button
|
||||
SCIPAListKindUnfollow, // show "Unfollow" button (you follow them)
|
||||
SCIPAListKindFollow, // show "Follow" button (you don't follow them)
|
||||
SCIPAListKindProfileUpdate, // displays previous → current change rows
|
||||
};
|
||||
|
||||
@interface SCIProfileAnalyzerListViewController : UIViewController
|
||||
- (instancetype)initWithTitle:(NSString *)title
|
||||
users:(NSArray<SCIProfileAnalyzerUser *> *)users
|
||||
kind:(SCIPAListKind)kind;
|
||||
- (instancetype)initWithTitle:(NSString *)title
|
||||
profileUpdates:(NSArray<SCIProfileAnalyzerProfileChange *> *)updates;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,730 @@
|
||||
#import "SCIProfileAnalyzerListViewController.h"
|
||||
#import "SCIProfileAnalyzerStorage.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIImageCache.h"
|
||||
#import "../../Settings/SCISearchBarStyler.h"
|
||||
#import "../../Localization/SCILocalization.h"
|
||||
|
||||
// IG throttles /friendships/ aggressively — 50/session + a 1.5s cushion
|
||||
// between calls keeps us well inside the soft limit.
|
||||
static const NSInteger kSCIPABatchCap = 50;
|
||||
static const NSTimeInterval kSCIPABatchDelay = 1.5;
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIPASortMode) {
|
||||
SCIPASortModeDefault, // original order from the snapshot
|
||||
SCIPASortModeAZ, // username ascending
|
||||
SCIPASortModeZA, // username descending
|
||||
};
|
||||
|
||||
#pragma mark - Cell
|
||||
|
||||
@interface SCIPAUserCell : UITableViewCell
|
||||
@property (nonatomic, strong) UIImageView *avatar;
|
||||
@property (nonatomic, strong) UILabel *usernameLabel;
|
||||
@property (nonatomic, strong) UIImageView *verifiedBadge;
|
||||
@property (nonatomic, strong) UILabel *subtitleLabel;
|
||||
@property (nonatomic, strong) UIButton *actionButton;
|
||||
@property (nonatomic, strong) NSLayoutConstraint *usernameTrailingToButton;
|
||||
@property (nonatomic, strong) NSLayoutConstraint *usernameTrailingToEdge;
|
||||
@property (nonatomic, copy) void(^onActionTap)(SCIPAUserCell *);
|
||||
@end
|
||||
|
||||
@implementation SCIPAUserCell
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
|
||||
self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
|
||||
if (!self) return self;
|
||||
self.selectionStyle = UITableViewCellSelectionStyleDefault;
|
||||
|
||||
_avatar = [UIImageView new];
|
||||
_avatar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_avatar.backgroundColor = [UIColor secondarySystemBackgroundColor];
|
||||
_avatar.layer.cornerRadius = 24;
|
||||
_avatar.layer.masksToBounds = YES;
|
||||
_avatar.contentMode = UIViewContentModeScaleAspectFill;
|
||||
[self.contentView addSubview:_avatar];
|
||||
|
||||
_usernameLabel = [UILabel new];
|
||||
_usernameLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_usernameLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
_usernameLabel.textColor = [UIColor labelColor];
|
||||
[_usernameLabel setContentHuggingPriority:UILayoutPriorityDefaultHigh forAxis:UILayoutConstraintAxisHorizontal];
|
||||
[_usernameLabel setContentCompressionResistancePriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal];
|
||||
[self.contentView addSubview:_usernameLabel];
|
||||
|
||||
_verifiedBadge = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.seal.fill"]];
|
||||
_verifiedBadge.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_verifiedBadge.tintColor = [UIColor systemBlueColor];
|
||||
_verifiedBadge.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_verifiedBadge.hidden = YES;
|
||||
[_verifiedBadge setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
|
||||
[self.contentView addSubview:_verifiedBadge];
|
||||
|
||||
_subtitleLabel = [UILabel new];
|
||||
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_subtitleLabel.font = [UIFont systemFontOfSize:13];
|
||||
_subtitleLabel.textColor = [UIColor secondaryLabelColor];
|
||||
_subtitleLabel.numberOfLines = 2;
|
||||
[self.contentView addSubview:_subtitleLabel];
|
||||
|
||||
_actionButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_actionButton.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_actionButton.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
|
||||
_actionButton.layer.cornerRadius = 8;
|
||||
_actionButton.contentEdgeInsets = UIEdgeInsetsMake(6, 14, 6, 14);
|
||||
_actionButton.hidden = YES;
|
||||
[_actionButton addTarget:self action:@selector(onAction) forControlEvents:UIControlEventTouchUpInside];
|
||||
[_actionButton setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal];
|
||||
[self.contentView addSubview:_actionButton];
|
||||
|
||||
_usernameTrailingToButton = [_verifiedBadge.trailingAnchor constraintLessThanOrEqualToAnchor:_actionButton.leadingAnchor constant:-10];
|
||||
_usernameTrailingToEdge = [_verifiedBadge.trailingAnchor constraintLessThanOrEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_avatar.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor],
|
||||
[_avatar.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
|
||||
[_avatar.widthAnchor constraintEqualToConstant:48],
|
||||
[_avatar.heightAnchor constraintEqualToConstant:48],
|
||||
|
||||
[_usernameLabel.leadingAnchor constraintEqualToAnchor:_avatar.trailingAnchor constant:12],
|
||||
[_usernameLabel.topAnchor constraintEqualToAnchor:_avatar.topAnchor constant:2],
|
||||
|
||||
[_verifiedBadge.leadingAnchor constraintEqualToAnchor:_usernameLabel.trailingAnchor constant:4],
|
||||
[_verifiedBadge.centerYAnchor constraintEqualToAnchor:_usernameLabel.centerYAnchor],
|
||||
[_verifiedBadge.widthAnchor constraintEqualToConstant:14],
|
||||
[_verifiedBadge.heightAnchor constraintEqualToConstant:14],
|
||||
|
||||
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_usernameLabel.leadingAnchor],
|
||||
[_subtitleLabel.topAnchor constraintEqualToAnchor:_usernameLabel.bottomAnchor constant:2],
|
||||
[_subtitleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_actionButton.leadingAnchor constant:-10],
|
||||
[_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-8],
|
||||
|
||||
[_actionButton.trailingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor],
|
||||
[_actionButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
|
||||
|
||||
_usernameTrailingToButton,
|
||||
]];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setActionVisible:(BOOL)visible {
|
||||
self.actionButton.hidden = !visible;
|
||||
self.usernameTrailingToButton.active = visible;
|
||||
self.usernameTrailingToEdge.active = !visible;
|
||||
}
|
||||
|
||||
- (void)onAction { if (self.onActionTap) self.onActionTap(self); }
|
||||
- (void)prepareForReuse {
|
||||
[super prepareForReuse];
|
||||
self.avatar.image = nil;
|
||||
self.onActionTap = nil;
|
||||
self.verifiedBadge.hidden = YES;
|
||||
}
|
||||
@end
|
||||
|
||||
#pragma mark - VC
|
||||
|
||||
@interface SCIProfileAnalyzerListViewController () <UITableViewDataSource, UITableViewDelegate, UISearchResultsUpdating, UISearchControllerDelegate>
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *allUsers;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *filteredUsers;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerProfileChange *> *allChanges;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerProfileChange *> *filteredChanges;
|
||||
@property (nonatomic, assign) SCIPAListKind kind;
|
||||
@property (nonatomic, strong) UITableView *tableView;
|
||||
@property (nonatomic, strong) UISearchController *searchController;
|
||||
@property (nonatomic, strong) UILabel *emptyLabel;
|
||||
@property (nonatomic, strong) NSMutableSet<NSString *> *pendingPKs;
|
||||
|
||||
// Multi-select state
|
||||
@property (nonatomic, assign) BOOL selectionMode;
|
||||
@property (nonatomic, strong) NSMutableSet<NSString *> *selectedPKs;
|
||||
@property (nonatomic, strong) UIView *batchBar;
|
||||
@property (nonatomic, strong) UIButton *batchActionButton;
|
||||
|
||||
// Filter / sort state
|
||||
@property (nonatomic, assign) SCIPASortMode sortMode;
|
||||
@property (nonatomic, assign) BOOL filterVerifiedOnly;
|
||||
@property (nonatomic, assign) BOOL filterNotVerifiedOnly;
|
||||
@property (nonatomic, assign) BOOL filterPrivateOnly;
|
||||
@property (nonatomic, copy) NSString *currentQuery;
|
||||
@end
|
||||
|
||||
@implementation SCIProfileAnalyzerListViewController
|
||||
|
||||
- (instancetype)initWithTitle:(NSString *)title
|
||||
users:(NSArray<SCIProfileAnalyzerUser *> *)users
|
||||
kind:(SCIPAListKind)kind {
|
||||
self = [super init];
|
||||
if (!self) return self;
|
||||
self.title = title;
|
||||
self.kind = kind;
|
||||
self.allUsers = users ?: @[];
|
||||
self.filteredUsers = self.allUsers;
|
||||
self.pendingPKs = [NSMutableSet set];
|
||||
self.selectedPKs = [NSMutableSet set];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (instancetype)initWithTitle:(NSString *)title
|
||||
profileUpdates:(NSArray<SCIProfileAnalyzerProfileChange *> *)updates {
|
||||
self = [super init];
|
||||
if (!self) return self;
|
||||
self.title = title;
|
||||
self.kind = SCIPAListKindProfileUpdate;
|
||||
self.allChanges = updates ?: @[];
|
||||
self.filteredChanges = self.allChanges;
|
||||
self.pendingPKs = [NSMutableSet set];
|
||||
self.selectedPKs = [NSMutableSet set];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor systemBackgroundColor];
|
||||
[self setupTable];
|
||||
[self setupSearch];
|
||||
[self setupEmptyState];
|
||||
[self setupBatchBar];
|
||||
[self updateNavBar];
|
||||
[self refreshCounts];
|
||||
}
|
||||
|
||||
- (void)setupTable {
|
||||
self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain];
|
||||
self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.tableView.dataSource = self;
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.rowHeight = 72;
|
||||
self.tableView.separatorInset = UIEdgeInsetsMake(0, 78, 0, 0);
|
||||
self.tableView.allowsMultipleSelection = NO;
|
||||
[self.tableView registerClass:[SCIPAUserCell class] forCellReuseIdentifier:@"cell"];
|
||||
[self.view addSubview:self.tableView];
|
||||
}
|
||||
|
||||
- (void)setupSearch {
|
||||
self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil];
|
||||
self.searchController.searchResultsUpdater = self;
|
||||
self.searchController.delegate = self;
|
||||
self.searchController.obscuresBackgroundDuringPresentation = NO;
|
||||
self.searchController.searchBar.placeholder = SCILocalized(@"Search username or name");
|
||||
self.navigationItem.searchController = self.searchController;
|
||||
self.navigationItem.hidesSearchBarWhenScrolling = NO;
|
||||
self.definesPresentationContext = YES;
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
[super viewWillAppear:animated];
|
||||
[self sciStyleSearchBar];
|
||||
}
|
||||
|
||||
- (void)sciStyleSearchBar {
|
||||
[SCISearchBarStyler styleSearchBar:self.searchController.searchBar];
|
||||
}
|
||||
|
||||
- (void)willPresentSearchController:(UISearchController *)searchController { [self sciStyleSearchBar]; }
|
||||
- (void)didPresentSearchController:(UISearchController *)searchController {
|
||||
[self sciStyleSearchBar];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
[self sciStyleSearchBar];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)setupEmptyState {
|
||||
self.emptyLabel = [UILabel new];
|
||||
self.emptyLabel.text = SCILocalized(@"No results");
|
||||
self.emptyLabel.textColor = [UIColor tertiaryLabelColor];
|
||||
self.emptyLabel.font = [UIFont systemFontOfSize:15];
|
||||
self.emptyLabel.textAlignment = NSTextAlignmentCenter;
|
||||
self.emptyLabel.hidden = YES;
|
||||
self.emptyLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.emptyLabel];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.emptyLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
|
||||
[self.emptyLabel.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor constant:-40],
|
||||
]];
|
||||
}
|
||||
|
||||
- (void)setupBatchBar {
|
||||
// Floating capsule above the home indicator.
|
||||
self.batchActionButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
self.batchActionButton.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
self.batchActionButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold];
|
||||
[self.batchActionButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
self.batchActionButton.backgroundColor = [UIColor systemRedColor];
|
||||
self.batchActionButton.layer.cornerRadius = 26;
|
||||
self.batchActionButton.contentEdgeInsets = UIEdgeInsetsMake(0, 28, 0, 28);
|
||||
self.batchActionButton.layer.shadowColor = UIColor.blackColor.CGColor;
|
||||
self.batchActionButton.layer.shadowOffset = CGSizeMake(0, 6);
|
||||
self.batchActionButton.layer.shadowOpacity = 0.22;
|
||||
self.batchActionButton.layer.shadowRadius = 12;
|
||||
[self.batchActionButton addTarget:self action:@selector(batchActionTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
self.batchActionButton.hidden = YES;
|
||||
[self.view addSubview:self.batchActionButton];
|
||||
|
||||
self.batchBar = self.batchActionButton;
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.batchActionButton.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
|
||||
[self.batchActionButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-16],
|
||||
[self.batchActionButton.heightAnchor constraintEqualToConstant:52],
|
||||
[self.batchActionButton.widthAnchor constraintGreaterThanOrEqualToConstant:220],
|
||||
[self.batchActionButton.widthAnchor constraintLessThanOrEqualToAnchor:self.view.widthAnchor constant:-40],
|
||||
]];
|
||||
}
|
||||
|
||||
- (BOOL)supportsBatchAction {
|
||||
return self.kind == SCIPAListKindUnfollow || self.kind == SCIPAListKindFollow;
|
||||
}
|
||||
|
||||
- (void)updateNavBar {
|
||||
NSMutableArray *rights = [NSMutableArray array];
|
||||
if (self.supportsBatchAction) {
|
||||
NSString *t = self.selectionMode ? SCILocalized(@"Done") : SCILocalized(@"Select");
|
||||
UIBarButtonItem *sel = [[UIBarButtonItem alloc] initWithTitle:t
|
||||
style:UIBarButtonItemStylePlain
|
||||
target:self action:@selector(toggleSelectionMode)];
|
||||
[rights addObject:sel];
|
||||
}
|
||||
// Filled variant signals "filter/sort active".
|
||||
NSString *symbol = [self hasActiveFilterOrSort]
|
||||
? @"line.3.horizontal.decrease.circle.fill"
|
||||
: @"line.3.horizontal.decrease.circle";
|
||||
UIBarButtonItem *filter = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:symbol]
|
||||
menu:[self buildFilterMenu]];
|
||||
[rights addObject:filter];
|
||||
self.navigationItem.rightBarButtonItems = rights;
|
||||
}
|
||||
|
||||
- (UIMenu *)buildFilterMenu {
|
||||
__weak typeof(self) weakSelf = self;
|
||||
UIAction *az = [UIAction actionWithTitle:SCILocalized(@"Username A → Z")
|
||||
image:[UIImage systemImageNamed:@"arrow.up"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
weakSelf.sortMode = weakSelf.sortMode == SCIPASortModeAZ ? SCIPASortModeDefault : SCIPASortModeAZ;
|
||||
[weakSelf applyFiltersAndSort];
|
||||
}];
|
||||
az.state = (self.sortMode == SCIPASortModeAZ) ? UIMenuElementStateOn : UIMenuElementStateOff;
|
||||
|
||||
UIAction *za = [UIAction actionWithTitle:SCILocalized(@"Username Z → A")
|
||||
image:[UIImage systemImageNamed:@"arrow.down"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
weakSelf.sortMode = weakSelf.sortMode == SCIPASortModeZA ? SCIPASortModeDefault : SCIPASortModeZA;
|
||||
[weakSelf applyFiltersAndSort];
|
||||
}];
|
||||
za.state = (self.sortMode == SCIPASortModeZA) ? UIMenuElementStateOn : UIMenuElementStateOff;
|
||||
|
||||
UIMenu *sortGroup = [UIMenu menuWithTitle:SCILocalized(@"Sort")
|
||||
image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline
|
||||
children:@[az, za]];
|
||||
|
||||
UIAction *verified = [UIAction actionWithTitle:SCILocalized(@"Verified only")
|
||||
image:[UIImage systemImageNamed:@"checkmark.seal.fill"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
weakSelf.filterVerifiedOnly = !weakSelf.filterVerifiedOnly;
|
||||
if (weakSelf.filterVerifiedOnly) weakSelf.filterNotVerifiedOnly = NO;
|
||||
[weakSelf applyFiltersAndSort];
|
||||
}];
|
||||
verified.state = self.filterVerifiedOnly ? UIMenuElementStateOn : UIMenuElementStateOff;
|
||||
|
||||
UIAction *notVerified = [UIAction actionWithTitle:SCILocalized(@"Not verified only")
|
||||
image:[UIImage systemImageNamed:@"seal"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
weakSelf.filterNotVerifiedOnly = !weakSelf.filterNotVerifiedOnly;
|
||||
if (weakSelf.filterNotVerifiedOnly) weakSelf.filterVerifiedOnly = NO;
|
||||
[weakSelf applyFiltersAndSort];
|
||||
}];
|
||||
notVerified.state = self.filterNotVerifiedOnly ? UIMenuElementStateOn : UIMenuElementStateOff;
|
||||
|
||||
UIAction *priv = [UIAction actionWithTitle:SCILocalized(@"Private only")
|
||||
image:[UIImage systemImageNamed:@"lock.fill"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
weakSelf.filterPrivateOnly = !weakSelf.filterPrivateOnly;
|
||||
[weakSelf applyFiltersAndSort];
|
||||
}];
|
||||
priv.state = self.filterPrivateOnly ? UIMenuElementStateOn : UIMenuElementStateOff;
|
||||
|
||||
UIMenu *filterGroup = [UIMenu menuWithTitle:SCILocalized(@"Filter")
|
||||
image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline
|
||||
children:@[verified, notVerified, priv]];
|
||||
|
||||
NSMutableArray *children = [NSMutableArray arrayWithObjects:sortGroup, filterGroup, nil];
|
||||
if ([self hasActiveFilterOrSort]) {
|
||||
UIAction *clear = [UIAction actionWithTitle:SCILocalized(@"Clear")
|
||||
image:[UIImage systemImageNamed:@"arrow.counterclockwise"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
weakSelf.sortMode = SCIPASortModeDefault;
|
||||
weakSelf.filterVerifiedOnly = NO;
|
||||
weakSelf.filterNotVerifiedOnly = NO;
|
||||
weakSelf.filterPrivateOnly = NO;
|
||||
[weakSelf applyFiltersAndSort];
|
||||
}];
|
||||
clear.attributes = UIMenuElementAttributesDestructive;
|
||||
[children addObject:[UIMenu menuWithTitle:@"" image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:@[clear]]];
|
||||
}
|
||||
return [UIMenu menuWithChildren:children];
|
||||
}
|
||||
|
||||
- (void)refreshCounts {
|
||||
NSUInteger total = self.kind == SCIPAListKindProfileUpdate ? self.allChanges.count : self.allUsers.count;
|
||||
NSUInteger shown = self.kind == SCIPAListKindProfileUpdate ? self.filteredChanges.count : self.filteredUsers.count;
|
||||
self.navigationItem.prompt = [NSString stringWithFormat:SCILocalized(@"%lu of %lu"),
|
||||
(unsigned long)shown, (unsigned long)total];
|
||||
self.emptyLabel.hidden = shown > 0;
|
||||
}
|
||||
|
||||
#pragma mark - Search
|
||||
|
||||
- (void)updateSearchResultsForSearchController:(UISearchController *)searchController {
|
||||
self.currentQuery = [searchController.searchBar.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
|
||||
[self applyFiltersAndSort];
|
||||
}
|
||||
|
||||
// Pipeline: search → verified/private filter → sort.
|
||||
- (void)applyFiltersAndSort {
|
||||
NSString *q = self.currentQuery;
|
||||
BOOL hasQuery = q.length > 0;
|
||||
BOOL verified = self.filterVerifiedOnly;
|
||||
BOOL notVerified = self.filterNotVerifiedOnly;
|
||||
BOOL priv = self.filterPrivateOnly;
|
||||
|
||||
NSArray *(^applyToUsers)(NSArray *) = ^NSArray *(NSArray *src) {
|
||||
NSMutableArray *out = [NSMutableArray arrayWithCapacity:src.count];
|
||||
for (SCIProfileAnalyzerUser *u in src) {
|
||||
if (hasQuery && ![u.username localizedCaseInsensitiveContainsString:q]
|
||||
&& ![u.fullName localizedCaseInsensitiveContainsString:q]) continue;
|
||||
if (verified && !u.isVerified) continue;
|
||||
if (notVerified && u.isVerified) continue;
|
||||
if (priv && !u.isPrivate) continue;
|
||||
[out addObject:u];
|
||||
}
|
||||
return [self sortUsers:out];
|
||||
};
|
||||
|
||||
if (self.kind == SCIPAListKindProfileUpdate) {
|
||||
NSMutableArray *out = [NSMutableArray arrayWithCapacity:self.allChanges.count];
|
||||
for (SCIProfileAnalyzerProfileChange *c in self.allChanges) {
|
||||
SCIProfileAnalyzerUser *u = c.current;
|
||||
if (hasQuery && ![u.username localizedCaseInsensitiveContainsString:q]
|
||||
&& ![u.fullName localizedCaseInsensitiveContainsString:q]) continue;
|
||||
if (verified && !u.isVerified) continue;
|
||||
if (notVerified && u.isVerified) continue;
|
||||
if (priv && !u.isPrivate) continue;
|
||||
[out addObject:c];
|
||||
}
|
||||
self.filteredChanges = [self sortChanges:out];
|
||||
} else {
|
||||
self.filteredUsers = applyToUsers(self.allUsers);
|
||||
}
|
||||
[self refreshCounts];
|
||||
[self updateNavBar]; // refresh filter-icon "active" state
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (NSArray *)sortUsers:(NSArray<SCIProfileAnalyzerUser *> *)src {
|
||||
if (self.sortMode == SCIPASortModeDefault) return src;
|
||||
BOOL asc = (self.sortMode == SCIPASortModeAZ);
|
||||
return [src sortedArrayUsingComparator:^NSComparisonResult(SCIProfileAnalyzerUser *a, SCIProfileAnalyzerUser *b) {
|
||||
NSComparisonResult r = [a.username caseInsensitiveCompare:b.username ?: @""];
|
||||
return asc ? r : -r;
|
||||
}];
|
||||
}
|
||||
|
||||
- (NSArray *)sortChanges:(NSArray<SCIProfileAnalyzerProfileChange *> *)src {
|
||||
if (self.sortMode == SCIPASortModeDefault) return src;
|
||||
BOOL asc = (self.sortMode == SCIPASortModeAZ);
|
||||
return [src sortedArrayUsingComparator:^NSComparisonResult(SCIProfileAnalyzerProfileChange *a, SCIProfileAnalyzerProfileChange *b) {
|
||||
NSComparisonResult r = [a.current.username caseInsensitiveCompare:b.current.username ?: @""];
|
||||
return asc ? r : -r;
|
||||
}];
|
||||
}
|
||||
|
||||
- (BOOL)hasActiveFilterOrSort {
|
||||
return self.filterVerifiedOnly || self.filterNotVerifiedOnly || self.filterPrivateOnly || self.sortMode != SCIPASortModeDefault;
|
||||
}
|
||||
|
||||
#pragma mark - Table
|
||||
|
||||
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
|
||||
return self.kind == SCIPAListKindProfileUpdate ? self.filteredChanges.count : self.filteredUsers.count;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
SCIPAUserCell *cell = [tv dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath];
|
||||
SCIProfileAnalyzerUser *user;
|
||||
SCIProfileAnalyzerProfileChange *change = nil;
|
||||
if (self.kind == SCIPAListKindProfileUpdate) {
|
||||
change = self.filteredChanges[indexPath.row];
|
||||
user = change.current;
|
||||
} else {
|
||||
user = self.filteredUsers[indexPath.row];
|
||||
}
|
||||
|
||||
cell.usernameLabel.text = user.username.length ? [NSString stringWithFormat:@"@%@", user.username] : @"(unknown)";
|
||||
cell.verifiedBadge.hidden = !user.isVerified;
|
||||
|
||||
if (self.kind == SCIPAListKindProfileUpdate) {
|
||||
NSMutableArray *lines = [NSMutableArray array];
|
||||
if (change.usernameChanged) {
|
||||
[lines addObject:[NSString stringWithFormat:SCILocalized(@"Username: @%@ → @%@"),
|
||||
change.previous.username ?: @"", change.current.username ?: @""]];
|
||||
}
|
||||
if (change.fullNameChanged) {
|
||||
[lines addObject:[NSString stringWithFormat:SCILocalized(@"Name: %@ → %@"),
|
||||
change.previous.fullName.length ? change.previous.fullName : @"—",
|
||||
change.current.fullName.length ? change.current.fullName : @"—"]];
|
||||
}
|
||||
if (change.profilePicChanged) [lines addObject:SCILocalized(@"Profile picture changed")];
|
||||
cell.subtitleLabel.text = [lines componentsJoinedByString:@"\n"];
|
||||
cell.subtitleLabel.numberOfLines = 3;
|
||||
} else {
|
||||
cell.subtitleLabel.text = user.fullName.length ? user.fullName : (user.isPrivate ? SCILocalized(@"Private account") : @"");
|
||||
cell.subtitleLabel.numberOfLines = 1;
|
||||
}
|
||||
|
||||
[self configureActionForCell:cell user:user];
|
||||
|
||||
// Selection-mode checkmark affordance
|
||||
if (self.selectionMode) {
|
||||
BOOL on = [self.selectedPKs containsObject:user.pk];
|
||||
cell.accessoryType = on ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone;
|
||||
} else {
|
||||
cell.accessoryType = UITableViewCellAccessoryNone;
|
||||
}
|
||||
|
||||
if (user.profilePicURL.length) {
|
||||
NSURL *url = [NSURL URLWithString:user.profilePicURL];
|
||||
NSString *pkTag = user.pk;
|
||||
cell.avatar.tag = pkTag.hash;
|
||||
[SCIImageCache loadImageFromURL:url completion:^(UIImage *image) {
|
||||
if (cell.avatar.tag == (NSInteger)pkTag.hash) cell.avatar.image = image;
|
||||
}];
|
||||
} else {
|
||||
cell.avatar.image = [UIImage systemImageNamed:@"person.circle.fill"];
|
||||
cell.avatar.tintColor = [UIColor systemGrayColor];
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)configureActionForCell:(SCIPAUserCell *)cell user:(SCIProfileAnalyzerUser *)user {
|
||||
BOOL hasButton = !self.selectionMode
|
||||
&& (self.kind == SCIPAListKindFollow || self.kind == SCIPAListKindUnfollow);
|
||||
[cell setActionVisible:hasButton];
|
||||
if (!hasButton) return;
|
||||
|
||||
BOOL pending = [self.pendingPKs containsObject:user.pk];
|
||||
if (self.kind == SCIPAListKindUnfollow) {
|
||||
[cell.actionButton setTitle:SCILocalized(@"Unfollow") forState:UIControlStateNormal];
|
||||
cell.actionButton.backgroundColor = [[UIColor systemRedColor] colorWithAlphaComponent:0.12];
|
||||
[cell.actionButton setTitleColor:[UIColor systemRedColor] forState:UIControlStateNormal];
|
||||
} else {
|
||||
[cell.actionButton setTitle:SCILocalized(@"Follow") forState:UIControlStateNormal];
|
||||
cell.actionButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
|
||||
[cell.actionButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
}
|
||||
cell.actionButton.enabled = !pending;
|
||||
cell.actionButton.alpha = pending ? 0.5 : 1.0;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
cell.onActionTap = ^(SCIPAUserCell *c) { [weakSelf performActionForUser:user]; };
|
||||
}
|
||||
|
||||
#pragma mark - Single-row action
|
||||
|
||||
- (void)performActionForUser:(SCIProfileAnalyzerUser *)user {
|
||||
if ([self.pendingPKs containsObject:user.pk]) return;
|
||||
if (self.kind == SCIPAListKindUnfollow) {
|
||||
NSString *msg = [NSString stringWithFormat:SCILocalized(@"Unfollow @%@?"), user.username ?: @""];
|
||||
UIAlertController *a = [UIAlertController alertControllerWithTitle:nil message:msg preferredStyle:UIAlertControllerStyleAlert];
|
||||
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Unfollow") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
|
||||
[self sendFriendshipForUser:user follow:NO reload:YES];
|
||||
}]];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
} else {
|
||||
[self sendFriendshipForUser:user follow:YES reload:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)sendFriendshipForUser:(SCIProfileAnalyzerUser *)user follow:(BOOL)follow reload:(BOOL)reload {
|
||||
[self.pendingPKs addObject:user.pk];
|
||||
if (reload) [self.tableView reloadData];
|
||||
void(^done)(NSDictionary *, NSError *) = ^(NSDictionary *resp, NSError *err) {
|
||||
[self.pendingPKs removeObject:user.pk];
|
||||
BOOL success = (err == nil) && ([resp[@"status"] isEqualToString:@"ok"] || resp[@"friendship_status"]);
|
||||
if (success) {
|
||||
[self persistFriendshipChangeForUser:user followed:follow];
|
||||
[self removeUserFromList:user];
|
||||
} else {
|
||||
[SCIUtils showErrorHUDWithDescription:err.localizedDescription ?: SCILocalized(@"Request failed")];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
};
|
||||
if (follow) [SCIInstagramAPI followUserPK:user.pk completion:done];
|
||||
else [SCIInstagramAPI unfollowUserPK:user.pk completion:done];
|
||||
}
|
||||
|
||||
// Mirror in-app follow/unfollow into the cached snapshot so category counts
|
||||
// + header stats update live without a rescan.
|
||||
- (void)persistFriendshipChangeForUser:(SCIProfileAnalyzerUser *)user followed:(BOOL)followed {
|
||||
NSString *pk = [SCIUtils currentUserPK];
|
||||
SCIProfileAnalyzerSnapshot *snap = [SCIProfileAnalyzerStorage currentSnapshotForUserPK:pk];
|
||||
if (!snap) return;
|
||||
NSMutableArray *following = [snap.following mutableCopy] ?: [NSMutableArray array];
|
||||
BOOL alreadyIn = [following containsObject:user];
|
||||
if (followed && !alreadyIn) {
|
||||
[following addObject:user];
|
||||
snap.followingCount = MAX(0, snap.followingCount + 1);
|
||||
} else if (!followed && alreadyIn) {
|
||||
[following removeObject:user];
|
||||
snap.followingCount = MAX(0, snap.followingCount - 1);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
snap.following = following;
|
||||
[SCIProfileAnalyzerStorage updateCurrentSnapshot:snap forUserPK:pk];
|
||||
}
|
||||
|
||||
- (void)removeUserFromList:(SCIProfileAnalyzerUser *)user {
|
||||
NSMutableArray *all = [self.allUsers mutableCopy];
|
||||
[all removeObject:user];
|
||||
self.allUsers = all;
|
||||
NSMutableArray *filt = [self.filteredUsers mutableCopy];
|
||||
[filt removeObject:user];
|
||||
self.filteredUsers = filt;
|
||||
[self.selectedPKs removeObject:user.pk];
|
||||
[self refreshCounts];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
#pragma mark - Tap row
|
||||
|
||||
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
[tv deselectRowAtIndexPath:indexPath animated:YES];
|
||||
SCIProfileAnalyzerUser *user = self.kind == SCIPAListKindProfileUpdate
|
||||
? self.filteredChanges[indexPath.row].current
|
||||
: self.filteredUsers[indexPath.row];
|
||||
|
||||
if (self.selectionMode) {
|
||||
if ([self.selectedPKs containsObject:user.pk]) [self.selectedPKs removeObject:user.pk];
|
||||
else [self.selectedPKs addObject:user.pk];
|
||||
[self refreshBatchBar];
|
||||
[tv reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone];
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.username.length) return;
|
||||
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", user.username]];
|
||||
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Multi-select
|
||||
|
||||
- (void)toggleSelectionMode {
|
||||
self.selectionMode = !self.selectionMode;
|
||||
[self.selectedPKs removeAllObjects];
|
||||
self.batchActionButton.hidden = !self.selectionMode;
|
||||
// Leave room for the capsule so last-row cells don't sit under it.
|
||||
self.tableView.contentInset = UIEdgeInsetsMake(0, 0, self.selectionMode ? 96 : 0, 0);
|
||||
[self updateNavBar];
|
||||
[self refreshBatchBar];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (void)refreshBatchBar {
|
||||
NSUInteger n = self.selectedPKs.count;
|
||||
BOOL follow = (self.kind == SCIPAListKindFollow);
|
||||
NSString *t = follow
|
||||
? [NSString stringWithFormat:SCILocalized(@"Follow %lu"), (unsigned long)n]
|
||||
: [NSString stringWithFormat:SCILocalized(@"Unfollow %lu"), (unsigned long)n];
|
||||
[self.batchActionButton setTitle:t forState:UIControlStateNormal];
|
||||
self.batchActionButton.backgroundColor = follow
|
||||
? ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor])
|
||||
: [UIColor systemRedColor];
|
||||
self.batchActionButton.enabled = n > 0;
|
||||
self.batchActionButton.alpha = n > 0 ? 1.0 : 0.5;
|
||||
}
|
||||
|
||||
- (void)batchActionTapped {
|
||||
NSUInteger n = self.selectedPKs.count;
|
||||
if (!n) return;
|
||||
BOOL follow = (self.kind == SCIPAListKindFollow);
|
||||
NSString *verb = follow ? SCILocalized(@"Follow") : SCILocalized(@"Unfollow");
|
||||
NSString *title = follow ? SCILocalized(@"Batch follow") : SCILocalized(@"Batch unfollow");
|
||||
NSString *msg;
|
||||
if (n > kSCIPABatchCap) {
|
||||
msg = [NSString stringWithFormat:SCILocalized(@"%@ %lu accounts? The first %ld will be processed to avoid rate limits."),
|
||||
verb, (unsigned long)n, (long)kSCIPABatchCap];
|
||||
} else {
|
||||
msg = [NSString stringWithFormat:SCILocalized(@"%@ %lu accounts? This runs sequentially with a short pause between each."),
|
||||
verb, (unsigned long)n];
|
||||
}
|
||||
UIAlertController *a = [UIAlertController alertControllerWithTitle:title
|
||||
message:msg preferredStyle:UIAlertControllerStyleAlert];
|
||||
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
UIAlertActionStyle style = follow ? UIAlertActionStyleDefault : UIAlertActionStyleDestructive;
|
||||
[a addAction:[UIAlertAction actionWithTitle:verb style:style handler:^(UIAlertAction *_) {
|
||||
[self runBatchAction];
|
||||
}]];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)runBatchAction {
|
||||
NSMutableArray<SCIProfileAnalyzerUser *> *queue = [NSMutableArray array];
|
||||
for (SCIProfileAnalyzerUser *u in self.allUsers) {
|
||||
if ([self.selectedPKs containsObject:u.pk]) [queue addObject:u];
|
||||
if (queue.count >= kSCIPABatchCap) break;
|
||||
}
|
||||
[self.selectedPKs removeAllObjects];
|
||||
[self refreshBatchBar];
|
||||
[self batchStep:queue done:0 total:queue.count];
|
||||
}
|
||||
|
||||
- (void)batchStep:(NSMutableArray<SCIProfileAnalyzerUser *> *)queue
|
||||
done:(NSUInteger)done
|
||||
total:(NSUInteger)total {
|
||||
BOOL follow = (self.kind == SCIPAListKindFollow);
|
||||
if (!queue.count) {
|
||||
NSString *finishedTitle = follow ? SCILocalized(@"Batch follow finished") : SCILocalized(@"Batch unfollow finished");
|
||||
NSString *finishedSub = follow
|
||||
? [NSString stringWithFormat:SCILocalized(@"%lu accounts followed"), (unsigned long)total]
|
||||
: [NSString stringWithFormat:SCILocalized(@"%lu accounts unfollowed"), (unsigned long)total];
|
||||
[SCIUtils showToastForDuration:2.0 title:finishedTitle subtitle:finishedSub];
|
||||
self.navigationItem.prompt = nil;
|
||||
[self toggleSelectionMode];
|
||||
[self refreshCounts];
|
||||
return;
|
||||
}
|
||||
SCIProfileAnalyzerUser *u = queue.firstObject;
|
||||
[queue removeObjectAtIndex:0];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
void(^handler)(NSDictionary *, NSError *) = ^(NSDictionary *resp, NSError *err) {
|
||||
typeof(self) strongSelf = weakSelf;
|
||||
if (!strongSelf) return;
|
||||
NSUInteger nextDone = done + 1;
|
||||
BOOL ok = (err == nil) && ([resp[@"status"] isEqualToString:@"ok"] || resp[@"friendship_status"]);
|
||||
if (ok) {
|
||||
[strongSelf persistFriendshipChangeForUser:u followed:follow];
|
||||
[strongSelf removeUserFromList:u];
|
||||
}
|
||||
NSString *progressFmt = follow ? SCILocalized(@"Following… %lu / %lu") : SCILocalized(@"Unfollowing… %lu / %lu");
|
||||
strongSelf.navigationItem.prompt = [NSString stringWithFormat:progressFmt,
|
||||
(unsigned long)nextDone, (unsigned long)total];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSCIPABatchDelay * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[weakSelf batchStep:queue done:nextDone total:total];
|
||||
});
|
||||
};
|
||||
if (follow) [SCIInstagramAPI followUserPK:u.pk completion:handler];
|
||||
else [SCIInstagramAPI unfollowUserPK:u.pk completion:handler];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,74 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Lightweight user record — what we cache per follower/following entry.
|
||||
@interface SCIProfileAnalyzerUser : NSObject <NSCopying>
|
||||
|
||||
@property (nonatomic, copy) NSString *pk;
|
||||
@property (nonatomic, copy) NSString *username;
|
||||
@property (nonatomic, copy, nullable) NSString *fullName;
|
||||
@property (nonatomic, copy, nullable) NSString *profilePicURL;
|
||||
// Stable IG-internal ID of the current profile picture — changes only when
|
||||
// the user uploads a new one. Used for reliable change detection.
|
||||
@property (nonatomic, copy, nullable) NSString *profilePicID;
|
||||
@property (nonatomic, assign) BOOL isPrivate;
|
||||
@property (nonatomic, assign) BOOL isVerified;
|
||||
|
||||
+ (nullable instancetype)userFromAPIDict:(NSDictionary *)dict;
|
||||
+ (nullable instancetype)userFromJSONDict:(NSDictionary *)dict;
|
||||
- (NSDictionary *)toJSONDict;
|
||||
|
||||
@end
|
||||
|
||||
// One-point-in-time capture of an account's graph + self info. Persisted
|
||||
// to disk as JSON; diffs between snapshots produce the report categories.
|
||||
@interface SCIProfileAnalyzerSnapshot : NSObject
|
||||
|
||||
@property (nonatomic, strong) NSDate *scanDate;
|
||||
@property (nonatomic, copy) NSString *selfPK;
|
||||
@property (nonatomic, copy, nullable) NSString *selfUsername;
|
||||
@property (nonatomic, copy, nullable) NSString *selfFullName;
|
||||
@property (nonatomic, copy, nullable) NSString *selfProfilePicURL;
|
||||
@property (nonatomic, assign) NSInteger followerCount;
|
||||
@property (nonatomic, assign) NSInteger followingCount;
|
||||
@property (nonatomic, assign) NSInteger mediaCount;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *followers;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *following;
|
||||
|
||||
+ (nullable instancetype)snapshotFromJSONDict:(NSDictionary *)dict;
|
||||
- (NSDictionary *)toJSONDict;
|
||||
|
||||
@end
|
||||
|
||||
// Per-profile change entry (username/fullName/pic edited since last scan).
|
||||
@interface SCIProfileAnalyzerProfileChange : NSObject
|
||||
@property (nonatomic, strong) SCIProfileAnalyzerUser *previous;
|
||||
@property (nonatomic, strong) SCIProfileAnalyzerUser *current;
|
||||
@property (nonatomic, readonly) BOOL usernameChanged;
|
||||
@property (nonatomic, readonly) BOOL fullNameChanged;
|
||||
@property (nonatomic, readonly) BOOL profilePicChanged;
|
||||
@end
|
||||
|
||||
// Derived category arrays, computed from (current, previous) snapshots.
|
||||
@interface SCIProfileAnalyzerReport : NSObject
|
||||
|
||||
@property (nonatomic, strong, nullable) SCIProfileAnalyzerSnapshot *current;
|
||||
@property (nonatomic, strong, nullable) SCIProfileAnalyzerSnapshot *previous;
|
||||
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *mutualFollowers;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *notFollowingYouBack;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *youDontFollowBack;
|
||||
// `new*` getters are reserved by ARC's Cocoa new-family rule, hence the name.
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *recentFollowers;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *lostFollowers;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *youStartedFollowing;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerUser *> *youUnfollowed;
|
||||
@property (nonatomic, copy) NSArray<SCIProfileAnalyzerProfileChange *> *profileUpdates;
|
||||
|
||||
+ (SCIProfileAnalyzerReport *)reportFromCurrent:(nullable SCIProfileAnalyzerSnapshot *)current
|
||||
previous:(nullable SCIProfileAnalyzerSnapshot *)previous;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,212 @@
|
||||
#import "SCIProfileAnalyzerModels.h"
|
||||
|
||||
#pragma mark - User
|
||||
|
||||
@implementation SCIProfileAnalyzerUser
|
||||
|
||||
+ (instancetype)userFromAPIDict:(NSDictionary *)d {
|
||||
id pkRaw = d[@"pk"] ?: d[@"pk_id"] ?: d[@"id"];
|
||||
NSString *pk = [pkRaw isKindOfClass:[NSString class]] ? pkRaw
|
||||
: [pkRaw respondsToSelector:@selector(stringValue)] ? [pkRaw stringValue] : nil;
|
||||
if (!pk.length) return nil;
|
||||
|
||||
SCIProfileAnalyzerUser *u = [self new];
|
||||
u.pk = pk;
|
||||
u.username = [d[@"username"] isKindOfClass:[NSString class]] ? d[@"username"] : @"";
|
||||
u.fullName = [d[@"full_name"] isKindOfClass:[NSString class]] ? d[@"full_name"] : nil;
|
||||
u.profilePicURL = [d[@"profile_pic_url"] isKindOfClass:[NSString class]] ? d[@"profile_pic_url"] : nil;
|
||||
id pid = d[@"profile_pic_id"];
|
||||
if ([pid isKindOfClass:[NSString class]]) u.profilePicID = pid;
|
||||
else if ([pid respondsToSelector:@selector(stringValue)]) u.profilePicID = [pid stringValue];
|
||||
u.isPrivate = [d[@"is_private"] boolValue];
|
||||
u.isVerified = [d[@"is_verified"] boolValue];
|
||||
return u;
|
||||
}
|
||||
|
||||
+ (instancetype)userFromJSONDict:(NSDictionary *)d {
|
||||
if (![d[@"pk"] isKindOfClass:[NSString class]]) return nil;
|
||||
SCIProfileAnalyzerUser *u = [self new];
|
||||
u.pk = d[@"pk"];
|
||||
u.username = d[@"username"] ?: @"";
|
||||
u.fullName = d[@"full_name"];
|
||||
u.profilePicURL = d[@"profile_pic_url"];
|
||||
u.profilePicID = d[@"profile_pic_id"];
|
||||
u.isPrivate = [d[@"is_private"] boolValue];
|
||||
u.isVerified = [d[@"is_verified"] boolValue];
|
||||
return u;
|
||||
}
|
||||
|
||||
- (NSDictionary *)toJSONDict {
|
||||
NSMutableDictionary *d = [NSMutableDictionary dictionary];
|
||||
d[@"pk"] = self.pk ?: @"";
|
||||
d[@"username"] = self.username ?: @"";
|
||||
if (self.fullName) d[@"full_name"] = self.fullName;
|
||||
if (self.profilePicURL) d[@"profile_pic_url"] = self.profilePicURL;
|
||||
if (self.profilePicID) d[@"profile_pic_id"] = self.profilePicID;
|
||||
d[@"is_private"] = @(self.isPrivate);
|
||||
d[@"is_verified"] = @(self.isVerified);
|
||||
return d;
|
||||
}
|
||||
|
||||
- (id)copyWithZone:(NSZone *)zone {
|
||||
SCIProfileAnalyzerUser *u = [SCIProfileAnalyzerUser new];
|
||||
u.pk = self.pk;
|
||||
u.username = self.username;
|
||||
u.fullName = self.fullName;
|
||||
u.profilePicURL = self.profilePicURL;
|
||||
u.profilePicID = self.profilePicID;
|
||||
u.isPrivate = self.isPrivate;
|
||||
u.isVerified = self.isVerified;
|
||||
return u;
|
||||
}
|
||||
|
||||
- (NSUInteger)hash { return self.pk.hash; }
|
||||
- (BOOL)isEqual:(id)other {
|
||||
if (![other isKindOfClass:[SCIProfileAnalyzerUser class]]) return NO;
|
||||
return [self.pk isEqualToString:((SCIProfileAnalyzerUser *)other).pk];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Snapshot
|
||||
|
||||
@implementation SCIProfileAnalyzerSnapshot
|
||||
|
||||
+ (instancetype)snapshotFromJSONDict:(NSDictionary *)d {
|
||||
if (!d[@"self_pk"]) return nil;
|
||||
SCIProfileAnalyzerSnapshot *s = [self new];
|
||||
s.scanDate = [NSDate dateWithTimeIntervalSince1970:[d[@"scan_date"] doubleValue]];
|
||||
s.selfPK = d[@"self_pk"];
|
||||
s.selfUsername = d[@"self_username"];
|
||||
s.selfFullName = d[@"self_full_name"];
|
||||
s.selfProfilePicURL = d[@"self_profile_pic_url"];
|
||||
s.followerCount = [d[@"follower_count"] integerValue];
|
||||
s.followingCount = [d[@"following_count"] integerValue];
|
||||
s.mediaCount = [d[@"media_count"] integerValue];
|
||||
|
||||
NSMutableArray *f = [NSMutableArray array];
|
||||
for (NSDictionary *u in d[@"followers"]) {
|
||||
SCIProfileAnalyzerUser *user = [SCIProfileAnalyzerUser userFromJSONDict:u];
|
||||
if (user) [f addObject:user];
|
||||
}
|
||||
s.followers = f;
|
||||
|
||||
NSMutableArray *g = [NSMutableArray array];
|
||||
for (NSDictionary *u in d[@"following"]) {
|
||||
SCIProfileAnalyzerUser *user = [SCIProfileAnalyzerUser userFromJSONDict:u];
|
||||
if (user) [g addObject:user];
|
||||
}
|
||||
s.following = g;
|
||||
return s;
|
||||
}
|
||||
|
||||
- (NSDictionary *)toJSONDict {
|
||||
NSMutableArray *f = [NSMutableArray arrayWithCapacity:self.followers.count];
|
||||
for (SCIProfileAnalyzerUser *u in self.followers) [f addObject:[u toJSONDict]];
|
||||
NSMutableArray *g = [NSMutableArray arrayWithCapacity:self.following.count];
|
||||
for (SCIProfileAnalyzerUser *u in self.following) [g addObject:[u toJSONDict]];
|
||||
|
||||
return @{
|
||||
@"scan_date": @([self.scanDate timeIntervalSince1970]),
|
||||
@"self_pk": self.selfPK ?: @"",
|
||||
@"self_username": self.selfUsername ?: @"",
|
||||
@"self_full_name": self.selfFullName ?: @"",
|
||||
@"self_profile_pic_url": self.selfProfilePicURL ?: @"",
|
||||
@"follower_count": @(self.followerCount),
|
||||
@"following_count": @(self.followingCount),
|
||||
@"media_count": @(self.mediaCount),
|
||||
@"followers": f,
|
||||
@"following": g,
|
||||
};
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
#pragma mark - Profile change
|
||||
|
||||
@implementation SCIProfileAnalyzerProfileChange
|
||||
- (BOOL)usernameChanged { return ![self.previous.username isEqualToString:self.current.username]; }
|
||||
- (BOOL)fullNameChanged { return ![(self.previous.fullName ?: @"") isEqualToString:(self.current.fullName ?: @"")]; }
|
||||
// Compare profile_pic_id (stable per pic; changes only on upload). URL
|
||||
// diffing was unusable — IG rotates the CDN host + path hash per request.
|
||||
// Skip when either side is missing the id (old snapshots pre-feature).
|
||||
- (BOOL)profilePicChanged {
|
||||
NSString *a = self.previous.profilePicID;
|
||||
NSString *b = self.current.profilePicID;
|
||||
if (!a.length || !b.length) return NO;
|
||||
return ![a isEqualToString:b];
|
||||
}
|
||||
@end
|
||||
|
||||
#pragma mark - Report
|
||||
|
||||
@implementation SCIProfileAnalyzerReport
|
||||
|
||||
static NSArray *sciSubtract(NSArray *a, NSSet *bSet) {
|
||||
if (!a.count) return @[];
|
||||
NSMutableArray *out = [NSMutableArray arrayWithCapacity:a.count];
|
||||
for (SCIProfileAnalyzerUser *u in a) if (![bSet containsObject:u]) [out addObject:u];
|
||||
return out;
|
||||
}
|
||||
|
||||
static NSArray *sciIntersect(NSArray *a, NSSet *bSet) {
|
||||
if (!a.count) return @[];
|
||||
NSMutableArray *out = [NSMutableArray arrayWithCapacity:a.count];
|
||||
for (SCIProfileAnalyzerUser *u in a) if ([bSet containsObject:u]) [out addObject:u];
|
||||
return out;
|
||||
}
|
||||
|
||||
+ (SCIProfileAnalyzerReport *)reportFromCurrent:(SCIProfileAnalyzerSnapshot *)current
|
||||
previous:(SCIProfileAnalyzerSnapshot *)previous {
|
||||
SCIProfileAnalyzerReport *r = [self new];
|
||||
r.current = current;
|
||||
r.previous = previous;
|
||||
r.mutualFollowers = @[];
|
||||
r.notFollowingYouBack = @[];
|
||||
r.youDontFollowBack = @[];
|
||||
r.recentFollowers = @[];
|
||||
r.lostFollowers = @[];
|
||||
r.youStartedFollowing = @[];
|
||||
r.youUnfollowed = @[];
|
||||
r.profileUpdates = @[];
|
||||
if (!current) return r;
|
||||
|
||||
NSSet *followersSet = [NSSet setWithArray:current.followers];
|
||||
NSSet *followingSet = [NSSet setWithArray:current.following];
|
||||
|
||||
r.mutualFollowers = sciIntersect(current.followers, followingSet);
|
||||
r.notFollowingYouBack = sciSubtract(current.following, followersSet);
|
||||
r.youDontFollowBack = sciSubtract(current.followers, followingSet);
|
||||
|
||||
if (previous) {
|
||||
NSSet *prevFollowers = [NSSet setWithArray:previous.followers];
|
||||
NSSet *prevFollowing = [NSSet setWithArray:previous.following];
|
||||
r.recentFollowers = sciSubtract(current.followers, prevFollowers);
|
||||
r.lostFollowers = sciSubtract(previous.followers, followersSet);
|
||||
r.youStartedFollowing = sciSubtract(current.following, prevFollowing);
|
||||
r.youUnfollowed = sciSubtract(previous.following, followingSet);
|
||||
|
||||
// Profile updates: same pk in both snapshots, any field differs.
|
||||
NSMutableDictionary *prevByPK = [NSMutableDictionary dictionary];
|
||||
for (SCIProfileAnalyzerUser *u in previous.followers) prevByPK[u.pk] = u;
|
||||
for (SCIProfileAnalyzerUser *u in previous.following) prevByPK[u.pk] = u;
|
||||
|
||||
NSMutableArray *updates = [NSMutableArray array];
|
||||
NSMutableSet *seen = [NSMutableSet set];
|
||||
NSArray *currentAll = [current.followers arrayByAddingObjectsFromArray:current.following];
|
||||
for (SCIProfileAnalyzerUser *u in currentAll) {
|
||||
if ([seen containsObject:u.pk]) continue;
|
||||
[seen addObject:u.pk];
|
||||
SCIProfileAnalyzerUser *prev = prevByPK[u.pk];
|
||||
if (!prev) continue;
|
||||
SCIProfileAnalyzerProfileChange *ch = [SCIProfileAnalyzerProfileChange new];
|
||||
ch.previous = prev;
|
||||
ch.current = u;
|
||||
if (ch.usernameChanged || ch.fullNameChanged || ch.profilePicChanged) [updates addObject:ch];
|
||||
}
|
||||
r.profileUpdates = updates;
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,36 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import "SCIProfileAnalyzerModels.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIProfileAnalyzerError) {
|
||||
SCIProfileAnalyzerErrorNoSession = 1,
|
||||
SCIProfileAnalyzerErrorTooManyFollowers,
|
||||
SCIProfileAnalyzerErrorNetwork,
|
||||
SCIProfileAnalyzerErrorCancelled,
|
||||
};
|
||||
|
||||
// Hard cap — beyond this follower count we refuse to run. Each followers
|
||||
// page returns ~25-50 users so large accounts hit IG rate limits fast.
|
||||
extern const NSInteger SCIProfileAnalyzerMaxFollowerCount;
|
||||
|
||||
typedef void(^SCIPAProgress)(NSString *status, double fraction);
|
||||
typedef void(^SCIPACompletion)(SCIProfileAnalyzerSnapshot * _Nullable snapshot, NSError * _Nullable error);
|
||||
// Fires once, right after the self-user-info call returns. Lets the UI
|
||||
// paint the header immediately instead of waiting for the full run to finish.
|
||||
typedef void(^SCIPAHeaderInfo)(NSDictionary *userInfo);
|
||||
|
||||
@interface SCIProfileAnalyzerService : NSObject
|
||||
|
||||
@property (nonatomic, readonly) BOOL isRunning;
|
||||
|
||||
+ (instancetype)sharedService;
|
||||
|
||||
- (void)runForSelfWithHeaderInfo:(nullable SCIPAHeaderInfo)headerInfo
|
||||
progress:(SCIPAProgress)progress
|
||||
completion:(SCIPACompletion)completion;
|
||||
- (void)cancel;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,213 @@
|
||||
#import "SCIProfileAnalyzerService.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import "../../Utils.h"
|
||||
|
||||
const NSInteger SCIProfileAnalyzerMaxFollowerCount = 13000;
|
||||
|
||||
#define SCI_PA_PAGE_DELAY_S 0.25 // small pause between pages — lightweight rate cushion
|
||||
|
||||
@interface SCIProfileAnalyzerService () {
|
||||
@public
|
||||
NSInteger _expectedFollowers;
|
||||
NSInteger _expectedFollowing;
|
||||
}
|
||||
@property (nonatomic, assign) BOOL cancelled;
|
||||
@property (nonatomic, assign) BOOL isRunning;
|
||||
@end
|
||||
|
||||
@implementation SCIProfileAnalyzerService
|
||||
|
||||
+ (instancetype)sharedService {
|
||||
static SCIProfileAnalyzerService *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [self new]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
- (void)cancel { self.cancelled = YES; }
|
||||
|
||||
- (void)finishWithSnapshot:(SCIProfileAnalyzerSnapshot *)s error:(NSError *)e completion:(SCIPACompletion)completion {
|
||||
self.isRunning = NO;
|
||||
self.cancelled = NO;
|
||||
if (completion) dispatch_async(dispatch_get_main_queue(), ^{ completion(s, e); });
|
||||
}
|
||||
|
||||
- (NSError *)errorWithCode:(SCIProfileAnalyzerError)code message:(NSString *)msg {
|
||||
return [NSError errorWithDomain:@"SCIProfileAnalyzer" code:code
|
||||
userInfo:@{ NSLocalizedDescriptionKey: msg ?: @"" }];
|
||||
}
|
||||
|
||||
- (void)runForSelfWithHeaderInfo:(SCIPAHeaderInfo)headerInfo
|
||||
progress:(SCIPAProgress)progress
|
||||
completion:(SCIPACompletion)completion {
|
||||
if (self.isRunning) {
|
||||
if (completion) completion(nil, [self errorWithCode:SCIProfileAnalyzerErrorCancelled
|
||||
message:SCILocalized(@"Another analysis is already running")]);
|
||||
return;
|
||||
}
|
||||
self.isRunning = YES;
|
||||
self.cancelled = NO;
|
||||
|
||||
NSString *selfPK = [SCIUtils currentUserPK];
|
||||
if (!selfPK.length) {
|
||||
[self finishWithSnapshot:nil
|
||||
error:[self errorWithCode:SCIProfileAnalyzerErrorNoSession message:SCILocalized(@"No active Instagram session found")]
|
||||
completion:completion];
|
||||
return;
|
||||
}
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[self reportProgress:progress status:SCILocalized(@"Fetching profile info…") fraction:0.02];
|
||||
|
||||
[SCIInstagramAPI sendRequestWithMethod:@"GET"
|
||||
path:[NSString stringWithFormat:@"users/%@/info/", selfPK]
|
||||
body:nil
|
||||
completion:^(NSDictionary *resp, NSError *error) {
|
||||
typeof(self) strongSelf = weakSelf;
|
||||
if (!strongSelf) return;
|
||||
if (strongSelf.cancelled) {
|
||||
[strongSelf finishWithSnapshot:nil error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]
|
||||
completion:completion];
|
||||
return;
|
||||
}
|
||||
NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil;
|
||||
if (!user) {
|
||||
[strongSelf finishWithSnapshot:nil
|
||||
error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorNetwork message:SCILocalized(@"Couldn't fetch profile information")]
|
||||
completion:completion];
|
||||
return;
|
||||
}
|
||||
NSInteger followerCount = [user[@"follower_count"] integerValue];
|
||||
if (followerCount > SCIProfileAnalyzerMaxFollowerCount) {
|
||||
[strongSelf finishWithSnapshot:nil
|
||||
error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorTooManyFollowers
|
||||
message:SCILocalized(@"Too many followers to analyze")]
|
||||
completion:completion];
|
||||
return;
|
||||
}
|
||||
|
||||
SCIProfileAnalyzerSnapshot *snap = [SCIProfileAnalyzerSnapshot new];
|
||||
snap.selfPK = selfPK;
|
||||
snap.selfUsername = user[@"username"];
|
||||
snap.selfFullName = user[@"full_name"];
|
||||
snap.selfProfilePicURL = user[@"profile_pic_url"];
|
||||
snap.followerCount = followerCount;
|
||||
snap.followingCount = [user[@"following_count"] integerValue];
|
||||
snap.mediaCount = [user[@"media_count"] integerValue];
|
||||
snap.scanDate = [NSDate date];
|
||||
|
||||
strongSelf->_expectedFollowers = followerCount;
|
||||
strongSelf->_expectedFollowing = snap.followingCount;
|
||||
|
||||
if (headerInfo) dispatch_async(dispatch_get_main_queue(), ^{ headerInfo(user); });
|
||||
[strongSelf fetchFollowersForPK:selfPK snapshot:snap progress:progress completion:completion];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)reportProgress:(SCIPAProgress)p status:(NSString *)s fraction:(double)f {
|
||||
if (!p) return;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ p(s, f); });
|
||||
}
|
||||
|
||||
#pragma mark - Paginated fetchers
|
||||
|
||||
- (void)fetchFollowersForPK:(NSString *)pk
|
||||
snapshot:(SCIProfileAnalyzerSnapshot *)snap
|
||||
progress:(SCIPAProgress)progress
|
||||
completion:(SCIPACompletion)completion {
|
||||
NSMutableArray *acc = [NSMutableArray array];
|
||||
[self pagePath:[NSString stringWithFormat:@"friendships/%@/followers/", pk]
|
||||
acc:acc
|
||||
maxId:nil
|
||||
total:snap.followerCount
|
||||
stage:@"followers"
|
||||
progress:progress
|
||||
completion:^(NSArray *users, NSError *error) {
|
||||
if (error || self.cancelled) {
|
||||
[self finishWithSnapshot:nil error:error ?: [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]
|
||||
completion:completion];
|
||||
return;
|
||||
}
|
||||
snap.followers = users;
|
||||
[self fetchFollowingForPK:pk snapshot:snap progress:progress completion:completion];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)fetchFollowingForPK:(NSString *)pk
|
||||
snapshot:(SCIProfileAnalyzerSnapshot *)snap
|
||||
progress:(SCIPAProgress)progress
|
||||
completion:(SCIPACompletion)completion {
|
||||
NSMutableArray *acc = [NSMutableArray array];
|
||||
[self pagePath:[NSString stringWithFormat:@"friendships/%@/following/", pk]
|
||||
acc:acc
|
||||
maxId:nil
|
||||
total:snap.followingCount
|
||||
stage:@"following"
|
||||
progress:progress
|
||||
completion:^(NSArray *users, NSError *error) {
|
||||
if (error || self.cancelled) {
|
||||
[self finishWithSnapshot:nil error:error ?: [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]
|
||||
completion:completion];
|
||||
return;
|
||||
}
|
||||
snap.following = users;
|
||||
[self finishWithSnapshot:snap error:nil completion:completion];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)pagePath:(NSString *)basePath
|
||||
acc:(NSMutableArray *)acc
|
||||
maxId:(NSString *)maxId
|
||||
total:(NSInteger)total
|
||||
stage:(NSString *)stage
|
||||
progress:(SCIPAProgress)progress
|
||||
completion:(void(^)(NSArray *users, NSError *error))completion {
|
||||
if (self.cancelled) {
|
||||
completion(nil, [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]);
|
||||
return;
|
||||
}
|
||||
NSString *path = maxId.length ? [NSString stringWithFormat:@"%@?max_id=%@", basePath, maxId] : basePath;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *resp, NSError *error) {
|
||||
typeof(self) strongSelf = weakSelf;
|
||||
if (!strongSelf) return;
|
||||
if (error) { completion(nil, [strongSelf errorWithCode:SCIProfileAnalyzerErrorNetwork message:error.localizedDescription]); return; }
|
||||
|
||||
NSArray *users = resp[@"users"];
|
||||
if ([users isKindOfClass:[NSArray class]]) {
|
||||
for (NSDictionary *d in users) {
|
||||
SCIProfileAnalyzerUser *u = [SCIProfileAnalyzerUser userFromAPIDict:d];
|
||||
if (u) [acc addObject:u];
|
||||
}
|
||||
}
|
||||
// Weight each stage by its share of expected work so the ring moves
|
||||
// proportionally regardless of follower/following ratio. 3% reserved
|
||||
// up front for the initial user-info call.
|
||||
NSInteger followerTarget = strongSelf->_expectedFollowers;
|
||||
NSInteger followingTarget = strongSelf->_expectedFollowing;
|
||||
double total0 = MAX(1, followerTarget + followingTarget);
|
||||
double stageWeight = ([stage isEqualToString:@"followers"] ? followerTarget : followingTarget) / total0;
|
||||
double stageOffset = ([stage isEqualToString:@"followers"] ? 0.0 : (double)followerTarget / total0);
|
||||
double stageLocal = total > 0 ? MIN(1.0, (double)acc.count / (double)total) : 0;
|
||||
double frac = 0.03 + (stageOffset + stageLocal * stageWeight) * 0.97;
|
||||
NSString *fmt = [stage isEqualToString:@"followers"]
|
||||
? SCILocalized(@"Fetching followers (%lu/%ld)…")
|
||||
: SCILocalized(@"Fetching following (%lu/%ld)…");
|
||||
NSString *label = [NSString stringWithFormat:fmt, (unsigned long)acc.count, (long)total];
|
||||
[strongSelf reportProgress:progress status:label fraction:frac];
|
||||
|
||||
id next = resp[@"next_max_id"];
|
||||
NSString *nextMax = [next isKindOfClass:[NSString class]] ? next : ([next respondsToSelector:@selector(stringValue)] ? [next stringValue] : nil);
|
||||
if (!nextMax.length || strongSelf.cancelled) {
|
||||
completion(acc, strongSelf.cancelled ? [strongSelf errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")] : nil);
|
||||
return;
|
||||
}
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SCI_PA_PAGE_DELAY_S * NSEC_PER_SEC)),
|
||||
dispatch_get_main_queue(), ^{
|
||||
[strongSelf pagePath:basePath acc:acc maxId:nextMax total:total stage:stage progress:progress completion:completion];
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,41 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
#import "SCIProfileAnalyzerModels.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
// Posted on every save/update/reset. userInfo carries @"user_pk".
|
||||
extern NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification;
|
||||
|
||||
// Per-account on-disk store: current + previous snapshots (for since-last-scan
|
||||
// diffs), an optional baseline for cumulative tracking, and a lightweight
|
||||
// header cache keyed by PK.
|
||||
@interface SCIProfileAnalyzerStorage : NSObject
|
||||
|
||||
+ (nullable SCIProfileAnalyzerSnapshot *)currentSnapshotForUserPK:(NSString *)userPK;
|
||||
+ (nullable SCIProfileAnalyzerSnapshot *)previousSnapshotForUserPK:(NSString *)userPK;
|
||||
+ (nullable SCIProfileAnalyzerSnapshot *)baselineSnapshotForUserPK:(NSString *)userPK;
|
||||
+ (BOOL)saveBaselineSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK;
|
||||
+ (void)clearBaselineForUserPK:(NSString *)userPK;
|
||||
|
||||
// Rotates current → previous, then writes the new current.
|
||||
+ (BOOL)saveSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK;
|
||||
|
||||
// Overwrites current without touching previous — keeps the diff baseline
|
||||
// intact across in-app follow/unfollow mutations.
|
||||
+ (BOOL)updateCurrentSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK;
|
||||
|
||||
+ (void)resetForUserPK:(NSString *)userPK;
|
||||
+ (void)resetAll;
|
||||
|
||||
// Self-profile summary (username, name, counts, pic) cached so the header
|
||||
// paints on cold launch without a /users/{pk}/info/ call.
|
||||
+ (nullable NSDictionary *)headerInfoForUserPK:(NSString *)userPK;
|
||||
+ (void)saveHeaderInfo:(NSDictionary *)info forUserPK:(NSString *)userPK;
|
||||
|
||||
// Backup/Restore hooks — opaque pk-keyed JSON blob.
|
||||
+ (NSDictionary *)exportedDict;
|
||||
+ (BOOL)importFromDict:(NSDictionary *)dict;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,136 @@
|
||||
#import "SCIProfileAnalyzerStorage.h"
|
||||
|
||||
NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification = @"SCIProfileAnalyzerDataDidChangeNotification";
|
||||
|
||||
@implementation SCIProfileAnalyzerStorage
|
||||
|
||||
static NSString *const kSCIPAStorageDir = @"RyukGram/ProfileAnalyzer";
|
||||
|
||||
static void sciPostDataChanged(NSString *userPK) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:SCIProfileAnalyzerDataDidChangeNotification
|
||||
object:nil
|
||||
userInfo:userPK.length ? @{ @"user_pk": userPK } : @{}];
|
||||
});
|
||||
}
|
||||
|
||||
static NSString *sciStorageDir(void) {
|
||||
NSArray *roots = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES);
|
||||
NSString *dir = [roots.firstObject stringByAppendingPathComponent:kSCIPAStorageDir];
|
||||
[[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil];
|
||||
return dir;
|
||||
}
|
||||
|
||||
static NSString *sciPath(NSString *userPK, NSString *slot) {
|
||||
NSString *safePK = userPK.length ? userPK : @"anon";
|
||||
return [sciStorageDir() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"%@.%@.json", safePK, slot]];
|
||||
}
|
||||
|
||||
static NSDictionary *sciReadJSON(NSString *path) {
|
||||
NSData *data = [NSData dataWithContentsOfFile:path];
|
||||
if (!data.length) return nil;
|
||||
id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
|
||||
return [obj isKindOfClass:[NSDictionary class]] ? obj : nil;
|
||||
}
|
||||
|
||||
static BOOL sciWriteJSON(NSString *path, NSDictionary *dict) {
|
||||
NSError *err = nil;
|
||||
NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&err];
|
||||
if (!data) return NO;
|
||||
return [data writeToFile:path atomically:YES];
|
||||
}
|
||||
|
||||
+ (SCIProfileAnalyzerSnapshot *)currentSnapshotForUserPK:(NSString *)userPK {
|
||||
return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"current"))];
|
||||
}
|
||||
|
||||
+ (SCIProfileAnalyzerSnapshot *)previousSnapshotForUserPK:(NSString *)userPK {
|
||||
return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"previous"))];
|
||||
}
|
||||
|
||||
+ (SCIProfileAnalyzerSnapshot *)baselineSnapshotForUserPK:(NSString *)userPK {
|
||||
return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"baseline"))];
|
||||
}
|
||||
|
||||
+ (BOOL)saveBaselineSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK {
|
||||
if (!snapshot) return NO;
|
||||
BOOL ok = sciWriteJSON(sciPath(userPK, @"baseline"), [snapshot toJSONDict]);
|
||||
if (ok) sciPostDataChanged(userPK);
|
||||
return ok;
|
||||
}
|
||||
|
||||
+ (void)clearBaselineForUserPK:(NSString *)userPK {
|
||||
[[NSFileManager defaultManager] removeItemAtPath:sciPath(userPK, @"baseline") error:nil];
|
||||
sciPostDataChanged(userPK);
|
||||
}
|
||||
|
||||
+ (BOOL)saveSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK {
|
||||
if (!snapshot) return NO;
|
||||
NSString *cur = sciPath(userPK, @"current");
|
||||
NSString *prev = sciPath(userPK, @"previous");
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
if ([fm fileExistsAtPath:cur]) {
|
||||
[fm removeItemAtPath:prev error:nil];
|
||||
[fm moveItemAtPath:cur toPath:prev error:nil];
|
||||
}
|
||||
BOOL ok = sciWriteJSON(cur, [snapshot toJSONDict]);
|
||||
if (ok) sciPostDataChanged(userPK);
|
||||
return ok;
|
||||
}
|
||||
|
||||
+ (BOOL)updateCurrentSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK {
|
||||
if (!snapshot) return NO;
|
||||
BOOL ok = sciWriteJSON(sciPath(userPK, @"current"), [snapshot toJSONDict]);
|
||||
if (ok) sciPostDataChanged(userPK);
|
||||
return ok;
|
||||
}
|
||||
|
||||
+ (void)resetForUserPK:(NSString *)userPK {
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
[fm removeItemAtPath:sciPath(userPK, @"current") error:nil];
|
||||
[fm removeItemAtPath:sciPath(userPK, @"previous") error:nil];
|
||||
[fm removeItemAtPath:sciPath(userPK, @"baseline") error:nil];
|
||||
sciPostDataChanged(userPK);
|
||||
}
|
||||
|
||||
+ (void)resetAll {
|
||||
[[NSFileManager defaultManager] removeItemAtPath:sciStorageDir() error:nil];
|
||||
sciPostDataChanged(nil);
|
||||
}
|
||||
|
||||
+ (NSDictionary *)headerInfoForUserPK:(NSString *)userPK {
|
||||
return sciReadJSON(sciPath(userPK, @"header"));
|
||||
}
|
||||
|
||||
+ (void)saveHeaderInfo:(NSDictionary *)info forUserPK:(NSString *)userPK {
|
||||
if (!info.count) return;
|
||||
NSMutableDictionary *stored = [info mutableCopy];
|
||||
stored[@"cached_at"] = @([[NSDate date] timeIntervalSince1970]);
|
||||
sciWriteJSON(sciPath(userPK, @"header"), stored);
|
||||
}
|
||||
|
||||
+ (NSDictionary *)exportedDict {
|
||||
NSMutableDictionary *out = [NSMutableDictionary dictionary];
|
||||
NSFileManager *fm = [NSFileManager defaultManager];
|
||||
for (NSString *name in [fm contentsOfDirectoryAtPath:sciStorageDir() error:nil]) {
|
||||
NSDictionary *d = sciReadJSON([sciStorageDir() stringByAppendingPathComponent:name]);
|
||||
if (d) out[name] = d;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+ (BOOL)importFromDict:(NSDictionary *)dict {
|
||||
if (![dict isKindOfClass:[NSDictionary class]] || !dict.count) return NO;
|
||||
[self resetAll];
|
||||
NSString *dir = sciStorageDir();
|
||||
for (NSString *name in dict) {
|
||||
if (![name hasSuffix:@".json"]) continue;
|
||||
NSDictionary *d = dict[name];
|
||||
if (![d isKindOfClass:[NSDictionary class]]) continue;
|
||||
sciWriteJSON([dir stringByAppendingPathComponent:name], d);
|
||||
}
|
||||
return YES;
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,4 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCIProfileAnalyzerViewController : UIViewController
|
||||
@end
|
||||
@@ -0,0 +1,990 @@
|
||||
#import "SCIProfileAnalyzerViewController.h"
|
||||
#import "SCIProfileAnalyzerModels.h"
|
||||
#import "SCIProfileAnalyzerStorage.h"
|
||||
#import "SCIProfileAnalyzerService.h"
|
||||
#import "SCIProfileAnalyzerListViewController.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIImageCache.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import "../../Localization/SCILocalization.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
extern NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification;
|
||||
|
||||
#pragma mark - Category descriptor
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIPACategory) {
|
||||
SCIPACategoryMutual,
|
||||
SCIPACategoryNotFollowingBack,
|
||||
SCIPACategoryDontFollowBack,
|
||||
SCIPACategoryNewFollowers,
|
||||
SCIPACategoryLostFollowers,
|
||||
SCIPACategoryYouStartedFollowing,
|
||||
SCIPACategoryYouUnfollowed,
|
||||
SCIPACategoryProfileUpdates,
|
||||
};
|
||||
|
||||
@interface SCIPACategoryDescriptor : NSObject
|
||||
@property (nonatomic, assign) SCIPACategory category;
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, copy) NSString *subtitle;
|
||||
@property (nonatomic, copy) NSString *symbol;
|
||||
@property (nonatomic, strong) UIColor *color;
|
||||
@property (nonatomic, assign) NSInteger count;
|
||||
@property (nonatomic, assign) BOOL requiresPrevious;
|
||||
@end
|
||||
@implementation SCIPACategoryDescriptor @end
|
||||
|
||||
#pragma mark - Avatar with progress ring
|
||||
|
||||
@interface SCIPAAvatarRingView : UIView
|
||||
@property (nonatomic, strong) UIImageView *imageView;
|
||||
@property (nonatomic, strong) CAShapeLayer *trackLayer;
|
||||
@property (nonatomic, strong) CAShapeLayer *progressLayer;
|
||||
@property (nonatomic, assign) double progress; // 0..1
|
||||
@property (nonatomic, assign) BOOL showProgress;
|
||||
@end
|
||||
|
||||
@implementation SCIPAAvatarRingView
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (!self) return self;
|
||||
|
||||
_trackLayer = [CAShapeLayer layer];
|
||||
_trackLayer.fillColor = UIColor.clearColor.CGColor;
|
||||
_trackLayer.strokeColor = [UIColor systemGray5Color].CGColor;
|
||||
_trackLayer.lineWidth = 3.5;
|
||||
_trackLayer.hidden = YES;
|
||||
[self.layer addSublayer:_trackLayer];
|
||||
|
||||
_progressLayer = [CAShapeLayer layer];
|
||||
_progressLayer.fillColor = UIColor.clearColor.CGColor;
|
||||
_progressLayer.strokeColor = ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]).CGColor;
|
||||
_progressLayer.lineWidth = 3.5;
|
||||
_progressLayer.lineCap = kCALineCapRound;
|
||||
_progressLayer.strokeEnd = 0;
|
||||
_progressLayer.hidden = YES;
|
||||
[self.layer addSublayer:_progressLayer];
|
||||
|
||||
_imageView = [UIImageView new];
|
||||
_imageView.contentMode = UIViewContentModeScaleAspectFill;
|
||||
_imageView.backgroundColor = [UIColor secondarySystemBackgroundColor];
|
||||
_imageView.layer.masksToBounds = YES;
|
||||
_imageView.image = [UIImage systemImageNamed:@"person.circle.fill"];
|
||||
_imageView.tintColor = [UIColor systemGrayColor];
|
||||
[self addSubview:_imageView];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
[super layoutSubviews];
|
||||
CGFloat size = MIN(self.bounds.size.width, self.bounds.size.height);
|
||||
if (size < 16) return; // transient tiny bounds during transitions
|
||||
CGFloat inset = 7;
|
||||
CGRect imgFrame = CGRectInset(CGRectMake(0, 0, size, size), inset, inset);
|
||||
self.imageView.frame = imgFrame;
|
||||
self.imageView.layer.cornerRadius = imgFrame.size.width / 2.0;
|
||||
|
||||
UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(size / 2.0, size / 2.0)
|
||||
radius:size / 2.0 - 2
|
||||
startAngle:-M_PI_2
|
||||
endAngle:-M_PI_2 + 2 * M_PI
|
||||
clockwise:YES];
|
||||
self.trackLayer.frame = self.bounds;
|
||||
self.progressLayer.frame = self.bounds;
|
||||
self.trackLayer.path = path.CGPath;
|
||||
self.progressLayer.path = path.CGPath;
|
||||
}
|
||||
|
||||
- (void)setProgress:(double)progress {
|
||||
_progress = MAX(0, MIN(1, progress));
|
||||
[CATransaction begin];
|
||||
[CATransaction setAnimationDuration:0.25];
|
||||
self.progressLayer.strokeEnd = _progress;
|
||||
[CATransaction commit];
|
||||
}
|
||||
|
||||
- (void)setShowProgress:(BOOL)show {
|
||||
_showProgress = show;
|
||||
self.trackLayer.hidden = !show;
|
||||
self.progressLayer.hidden = !show;
|
||||
if (show) self.progressLayer.strokeEnd = _progress;
|
||||
}
|
||||
@end
|
||||
|
||||
#pragma mark - Header
|
||||
|
||||
@interface SCIPAHeaderView : UIView
|
||||
@property (nonatomic, strong) SCIPAAvatarRingView *avatar;
|
||||
@property (nonatomic, strong) UILabel *fullNameLabel;
|
||||
@property (nonatomic, strong) UILabel *usernameLabel;
|
||||
@property (nonatomic, strong) UIStackView *statsRow;
|
||||
@property (nonatomic, strong) UILabel *scanDateLabel;
|
||||
@property (nonatomic, strong) UILabel *warningLabel;
|
||||
@property (nonatomic, strong) UIButton *scanButton;
|
||||
@property (nonatomic, strong) UILabel *progressLabel;
|
||||
@end
|
||||
|
||||
@implementation SCIPAHeaderView
|
||||
- (instancetype)initWithFrame:(CGRect)frame {
|
||||
self = [super initWithFrame:frame];
|
||||
if (!self) return self;
|
||||
self.backgroundColor = [UIColor secondarySystemBackgroundColor];
|
||||
self.layer.cornerRadius = 18;
|
||||
|
||||
_avatar = [[SCIPAAvatarRingView alloc] init];
|
||||
_avatar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:_avatar];
|
||||
|
||||
_fullNameLabel = [UILabel new];
|
||||
_fullNameLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_fullNameLabel.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold];
|
||||
_fullNameLabel.textColor = [UIColor labelColor];
|
||||
_fullNameLabel.textAlignment = NSTextAlignmentCenter;
|
||||
[self addSubview:_fullNameLabel];
|
||||
|
||||
_usernameLabel = [UILabel new];
|
||||
_usernameLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_usernameLabel.font = [UIFont systemFontOfSize:14];
|
||||
_usernameLabel.textColor = [UIColor secondaryLabelColor];
|
||||
_usernameLabel.textAlignment = NSTextAlignmentCenter;
|
||||
[self addSubview:_usernameLabel];
|
||||
|
||||
_statsRow = [[UIStackView alloc] init];
|
||||
_statsRow.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_statsRow.axis = UILayoutConstraintAxisHorizontal;
|
||||
_statsRow.distribution = UIStackViewDistributionFillEqually;
|
||||
_statsRow.spacing = 0;
|
||||
[self addSubview:_statsRow];
|
||||
|
||||
_scanDateLabel = [UILabel new];
|
||||
_scanDateLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_scanDateLabel.font = [UIFont systemFontOfSize:12];
|
||||
_scanDateLabel.textColor = [UIColor tertiaryLabelColor];
|
||||
_scanDateLabel.textAlignment = NSTextAlignmentCenter;
|
||||
[self addSubview:_scanDateLabel];
|
||||
|
||||
_warningLabel = [UILabel new];
|
||||
_warningLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_warningLabel.font = [UIFont systemFontOfSize:12];
|
||||
_warningLabel.textColor = [UIColor systemOrangeColor];
|
||||
_warningLabel.numberOfLines = 0;
|
||||
_warningLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_warningLabel.hidden = YES;
|
||||
[self addSubview:_warningLabel];
|
||||
|
||||
_scanButton = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
_scanButton.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_scanButton.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
|
||||
_scanButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
|
||||
[_scanButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
_scanButton.layer.cornerRadius = 18;
|
||||
_scanButton.contentEdgeInsets = UIEdgeInsetsMake(0, 22, 0, 22);
|
||||
[_scanButton setTitle:SCILocalized(@"Run analysis") forState:UIControlStateNormal];
|
||||
[self addSubview:_scanButton];
|
||||
|
||||
_progressLabel = [UILabel new];
|
||||
_progressLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_progressLabel.font = [UIFont systemFontOfSize:12];
|
||||
_progressLabel.textColor = [UIColor secondaryLabelColor];
|
||||
_progressLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_progressLabel.hidden = YES;
|
||||
[self addSubview:_progressLabel];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_avatar.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[_avatar.topAnchor constraintEqualToAnchor:self.topAnchor constant:18],
|
||||
[_avatar.widthAnchor constraintEqualToConstant:96],
|
||||
[_avatar.heightAnchor constraintEqualToConstant:96],
|
||||
|
||||
[_fullNameLabel.topAnchor constraintEqualToAnchor:_avatar.bottomAnchor constant:10],
|
||||
[_fullNameLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
|
||||
[_fullNameLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
|
||||
|
||||
[_usernameLabel.topAnchor constraintEqualToAnchor:_fullNameLabel.bottomAnchor constant:2],
|
||||
[_usernameLabel.leadingAnchor constraintEqualToAnchor:_fullNameLabel.leadingAnchor],
|
||||
[_usernameLabel.trailingAnchor constraintEqualToAnchor:_fullNameLabel.trailingAnchor],
|
||||
|
||||
[_statsRow.topAnchor constraintEqualToAnchor:_usernameLabel.bottomAnchor constant:14],
|
||||
[_statsRow.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12],
|
||||
[_statsRow.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[_statsRow.heightAnchor constraintEqualToConstant:44],
|
||||
|
||||
[_scanDateLabel.topAnchor constraintEqualToAnchor:_statsRow.bottomAnchor constant:10],
|
||||
[_scanDateLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
|
||||
[_scanDateLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
|
||||
|
||||
[_warningLabel.topAnchor constraintEqualToAnchor:_scanDateLabel.bottomAnchor constant:6],
|
||||
[_warningLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:20],
|
||||
[_warningLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-20],
|
||||
|
||||
[_scanButton.topAnchor constraintEqualToAnchor:_warningLabel.bottomAnchor constant:12],
|
||||
[_scanButton.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[_scanButton.heightAnchor constraintEqualToConstant:36],
|
||||
[_scanButton.widthAnchor constraintGreaterThanOrEqualToConstant:160],
|
||||
|
||||
[_progressLabel.topAnchor constraintEqualToAnchor:_scanButton.bottomAnchor constant:6],
|
||||
[_progressLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
|
||||
[_progressLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
|
||||
[_progressLabel.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-16],
|
||||
]];
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)setStatsLabelsPosts:(NSString *)posts followers:(NSString *)followers following:(NSString *)following {
|
||||
for (UIView *v in self.statsRow.arrangedSubviews) [self.statsRow removeArrangedSubview:v], [v removeFromSuperview];
|
||||
[self.statsRow addArrangedSubview:[self statColumn:posts caption:SCILocalized(@"Posts")]];
|
||||
[self.statsRow addArrangedSubview:[self statColumn:followers caption:SCILocalized(@"Followers")]];
|
||||
[self.statsRow addArrangedSubview:[self statColumn:following caption:SCILocalized(@"Following")]];
|
||||
}
|
||||
|
||||
- (UIView *)statColumn:(NSString *)value caption:(NSString *)caption {
|
||||
UIView *w = [UIView new];
|
||||
UILabel *v = [UILabel new];
|
||||
v.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
v.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
|
||||
v.textColor = [UIColor labelColor];
|
||||
v.textAlignment = NSTextAlignmentCenter;
|
||||
v.text = value;
|
||||
[w addSubview:v];
|
||||
|
||||
UILabel *c = [UILabel new];
|
||||
c.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
c.font = [UIFont systemFontOfSize:12];
|
||||
c.textColor = [UIColor secondaryLabelColor];
|
||||
c.textAlignment = NSTextAlignmentCenter;
|
||||
c.text = caption;
|
||||
[w addSubview:c];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[v.topAnchor constraintEqualToAnchor:w.topAnchor],
|
||||
[v.leadingAnchor constraintEqualToAnchor:w.leadingAnchor],
|
||||
[v.trailingAnchor constraintEqualToAnchor:w.trailingAnchor],
|
||||
[c.topAnchor constraintEqualToAnchor:v.bottomAnchor constant:1],
|
||||
[c.leadingAnchor constraintEqualToAnchor:w.leadingAnchor],
|
||||
[c.trailingAnchor constraintEqualToAnchor:w.trailingAnchor],
|
||||
[c.bottomAnchor constraintEqualToAnchor:w.bottomAnchor],
|
||||
]];
|
||||
return w;
|
||||
}
|
||||
@end
|
||||
|
||||
#pragma mark - Category cell
|
||||
|
||||
@interface SCIPACategoryCell : UITableViewCell
|
||||
@property (nonatomic, strong) UIView *iconBadge;
|
||||
@property (nonatomic, strong) UIImageView *iconView;
|
||||
@property (nonatomic, strong) UILabel *titleLabel;
|
||||
@property (nonatomic, strong) UILabel *subtitleLabel;
|
||||
@property (nonatomic, strong) UILabel *countLabel;
|
||||
@end
|
||||
|
||||
@implementation SCIPACategoryCell
|
||||
- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)rid {
|
||||
self = [super initWithStyle:style reuseIdentifier:rid];
|
||||
if (!self) return self;
|
||||
self.accessoryType = UITableViewCellAccessoryDisclosureIndicator;
|
||||
|
||||
_iconBadge = [UIView new];
|
||||
_iconBadge.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_iconBadge.layer.cornerRadius = 8;
|
||||
[self.contentView addSubview:_iconBadge];
|
||||
|
||||
_iconView = [UIImageView new];
|
||||
_iconView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_iconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
_iconView.tintColor = [UIColor whiteColor];
|
||||
[_iconBadge addSubview:_iconView];
|
||||
|
||||
_titleLabel = [UILabel new];
|
||||
_titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_titleLabel.font = [UIFont systemFontOfSize:16];
|
||||
[self.contentView addSubview:_titleLabel];
|
||||
|
||||
_subtitleLabel = [UILabel new];
|
||||
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_subtitleLabel.font = [UIFont systemFontOfSize:12];
|
||||
_subtitleLabel.textColor = [UIColor tertiaryLabelColor];
|
||||
[self.contentView addSubview:_subtitleLabel];
|
||||
|
||||
_countLabel = [UILabel new];
|
||||
_countLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_countLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
|
||||
_countLabel.textColor = [UIColor secondaryLabelColor];
|
||||
_countLabel.textAlignment = NSTextAlignmentRight;
|
||||
[self.contentView addSubview:_countLabel];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_iconBadge.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor],
|
||||
[_iconBadge.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
|
||||
[_iconBadge.widthAnchor constraintEqualToConstant:32],
|
||||
[_iconBadge.heightAnchor constraintEqualToConstant:32],
|
||||
|
||||
[_iconView.centerXAnchor constraintEqualToAnchor:_iconBadge.centerXAnchor],
|
||||
[_iconView.centerYAnchor constraintEqualToAnchor:_iconBadge.centerYAnchor],
|
||||
[_iconView.widthAnchor constraintEqualToConstant:18],
|
||||
[_iconView.heightAnchor constraintEqualToConstant:18],
|
||||
|
||||
[_titleLabel.leadingAnchor constraintEqualToAnchor:_iconBadge.trailingAnchor constant:12],
|
||||
[_titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10],
|
||||
[_titleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_countLabel.leadingAnchor constant:-8],
|
||||
|
||||
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_titleLabel.leadingAnchor],
|
||||
[_subtitleLabel.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:1],
|
||||
[_subtitleLabel.trailingAnchor constraintEqualToAnchor:_titleLabel.trailingAnchor],
|
||||
[_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-10],
|
||||
|
||||
[_countLabel.trailingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor],
|
||||
[_countLabel.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor],
|
||||
[_countLabel.widthAnchor constraintGreaterThanOrEqualToConstant:40],
|
||||
]];
|
||||
return self;
|
||||
}
|
||||
@end
|
||||
|
||||
#pragma mark - Main VC
|
||||
|
||||
@interface SCIProfileAnalyzerViewController () <UITableViewDataSource, UITableViewDelegate>
|
||||
@property (nonatomic, strong) UITableView *tableView;
|
||||
@property (nonatomic, strong) UIView *headerContainer;
|
||||
@property (nonatomic, strong) SCIPAHeaderView *headerView;
|
||||
|
||||
@property (nonatomic, strong) SCIProfileAnalyzerReport *report;
|
||||
@property (nonatomic, strong) NSArray<SCIPACategoryDescriptor *> *categories;
|
||||
@property (nonatomic, assign) BOOL running;
|
||||
@property (nonatomic, copy) NSString *lastHeaderPK;
|
||||
@property (nonatomic, assign) BOOL pendingHeaderFetch;
|
||||
@end
|
||||
|
||||
@implementation SCIProfileAnalyzerViewController
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor systemGroupedBackgroundColor];
|
||||
self.title = SCILocalized(@"Profile Analyzer");
|
||||
self.navigationItem.titleView = [self buildTitleViewWithBeta];
|
||||
|
||||
[self setupTable];
|
||||
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(analyzerDataChanged:)
|
||||
name:SCIProfileAnalyzerDataDidChangeNotification
|
||||
object:nil];
|
||||
}
|
||||
|
||||
- (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; }
|
||||
|
||||
- (void)analyzerDataChanged:(NSNotification *)note {
|
||||
if (!self.isViewLoaded || !self.view.window) return;
|
||||
NSString *pk = note.userInfo[@"user_pk"];
|
||||
NSString *current = [SCIUtils currentUserPK];
|
||||
if (pk.length && current.length && ![pk isEqualToString:current]) return;
|
||||
@try {
|
||||
[self loadCachedReport];
|
||||
SCIProfileAnalyzerSnapshot *cur = self.report.current;
|
||||
if (cur) {
|
||||
[self.headerView setStatsLabelsPosts:[self compactNumber:cur.mediaCount]
|
||||
followers:[self compactNumber:cur.followerCount]
|
||||
following:[self compactNumber:cur.followingCount]];
|
||||
}
|
||||
} @catch (__unused NSException *e) {}
|
||||
}
|
||||
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
[super viewWillAppear:animated];
|
||||
// Cheap disk + set math; safe during push.
|
||||
@try { [self loadCachedReport]; } @catch (__unused NSException *e) {}
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
[super viewDidAppear:animated];
|
||||
// Header merge + any network fetch wait until the transition settles.
|
||||
@try { [self loadHeaderLayered]; } @catch (__unused NSException *e) {}
|
||||
if (self.pendingHeaderFetch) {
|
||||
self.pendingHeaderFetch = NO;
|
||||
@try { [self fetchAndCacheHeader]; } @catch (__unused NSException *e) {}
|
||||
}
|
||||
}
|
||||
|
||||
- (UIView *)buildTitleViewWithBeta {
|
||||
UILabel *title = [UILabel new];
|
||||
title.text = SCILocalized(@"Profile Analyzer");
|
||||
title.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
|
||||
title.textColor = [UIColor labelColor];
|
||||
|
||||
UILabel *beta = [UILabel new];
|
||||
beta.text = @" BETA ";
|
||||
beta.font = [UIFont systemFontOfSize:10 weight:UIFontWeightHeavy];
|
||||
beta.textColor = [UIColor whiteColor];
|
||||
beta.backgroundColor = [UIColor systemOrangeColor];
|
||||
beta.layer.cornerRadius = 5;
|
||||
beta.layer.masksToBounds = YES;
|
||||
beta.textAlignment = NSTextAlignmentCenter;
|
||||
|
||||
UIStackView *row = [[UIStackView alloc] initWithArrangedSubviews:@[title, beta]];
|
||||
row.axis = UILayoutConstraintAxisHorizontal;
|
||||
row.alignment = UIStackViewAlignmentCenter;
|
||||
row.spacing = 6;
|
||||
return row;
|
||||
}
|
||||
|
||||
- (void)setupTable {
|
||||
self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped];
|
||||
self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.tableView.dataSource = self;
|
||||
self.tableView.delegate = self;
|
||||
self.tableView.sectionHeaderTopPadding = 0;
|
||||
self.tableView.rowHeight = UITableViewAutomaticDimension;
|
||||
self.tableView.estimatedRowHeight = 60;
|
||||
[self.tableView registerClass:[SCIPACategoryCell class] forCellReuseIdentifier:@"cat"];
|
||||
|
||||
UIRefreshControl *rc = [UIRefreshControl new];
|
||||
[rc addTarget:self action:@selector(pullToRefreshProfile:) forControlEvents:UIControlEventValueChanged];
|
||||
self.tableView.refreshControl = rc;
|
||||
|
||||
[self.view addSubview:self.tableView];
|
||||
[self buildTableHeader];
|
||||
}
|
||||
|
||||
// Pull-to-refresh: re-fetch just the self-profile (/users/{pk}/info/) so the
|
||||
// header reflects IG's truth on demand. No rescan, no data reset.
|
||||
- (void)pullToRefreshProfile:(UIRefreshControl *)sender {
|
||||
NSString *pk = [SCIUtils currentUserPK];
|
||||
if (!pk.length) { [sender endRefreshing]; return; }
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[SCIInstagramAPI sendRequestWithMethod:@"GET"
|
||||
path:[NSString stringWithFormat:@"users/%@/info/", pk]
|
||||
body:nil
|
||||
completion:^(NSDictionary *resp, NSError *error) {
|
||||
NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil;
|
||||
if (user.count) {
|
||||
[SCIProfileAnalyzerStorage saveHeaderInfo:user forUserPK:pk];
|
||||
typeof(self) strongSelf = weakSelf;
|
||||
if (strongSelf.isViewLoaded && strongSelf.view.window) {
|
||||
[strongSelf paintHeaderFromUserInfo:user];
|
||||
[strongSelf applyFollowerLimitGateFor:[user[@"follower_count"] integerValue]];
|
||||
}
|
||||
}
|
||||
[sender endRefreshing];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)buildTableHeader {
|
||||
// tableHeaderView is frame-driven; let viewWillLayoutSubviews set width.
|
||||
self.headerContainer = [UIView new];
|
||||
self.headerContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth;
|
||||
|
||||
self.headerView = [[SCIPAHeaderView alloc] init];
|
||||
self.headerView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.headerContainer addSubview:self.headerView];
|
||||
|
||||
[self.headerView.scanButton addTarget:self action:@selector(analyzeTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.headerView.topAnchor constraintEqualToAnchor:self.headerContainer.topAnchor constant:12],
|
||||
[self.headerView.leadingAnchor constraintEqualToAnchor:self.headerContainer.leadingAnchor constant:16],
|
||||
[self.headerView.trailingAnchor constraintEqualToAnchor:self.headerContainer.trailingAnchor constant:-16],
|
||||
[self.headerView.bottomAnchor constraintEqualToAnchor:self.headerContainer.bottomAnchor constant:-4],
|
||||
]];
|
||||
}
|
||||
|
||||
- (void)viewWillLayoutSubviews {
|
||||
[super viewWillLayoutSubviews];
|
||||
if (!self.headerContainer) return;
|
||||
CGFloat w = self.tableView.bounds.size.width;
|
||||
if (w < 1) return;
|
||||
|
||||
// Resolve internal height against the tableView's width.
|
||||
self.headerContainer.frame = CGRectMake(0, 0, w, 1);
|
||||
[self.headerContainer setNeedsLayout];
|
||||
[self.headerContainer layoutIfNeeded];
|
||||
CGFloat h = [self.headerContainer systemLayoutSizeFittingSize:CGSizeMake(w, UILayoutFittingCompressedSize.height)
|
||||
withHorizontalFittingPriority:UILayoutPriorityRequired
|
||||
verticalFittingPriority:UILayoutPriorityFittingSizeLevel].height;
|
||||
CGRect target = CGRectMake(0, 0, w, h);
|
||||
if (!CGRectEqualToRect(self.headerContainer.frame, target)) {
|
||||
self.headerContainer.frame = target;
|
||||
self.tableView.tableHeaderView = self.headerContainer;
|
||||
} else if (self.tableView.tableHeaderView != self.headerContainer) {
|
||||
self.tableView.tableHeaderView = self.headerContainer;
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Header resolution (IG memory → our cache → network)
|
||||
|
||||
// Layered header lookup: IG fieldCache → on-disk cache → network (only when
|
||||
// neither source has usable counts). Results get persisted so cold relaunch
|
||||
// is offline.
|
||||
- (void)loadHeaderLayered {
|
||||
NSString *pk = [SCIUtils currentUserPK];
|
||||
self.lastHeaderPK = pk;
|
||||
|
||||
NSDictionary *live = [self liveSelfInfoFromSession];
|
||||
NSMutableDictionary *cached = [[SCIProfileAnalyzerStorage headerInfoForUserPK:pk] mutableCopy]
|
||||
?: [NSMutableDictionary dictionary];
|
||||
SCIProfileAnalyzerSnapshot *snap = self.report.current;
|
||||
|
||||
// Hybrid reconciliation for following_count:
|
||||
// * snapshot.followingCount captures in-app follow/unfollow mutations.
|
||||
// * IG's fieldCache only refreshes when the user visits own profile.
|
||||
// We store the last fieldCache value we saw; when it moves, IG refreshed
|
||||
// and is authoritative — we align the snapshot to match. Otherwise the
|
||||
// snapshot (possibly just mutated) wins so unfollows show up live.
|
||||
NSNumber *liveFollowing = live[@"following_count"];
|
||||
NSNumber *lastSeenFollowing = cached[@"last_synced_following_count"];
|
||||
BOOL fieldCacheRefreshed = liveFollowing && (!lastSeenFollowing || ![liveFollowing isEqual:lastSeenFollowing]);
|
||||
if (fieldCacheRefreshed) {
|
||||
cached[@"following_count"] = liveFollowing;
|
||||
cached[@"last_synced_following_count"] = liveFollowing;
|
||||
if (snap && snap.followingCount != liveFollowing.integerValue) {
|
||||
snap.followingCount = liveFollowing.integerValue;
|
||||
[SCIProfileAnalyzerStorage updateCurrentSnapshot:snap forUserPK:pk];
|
||||
}
|
||||
} else if (snap && snap.followingCount > 0) {
|
||||
cached[@"following_count"] = @(snap.followingCount);
|
||||
} else if (liveFollowing) {
|
||||
cached[@"following_count"] = liveFollowing;
|
||||
}
|
||||
|
||||
// Non-mutable-in-app fields: fieldCache wins when present.
|
||||
for (NSString *k in @[@"username", @"full_name", @"profile_pic_url",
|
||||
@"profile_pic_id", @"follower_count", @"media_count"]) {
|
||||
if (live[k]) cached[k] = live[k];
|
||||
}
|
||||
// Fallbacks from snapshot if fieldCache lacks them entirely.
|
||||
if (snap && !cached[@"follower_count"] && snap.followerCount > 0) cached[@"follower_count"] = @(snap.followerCount);
|
||||
if (snap && !cached[@"media_count"] && snap.mediaCount > 0) cached[@"media_count"] = @(snap.mediaCount);
|
||||
|
||||
if (cached[@"username"] || [cached[@"follower_count"] integerValue] > 0) {
|
||||
[self paintHeaderFromUserInfo:cached];
|
||||
[self applyFollowerLimitGateFor:[cached[@"follower_count"] integerValue]];
|
||||
} else if (!snap) {
|
||||
self.headerView.fullNameLabel.text = SCILocalized(@"No scan yet");
|
||||
self.headerView.usernameLabel.text = @"";
|
||||
[self.headerView setStatsLabelsPosts:@"—" followers:@"—" following:@"—"];
|
||||
}
|
||||
|
||||
BOOL haveCounts = [cached[@"follower_count"] integerValue] > 0
|
||||
|| [cached[@"following_count"] integerValue] > 0
|
||||
|| [cached[@"media_count"] integerValue] > 0;
|
||||
if (haveCounts) {
|
||||
[SCIProfileAnalyzerStorage saveHeaderInfo:cached forUserPK:pk];
|
||||
} else {
|
||||
// Defer to next runloop so the push transition can complete before
|
||||
// any completion-block layout mutations.
|
||||
__weak typeof(self) weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (weakSelf.isViewLoaded && weakSelf.view.window) {
|
||||
[weakSelf fetchAndCacheHeader];
|
||||
} else {
|
||||
weakSelf.pendingHeaderFetch = YES;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
- (NSDictionary *)liveSelfInfoFromSession {
|
||||
id session = [SCIUtils activeUserSession];
|
||||
id igUser = nil;
|
||||
@try { if ([session respondsToSelector:@selector(user)]) igUser = [session valueForKey:@"user"]; } @catch (__unused id e) {}
|
||||
NSDictionary *fc = [self fieldCacheForUser:igUser];
|
||||
NSMutableDictionary *out = [NSMutableDictionary dictionary];
|
||||
for (NSString *k in @[@"username", @"full_name", @"profile_pic_url",
|
||||
@"follower_count", @"following_count", @"media_count"]) {
|
||||
if (fc[k]) out[k] = fc[k];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
- (void)fetchAndCacheHeader {
|
||||
NSString *pk = self.lastHeaderPK;
|
||||
if (!pk.length) return;
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[SCIInstagramAPI sendRequestWithMethod:@"GET"
|
||||
path:[NSString stringWithFormat:@"users/%@/info/", pk]
|
||||
body:nil
|
||||
completion:^(NSDictionary *resp, NSError *error) {
|
||||
NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil;
|
||||
if (!user.count) return;
|
||||
[SCIProfileAnalyzerStorage saveHeaderInfo:user forUserPK:pk];
|
||||
typeof(self) strongSelf = weakSelf;
|
||||
// Drop UI updates if the VC left the window between send + callback.
|
||||
if (!strongSelf.isViewLoaded || !strongSelf.view.window) return;
|
||||
[strongSelf paintHeaderFromUserInfo:user];
|
||||
[strongSelf applyFollowerLimitGateFor:[user[@"follower_count"] integerValue]];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)applyFollowerLimitGateFor:(NSInteger)followers {
|
||||
if (followers > SCIProfileAnalyzerMaxFollowerCount) {
|
||||
self.headerView.warningLabel.hidden = NO;
|
||||
self.headerView.warningLabel.text = [NSString stringWithFormat:
|
||||
SCILocalized(@"Follower count exceeds %ld — analysis disabled to avoid rate limits."),
|
||||
(long)SCIProfileAnalyzerMaxFollowerCount];
|
||||
self.headerView.scanButton.enabled = NO;
|
||||
self.headerView.scanButton.alpha = 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
- (NSDictionary *)fieldCacheForUser:(id)user {
|
||||
if (!user) return @{};
|
||||
Ivar iv = NULL;
|
||||
for (Class c = [user class]; c && !iv; c = class_getSuperclass(c))
|
||||
iv = class_getInstanceVariable(c, "_fieldCache");
|
||||
if (!iv) return @{};
|
||||
id d = object_getIvar(user, iv);
|
||||
return [d isKindOfClass:[NSDictionary class]] ? d : @{};
|
||||
}
|
||||
|
||||
- (NSString *)compactNumber:(NSInteger)n {
|
||||
if (n < 1000) return [NSString stringWithFormat:@"%ld", (long)n];
|
||||
if (n < 10000) return [NSString stringWithFormat:@"%.1fK", n / 1000.0];
|
||||
if (n < 1000000) return [NSString stringWithFormat:@"%ldK", (long)(n / 1000)];
|
||||
return [NSString stringWithFormat:@"%.1fM", n / 1000000.0];
|
||||
}
|
||||
|
||||
#pragma mark - Data
|
||||
|
||||
- (void)loadCachedReport {
|
||||
NSString *pk = [SCIUtils currentUserPK];
|
||||
SCIProfileAnalyzerSnapshot *cur = [SCIProfileAnalyzerStorage currentSnapshotForUserPK:pk];
|
||||
SCIProfileAnalyzerSnapshot *prev = [SCIProfileAnalyzerStorage previousSnapshotForUserPK:pk];
|
||||
SCIProfileAnalyzerSnapshot *base = [SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk];
|
||||
// Baseline wins when present; the toggle only drives its lifecycle.
|
||||
SCIProfileAnalyzerSnapshot *diffAgainst = base ?: prev;
|
||||
self.report = [SCIProfileAnalyzerReport reportFromCurrent:cur previous:diffAgainst];
|
||||
[self rebuildCategories];
|
||||
[self refreshHeader];
|
||||
[self.tableView reloadData];
|
||||
}
|
||||
|
||||
- (void)rebuildCategories {
|
||||
SCIProfileAnalyzerReport *r = self.report;
|
||||
NSArray<SCIPACategoryDescriptor *> *(^build)(void) = ^NSArray *{
|
||||
SCIPACategoryDescriptor *(^make)(SCIPACategory, NSString *, NSString *, NSString *, UIColor *, NSInteger, BOOL) =
|
||||
^SCIPACategoryDescriptor *(SCIPACategory c, NSString *t, NSString *s, NSString *sym, UIColor *col, NSInteger count, BOOL needsPrev) {
|
||||
SCIPACategoryDescriptor *d = [SCIPACategoryDescriptor new];
|
||||
d.category = c; d.title = t; d.subtitle = s; d.symbol = sym; d.color = col;
|
||||
d.count = count; d.requiresPrevious = needsPrev;
|
||||
return d;
|
||||
};
|
||||
return @[
|
||||
make(SCIPACategoryMutual, SCILocalized(@"Mutual followers"),
|
||||
SCILocalized(@"You both follow each other"),
|
||||
@"person.2.fill", [UIColor systemBlueColor], r.mutualFollowers.count, NO),
|
||||
make(SCIPACategoryNotFollowingBack, SCILocalized(@"Not following you back"),
|
||||
SCILocalized(@"You follow them, they don't follow back"),
|
||||
@"person.fill.xmark", [UIColor systemOrangeColor], r.notFollowingYouBack.count, NO),
|
||||
make(SCIPACategoryDontFollowBack, SCILocalized(@"You don't follow back"),
|
||||
SCILocalized(@"They follow you, you don't follow back"),
|
||||
@"person.fill.questionmark", [UIColor systemTealColor], r.youDontFollowBack.count, NO),
|
||||
make(SCIPACategoryNewFollowers, SCILocalized(@"New followers"),
|
||||
SCILocalized(@"Gained since last scan"),
|
||||
@"person.fill.badge.plus", [UIColor systemGreenColor], r.recentFollowers.count, YES),
|
||||
make(SCIPACategoryLostFollowers, SCILocalized(@"Lost followers"),
|
||||
SCILocalized(@"Unfollowed you since last scan"),
|
||||
@"person.fill.badge.minus", [UIColor systemRedColor], r.lostFollowers.count, YES),
|
||||
make(SCIPACategoryYouStartedFollowing, SCILocalized(@"You started following"),
|
||||
SCILocalized(@"Since last scan"),
|
||||
@"arrow.up.forward.circle.fill", [UIColor systemIndigoColor], r.youStartedFollowing.count, YES),
|
||||
make(SCIPACategoryYouUnfollowed, SCILocalized(@"You unfollowed"),
|
||||
SCILocalized(@"Since last scan"),
|
||||
@"arrow.down.backward.circle.fill", [UIColor systemPurpleColor], r.youUnfollowed.count, YES),
|
||||
make(SCIPACategoryProfileUpdates, SCILocalized(@"Profile updates"),
|
||||
SCILocalized(@"Username, name or picture changes"),
|
||||
@"person.crop.circle.badge.exclamationmark", [UIColor systemPinkColor], r.profileUpdates.count, YES),
|
||||
];
|
||||
};
|
||||
self.categories = build();
|
||||
}
|
||||
|
||||
// Snapshot-backed paint: only scan-date + warning. Identity + stats + avatar
|
||||
// are owned by loadHeaderLayered so fieldCache always wins.
|
||||
- (void)refreshHeader {
|
||||
self.headerView.scanDateLabel.text = self.report.current
|
||||
? [self scanDateText]
|
||||
: SCILocalized(@"Run your first analysis");
|
||||
[self refreshWarning];
|
||||
}
|
||||
|
||||
- (NSString *)scanDateText {
|
||||
if (!self.report.current.scanDate) return @"";
|
||||
NSDateFormatter *f = [NSDateFormatter new];
|
||||
f.dateStyle = NSDateFormatterMediumStyle;
|
||||
f.timeStyle = NSDateFormatterShortStyle;
|
||||
NSString *when = [f stringFromDate:self.report.current.scanDate];
|
||||
if (self.report.previous) return [NSString stringWithFormat:SCILocalized(@"Last scan: %@"), when];
|
||||
return [NSString stringWithFormat:SCILocalized(@"First scan: %@"), when];
|
||||
}
|
||||
|
||||
- (void)refreshWarning {
|
||||
SCIProfileAnalyzerSnapshot *cur = self.report.current;
|
||||
NSInteger followers = cur ? cur.followerCount
|
||||
: [[self fieldCacheForUser:[[SCIUtils activeUserSession] valueForKey:@"user"]][@"follower_count"] integerValue];
|
||||
if (followers > SCIProfileAnalyzerMaxFollowerCount) {
|
||||
self.headerView.warningLabel.hidden = NO;
|
||||
self.headerView.warningLabel.text = [NSString stringWithFormat:
|
||||
SCILocalized(@"Follower count exceeds %ld — analysis disabled to avoid rate limits."),
|
||||
(long)SCIProfileAnalyzerMaxFollowerCount];
|
||||
self.headerView.scanButton.enabled = NO;
|
||||
self.headerView.scanButton.alpha = 0.5;
|
||||
} else {
|
||||
self.headerView.warningLabel.hidden = YES;
|
||||
self.headerView.scanButton.enabled = !self.running;
|
||||
self.headerView.scanButton.alpha = self.running ? 0.5 : 1.0;
|
||||
}
|
||||
[self.view setNeedsLayout];
|
||||
}
|
||||
|
||||
#pragma mark - Actions
|
||||
|
||||
- (void)analyzeTapped {
|
||||
if (self.running) { [[SCIProfileAnalyzerService sharedService] cancel]; return; }
|
||||
self.running = YES;
|
||||
self.headerView.progressLabel.hidden = NO;
|
||||
self.headerView.progressLabel.text = SCILocalized(@"Starting…");
|
||||
[self.headerView.avatar setShowProgress:YES];
|
||||
self.headerView.avatar.progress = 0;
|
||||
[self.headerView.scanButton setTitle:SCILocalized(@"Cancel") forState:UIControlStateNormal];
|
||||
self.headerView.scanButton.backgroundColor = [UIColor systemRedColor];
|
||||
[self.view setNeedsLayout];
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[[SCIProfileAnalyzerService sharedService] runForSelfWithHeaderInfo:^(NSDictionary *userInfo) {
|
||||
// Paint the header the moment user-info returns — before follower fetch.
|
||||
[weakSelf paintHeaderFromUserInfo:userInfo];
|
||||
} progress:^(NSString *status, double fraction) {
|
||||
weakSelf.headerView.progressLabel.text = status;
|
||||
weakSelf.headerView.avatar.progress = fraction;
|
||||
} completion:^(SCIProfileAnalyzerSnapshot *snapshot, NSError *error) {
|
||||
[weakSelf onAnalysisFinished:snapshot error:error];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)paintHeaderFromUserInfo:(NSDictionary *)user {
|
||||
NSString *username = user[@"username"];
|
||||
NSString *fullName = user[@"full_name"];
|
||||
NSString *picURL = user[@"profile_pic_url"];
|
||||
NSInteger followers = [user[@"follower_count"] integerValue];
|
||||
NSInteger following = [user[@"following_count"] integerValue];
|
||||
NSInteger posts = [user[@"media_count"] integerValue];
|
||||
self.headerView.fullNameLabel.text = fullName.length ? fullName : (username.length ? username : SCILocalized(@"No scan yet"));
|
||||
self.headerView.usernameLabel.text = username.length ? [NSString stringWithFormat:@"@%@", username] : @"";
|
||||
[self.headerView setStatsLabelsPosts:[self compactNumber:posts]
|
||||
followers:[self compactNumber:followers]
|
||||
following:[self compactNumber:following]];
|
||||
if (picURL.length) {
|
||||
__weak UIImageView *iv = self.headerView.avatar.imageView;
|
||||
[SCIImageCache loadImageFromURL:[NSURL URLWithString:picURL] completion:^(UIImage *img) {
|
||||
if (img) iv.image = img;
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)onAnalysisFinished:(SCIProfileAnalyzerSnapshot *)snapshot error:(NSError *)error {
|
||||
self.running = NO;
|
||||
self.headerView.progressLabel.hidden = YES;
|
||||
[self.headerView.avatar setShowProgress:NO];
|
||||
[self.headerView.scanButton setTitle:SCILocalized(@"Run analysis") forState:UIControlStateNormal];
|
||||
self.headerView.scanButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
|
||||
[self.view setNeedsLayout];
|
||||
|
||||
if (error && error.code == SCIProfileAnalyzerErrorTooManyFollowers) {
|
||||
[self alertTitle:SCILocalized(@"Too many followers")
|
||||
message:[NSString stringWithFormat:SCILocalized(@"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits."),
|
||||
(long)SCIProfileAnalyzerMaxFollowerCount]];
|
||||
return;
|
||||
}
|
||||
if (error && error.code != SCIProfileAnalyzerErrorCancelled) {
|
||||
[self alertTitle:SCILocalized(@"Analysis failed") message:error.localizedDescription ?: @""];
|
||||
return;
|
||||
}
|
||||
if (!snapshot) { [self loadCachedReport]; return; }
|
||||
|
||||
NSString *pk = [SCIUtils currentUserPK];
|
||||
[SCIProfileAnalyzerStorage saveSnapshot:snapshot forUserPK:pk];
|
||||
// Baseline lifecycle lives at scan boundaries so flipping the toggle
|
||||
// mid-session doesn't wipe what's on screen.
|
||||
BOOL accumulate = [SCIUtils getBoolPref:@"profile_analyzer_accumulate"];
|
||||
BOOL baselineExists = [SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk] != nil;
|
||||
if (accumulate && !baselineExists) {
|
||||
[SCIProfileAnalyzerStorage saveBaselineSnapshot:snapshot forUserPK:pk];
|
||||
} else if (!accumulate && baselineExists) {
|
||||
[SCIProfileAnalyzerStorage clearBaselineForUserPK:pk];
|
||||
}
|
||||
[SCIProfileAnalyzerStorage saveHeaderInfo:@{
|
||||
@"username": snapshot.selfUsername ?: @"",
|
||||
@"full_name": snapshot.selfFullName ?: @"",
|
||||
@"profile_pic_url": snapshot.selfProfilePicURL ?: @"",
|
||||
@"follower_count": @(snapshot.followerCount),
|
||||
@"following_count": @(snapshot.followingCount),
|
||||
@"media_count": @(snapshot.mediaCount),
|
||||
} forUserPK:pk];
|
||||
[self loadCachedReport];
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Analysis complete")
|
||||
subtitle:[NSString stringWithFormat:SCILocalized(@"%lu followers · %lu following"),
|
||||
(unsigned long)snapshot.followers.count, (unsigned long)snapshot.following.count]];
|
||||
}
|
||||
|
||||
- (void)resetTapped {
|
||||
UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Reset analyzer data?")
|
||||
message:SCILocalized(@"Removes cached snapshots for this account. You'll lose since-last-scan diffs.")
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Reset") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
|
||||
[SCIProfileAnalyzerStorage resetForUserPK:[SCIUtils currentUserPK]];
|
||||
[self loadCachedReport];
|
||||
}]];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)infoTapped {
|
||||
NSString *body = [@[
|
||||
SCILocalized(@"First scan: we collect your followers and following lists and save them locally."),
|
||||
SCILocalized(@"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates."),
|
||||
SCILocalized(@"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon."),
|
||||
SCILocalized(@"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app."),
|
||||
SCILocalized(@"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk."),
|
||||
] componentsJoinedByString:@"\n\n"];
|
||||
UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"About Profile Analyzer") message:body preferredStyle:UIAlertControllerStyleAlert];
|
||||
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleDefault handler:nil]];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)alertTitle:(NSString *)title message:(NSString *)msg {
|
||||
UIAlertController *a = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert];
|
||||
[a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleDefault handler:nil]];
|
||||
[self presentViewController:a animated:YES completion:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Table
|
||||
|
||||
- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 3; }
|
||||
- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section {
|
||||
if (section == 0) return (NSInteger)self.categories.count;
|
||||
if (section == 1) return 1; // Preferences: keep-changes toggle
|
||||
return 2; // Actions: About + Reset
|
||||
}
|
||||
- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section {
|
||||
if (section == 0) return SCILocalized(@"Categories");
|
||||
if (section == 1) return SCILocalized(@"Preferences");
|
||||
return @"";
|
||||
}
|
||||
- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)section {
|
||||
if (section == 1) return SCILocalized(@"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans.");
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
if (indexPath.section == 1) return [self preferencesCellForRow:indexPath.row tableView:tv];
|
||||
if (indexPath.section == 2) return [self actionCellForRow:indexPath.row tableView:tv];
|
||||
SCIPACategoryCell *cell = [tv dequeueReusableCellWithIdentifier:@"cat" forIndexPath:indexPath];
|
||||
SCIPACategoryDescriptor *d = self.categories[indexPath.row];
|
||||
BOOL waitingForPrev = d.requiresPrevious && !self.report.previous;
|
||||
BOOL hasReport = self.report.current != nil;
|
||||
BOOL disabled = waitingForPrev || !hasReport || d.count == 0;
|
||||
|
||||
cell.titleLabel.text = d.title;
|
||||
if (waitingForPrev) {
|
||||
cell.subtitleLabel.text = SCILocalized(@"Available after your next scan");
|
||||
} else if (!hasReport) {
|
||||
cell.subtitleLabel.text = d.subtitle;
|
||||
} else {
|
||||
cell.subtitleLabel.text = d.subtitle;
|
||||
}
|
||||
cell.countLabel.text = (waitingForPrev || !hasReport) ? @"—" : [NSString stringWithFormat:@"%ld", (long)d.count];
|
||||
cell.iconBadge.backgroundColor = disabled ? [UIColor systemGray3Color] : d.color;
|
||||
cell.iconView.image = [UIImage systemImageNamed:d.symbol];
|
||||
cell.contentView.alpha = disabled ? 0.5 : 1.0;
|
||||
cell.selectionStyle = disabled ? UITableViewCellSelectionStyleNone : UITableViewCellSelectionStyleDefault;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
|
||||
[tv deselectRowAtIndexPath:indexPath animated:YES];
|
||||
if (indexPath.section == 1) return; // toggle row handles its own tap
|
||||
if (indexPath.section == 2) {
|
||||
if (indexPath.row == 0) [self infoTapped];
|
||||
else [self resetTapped];
|
||||
return;
|
||||
}
|
||||
SCIPACategoryDescriptor *d = self.categories[indexPath.row];
|
||||
if (d.requiresPrevious && !self.report.previous) return;
|
||||
if (!self.report.current) return;
|
||||
if (d.count == 0) return;
|
||||
[self.navigationController pushViewController:[self listVCForCategory:d] animated:YES];
|
||||
}
|
||||
|
||||
- (UITableViewCell *)preferencesCellForRow:(NSInteger)row tableView:(UITableView *)tv {
|
||||
static NSString *rid = @"pref";
|
||||
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid];
|
||||
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
|
||||
cell.selectionStyle = UITableViewCellSelectionStyleNone;
|
||||
cell.textLabel.text = SCILocalized(@"Keep scan history");
|
||||
cell.imageView.image = [UIImage systemImageNamed:@"clock.arrow.circlepath"];
|
||||
cell.imageView.tintColor = [UIColor systemIndigoColor];
|
||||
|
||||
UISwitch *sw = [UISwitch new];
|
||||
sw.on = [SCIUtils getBoolPref:@"profile_analyzer_accumulate"];
|
||||
sw.onTintColor = [SCIUtils SCIColor_Primary];
|
||||
[sw addTarget:self action:@selector(accumulateToggled:) forControlEvents:UIControlEventValueChanged];
|
||||
cell.accessoryView = sw;
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (void)accumulateToggled:(UISwitch *)sw {
|
||||
[[NSUserDefaults standardUserDefaults] setBool:sw.isOn forKey:@"profile_analyzer_accumulate"];
|
||||
NSString *pk = [SCIUtils currentUserPK];
|
||||
if (sw.isOn) {
|
||||
// Promote the current snapshot to baseline immediately.
|
||||
if (![SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk] && self.report.current) {
|
||||
[SCIProfileAnalyzerStorage saveBaselineSnapshot:self.report.current forUserPK:pk];
|
||||
[self loadCachedReport];
|
||||
}
|
||||
}
|
||||
// Flipping off is deferred — the baseline is dropped on the next scan.
|
||||
}
|
||||
|
||||
- (UITableViewCell *)actionCellForRow:(NSInteger)row tableView:(UITableView *)tv {
|
||||
static NSString *rid = @"action";
|
||||
UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid];
|
||||
if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
|
||||
cell.accessoryType = UITableViewCellAccessoryNone;
|
||||
cell.imageView.contentMode = UIViewContentModeCenter;
|
||||
if (row == 0) {
|
||||
cell.textLabel.text = SCILocalized(@"About Profile Analyzer");
|
||||
cell.textLabel.textColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor];
|
||||
cell.imageView.image = [UIImage systemImageNamed:@"info.circle"];
|
||||
cell.imageView.tintColor = cell.textLabel.textColor;
|
||||
} else {
|
||||
cell.textLabel.text = SCILocalized(@"Reset analyzer data");
|
||||
cell.textLabel.textColor = [UIColor systemRedColor];
|
||||
cell.imageView.image = [UIImage systemImageNamed:@"trash"];
|
||||
cell.imageView.tintColor = [UIColor systemRedColor];
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
- (UIViewController *)listVCForCategory:(SCIPACategoryDescriptor *)d {
|
||||
SCIProfileAnalyzerReport *r = self.report;
|
||||
switch (d.category) {
|
||||
case SCIPACategoryMutual:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.mutualFollowers kind:SCIPAListKindPlain];
|
||||
case SCIPACategoryNotFollowingBack:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.notFollowingYouBack kind:SCIPAListKindUnfollow];
|
||||
case SCIPACategoryDontFollowBack:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youDontFollowBack kind:SCIPAListKindFollow];
|
||||
case SCIPACategoryNewFollowers:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.recentFollowers kind:SCIPAListKindPlain];
|
||||
case SCIPACategoryLostFollowers:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.lostFollowers kind:SCIPAListKindPlain];
|
||||
case SCIPACategoryYouStartedFollowing:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youStartedFollowing kind:SCIPAListKindUnfollow];
|
||||
case SCIPACategoryYouUnfollowed:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youUnfollowed kind:SCIPAListKindFollow];
|
||||
case SCIPACategoryProfileUpdates:
|
||||
return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title profileUpdates:r.profileUpdates];
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,71 @@
|
||||
// Auto-scroll reels. Modes:
|
||||
// * ig — flip IG's own auto-scroll gates; covers video + photo reels
|
||||
// * custom — same flag flip (photos) + per-cell loopCount trigger calling
|
||||
// WantsScrollToNextItem each loop (videos keep advancing after back-swipe)
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static const void *kSCILoopCountKey = &kSCILoopCountKey;
|
||||
static BOOL sciAdvanceInFlight = NO;
|
||||
|
||||
static inline NSString *sciMode(void) {
|
||||
NSString *m = [SCIUtils getStringPref:@"auto_scroll_reels_mode"];
|
||||
return m.length ? m : @"off";
|
||||
}
|
||||
static inline BOOL sciModeOn(void) { return ![sciMode() isEqualToString:@"off"]; }
|
||||
static inline BOOL sciModeCustom(void) { return [sciMode() isEqualToString:@"custom"]; }
|
||||
|
||||
static UIViewController *sciFindFeedVCFromView(UIView *view) {
|
||||
UIResponder *r = view;
|
||||
while (r) {
|
||||
if ([r isKindOfClass:[UIViewController class]] &&
|
||||
[NSStringFromClass([r class]) isEqualToString:@"IGSundialFeedViewController"])
|
||||
return (UIViewController *)r;
|
||||
r = [r nextResponder];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGSundialFeedViewController
|
||||
- (BOOL)shouldForceEnableAutoScroll {
|
||||
if (sciModeOn()) return YES;
|
||||
return %orig;
|
||||
}
|
||||
- (BOOL)autoAdvanceToNextItem {
|
||||
if (sciModeOn()) return YES;
|
||||
return %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGSundialViewerVideoCell
|
||||
- (void)videoView:(id)v didUpdatePlaybackStatus:(id)status {
|
||||
%orig;
|
||||
if (!sciModeCustom() || !status) return;
|
||||
SEL loopSel = @selector(loopCount);
|
||||
if (![status respondsToSelector:loopSel]) return;
|
||||
|
||||
long long cur = ((long long(*)(id, SEL))objc_msgSend)(status, loopSel);
|
||||
NSNumber *prev = objc_getAssociatedObject(self, kSCILoopCountKey);
|
||||
objc_setAssociatedObject(self, kSCILoopCountKey, @(cur), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
if (!prev || cur <= prev.longLongValue || sciAdvanceInFlight) return;
|
||||
|
||||
UIViewController *feedVC = sciFindFeedVCFromView((UIView *)self);
|
||||
if (!feedVC || !feedVC.viewIfLoaded.window) return;
|
||||
|
||||
sciAdvanceInFlight = YES;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
SEL wants = @selector(sundialViewerInteractionCoordinatorWantsScrollToNextItemAnimated:);
|
||||
if ([feedVC respondsToSelector:wants])
|
||||
((void(*)(id, SEL, BOOL))objc_msgSend)(feedVC, wants, YES);
|
||||
sciAdvanceInFlight = NO;
|
||||
});
|
||||
}
|
||||
|
||||
- (void)prepareForReuse {
|
||||
objc_setAssociatedObject(self, kSCILoopCountKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
@@ -160,6 +160,8 @@ static void sciForceUnmuteCell(id videoCell) {
|
||||
}
|
||||
}
|
||||
|
||||
%group ReelsPauseModeGroup
|
||||
|
||||
%hook IGSundialViewerVideoCell
|
||||
// hidden=YES on play; IG resets it on the next pause.
|
||||
- (void)sundialVideoPlaybackViewDidStartPlaying:(id)view {
|
||||
@@ -196,6 +198,57 @@ static void sciForceUnmuteCell(id videoCell) {
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ PHOTO REELS: TAP-TO-MUTE ============
|
||||
// Skip IG's single-tap delegate on photo cells and drive the mute via the
|
||||
// same hardware-switch notification StoryAudioToggle uses.
|
||||
|
||||
extern "C" void sciToggleStoryAudio(void);
|
||||
|
||||
static BOOL sciIsPhotoMuteEnabled(void) {
|
||||
return sciIsPausePlayMode() && [SCIUtils getBoolPref:@"reels_photo_tap_mute"];
|
||||
}
|
||||
|
||||
%hook IGSundialViewerPhotoCell
|
||||
- (void)gestureController:(id)gc didObserveSingleTap:(id)tap {
|
||||
if (sciIsPhotoMuteEnabled()) { sciToggleStoryAudio(); return; }
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGSundialViewerCarouselPhotoCell
|
||||
- (void)gestureController:(id)gc didObserveSingleTap:(id)tap {
|
||||
if (sciIsPhotoMuteEnabled()) { sciToggleStoryAudio(); return; }
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// Carousels route the tap through the outer cell, so hijack there too —
|
||||
// but only when the visible page is a photo. Video pages keep %orig.
|
||||
%hook IGSundialViewerCarouselCell
|
||||
- (void)gestureController:(id)gc didObserveSingleTap:(id)tap {
|
||||
if (!sciIsPhotoMuteEnabled()) { %orig; return; }
|
||||
BOOL hasVideo = NO, hasPhoto = NO;
|
||||
NSMutableArray<UIView *> *stack = [NSMutableArray arrayWithObject:self];
|
||||
for (int d = 0; d < 6 && stack.count && !hasVideo; d++) {
|
||||
NSMutableArray<UIView *> *next = [NSMutableArray array];
|
||||
for (UIView *sub in stack) {
|
||||
NSString *cls = NSStringFromClass([sub class]);
|
||||
if ([cls isEqualToString:@"IGSundialViewerCarouselVideoCell"]) {
|
||||
if (!CGRectIsEmpty(CGRectIntersection(sub.bounds, self.bounds)) &&
|
||||
sub.window) hasVideo = YES;
|
||||
} else if ([cls isEqualToString:@"IGSundialViewerCarouselPhotoCell"]) {
|
||||
if (!CGRectIsEmpty(CGRectIntersection(sub.bounds, self.bounds)) &&
|
||||
sub.window) hasPhoto = YES;
|
||||
}
|
||||
for (UIView *s in sub.subviews) [next addObject:s];
|
||||
}
|
||||
stack = next;
|
||||
}
|
||||
if (hasPhoto && !hasVideo) { sciToggleStoryAudio(); return; }
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ UFI: SYNC DOWNLOAD BUTTON + SETUP KVO ============
|
||||
|
||||
%hook IGSundialViewerVerticalUFI
|
||||
@@ -309,9 +362,15 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
|
||||
}
|
||||
%end
|
||||
|
||||
%end // ReelsPauseModeGroup
|
||||
|
||||
// ============ RUNTIME HOOKS ============
|
||||
|
||||
%ctor {
|
||||
if (![[SCIUtils getStringPref:@"reels_tap_control"] isEqualToString:@"pause"]) return;
|
||||
|
||||
%init(ReelsPauseModeGroup);
|
||||
|
||||
Class toggleClass = objc_getClass("IGSundialPlaybackToggle.IGSundialPlaybackToggleView");
|
||||
if (toggleClass) {
|
||||
MSHookMessageEx(toggleClass, @selector(didMoveToSuperview),
|
||||
|
||||
@@ -0,0 +1,601 @@
|
||||
// Reveal poll/slider vote counts and quiz correct answers on story/reel
|
||||
// stickers, plus force the legacy Quiz sticker back into the composer tray.
|
||||
//
|
||||
// Prefs:
|
||||
// stories_show_poll_votes_count / stories_show_quiz_answer
|
||||
// reels_show_poll_votes_count / reels_show_quiz_answer
|
||||
// force_enable_quiz_sticker
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../StoriesAndMessages/StoryHelpers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
|
||||
|
||||
// ============ Runtime helpers ============
|
||||
|
||||
static id sciCallMaybe(id obj, NSString *selName) {
|
||||
SEL sel = NSSelectorFromString(selName);
|
||||
if (!obj || ![obj respondsToSelector:sel]) return nil;
|
||||
@try { return ((id(*)(id,SEL))objc_msgSend)(obj, sel); }
|
||||
@catch (__unused id e) { return nil; }
|
||||
}
|
||||
|
||||
static NSArray *sciArrayIvar(id obj, const char *name) {
|
||||
if (!obj || !name) return nil;
|
||||
Class cls = [obj class];
|
||||
while (cls && cls != [NSObject class]) {
|
||||
Ivar iv = class_getInstanceVariable(cls, name);
|
||||
if (iv) {
|
||||
id v = object_getIvar(obj, iv);
|
||||
return [v isKindOfClass:[NSArray class]] ? (NSArray *)v : nil;
|
||||
}
|
||||
cls = class_getSuperclass(cls);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// ============ Context detection (stories vs reels) ============
|
||||
|
||||
// Reels surface via IGSundialFeedViewController and also via contextual
|
||||
// feeds (profile reels) that host Sundial-prefixed cells.
|
||||
static BOOL sciIsInReelsContext(UIView *anchor) {
|
||||
Class reelCls = NSClassFromString(@"IGSundialFeedViewController");
|
||||
for (UIResponder *r = anchor; r; r = r.nextResponder) {
|
||||
if (reelCls && [r isKindOfClass:reelCls]) return YES;
|
||||
if ([NSStringFromClass([r class]) hasPrefix:@"IGSundial"]) return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL sciPrefShowPollCounts(UIView *anchor) {
|
||||
return [SCIUtils getBoolPref:
|
||||
sciIsInReelsContext(anchor)
|
||||
? @"reels_show_poll_votes_count"
|
||||
: @"stories_show_poll_votes_count"];
|
||||
}
|
||||
static BOOL sciPrefShowQuizAnswer(UIView *anchor) {
|
||||
return [SCIUtils getBoolPref:
|
||||
sciIsInReelsContext(anchor)
|
||||
? @"reels_show_quiz_answer"
|
||||
: @"stories_show_quiz_answer"];
|
||||
}
|
||||
|
||||
// ============ Media lookup ============
|
||||
|
||||
static UIViewController *sciFindAnyStoryViewerVC(UIView *start) {
|
||||
Class target = NSClassFromString(@"IGStoryViewerViewController");
|
||||
if (!target) return nil;
|
||||
for (UIResponder *r = start; r; r = r.nextResponder) {
|
||||
if ([r isKindOfClass:target]) return (UIViewController *)r;
|
||||
}
|
||||
if (sciActiveStoryViewerVC) return sciActiveStoryViewerVC;
|
||||
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
|
||||
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
|
||||
for (UIWindow *w in ((UIWindowScene *)scene).windows) {
|
||||
NSMutableArray *stack = [NSMutableArray array];
|
||||
if (w.rootViewController) [stack addObject:w.rootViewController];
|
||||
while (stack.count) {
|
||||
UIViewController *cur = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([cur isKindOfClass:target]) return cur;
|
||||
for (UIViewController *child in cur.childViewControllers) [stack addObject:child];
|
||||
if (cur.presentedViewController) [stack addObject:cur.presentedViewController];
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static IGMedia *sciCurrentStoryMedia(UIView *anchor) {
|
||||
UIViewController *vc = sciFindAnyStoryViewerVC(anchor);
|
||||
if (!vc) return nil;
|
||||
IGMedia *media = nil;
|
||||
@try {
|
||||
id vm = sciCall(vc, @selector(currentViewModel));
|
||||
id item = sciCall1(vc, @selector(currentStoryItemForViewModel:), vm);
|
||||
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) media = (IGMedia *)item;
|
||||
else media = sciExtractMediaFromItem(item);
|
||||
} @catch (__unused id e) {}
|
||||
return media;
|
||||
}
|
||||
|
||||
// Walks the responder chain probing common getters for an IGMedia — covers
|
||||
// reel cells where no story viewer VC is in the chain.
|
||||
static IGMedia *sciFindMediaFromAnchor(UIView *anchor) {
|
||||
IGMedia *m = sciCurrentStoryMedia(anchor);
|
||||
if (m) return m;
|
||||
Class mediaCls = NSClassFromString(@"IGMedia");
|
||||
if (!mediaCls) return nil;
|
||||
NSArray *probes = @[@"media", @"post", @"feedItem", @"igMedia", @"storyItem",
|
||||
@"item", @"model", @"backingModel", @"storyMedia",
|
||||
@"currentMedia", @"currentMediaItem", @"currentStoryItem",
|
||||
@"mediaModel", @"mediaItem"];
|
||||
for (UIResponder *r = anchor; r; r = r.nextResponder) {
|
||||
for (NSString *sel in probes) {
|
||||
id v = sciCallMaybe(r, sel);
|
||||
if ([v isKindOfClass:mediaCls]) return (IGMedia *)v;
|
||||
IGMedia *nested = sciExtractMediaFromItem(v);
|
||||
if (nested) return nested;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// View-local sticker models zero their tallies for unvoted viewers; the real
|
||||
// counts live on IGMedia.{storyPolls,storyQuizs,storySliders} — match by pk.
|
||||
static id sciAuthoritativeSticker(UIView *anchor, NSString *arrayKey, NSString *innerKey, id viewModel, NSString *idKey) {
|
||||
IGMedia *media = sciFindMediaFromAnchor(anchor);
|
||||
if (!media) return nil;
|
||||
NSArray *arr = sciCallMaybe(media, arrayKey);
|
||||
if (![arr isKindOfClass:[NSArray class]]) return nil;
|
||||
NSString *viewId = idKey ? [sciCallMaybe(viewModel, idKey) description] : nil;
|
||||
for (id entry in arr) {
|
||||
id sticker = sciCallMaybe(entry, innerKey);
|
||||
if (!sticker) continue;
|
||||
if (viewId.length) {
|
||||
NSString *stickerId = [sciCallMaybe(sticker, idKey) description];
|
||||
if ([stickerId isEqualToString:viewId]) return sticker;
|
||||
}
|
||||
}
|
||||
if (arr.count > 0) {
|
||||
id sticker = sciCallMaybe(arr[0], innerKey);
|
||||
if (sticker) return sticker;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSInteger sciHighestTallyIndex(NSArray *tallies) {
|
||||
NSInteger best = -1, bestCount = 0;
|
||||
for (NSUInteger i = 0; i < tallies.count; i++) {
|
||||
NSInteger c = [(NSNumber *)sciCallMaybe(tallies[i], @"totalCount") integerValue];
|
||||
if (c > bestCount) { best = (NSInteger)i; bestCount = c; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// ============ Editing/composer detection ============
|
||||
|
||||
static BOOL sciIsStickerEditing(UIView *v) {
|
||||
Class cls = [v class];
|
||||
while (cls && cls != [NSObject class]) {
|
||||
const char *names[] = { "_isEditing", "_editing" };
|
||||
for (size_t k = 0; k < sizeof(names)/sizeof(names[0]); k++) {
|
||||
Ivar iv = class_getInstanceVariable(cls, names[k]);
|
||||
if (!iv) continue;
|
||||
ptrdiff_t off = ivar_getOffset(iv);
|
||||
BOOL val = NO;
|
||||
memcpy(&val, (uint8_t *)(__bridge void *)v + off, sizeof(val));
|
||||
if (val) return YES;
|
||||
}
|
||||
cls = class_getSuperclass(cls);
|
||||
}
|
||||
NSArray *composers = @[@"IGStoryStickerTrayViewController",
|
||||
@"IGStoryPostCaptureEditingViewController",
|
||||
@"IGStoryMediaCompositionEditingViewController"];
|
||||
for (UIResponder *r = v; r; r = r.nextResponder) {
|
||||
NSString *cn = NSStringFromClass([r class]);
|
||||
for (NSString *c in composers) if ([cn isEqualToString:c]) return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Keeps overlays in sync with the current item on story/reel nav.
|
||||
static void sciForceRelayoutStickers(UIView *root) {
|
||||
if (!root) return;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
|
||||
Class pollV2 = NSClassFromString(@"IGPollStickerV2View");
|
||||
Class pollV1 = NSClassFromString(@"IGPollStickerView");
|
||||
Class slider = NSClassFromString(@"IGSliderStickerView");
|
||||
Class quiz = NSClassFromString(@"IGQuizStickerView");
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ((pollV2 && [v isKindOfClass:pollV2]) ||
|
||||
(pollV1 && [v isKindOfClass:pollV1]) ||
|
||||
(slider && [v isKindOfClass:slider]) ||
|
||||
(quiz && [v isKindOfClass:quiz])) {
|
||||
[v setNeedsLayout];
|
||||
[v layoutIfNeeded];
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
}
|
||||
|
||||
// Sticker views often lay out once with zero bounds / no cells; retries
|
||||
// catch the settled state without relying on a second layoutSubviews.
|
||||
static void sciScheduleRetries(UIView *view, SEL action) {
|
||||
__weak UIView *weak = view;
|
||||
NSArray *delays = @[@0.1, @0.3, @0.7];
|
||||
for (NSNumber *d in delays) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(d.doubleValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
UIView *s = weak;
|
||||
if (s && s.window) ((void(*)(id,SEL))objc_msgSend)(s, action);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Overlay badges / highlight ============
|
||||
|
||||
static const char kSciPollBadgeKey = 0;
|
||||
static const char kSciSliderBadgeKey = 0;
|
||||
static const char kSciQuizHighlightKey = 0;
|
||||
|
||||
static UILabel *sciMakeBadge(void) {
|
||||
UILabel *b = [[UILabel alloc] init];
|
||||
b.font = [UIFont systemFontOfSize:13 weight:UIFontWeightBold];
|
||||
b.textColor = [UIColor whiteColor];
|
||||
b.backgroundColor = [UIColor colorWithRed:0.0 green:0.45 blue:0.95 alpha:0.92];
|
||||
b.textAlignment = NSTextAlignmentCenter;
|
||||
b.layer.cornerRadius = 10;
|
||||
b.clipsToBounds = YES;
|
||||
b.userInteractionEnabled = NO;
|
||||
return b;
|
||||
}
|
||||
|
||||
static void sciAttachPollCountBadge(UIView *optionView, NSInteger count, double total) {
|
||||
UILabel *badge = objc_getAssociatedObject(optionView, &kSciPollBadgeKey);
|
||||
if (!badge) {
|
||||
badge = sciMakeBadge();
|
||||
[optionView addSubview:badge];
|
||||
objc_setAssociatedObject(optionView, &kSciPollBadgeKey, badge, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
badge.text = total > 0
|
||||
? [NSString stringWithFormat:@" %ld · %.0f%% ", (long)count, 100.0 * (double)count / total]
|
||||
: [NSString stringWithFormat:@" %ld ", (long)count];
|
||||
[badge sizeToFit];
|
||||
CGSize sz = badge.bounds.size;
|
||||
sz.width += 10;
|
||||
sz.height = MAX(sz.height + 4, 22);
|
||||
CGRect b = optionView.bounds;
|
||||
badge.frame = CGRectMake(b.size.width - sz.width - 4, -sz.height * 0.35, sz.width, sz.height);
|
||||
badge.layer.zPosition = 1000;
|
||||
[optionView bringSubviewToFront:badge];
|
||||
optionView.clipsToBounds = NO;
|
||||
}
|
||||
|
||||
static void sciAttachSliderBadge(UIView *sliderView, NSUInteger count, double avg) {
|
||||
UILabel *badge = objc_getAssociatedObject(sliderView, &kSciSliderBadgeKey);
|
||||
if (!badge) {
|
||||
badge = sciMakeBadge();
|
||||
[sliderView addSubview:badge];
|
||||
objc_setAssociatedObject(sliderView, &kSciSliderBadgeKey, badge, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
badge.text = [NSString stringWithFormat:@" %lu votes · avg %.0f%% ",
|
||||
(unsigned long)count, avg * 100.0];
|
||||
[badge sizeToFit];
|
||||
CGSize sz = badge.bounds.size;
|
||||
sz.height = MAX(sz.height, 18);
|
||||
CGRect b = sliderView.bounds;
|
||||
badge.frame = CGRectMake((b.size.width - sz.width) * 0.5, -sz.height - 4, sz.width, sz.height);
|
||||
[sliderView bringSubviewToFront:badge];
|
||||
}
|
||||
|
||||
static void sciAttachQuizHighlight(UIView *optionView, CGFloat cornerRadius) {
|
||||
CAShapeLayer *hl = objc_getAssociatedObject(optionView, &kSciQuizHighlightKey);
|
||||
if (!hl) {
|
||||
hl = [CAShapeLayer layer];
|
||||
UIColor *green = [UIColor colorWithRed:0.24 green:0.76 blue:0.38 alpha:1.0];
|
||||
hl.fillColor = [green colorWithAlphaComponent:0.35].CGColor;
|
||||
hl.strokeColor = green.CGColor;
|
||||
hl.lineWidth = 2.0;
|
||||
hl.zPosition = 50;
|
||||
[optionView.layer addSublayer:hl];
|
||||
objc_setAssociatedObject(optionView, &kSciQuizHighlightKey, hl, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
CGRect b = CGRectInset(optionView.bounds, 1.0, 1.0);
|
||||
hl.frame = optionView.bounds;
|
||||
hl.path = cornerRadius > 0
|
||||
? [UIBezierPath bezierPathWithRoundedRect:b cornerRadius:cornerRadius].CGPath
|
||||
: [UIBezierPath bezierPathWithRect:b].CGPath;
|
||||
}
|
||||
|
||||
static void sciRemovePollCountBadge(UIView *v) {
|
||||
UILabel *b = objc_getAssociatedObject(v, &kSciPollBadgeKey);
|
||||
if (b) { [b removeFromSuperview]; objc_setAssociatedObject(v, &kSciPollBadgeKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
|
||||
}
|
||||
static void sciRemoveSliderBadge(UIView *v) {
|
||||
UILabel *b = objc_getAssociatedObject(v, &kSciSliderBadgeKey);
|
||||
if (b) { [b removeFromSuperview]; objc_setAssociatedObject(v, &kSciSliderBadgeKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
|
||||
}
|
||||
static void sciRemoveQuizHighlight(UIView *v) {
|
||||
CAShapeLayer *l = objc_getAssociatedObject(v, &kSciQuizHighlightKey);
|
||||
if (l) { [l removeFromSuperlayer]; objc_setAssociatedObject(v, &kSciQuizHighlightKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); }
|
||||
}
|
||||
|
||||
// ============ Poll reveal (V2 + legacy) ============
|
||||
|
||||
static void sciApplyPollReveal(UIView *pollView, NSArray *opts) {
|
||||
BOOL showCounts = sciPrefShowPollCounts(pollView);
|
||||
BOOL showWinner = sciPrefShowQuizAnswer(pollView);
|
||||
BOOL editing = sciIsStickerEditing(pollView);
|
||||
|
||||
if ((!showCounts && !showWinner) || editing) {
|
||||
for (UIView *opt in opts) {
|
||||
if (![opt isKindOfClass:[UIView class]]) continue;
|
||||
sciRemovePollCountBadge(opt);
|
||||
sciRemoveQuizHighlight(opt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
id viewModel = sciCallMaybe(pollView, @"igapiStickerModel") ?: sciCallMaybe(pollView, @"exportModel");
|
||||
id model = sciAuthoritativeSticker(pollView, @"storyPolls", @"pollSticker", viewModel, @"pollId") ?: viewModel;
|
||||
NSArray *tallies = sciCallMaybe(model, @"tallies");
|
||||
if (![tallies isKindOfClass:[NSArray class]]) tallies = nil;
|
||||
double total = [(NSNumber *)sciCallMaybe(model, @"totalVotes") doubleValue];
|
||||
|
||||
NSNumber *correctAnswer = sciCallMaybe(model, @"correctAnswer");
|
||||
NSInteger winnerIdx = correctAnswer ? correctAnswer.integerValue : sciHighestTallyIndex(tallies ?: @[]);
|
||||
|
||||
// V2 poll preallocates up to 4 option views; only render on real slots.
|
||||
NSUInteger realOptCount = tallies ? tallies.count : 0;
|
||||
for (NSUInteger i = 0; i < opts.count; i++) {
|
||||
UIView *opt = opts[i];
|
||||
if (![opt isKindOfClass:[UIView class]]) continue;
|
||||
if (i >= realOptCount) {
|
||||
sciRemovePollCountBadge(opt);
|
||||
sciRemoveQuizHighlight(opt);
|
||||
continue;
|
||||
}
|
||||
if (showCounts) {
|
||||
NSInteger c = [(NSNumber *)sciCallMaybe(tallies[i], @"totalCount") integerValue];
|
||||
sciAttachPollCountBadge(opt, c, total);
|
||||
} else {
|
||||
sciRemovePollCountBadge(opt);
|
||||
}
|
||||
if (showWinner && winnerIdx >= 0 && (NSInteger)i == winnerIdx) {
|
||||
sciAttachQuizHighlight(opt, 0.0);
|
||||
} else {
|
||||
sciRemoveQuizHighlight(opt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// STORIES //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
%hook IGStoryViewerViewController
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
sciForceRelayoutStickers(((UIViewController *)self).view);
|
||||
}
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
sciForceRelayoutStickers(((UIViewController *)self).view);
|
||||
});
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Force-inject IGQuizStickerTrayModel into the composer tray (IG keeps the
|
||||
// class + handler wired but filtered it out of the picker).
|
||||
|
||||
static IGQuizStickerTrayModel *sciMakeQuizTrayModel(id neighborModel) {
|
||||
Class cls = NSClassFromString(@"IGQuizStickerTrayModel");
|
||||
if (!cls) return nil;
|
||||
id quiz = [[cls alloc] init];
|
||||
if (!quiz) return nil;
|
||||
@try {
|
||||
id section = sciCallMaybe(neighborModel, @"stickerSection");
|
||||
if (section && [quiz respondsToSelector:@selector(setStickerSection:)]) {
|
||||
[(IGQuizStickerTrayModel *)quiz setStickerSection:section];
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
if ([quiz respondsToSelector:@selector(setPrompts:)]) {
|
||||
[(IGQuizStickerTrayModel *)quiz setPrompts:@[]];
|
||||
}
|
||||
return quiz;
|
||||
}
|
||||
|
||||
%hook IGStoryStickerDataSourceImpl
|
||||
|
||||
- (NSArray *)items {
|
||||
NSArray *orig = %orig;
|
||||
if (!orig || ![SCIUtils getBoolPref:@"force_enable_quiz_sticker"]) return orig;
|
||||
for (id m in orig) {
|
||||
if ([NSStringFromClass([m class]) rangeOfString:@"Quiz" options:NSCaseInsensitiveSearch].location != NSNotFound) {
|
||||
return orig;
|
||||
}
|
||||
}
|
||||
|
||||
// Slot quiz next to the poll tray model so it lands in the interactive row.
|
||||
NSUInteger insertIdx = NSNotFound;
|
||||
id neighbor = nil;
|
||||
for (NSUInteger i = 0; i < orig.count; i++) {
|
||||
NSString *cn = NSStringFromClass([orig[i] class]);
|
||||
if ([cn isEqualToString:@"IGPollStickerV2TrayModel"] ||
|
||||
[cn isEqualToString:@"IGPollStickerTrayModel"]) {
|
||||
insertIdx = i + 1;
|
||||
neighbor = orig[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (insertIdx == NSNotFound) {
|
||||
for (NSUInteger i = 0; i < orig.count; i++) {
|
||||
if ([NSStringFromClass([orig[i] class]) isEqualToString:@"IGQuestionAnswerStickerModel"]) {
|
||||
insertIdx = i + 1;
|
||||
neighbor = orig[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
IGQuizStickerTrayModel *quiz = sciMakeQuizTrayModel(neighbor);
|
||||
if (!quiz) return orig;
|
||||
NSMutableArray *mutated = [orig mutableCopy];
|
||||
if (insertIdx == NSNotFound) insertIdx = mutated.count;
|
||||
[mutated insertObject:quiz atIndex:insertIdx];
|
||||
return mutated;
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// REELS //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
%hook IGSundialFeedViewController
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
sciForceRelayoutStickers(((UIViewController *)self).view);
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// STICKER VIEW HOOKS — shared by stories + reels //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// IGPollStickerV2View
|
||||
|
||||
%hook IGPollStickerV2View
|
||||
|
||||
%new
|
||||
- (void)sci_applyPollReveal {
|
||||
sciApplyPollReveal(self, sciArrayIvar(self, "_optionViews") ?: @[]);
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyPollReveal));
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
if (self.window) sciScheduleRetries(self, @selector(sci_applyPollReveal));
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// IGPollStickerView (legacy)
|
||||
|
||||
%hook IGPollStickerView
|
||||
|
||||
%new
|
||||
- (void)sci_applyPollReveal {
|
||||
NSArray *opts = sciArrayIvar(self, "_optionViews")
|
||||
?: sciArrayIvar(self, "_voteOptionViews")
|
||||
?: sciArrayIvar(self, "_options");
|
||||
sciApplyPollReveal(self, opts ?: @[]);
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyPollReveal));
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
if (self.window) sciScheduleRetries(self, @selector(sci_applyPollReveal));
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// IGSliderStickerView
|
||||
|
||||
%hook IGSliderStickerView
|
||||
|
||||
%new
|
||||
- (void)sci_applySliderReveal {
|
||||
if (!sciPrefShowPollCounts(self) || sciIsStickerEditing(self)) {
|
||||
sciRemoveSliderBadge(self);
|
||||
return;
|
||||
}
|
||||
NSUInteger count = 0;
|
||||
double avg = 0.0;
|
||||
id model = sciCallMaybe(self, @"igapiStickerModel") ?: sciCallMaybe(self, @"exportModel");
|
||||
if (model) {
|
||||
count = [(NSNumber *)sciCallMaybe(model, @"sliderVoteCount") unsignedIntegerValue];
|
||||
avg = [(NSNumber *)sciCallMaybe(model, @"sliderVoteAverage") doubleValue];
|
||||
}
|
||||
if (count == 0 && avg == 0.0) {
|
||||
Ivar vc = class_getInstanceVariable([self class], "_voteCount");
|
||||
if (vc) memcpy(&count, (uint8_t *)(__bridge void *)self + ivar_getOffset(vc), sizeof(count));
|
||||
Ivar va = class_getInstanceVariable([self class], "_averageVote");
|
||||
if (va) avg = [(NSNumber *)object_getIvar(self, va) doubleValue];
|
||||
}
|
||||
sciAttachSliderBadge(self, count, avg);
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
if (self.window) sciScheduleRetries(self, @selector(sci_applySliderReveal));
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applySliderReveal));
|
||||
}
|
||||
|
||||
// Refresh after the vote posts — count/average land on the ivars async.
|
||||
- (void)emojiSliderDidEndSliding:(id)arg {
|
||||
%orig;
|
||||
sciScheduleRetries(self, @selector(sci_applySliderReveal));
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// IGQuizStickerView
|
||||
|
||||
%hook IGQuizStickerView
|
||||
|
||||
%new
|
||||
- (void)sci_applyQuizReveal {
|
||||
BOOL showWinner = sciPrefShowQuizAnswer(self);
|
||||
BOOL editing = sciIsStickerEditing(self);
|
||||
|
||||
UICollectionView *cv = nil;
|
||||
Ivar cvIvar = class_getInstanceVariable([self class], "_optionsCollectionView");
|
||||
if (cvIvar) {
|
||||
id v = object_getIvar(self, cvIvar);
|
||||
if ([v isKindOfClass:[UICollectionView class]]) cv = (UICollectionView *)v;
|
||||
}
|
||||
// Populate visibleCells before we walk them; IG also ships quiz
|
||||
// interaction off on the consumption path, so restore it.
|
||||
if (cv) { [cv setNeedsLayout]; [cv layoutIfNeeded]; cv.userInteractionEnabled = YES; }
|
||||
self.userInteractionEnabled = YES;
|
||||
NSArray *cells = cv ? cv.visibleCells : @[];
|
||||
|
||||
if (!showWinner || editing) {
|
||||
for (UIView *cell in cells) sciRemoveQuizHighlight(cell);
|
||||
return;
|
||||
}
|
||||
|
||||
id viewModel = sciCallMaybe(self, @"igapiStickerModel") ?: sciCallMaybe(self, @"exportModel");
|
||||
id model = sciAuthoritativeSticker(self, @"storyQuizs", @"quizSticker", viewModel, @"quizId") ?: viewModel;
|
||||
NSNumber *correct = sciCallMaybe(model, @"correctAnswer");
|
||||
NSInteger winnerIdx = correct ? correct.integerValue : -1;
|
||||
|
||||
// Quiz cell corner radius lives on a sublayer; hardcode to match.
|
||||
for (UICollectionViewCell *cell in cells) {
|
||||
if (![cell isKindOfClass:[UICollectionViewCell class]]) continue;
|
||||
NSIndexPath *ip = cv ? [cv indexPathForCell:cell] : nil;
|
||||
NSInteger i = ip ? ip.row : -1;
|
||||
if (i < 0) continue;
|
||||
if (winnerIdx >= 0 && i == winnerIdx) {
|
||||
sciAttachQuizHighlight(cell, 18.0);
|
||||
} else {
|
||||
sciRemoveQuizHighlight(cell);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyQuizReveal));
|
||||
sciScheduleRetries(self, @selector(sci_applyQuizReveal));
|
||||
}
|
||||
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
if (self.window) sciScheduleRetries(self, @selector(sci_applyQuizReveal));
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,269 @@
|
||||
// DM disappearing-media overlay buttons — action / eye / audio (tags 1342–1344).
|
||||
// Hooks IGDirectVisualMessageViewerController directly; reads only dm_visual_* prefs.
|
||||
|
||||
#import "OverlayHelpers.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
|
||||
// Per-button weak ref to the owning DM VC so handlers skip the responder walk.
|
||||
static const void *kSCIDMOwnerVCKey = &kSCIDMOwnerVCKey;
|
||||
|
||||
// MARK: - Menu item builders
|
||||
|
||||
static NSArray<UIMenuElement *> *sciDMActionMenuItems(UIViewController *dmVC, UIView *sourceView) {
|
||||
__weak UIView *weakSource = sourceView;
|
||||
return @[
|
||||
[UIAction actionWithTitle:SCILocalized(@"Expand")
|
||||
image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) { sciDMExpandMedia(dmVC); }],
|
||||
[UIAction actionWithTitle:SCILocalized(@"Messages settings")
|
||||
image:[UIImage systemImageNamed:@"gearshape"]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) { sciOpenMessagesSettings(weakSource); }],
|
||||
[UIAction actionWithTitle:SCILocalized(@"Download and share")
|
||||
image:[UIImage systemImageNamed:@"square.and.arrow.up"]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) { sciDMShareMedia(dmVC); }],
|
||||
[UIAction actionWithTitle:SCILocalized(@"Download to Photos")
|
||||
image:[UIImage systemImageNamed:@"square.and.arrow.down"]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) { sciDMDownloadMedia(dmVC); }],
|
||||
];
|
||||
}
|
||||
|
||||
static NSArray<UIMenuElement *> *sciDMEyeMenuItems(UIViewController *dmVC, UIView *sourceView) {
|
||||
__weak UIView *weakSource = sourceView;
|
||||
return @[
|
||||
[UIAction actionWithTitle:SCILocalized(@"Mark as viewed")
|
||||
image:[UIImage systemImageNamed:@"eye"]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) { sciDMMarkCurrentAsViewed(dmVC); }],
|
||||
[UIAction actionWithTitle:SCILocalized(@"Messages settings")
|
||||
image:[UIImage systemImageNamed:@"gearshape"]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) { sciOpenMessagesSettings(weakSource); }],
|
||||
];
|
||||
}
|
||||
|
||||
static void sciDMApplyTapMenu(UIButton *btn, __weak UIViewController *weakDMVC) {
|
||||
__weak UIButton *weakBtn = btn;
|
||||
UIDeferredMenuElement *deferred = [UIDeferredMenuElement elementWithUncachedProvider:
|
||||
^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
|
||||
UIViewController *dmVC = weakDMVC;
|
||||
UIButton *strongBtn = weakBtn;
|
||||
if (!dmVC || !strongBtn) { completion(@[]); return; }
|
||||
completion(sciDMActionMenuItems(dmVC, strongBtn));
|
||||
}];
|
||||
btn.menu = [UIMenu menuWithChildren:@[deferred]];
|
||||
btn.showsMenuAsPrimaryAction = YES;
|
||||
}
|
||||
|
||||
// MARK: - Button delegate (tap handlers)
|
||||
|
||||
@interface SCIDMButtonDelegate : NSObject
|
||||
+ (instancetype)shared;
|
||||
- (void)actionTapped:(UIButton *)sender;
|
||||
- (void)eyeTapped:(UIButton *)sender;
|
||||
- (void)audioTapped:(UIButton *)sender;
|
||||
@end
|
||||
|
||||
@implementation SCIDMButtonDelegate
|
||||
|
||||
+ (instancetype)shared {
|
||||
static SCIDMButtonDelegate *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [SCIDMButtonDelegate new]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
- (UIViewController *)ownerForButton:(UIView *)btn {
|
||||
return objc_getAssociatedObject(btn, kSCIDMOwnerVCKey);
|
||||
}
|
||||
|
||||
// Default-tap path (pref != menu).
|
||||
- (void)actionTapped:(UIButton *)sender {
|
||||
UIViewController *dmVC = [self ownerForButton:sender];
|
||||
if (!dmVC) return;
|
||||
NSString *tap = [SCIUtils getStringPref:@"dm_visual_action_default"];
|
||||
if ([tap isEqualToString:@"expand"]) sciDMExpandMedia(dmVC);
|
||||
else if ([tap isEqualToString:@"download_share"]) sciDMShareMedia(dmVC);
|
||||
else if ([tap isEqualToString:@"download_photos"]) sciDMDownloadMedia(dmVC);
|
||||
}
|
||||
|
||||
- (void)eyeTapped:(UIButton *)sender {
|
||||
UIViewController *dmVC = [self ownerForButton:sender];
|
||||
if (!dmVC) return;
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
|
||||
sciDMMarkCurrentAsViewed(dmVC);
|
||||
}
|
||||
|
||||
- (void)audioTapped:(SCIChromeButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[haptic impactOccurred];
|
||||
sciToggleStoryAudio();
|
||||
sender.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
// MARK: - Long-press menu builder
|
||||
|
||||
// UIButton.menu + showsMenuAsPrimaryAction=NO is iOS's native pattern for
|
||||
// "tap fires action, long-press shows menu". Compose a UIDeferredMenuElement
|
||||
// so the menu rebuilds per presentation — owner lookup stays fresh.
|
||||
static void sciDMAttachLongPressMenu(SCIChromeButton *btn, NSInteger tag) {
|
||||
__weak SCIChromeButton *weakBtn = btn;
|
||||
UIDeferredMenuElement *deferred = [UIDeferredMenuElement elementWithUncachedProvider:
|
||||
^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
|
||||
SCIChromeButton *strongBtn = weakBtn;
|
||||
UIViewController *dmVC = strongBtn ? objc_getAssociatedObject(strongBtn, kSCIDMOwnerVCKey) : nil;
|
||||
if (!dmVC) { completion(@[]); return; }
|
||||
NSArray<UIMenuElement *> *items = (tag == SCI_DM_ACTION_TAG)
|
||||
? sciDMActionMenuItems(dmVC, strongBtn)
|
||||
: sciDMEyeMenuItems(dmVC, strongBtn);
|
||||
completion(items);
|
||||
}];
|
||||
btn.menu = [UIMenu menuWithChildren:@[deferred]];
|
||||
btn.showsMenuAsPrimaryAction = NO;
|
||||
}
|
||||
|
||||
// MARK: - Overlay injection
|
||||
|
||||
static void sciDMInstallButtons(UIViewController *dmVC) {
|
||||
if (!dmVC || !dmVC.isViewLoaded) return;
|
||||
UIView *overlay = sciFindOverlayInView(dmVC.view);
|
||||
if (!overlay) return;
|
||||
|
||||
// Kill any story-tag injections from the shared overlay hook.
|
||||
UIView *sA = [overlay viewWithTag:SCI_STORY_ACTION_TAG]; if (sA) [sA removeFromSuperview];
|
||||
UIView *sE = [overlay viewWithTag:SCI_STORY_EYE_TAG]; if (sE) [sE removeFromSuperview];
|
||||
UIView *sU = [overlay viewWithTag:SCI_STORY_AUDIO_TAG]; if (sU) [sU removeFromSuperview];
|
||||
|
||||
SCIDMButtonDelegate *dg = [SCIDMButtonDelegate shared];
|
||||
|
||||
// --- Action button (tag 1342) ---
|
||||
UIView *staleAction = [overlay viewWithTag:SCI_DM_ACTION_TAG];
|
||||
if (staleAction) [staleAction removeFromSuperview];
|
||||
if ([SCIUtils getBoolPref:@"dm_visual_action_button"]) {
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:18 diameter:36];
|
||||
btn.tag = SCI_DM_ACTION_TAG;
|
||||
objc_setAssociatedObject(btn, kSCIDMOwnerVCKey, dmVC, OBJC_ASSOCIATION_ASSIGN);
|
||||
[overlay addSubview:btn];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:overlay.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
|
||||
NSString *defaultTap = [SCIUtils getStringPref:@"dm_visual_action_default"];
|
||||
if (!defaultTap.length || [defaultTap isEqualToString:@"menu"]) {
|
||||
sciDMApplyTapMenu(btn, dmVC);
|
||||
} else {
|
||||
// Tap = default action, long-press = full menu.
|
||||
[btn addTarget:dg action:@selector(actionTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
sciDMAttachLongPressMenu(btn, SCI_DM_ACTION_TAG);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Eye / mark-as-viewed (tag 1343) ---
|
||||
UIView *staleEye = [overlay viewWithTag:SCI_DM_EYE_TAG];
|
||||
if (staleEye) [staleEye removeFromSuperview];
|
||||
if ([SCIUtils getBoolPref:@"dm_visual_seen_button"]) {
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"eye" pointSize:18 diameter:36];
|
||||
btn.tag = SCI_DM_EYE_TAG;
|
||||
objc_setAssociatedObject(btn, kSCIDMOwnerVCKey, dmVC, OBJC_ASSOCIATION_ASSIGN);
|
||||
[btn addTarget:dg action:@selector(eyeTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
sciDMAttachLongPressMenu(btn, SCI_DM_EYE_TAG);
|
||||
[overlay addSubview:btn];
|
||||
|
||||
UIView *anchor = [overlay viewWithTag:SCI_DM_ACTION_TAG];
|
||||
if (anchor) {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
} else {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:overlay.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
// --- Audio toggle (tag 1344) ---
|
||||
UIView *staleAudio = [overlay viewWithTag:SCI_DM_AUDIO_TAG];
|
||||
if (staleAudio) [staleAudio removeFromSuperview];
|
||||
sciInitStoryAudioState();
|
||||
if ([SCIUtils getBoolPref:@"dm_visual_audio_toggle"]) {
|
||||
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:icon pointSize:14 diameter:28];
|
||||
btn.tag = SCI_DM_AUDIO_TAG;
|
||||
[btn addTarget:dg action:@selector(audioTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[overlay addSubview:btn];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.leadingAnchor constraintEqualToAnchor:overlay.leadingAnchor constant:12],
|
||||
[btn.widthAnchor constraintEqualToConstant:28],
|
||||
[btn.heightAnchor constraintEqualToConstant:28]
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
// Rebuild only when an enabled button is missing — handles overlay recycling.
|
||||
static void sciDMEnsureButtons(UIViewController *dmVC) {
|
||||
if (!dmVC || !dmVC.isViewLoaded) return;
|
||||
UIView *overlay = sciFindOverlayInView(dmVC.view);
|
||||
if (!overlay) return;
|
||||
|
||||
BOOL needAction = [SCIUtils getBoolPref:@"dm_visual_action_button"] && ![overlay viewWithTag:SCI_DM_ACTION_TAG];
|
||||
BOOL needEye = [SCIUtils getBoolPref:@"dm_visual_seen_button"] && ![overlay viewWithTag:SCI_DM_EYE_TAG];
|
||||
BOOL needAudio = [SCIUtils getBoolPref:@"dm_visual_audio_toggle"] && ![overlay viewWithTag:SCI_DM_AUDIO_TAG];
|
||||
if (needAction || needEye || needAudio) sciDMInstallButtons(dmVC);
|
||||
}
|
||||
|
||||
// MARK: - VC hook
|
||||
|
||||
%group DMOverlayGroup
|
||||
|
||||
%hook IGDirectVisualMessageViewerController
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciDMInstallButtons(self);
|
||||
}
|
||||
|
||||
- (void)viewDidLayoutSubviews {
|
||||
%orig;
|
||||
sciDMEnsureButtons(self);
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
%orig;
|
||||
if (!self.isViewLoaded) return;
|
||||
UIView *overlay = sciFindOverlayInView(self.view);
|
||||
if (!overlay) return;
|
||||
UIView *a = [overlay viewWithTag:SCI_DM_ACTION_TAG]; if (a) [a removeFromSuperview];
|
||||
UIView *e = [overlay viewWithTag:SCI_DM_EYE_TAG]; if (e) [e removeFromSuperview];
|
||||
UIView *u = [overlay viewWithTag:SCI_DM_AUDIO_TAG]; if (u) [u removeFromSuperview];
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
%end // DMOverlayGroup
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"dm_visual_action_button"] ||
|
||||
[SCIUtils getBoolPref:@"dm_visual_seen_button"] ||
|
||||
[SCIUtils getBoolPref:@"dm_visual_audio_toggle"]) {
|
||||
%init(DMOverlayGroup);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
// Story seen receipt blocking + visual seen state blocking
|
||||
// Story seen-receipt blocking. Legacy + Sundial uploads are Swift-dispatched
|
||||
// via a `networker` ivar — we cache the uploaders at init and nil the ivar
|
||||
// while the active owner is blocked. `keep_seen_visual_local` ON runs orig
|
||||
// (local stores update, server blocked). OFF skips orig (full block).
|
||||
|
||||
#import "StoryHelpers.h"
|
||||
#import "SCIStoryInteractionPipeline.h"
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
@@ -12,6 +18,8 @@ NSMutableSet *sciAllowedSeenPKs = nil;
|
||||
extern BOOL sciIsCurrentStoryOwnerExcluded(void);
|
||||
extern BOOL sciIsObjectStoryOwnerExcluded(id obj);
|
||||
|
||||
static void sciStateRestore(void); // fwd — used by VC hook above its definition
|
||||
|
||||
static BOOL sciStorySeenToggleBypass(void) {
|
||||
return [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"] && sciStorySeenToggleEnabled;
|
||||
}
|
||||
@@ -28,41 +36,48 @@ static BOOL sciIsPKAllowed(id media) {
|
||||
if (!media || !sciAllowedSeenPKs || sciAllowedSeenPKs.count == 0) return NO;
|
||||
id pk = sciCall(media, @selector(pk));
|
||||
if (!pk) return NO;
|
||||
return [sciAllowedSeenPKs containsObject:[NSString stringWithFormat:@"%@", pk]];
|
||||
NSString *pkStr = [NSString stringWithFormat:@"%@", pk];
|
||||
if (![sciAllowedSeenPKs containsObject:pkStr]) return NO;
|
||||
if ([SCIExcludedStoryUsers isFeatureEnabled] && ![SCIExcludedStoryUsers isUserPKExcluded:pkStr])
|
||||
return NO;
|
||||
return YES;
|
||||
}
|
||||
|
||||
static BOOL sciShouldBlockSeenNetwork() {
|
||||
// ============ Feature gates ============
|
||||
|
||||
static BOOL sciShouldBlockSeenNetwork(void) {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (sciIsCurrentStoryOwnerExcluded()) return NO;
|
||||
return [SCIUtils getBoolPref:@"no_seen_receipt"];
|
||||
}
|
||||
|
||||
static BOOL sciShouldBlockSeenVisual() {
|
||||
static BOOL sciShouldBlockSeenVisual(void) {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (sciIsCurrentStoryOwnerExcluded()) return NO;
|
||||
return [SCIUtils getBoolPref:@"no_seen_receipt"] && [SCIUtils getBoolPref:@"no_seen_visual"];
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return NO;
|
||||
return ![SCIUtils getBoolPref:@"keep_seen_visual_local"];
|
||||
}
|
||||
|
||||
// Per-instance gating for tray/item/ring hooks where the "current" story
|
||||
// VC may not be the owner of the model in question.
|
||||
// Per-instance gate — tray/item/ring models may not match the active VC.
|
||||
static BOOL sciShouldBlockSeenVisualForObj(id obj) {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"] || ![SCIUtils getBoolPref:@"no_seen_visual"]) return NO;
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return NO;
|
||||
if ([SCIUtils getBoolPref:@"keep_seen_visual_local"]) return NO;
|
||||
if (sciIsObjectStoryOwnerExcluded(obj)) return NO;
|
||||
return YES;
|
||||
}
|
||||
|
||||
// network seen blocking
|
||||
// ============ Legacy network-upload hooks (pre-Sundial fallback) ============
|
||||
%hook IGStorySeenStateUploader
|
||||
- (void)uploadSeenStateWithMedia:(id)arg1 {
|
||||
if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !sciIsPKAllowed(arg1)) return;
|
||||
%orig;
|
||||
}
|
||||
- (void)uploadSeenState {
|
||||
if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !(sciAllowedSeenPKs && sciAllowedSeenPKs.count > 0)) return;
|
||||
if (!sciSeenBypassActive && sciShouldBlockSeenNetwork()) return;
|
||||
%orig;
|
||||
}
|
||||
- (void)_uploadSeenState:(id)arg1 {
|
||||
@@ -73,16 +88,16 @@ static BOOL sciShouldBlockSeenVisualForObj(id obj) {
|
||||
if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !sciIsPKAllowed(arg1)) return;
|
||||
%orig;
|
||||
}
|
||||
- (id)networker { return %orig; }
|
||||
%end
|
||||
|
||||
// visual seen blocking + story auto-advance
|
||||
// ============ Visual-seen hooks + auto-advance ============
|
||||
|
||||
%hook IGStoryFullscreenSectionController
|
||||
- (void)markItemAsSeen:(id)arg1 { if (sciShouldBlockSeenVisual() && !sciIsPKAllowed(arg1)) return; %orig; }
|
||||
- (void)_markItemAsSeen:(id)arg1 { if (sciShouldBlockSeenVisual() && !sciIsPKAllowed(arg1)) return; %orig; }
|
||||
- (void)storySeenStateDidChange:(id)arg1 { if (sciShouldBlockSeenVisual()) return; %orig; }
|
||||
- (void)sendSeenRequestForCurrentItem { if (sciShouldBlockSeenVisual()) return; %orig; }
|
||||
- (void)markCurrentItemAsSeen { if (sciShouldBlockSeenVisual()) return; %orig; }
|
||||
- (void)sendSeenRequestForCurrentItem { if (sciShouldBlockSeenNetwork()) return; %orig; }
|
||||
- (void)storyPlayerMediaViewDidPlayToEnd:(id)arg1 {
|
||||
if (!sciAdvanceBypassActive && [SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
|
||||
%orig;
|
||||
@@ -93,13 +108,6 @@ static BOOL sciShouldBlockSeenVisualForObj(id obj) {
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGStoryViewerViewController
|
||||
- (void)fullscreenSectionController:(id)arg1 didMarkItemAsSeen:(id)arg2 {
|
||||
if (sciShouldBlockSeenVisual() && !sciIsPKAllowed(arg2)) return;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGStoryTrayViewModel
|
||||
- (void)markAsSeen { if (sciShouldBlockSeenVisualForObj(self)) return; %orig; }
|
||||
- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(YES); return; } %orig; }
|
||||
@@ -119,9 +127,7 @@ static BOOL sciShouldBlockSeenVisualForObj(id obj) {
|
||||
- (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
%end
|
||||
|
||||
// ============ STORY LIKE HOOKS ============
|
||||
// Hooks all known like entry points to trigger mark-seen and auto-advance on like.
|
||||
// Uses sciMarkSeenTapped: from OverlayButtons.xm for the actual seen flow.
|
||||
// ============ Active story VC tracking ============
|
||||
|
||||
__weak UIViewController *sciActiveStoryVC = nil;
|
||||
|
||||
@@ -132,95 +138,171 @@ __weak UIViewController *sciActiveStoryVC = nil;
|
||||
}
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
if (sciActiveStoryVC == (UIViewController *)self) sciActiveStoryVC = nil;
|
||||
sciStateRestore();
|
||||
%orig;
|
||||
}
|
||||
- (void)fullscreenSectionController:(id)arg1 didMarkItemAsSeen:(id)arg2 {
|
||||
if (sciShouldBlockSeenVisual() && !sciIsPKAllowed(arg2)) return;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
static UIView *sciFindStoryOverlayView(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
Class targetCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!targetCls) return nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:targetCls]) return v;
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
// ============ Networker-ivar swap (v425+ split-mode) ============
|
||||
|
||||
static __weak id sciLegacyUploader = nil; // IGStorySeenStateUploader
|
||||
static __weak id sciSundialManager = nil; // IGSundialSeenStateManager
|
||||
|
||||
static id (*orig_pendingStoreInit)(id, SEL, id, id, id, BOOL);
|
||||
static id new_pendingStoreInit(id self, SEL _cmd, id sessionPK, id uploader, id fileMgr, BOOL bgTask) {
|
||||
if (uploader) sciLegacyUploader = uploader;
|
||||
return orig_pendingStoreInit(self, _cmd, sessionPK, uploader, fileMgr, bgTask);
|
||||
}
|
||||
|
||||
static void sciMarkActiveStorySeen(void) {
|
||||
if (![SCIUtils getBoolPref:@"seen_on_story_like"]) return;
|
||||
UIView *overlay = sciFindStoryOverlayView(sciActiveStoryVC);
|
||||
if (!overlay) return;
|
||||
SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:");
|
||||
if ([overlay respondsToSelector:sel])
|
||||
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
|
||||
static id (*orig_sundialMgrInit)(id, SEL, id, id, id, id);
|
||||
static id new_sundialMgrInit(id self, SEL _cmd, id networker, id diskMgr, id launcherSet, id announcer) {
|
||||
id res = orig_sundialMgrInit(self, _cmd, networker, diskMgr, launcherSet, announcer);
|
||||
if (res) sciSundialManager = res;
|
||||
return res;
|
||||
}
|
||||
|
||||
// Dedup guard — multiple hooks fire for the same like event
|
||||
static uint64_t sciLastLikeAdvanceTime = 0;
|
||||
|
||||
static void sciAdvanceOnStoryLike(void) {
|
||||
if (![SCIUtils getBoolPref:@"advance_on_story_like"]) return;
|
||||
UIViewController *storyVC = sciActiveStoryVC;
|
||||
if (!storyVC) return;
|
||||
id sectionCtrl = sciFindSectionController(storyVC);
|
||||
if (!sectionCtrl) return;
|
||||
|
||||
uint64_t now = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
|
||||
if (now - sciLastLikeAdvanceTime < 500000000ULL) return;
|
||||
sciLastLikeAdvanceTime = now;
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.3 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciAdvanceBypassActive = YES;
|
||||
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
|
||||
if ([sectionCtrl respondsToSelector:advSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sectionCtrl, advSel, 1);
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
id sc2 = storyVC ? sciFindSectionController(storyVC) : nil;
|
||||
if (sc2) {
|
||||
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
if ([sc2 respondsToSelector:resumeSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
|
||||
// Swap each cached uploader's networker ivar; saved dict is used to restore.
|
||||
static NSDictionary *sciSwapNetworkers(id newNetworker) {
|
||||
NSMutableDictionary *saved = [NSMutableDictionary dictionary];
|
||||
@try {
|
||||
id legacy = sciLegacyUploader;
|
||||
if (legacy) {
|
||||
Ivar iv = class_getInstanceVariable([legacy class], "_networker");
|
||||
if (iv) {
|
||||
id old = object_getIvar(legacy, iv);
|
||||
if (old) saved[@"legacy"] = old;
|
||||
object_setIvar(legacy, iv, newNetworker);
|
||||
}
|
||||
sciAdvanceBypassActive = NO;
|
||||
});
|
||||
});
|
||||
}
|
||||
id mgr = sciSundialManager;
|
||||
if (mgr) {
|
||||
for (NSString *ivName in @[@"seenStateUploader", @"seenStateUploaderDeprecated"]) {
|
||||
Ivar mgrIv = class_getInstanceVariable([mgr class], [ivName UTF8String]);
|
||||
if (!mgrIv) continue;
|
||||
id up = object_getIvar(mgr, mgrIv);
|
||||
if (!up) continue;
|
||||
Ivar netIv = class_getInstanceVariable([up class], "networker");
|
||||
if (!netIv) continue;
|
||||
id oldNet = object_getIvar(up, netIv);
|
||||
if (oldNet) saved[ivName] = oldNet;
|
||||
object_setIvar(up, netIv, newNetworker);
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
return saved;
|
||||
}
|
||||
|
||||
static void sciOnStoryLike(void) {
|
||||
sciMarkActiveStorySeen();
|
||||
sciAdvanceOnStoryLike();
|
||||
static void sciRestoreNetworkers(NSDictionary *saved) {
|
||||
@try {
|
||||
id legacy = sciLegacyUploader;
|
||||
if (legacy && saved[@"legacy"]) {
|
||||
Ivar iv = class_getInstanceVariable([legacy class], "_networker");
|
||||
if (iv) object_setIvar(legacy, iv, saved[@"legacy"]);
|
||||
}
|
||||
id mgr = sciSundialManager;
|
||||
if (mgr) {
|
||||
for (NSString *ivName in @[@"seenStateUploader", @"seenStateUploaderDeprecated"]) {
|
||||
if (!saved[ivName]) continue;
|
||||
Ivar mgrIv = class_getInstanceVariable([mgr class], [ivName UTF8String]);
|
||||
if (!mgrIv) continue;
|
||||
id up = object_getIvar(mgr, mgrIv);
|
||||
if (!up) continue;
|
||||
Ivar netIv = class_getInstanceVariable([up class], "networker");
|
||||
if (netIv) object_setIvar(up, netIv, saved[ivName]);
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
// Idempotent block/restore. Guard prevents double-swap clobbering the saved originals.
|
||||
static BOOL sciNetBlocked = NO;
|
||||
static NSDictionary *sciNetSaved = nil;
|
||||
|
||||
static void sciStateBlock(void) {
|
||||
if (sciNetBlocked) return;
|
||||
sciNetSaved = sciSwapNetworkers(nil);
|
||||
sciNetBlocked = YES;
|
||||
}
|
||||
|
||||
static void sciStateRestore(void) {
|
||||
if (!sciNetBlocked) return;
|
||||
sciRestoreNetworkers(sciNetSaved);
|
||||
sciNetSaved = nil;
|
||||
sciNetBlocked = NO;
|
||||
}
|
||||
|
||||
static NSString *sciExtractOwnerPKFromItem(id item) {
|
||||
NSString *pk = nil;
|
||||
@try {
|
||||
id reelPk = [item respondsToSelector:@selector(reelPk)] ? [item performSelector:@selector(reelPk)] : nil;
|
||||
if (reelPk) pk = [reelPk description];
|
||||
if (!pk) {
|
||||
id media = [item respondsToSelector:@selector(media)] ? [item performSelector:@selector(media)] : item;
|
||||
id user = [media respondsToSelector:@selector(user)] ? [media performSelector:@selector(user)] : nil;
|
||||
if (!user) user = [media respondsToSelector:@selector(owner)] ? [media performSelector:@selector(owner)] : nil;
|
||||
if (user) {
|
||||
Ivar pkIvar = NULL;
|
||||
for (Class c = [user class]; c && !pkIvar; c = class_getSuperclass(c))
|
||||
pkIvar = class_getInstanceVariable(c, "_pk");
|
||||
if (pkIvar) pk = [object_getIvar(user, pkIvar) description];
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
return pk;
|
||||
}
|
||||
|
||||
// Mark-seen delegate: restore on non-blocked owners, block + run orig on
|
||||
// blocked owners when split-mode is on, skip orig when it's off.
|
||||
static void (*orig_delegateMarkSeen)(id, SEL, id, id);
|
||||
static void new_delegateMarkSeen(id self, SEL _cmd, id ctrl, id item) {
|
||||
if (sciSeenBypassActive) { sciStateRestore(); orig_delegateMarkSeen(self, _cmd, ctrl, item); return; }
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) { sciStateRestore(); orig_delegateMarkSeen(self, _cmd, ctrl, item); return; }
|
||||
|
||||
NSString *ownerPK = sciExtractOwnerPKFromItem(item);
|
||||
BOOL shouldBlock;
|
||||
if ([SCIExcludedStoryUsers isFeatureEnabled])
|
||||
shouldBlock = ownerPK.length && ![SCIExcludedStoryUsers isUserPKExcluded:ownerPK];
|
||||
else
|
||||
shouldBlock = YES;
|
||||
|
||||
if (!shouldBlock) {
|
||||
sciStateRestore();
|
||||
orig_delegateMarkSeen(self, _cmd, ctrl, item);
|
||||
return;
|
||||
}
|
||||
|
||||
if (![SCIUtils getBoolPref:@"keep_seen_visual_local"]) {
|
||||
sciStateRestore();
|
||||
return;
|
||||
}
|
||||
|
||||
sciStateBlock();
|
||||
@try { orig_delegateMarkSeen(self, _cmd, ctrl, item); }
|
||||
@catch (__unused id e) { sciStateRestore(); }
|
||||
}
|
||||
|
||||
// ============ Like → mark-seen side effects ============
|
||||
|
||||
static void (*orig_didLikeSundial)(id, SEL, id);
|
||||
static void new_didLikeSundial(id self, SEL _cmd, id pk) {
|
||||
orig_didLikeSundial(self, _cmd, pk);
|
||||
sciOnStoryLike();
|
||||
sciStoryInteractionSideEffects(SCIStoryInteractionLike);
|
||||
}
|
||||
|
||||
static void (*orig_overlaySetIsLiked)(id, SEL, BOOL, BOOL);
|
||||
static void new_overlaySetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
|
||||
orig_overlaySetIsLiked(self, _cmd, isLiked, animated);
|
||||
if (isLiked) sciOnStoryLike();
|
||||
}
|
||||
|
||||
// IGUFIButton selected state: YES = heart filled (liked), NO = empty (not liked).
|
||||
// handleStoryLikeTapWithButton: is a toggle — check state before orig to determine direction.
|
||||
static void (*orig_handleLikeTap)(id, SEL, id);
|
||||
static void new_handleLikeTap(id self, SEL _cmd, id button) {
|
||||
BOOL isLike = [button isKindOfClass:[UIButton class]] && [(UIButton *)button isSelected];
|
||||
orig_handleLikeTap(self, _cmd, button);
|
||||
if (isLike) sciOnStoryLike();
|
||||
if (isLiked) sciStoryInteractionSideEffects(SCIStoryInteractionLike);
|
||||
}
|
||||
|
||||
static void (*orig_likeButtonSetIsLiked)(id, SEL, BOOL, BOOL);
|
||||
static void new_likeButtonSetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL animated) {
|
||||
orig_likeButtonSetIsLiked(self, _cmd, isLiked, animated);
|
||||
if (isLiked) sciOnStoryLike();
|
||||
if (isLiked) sciStoryInteractionSideEffects(SCIStoryInteractionLike);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
@@ -229,23 +311,39 @@ static void new_likeButtonSetIsLiked(id self, SEL _cmd, BOOL isLiked, BOOL anima
|
||||
SEL didLike = NSSelectorFromString(@"didLikeSundialWithMediaPK:");
|
||||
if (class_getInstanceMethod(overlayCtl, didLike))
|
||||
MSHookMessageEx(overlayCtl, didLike, (IMP)new_didLikeSundial, (IMP *)&orig_didLikeSundial);
|
||||
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(overlayCtl, setLiked))
|
||||
MSHookMessageEx(overlayCtl, setLiked, (IMP)new_overlaySetIsLiked, (IMP *)&orig_overlaySetIsLiked);
|
||||
}
|
||||
|
||||
Class likesImpl = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
|
||||
if (likesImpl) {
|
||||
SEL handleTap = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
|
||||
if (class_getInstanceMethod(likesImpl, handleTap))
|
||||
MSHookMessageEx(likesImpl, handleTap, (IMP)new_handleLikeTap, (IMP *)&orig_handleLikeTap);
|
||||
}
|
||||
|
||||
Class likeBtn = NSClassFromString(@"IGSundialViewerUFI.IGSundialLikeButton");
|
||||
if (likeBtn) {
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(likeBtn, setLiked))
|
||||
MSHookMessageEx(likeBtn, setLiked, (IMP)new_likeButtonSetIsLiked, (IMP *)&orig_likeButtonSetIsLiked);
|
||||
}
|
||||
|
||||
Class pending = NSClassFromString(@"IGStoryPendingSeenStateStore");
|
||||
SEL pendingSel = NSSelectorFromString(@"initWithUserSessionPK:uploader:fileManager:uploadInBackgroundTask:");
|
||||
if (pending && class_getInstanceMethod(pending, pendingSel))
|
||||
MSHookMessageEx(pending, pendingSel, (IMP)new_pendingStoreInit, (IMP *)&orig_pendingStoreInit);
|
||||
|
||||
Class sundialMgr = NSClassFromString(@"_TtC23IGSundialSeenStateSwift25IGSundialSeenStateManager");
|
||||
SEL mgrSel = NSSelectorFromString(@"initWithNetworker:diskManager:launcherSet:seenStateManagerAnnouncer:");
|
||||
if (sundialMgr && class_getInstanceMethod(sundialMgr, mgrSel))
|
||||
MSHookMessageEx(sundialMgr, mgrSel, (IMP)new_sundialMgrInit, (IMP *)&orig_sundialMgrInit);
|
||||
|
||||
// Mark-as-seen delegate; extras are forward-compat candidates.
|
||||
for (NSString *clsName in @[
|
||||
@"IGStoryViewerViewController",
|
||||
@"IGStoryViewerUpdater",
|
||||
@"IGStoryFullscreenViewModel",
|
||||
@"IGStoriesManager",
|
||||
]) {
|
||||
Class cls = NSClassFromString(clsName);
|
||||
if (!cls) continue;
|
||||
SEL delegateSel = NSSelectorFromString(@"fullscreenSectionController:didMarkItemAsSeen:");
|
||||
if (class_getInstanceMethod(cls, delegateSel))
|
||||
MSHookMessageEx(cls, delegateSel, (IMP)new_delegateMarkSeen, (IMP *)&orig_delegateMarkSeen);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,13 +139,14 @@ NSDictionary *sciOwnerInfoForView(UIView *view) {
|
||||
|
||||
BOOL sciIsCurrentStoryOwnerExcluded(void) {
|
||||
NSDictionary *info = sciCurrentStoryOwnerInfo();
|
||||
if (!info) return NO;
|
||||
// Unknown owner: block_selected → don't block; block_all → block.
|
||||
if (!info) return [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
|
||||
}
|
||||
|
||||
BOOL sciIsObjectStoryOwnerExcluded(id obj) {
|
||||
NSDictionary *info = sciOwnerInfoFromObject(obj);
|
||||
if (!info) return NO;
|
||||
if (!info) return [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
|
||||
}
|
||||
|
||||
@@ -156,7 +157,7 @@ void sciTriggerStoryMarkSeen(UIViewController *storyVC) {
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
|
||||
if (!overlayCls) return;
|
||||
SEL markSel = @selector(sciMarkSeenTapped:);
|
||||
SEL markSel = @selector(sciStoryMarkSeenTapped:);
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
|
||||
@@ -24,6 +24,8 @@ static BOOL sciPlatterContainsHiddenButton(UIView *platter) {
|
||||
return NO;
|
||||
}
|
||||
|
||||
%group HideCallButtonsGroup
|
||||
|
||||
// Block taps in case a hidden button still receives hit-test events during transitions.
|
||||
%hook IGDirectThreadCallButtonsCoordinator
|
||||
- (void)_didTapAudioButton:(id)arg1 {
|
||||
@@ -88,3 +90,12 @@ static void sciRepackPlatters(UIView *container) {
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
%end // HideCallButtonsGroup
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"hide_voice_call_button"] ||
|
||||
[SCIUtils getBoolPref:@"hide_video_call_button"]) {
|
||||
%init(HideCallButtonsGroup);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,8 +97,8 @@ static UIImage *sciGIFImageFromCell(UIView *cell) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Get audio URL from the cell's view model
|
||||
static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) {
|
||||
// Audio track from the note cell's view model. 426 added launcherSet.
|
||||
static id sciAudioTrackFromCell(UIView *cell) {
|
||||
if (!cell) return nil;
|
||||
Ivar vmIvar = class_getInstanceVariable([cell class], "viewModel");
|
||||
if (!vmIvar) vmIvar = class_getInstanceVariable([cell class], "_viewModel");
|
||||
@@ -106,41 +106,48 @@ static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) {
|
||||
id vm = object_getIvar(cell, vmIvar);
|
||||
if (!vm) return nil;
|
||||
|
||||
SEL audioSel = NSSelectorFromString(@"audioTrackWithUserMap:");
|
||||
if (![vm respondsToSelector:audioSel]) return nil;
|
||||
|
||||
SEL audioSel2 = NSSelectorFromString(@"audioTrackWithUserMap:launcherSet:");
|
||||
SEL audioSel1 = NSSelectorFromString(@"audioTrackWithUserMap:");
|
||||
@try {
|
||||
id track = ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel, nil);
|
||||
if (!track) return nil;
|
||||
|
||||
// audioFileURL is an IGAsyncTask — try to resolve it
|
||||
if ([track respondsToSelector:NSSelectorFromString(@"audioFileURL")]) {
|
||||
id urlOrTask = [track valueForKey:@"audioFileURL"];
|
||||
if ([urlOrTask isKindOfClass:[NSURL class]]) return urlOrTask;
|
||||
|
||||
// IGAsyncTask — try .result, .value, .get
|
||||
for (NSString *prop in @[@"result", @"value", @"get", @"cachedResult"]) {
|
||||
if ([urlOrTask respondsToSelector:NSSelectorFromString(prop)]) {
|
||||
@try {
|
||||
id resolved = [urlOrTask valueForKey:prop];
|
||||
if ([resolved isKindOfClass:[NSURL class]]) return resolved;
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
|
||||
SEL awaitSel = NSSelectorFromString(@"await");
|
||||
if ([urlOrTask respondsToSelector:awaitSel]) {
|
||||
@try {
|
||||
id resolved = ((id(*)(id,SEL))objc_msgSend)(urlOrTask, awaitSel);
|
||||
if ([resolved isKindOfClass:[NSURL class]]) return resolved;
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if ([vm respondsToSelector:audioSel2]) {
|
||||
id session = [SCIUtils activeUserSession];
|
||||
id launcher = nil;
|
||||
@try { launcher = session ? [session valueForKey:@"launcherSet"] : nil; } @catch (__unused id e) {}
|
||||
return ((id(*)(id,SEL,id,id))objc_msgSend)(vm, audioSel2, nil, launcher);
|
||||
}
|
||||
if ([vm respondsToSelector:audioSel1]) {
|
||||
return ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel1, nil);
|
||||
}
|
||||
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Pull URL from the track's IGAsyncTask — sync if cached, else async.
|
||||
static void sciResolveAudioURL(id track, void (^completion)(NSURL *)) {
|
||||
if (!track || !completion) { if (completion) completion(nil); return; }
|
||||
id task = nil;
|
||||
@try {
|
||||
if ([track respondsToSelector:@selector(audioFileURLTask)])
|
||||
task = ((id(*)(id,SEL))objc_msgSend)(track, @selector(audioFileURLTask));
|
||||
} @catch (__unused id e) {}
|
||||
if (!task) { completion(nil); return; }
|
||||
|
||||
@try {
|
||||
id res = [task valueForKey:@"result"];
|
||||
if ([res isKindOfClass:[NSURL class]]) { completion(res); return; }
|
||||
} @catch (__unused id e) {}
|
||||
|
||||
SEL onSuccess = NSSelectorFromString(@"onSuccess:");
|
||||
if (![task respondsToSelector:onSuccess]) { completion(nil); return; }
|
||||
void (^cb)(id) = ^(id resolved) {
|
||||
NSURL *u = [resolved isKindOfClass:[NSURL class]] ? resolved : nil;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{ completion(u); });
|
||||
};
|
||||
@try {
|
||||
((void(*)(id,SEL,id))objc_msgSend)(task, onSuccess, cb);
|
||||
} @catch (__unused id e) { completion(nil); }
|
||||
}
|
||||
|
||||
static SCIDownloadDelegate *sciNoteDl = nil;
|
||||
|
||||
static void (*orig_present)(UIViewController *, SEL, UIViewController *, BOOL, id);
|
||||
@@ -254,13 +261,18 @@ static void hook_present(UIViewController *self, SEL _cmd, UIViewController *vc,
|
||||
}]];
|
||||
}
|
||||
|
||||
// Audio (style=1): download from audioFileURL
|
||||
NSURL *audioURL = sciAudioURLFromCell(cell, note);
|
||||
if (audioURL) {
|
||||
id audioTrack = sciAudioTrackFromCell(cell);
|
||||
if (audioTrack) {
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Download audio")
|
||||
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:NO];
|
||||
[sciNoteDl downloadFileWithURL:audioURL fileExtension:@"m4a" hudLabel:nil];
|
||||
sciResolveAudioURL(audioTrack, ^(NSURL *audioURL) {
|
||||
if (!audioURL) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Audio URL not available")];
|
||||
return;
|
||||
}
|
||||
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:NO];
|
||||
[sciNoteDl downloadFileWithURL:audioURL fileExtension:@"m4a" hudLabel:nil];
|
||||
});
|
||||
}]];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,719 +0,0 @@
|
||||
// Action + mark-seen buttons on story/DM visual message overlay
|
||||
// Tags: [1339] eye [1340] action [1341] audio
|
||||
|
||||
#import "StoryHelpers.h"
|
||||
#import "SCIExcludedThreads.h"
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import "../../ActionButton/SCIActionMenu.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
|
||||
extern "C" BOOL sciSeenBypassActive;
|
||||
extern "C" BOOL sciAdvanceBypassActive;
|
||||
extern "C" NSMutableSet *sciAllowedSeenPKs;
|
||||
extern "C" void sciAllowSeenForPK(id);
|
||||
extern "C" BOOL sciIsCurrentStoryOwnerExcluded(void);
|
||||
extern "C" NSDictionary *sciCurrentStoryOwnerInfo(void);
|
||||
extern "C" NSDictionary *sciOwnerInfoForView(UIView *view);
|
||||
extern "C" BOOL sciStorySeenToggleEnabled;
|
||||
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC);
|
||||
extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC);
|
||||
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
|
||||
extern "C" void sciToggleStoryAudio(void);
|
||||
extern "C" BOOL sciIsStoryAudioEnabled(void);
|
||||
extern "C" void sciInitStoryAudioState(void);
|
||||
extern "C" void sciResetStoryAudioState(void);
|
||||
extern "C" void sciShowStoryMentions(UIViewController *, UIView *);
|
||||
|
||||
// ── Disappearing DM media ──
|
||||
static NSURL *sciDisappearingMediaURL(UIViewController *dmVC, BOOL *outIsVideo) {
|
||||
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
|
||||
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
|
||||
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
|
||||
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
|
||||
if (!msg) return nil;
|
||||
|
||||
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
|
||||
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
|
||||
Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil;
|
||||
id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil;
|
||||
if (!visMedia) return nil;
|
||||
|
||||
// Video
|
||||
@try {
|
||||
id rawVideo = [msg valueForKey:@"rawVideo"];
|
||||
if (rawVideo) {
|
||||
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
|
||||
if (url) { if (outIsVideo) *outIsVideo = YES; return url; }
|
||||
}
|
||||
} @catch (NSException *e) {}
|
||||
|
||||
// Photo
|
||||
Ivar pi = class_getInstanceVariable([visMedia class], "_photo_photo");
|
||||
id photo = pi ? object_getIvar(visMedia, pi) : nil;
|
||||
if (photo) {
|
||||
if (outIsVideo) *outIsVideo = NO;
|
||||
return [SCIUtils getPhotoUrl:photo];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static SCIDownloadDelegate *sciDMDownloadDelegate = nil;
|
||||
static void sciDownloadDisappearingMedia(UIViewController *dmVC) {
|
||||
BOOL isVideo = NO;
|
||||
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
|
||||
|
||||
sciDMDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:YES];
|
||||
[sciDMDownloadDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
|
||||
}
|
||||
|
||||
static SCIDownloadDelegate *sciDMShareDelegate = nil;
|
||||
static void sciShareDisappearingMedia(UIViewController *dmVC) {
|
||||
BOOL isVideo = NO;
|
||||
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
|
||||
|
||||
sciDMShareDelegate = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:YES];
|
||||
[sciDMShareDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
|
||||
}
|
||||
|
||||
static void sciExpandDisappearingMedia(UIViewController *dmVC) {
|
||||
BOOL isVideo = NO;
|
||||
NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo);
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
|
||||
|
||||
if (isVideo) {
|
||||
[SCIMediaViewer showWithVideoURL:url photoURL:nil caption:nil];
|
||||
} else {
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:url caption:nil];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Story playback control ──
|
||||
|
||||
static void sciPauseStoryPlayback(UIView *sourceView) {
|
||||
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
|
||||
if (!storyVC) return;
|
||||
id sc = sciFindSectionController(storyVC);
|
||||
|
||||
SEL pauseSel = NSSelectorFromString(@"pauseWithReason:");
|
||||
if (sc && [sc respondsToSelector:pauseSel]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, pauseSel, 10);
|
||||
return;
|
||||
}
|
||||
if ([storyVC respondsToSelector:pauseSel]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, pauseSel, 10);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
static void sciResumeStoryPlayback(UIView *sourceView) {
|
||||
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
|
||||
if (!storyVC) return;
|
||||
id sc = sciFindSectionController(storyVC);
|
||||
|
||||
SEL resumeSel1 = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
SEL resumeSel2 = NSSelectorFromString(@"tryResumePlayback");
|
||||
if (sc && [sc respondsToSelector:resumeSel1]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, resumeSel1, 0);
|
||||
return;
|
||||
}
|
||||
if ([storyVC respondsToSelector:resumeSel2]) {
|
||||
((void(*)(id, SEL))objc_msgSend)(storyVC, resumeSel2);
|
||||
return;
|
||||
}
|
||||
if ([storyVC respondsToSelector:resumeSel1]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, resumeSel1, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGStoryFullscreenOverlayView
|
||||
|
||||
// ============ Button injection ============
|
||||
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!self.superview) return;
|
||||
|
||||
// Action button
|
||||
if ([SCIUtils getBoolPref:@"stories_action_button"] && ![self viewWithTag:1340]) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1340;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
[btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor whiteColor];
|
||||
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
btn.layer.cornerRadius = 18;
|
||||
btn.clipsToBounds = YES;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:btn];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
|
||||
SCIActionMediaProvider storyProvider = ^id (UIView *sourceView) {
|
||||
// DM disappearing message — handle directly for tap actions
|
||||
UIViewController *dmVC = sciFindVC(sourceView, @"IGDirectVisualMessageViewerController");
|
||||
if (dmVC) {
|
||||
sciDownloadDisappearingMedia(dmVC);
|
||||
return (id)kCFNull;
|
||||
}
|
||||
|
||||
// Story path
|
||||
sciPauseStoryPlayback(sourceView);
|
||||
id item = sciGetCurrentStoryItem(sourceView);
|
||||
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) return item;
|
||||
return sciExtractMediaFromItem(item);
|
||||
};
|
||||
|
||||
[SCIActionButton configureButton:btn
|
||||
context:SCIActionContextStories
|
||||
prefKey:@"stories_action_default"
|
||||
mediaProvider:storyProvider];
|
||||
|
||||
// When configureButton chose "menu" mode, override with our custom
|
||||
// deferred menu that handles both DM and story contexts.
|
||||
if (btn.showsMenuAsPrimaryAction) {
|
||||
btn.menu = [UIMenu menuWithChildren:@[
|
||||
[UIDeferredMenuElement elementWithUncachedProvider:^(void (^completion)(NSArray<UIMenuElement *> *)) {
|
||||
UIViewController *dmVC = sciFindVC(btn, @"IGDirectVisualMessageViewerController");
|
||||
if (dmVC) {
|
||||
completion(@[
|
||||
[UIAction actionWithTitle:SCILocalized(@"Expand") image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"]
|
||||
identifier:nil handler:^(UIAction *a) { sciExpandDisappearingMedia(dmVC); }],
|
||||
[UIAction actionWithTitle:SCILocalized(@"Share") image:[UIImage systemImageNamed:@"square.and.arrow.up"]
|
||||
identifier:nil handler:^(UIAction *a) { sciShareDisappearingMedia(dmVC); }],
|
||||
[UIAction actionWithTitle:SCILocalized(@"Save to Photos") image:[UIImage systemImageNamed:@"square.and.arrow.down"]
|
||||
identifier:nil handler:^(UIAction *a) { sciDownloadDisappearingMedia(dmVC); }],
|
||||
]);
|
||||
} else {
|
||||
id media = nil;
|
||||
sciPauseStoryPlayback(btn);
|
||||
id item = sciGetCurrentStoryItem(btn);
|
||||
media = [item isKindOfClass:NSClassFromString(@"IGMedia")] ? item : sciExtractMediaFromItem(item);
|
||||
NSArray *actions = [SCIMediaActions actionsForContext:SCIActionContextStories media:media fromView:btn];
|
||||
UIMenu *built = [SCIActionMenu buildMenuWithActions:actions];
|
||||
completion(built.children);
|
||||
}
|
||||
}]
|
||||
]];
|
||||
}
|
||||
|
||||
// KVO highlighted → resume playback when menu dismisses.
|
||||
[btn addObserver:self forKeyPath:@"highlighted"
|
||||
options:NSKeyValueObservingOptionNew context:NULL];
|
||||
|
||||
|
||||
// Story reel items provider for "download all" detection.
|
||||
static const void *kStoryReelItemsProvider = &kStoryReelItemsProvider;
|
||||
objc_setAssociatedObject(btn, kStoryReelItemsProvider, ^NSArray *(UIView *src) {
|
||||
UIViewController *storyVC = sciFindVC(src, @"IGStoryViewerViewController");
|
||||
if (!storyVC) return nil;
|
||||
id vm = sciCall(storyVC, @selector(currentViewModel));
|
||||
if (!vm) return nil;
|
||||
|
||||
// Try known selectors
|
||||
for (NSString *sel in @[@"items", @"storyItems", @"reelItems", @"mediaItems", @"allItems"]) {
|
||||
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
|
||||
@try {
|
||||
id val = ((id(*)(id,SEL))objc_msgSend)(vm, NSSelectorFromString(sel));
|
||||
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
|
||||
return val;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Scan vm ivars for arrays of IGMedia
|
||||
Class mc = NSClassFromString(@"IGMedia");
|
||||
unsigned int cnt = 0;
|
||||
Ivar *ivs = class_copyIvarList(object_getClass(vm), &cnt);
|
||||
for (unsigned int i = 0; i < cnt; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivs[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(vm, ivs[i]);
|
||||
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
|
||||
id first = [(NSArray *)val firstObject];
|
||||
if (mc && [first isKindOfClass:mc]) {
|
||||
free(ivs);
|
||||
return val;
|
||||
}
|
||||
// Items might be wrapped — try extracting media from first
|
||||
IGMedia *extracted = sciExtractMediaFromItem(first);
|
||||
if (extracted) {
|
||||
free(ivs);
|
||||
return val;
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivs) free(ivs);
|
||||
|
||||
return nil;
|
||||
}, OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
}
|
||||
|
||||
// Audio toggle button
|
||||
sciInitStoryAudioState();
|
||||
if ([SCIUtils getBoolPref:@"story_audio_toggle"] && ![self viewWithTag:1341]) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1341;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
|
||||
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
[btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor whiteColor];
|
||||
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
btn.layer.cornerRadius = 14;
|
||||
btn.clipsToBounds = YES;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:self action:@selector(sciAudioToggleTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:btn];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12],
|
||||
[btn.widthAnchor constraintEqualToConstant:28],
|
||||
[btn.heightAnchor constraintEqualToConstant:28]
|
||||
]];
|
||||
}
|
||||
|
||||
// Seen button — deferred so the responder chain is wired up
|
||||
__weak UIView *weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIView *s = weakSelf;
|
||||
if (s && s.superview) ((void(*)(id, SEL))objc_msgSend)(s, @selector(sciRefreshSeenButton));
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Seen button lifecycle ============
|
||||
|
||||
// KVO: action button highlighted → NO means UIMenu dismissed → resume.
|
||||
%new - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
|
||||
change:(NSDictionary *)change context:(void *)context {
|
||||
if ([keyPath isEqualToString:@"highlighted"]) {
|
||||
BOOL highlighted = [change[NSKeyValueChangeNewKey] boolValue];
|
||||
if (!highlighted) {
|
||||
sciResumeStoryPlayback(self);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the audio toggle icon (tag 1341) to match current state.
|
||||
%new - (void)sciRefreshAudioButton {
|
||||
UIButton *btn = (UIButton *)[self viewWithTag:1341];
|
||||
if (!btn) return;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
|
||||
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
[btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
// Rebuilds the eye button (tag 1339) based on current owner + prefs. Idempotent.
|
||||
%new - (void)sciRefreshSeenButton {
|
||||
BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"];
|
||||
BOOL storyBlockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
// In block_selected mode, show the eye for list management even if global toggle is off
|
||||
if (!seenBlockingOn && !storyBlockSelected) return;
|
||||
// Skip for DM visual messages inside an excluded thread
|
||||
NSString *activeTid = [SCIExcludedThreads activeThreadId];
|
||||
if (activeTid && [SCIExcludedThreads isInList:activeTid] && ![SCIExcludedThreads isBlockSelectedMode]) return;
|
||||
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
|
||||
NSString *ownerPK = ownerInfo[@"pk"] ?: @"";
|
||||
BOOL ownerInList = ownerPK.length && [SCIExcludedStoryUsers isInList:ownerPK];
|
||||
// block_all + in list: show remove icon (excluded user, behaves normally)
|
||||
// block_selected + in list: show normal eye (blocked user, needs mark-seen)
|
||||
// block_selected + not in list: show add icon
|
||||
BOOL showExcludeIcon = ownerInList && !storyBlockSelected;
|
||||
BOOL showAddIcon = storyBlockSelected && !ownerInList;
|
||||
BOOL listBtnPref = [SCIUtils getBoolPref:@"story_excluded_show_unexclude_eye"];
|
||||
BOOL hideForListedOwner = (showExcludeIcon || showAddIcon) && !listBtnPref;
|
||||
BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"];
|
||||
|
||||
NSString *symName;
|
||||
UIColor *tint;
|
||||
if (showExcludeIcon) {
|
||||
// block_all + in list: remove-from-exclude icon
|
||||
symName = @"eye.slash.fill"; tint = SCIUtils.SCIColor_Primary;
|
||||
} else if (storyBlockSelected && !ownerInList) {
|
||||
// block_selected + not in list: add-to-block icon
|
||||
symName = @"eye.slash"; tint = [UIColor whiteColor];
|
||||
} else if (toggleMode) {
|
||||
symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
|
||||
tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
|
||||
} else {
|
||||
symName = @"eye"; tint = [UIColor whiteColor];
|
||||
}
|
||||
|
||||
UIButton *existing = (UIButton *)[self viewWithTag:1339];
|
||||
|
||||
if (hideForListedOwner) {
|
||||
[existing removeFromSuperview];
|
||||
return;
|
||||
}
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
|
||||
if (existing) {
|
||||
[existing setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
existing.tintColor = tint;
|
||||
return;
|
||||
}
|
||||
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1339;
|
||||
[btn setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
btn.tintColor = tint;
|
||||
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
btn.layer.cornerRadius = 18;
|
||||
btn.clipsToBounds = YES;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:self action:@selector(sciSeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(sciSeenButtonLongPressed:)];
|
||||
lp.minimumPressDuration = 0.4;
|
||||
[btn addGestureRecognizer:lp];
|
||||
[self addSubview:btn];
|
||||
UIView *anchor = [self viewWithTag:1340];
|
||||
if (anchor) {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
} else {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh when story owner changes or audio state changes
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
static char kLastPKKey;
|
||||
static char kLastExclKey;
|
||||
static char kLastAudioKey;
|
||||
|
||||
// Audio button: check if state changed
|
||||
UIButton *audioBtn = (UIButton *)[self viewWithTag:1341];
|
||||
if (audioBtn) {
|
||||
BOOL audioOn = sciIsStoryAudioEnabled();
|
||||
NSNumber *prevAudio = objc_getAssociatedObject(self, &kLastAudioKey);
|
||||
if (!prevAudio || [prevAudio boolValue] != audioOn) {
|
||||
objc_setAssociatedObject(self, &kLastAudioKey, @(audioOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshAudioButton));
|
||||
}
|
||||
}
|
||||
|
||||
// Seen button: check if owner/exclusion changed
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
|
||||
NSDictionary *info = sciOwnerInfoForView(self);
|
||||
NSString *pk = info[@"pk"] ?: @"";
|
||||
BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk];
|
||||
NSString *prev = objc_getAssociatedObject(self, &kLastPKKey);
|
||||
NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey);
|
||||
BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded);
|
||||
if (!changed) return;
|
||||
objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
|
||||
}
|
||||
|
||||
// ============ Audio toggle handler ============
|
||||
|
||||
%new - (void)sciAudioToggleTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[haptic impactOccurred];
|
||||
sciToggleStoryAudio();
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold];
|
||||
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
[sender setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
// ============ Seen button tap ============
|
||||
|
||||
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
|
||||
NSString *ownerPK = ownerInfo[@"pk"];
|
||||
BOOL inList = ownerPK && [SCIExcludedStoryUsers isInList:ownerPK];
|
||||
BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
|
||||
// Block selected + not in list: tap to ADD to block list (with confirmation)
|
||||
if (bs && !inList && ownerPK) {
|
||||
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
|
||||
UIAlertController *alert = [UIAlertController
|
||||
alertControllerWithTitle:SCILocalized(@"Add to block list?")
|
||||
message:[NSString stringWithFormat:SCILocalized(@"Story seen receipts will be blocked for @%@."), ownerInfo[@"username"] ?: @""]
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
[SCIExcludedStoryUsers addOrUpdateEntry:@{
|
||||
@"pk": ownerPK,
|
||||
@"username": ownerInfo[@"username"] ?: @"",
|
||||
@"fullName": ownerInfo[@"fullName"] ?: @""
|
||||
}];
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
|
||||
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[host presentViewController:alert animated:YES completion:nil];
|
||||
return;
|
||||
}
|
||||
|
||||
// Block selected + in list: blocked story, tap = mark seen (long-press to remove)
|
||||
if (bs && inList) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender);
|
||||
return;
|
||||
}
|
||||
|
||||
// Block all + in list: tap to remove from exclude list
|
||||
if (inList) {
|
||||
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
|
||||
NSString *alertTitle = bs ? SCILocalized(@"Remove from block list?") : SCILocalized(@"Un-exclude story seen?");
|
||||
NSString *alertMsg = bs ? [NSString stringWithFormat:@"@%@ will no longer have seen receipts blocked.", ownerInfo[@"username"] ?: @""]
|
||||
: [NSString stringWithFormat:@"@%@ will resume normal story-seen blocking.", ownerInfo[@"username"] ?: @""];
|
||||
UIAlertController *alert = [UIAlertController
|
||||
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:bs ? SCILocalized(@"Unblock") : SCILocalized(@"Un-exclude") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
|
||||
[SCIExcludedStoryUsers removePK:ownerPK];
|
||||
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
|
||||
if (bs) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
|
||||
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[host presentViewController:alert animated:YES completion:nil];
|
||||
return;
|
||||
}
|
||||
|
||||
// Toggle mode
|
||||
if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) {
|
||||
sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
[sender setImage:[UIImage systemImageNamed:(sciStorySeenToggleEnabled ? @"eye.fill" : @"eye") withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
sender.tintColor = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
|
||||
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")];
|
||||
return;
|
||||
}
|
||||
|
||||
// Button mode: mark seen once
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender);
|
||||
}
|
||||
|
||||
// ============ Seen button long-press menu ============
|
||||
|
||||
%new - (void)sciSeenButtonLongPressed:(UILongPressGestureRecognizer *)gr {
|
||||
if (gr.state != UIGestureRecognizerStateBegan) return;
|
||||
UIView *btn = gr.view;
|
||||
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
|
||||
if (!host) return;
|
||||
|
||||
// Pause story while the sheet is open
|
||||
sciPauseStoryPlayback(self);
|
||||
UIWindow *capturedWin = btn.window ?: self.window;
|
||||
if (!capturedWin) {
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) { if (w.isKeyWindow) { capturedWin = w; break; } }
|
||||
}
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
|
||||
NSString *pk = ownerInfo[@"pk"];
|
||||
NSString *username = ownerInfo[@"username"] ?: @"";
|
||||
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
|
||||
BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk];
|
||||
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
|
||||
__weak UIView *weakSelf = self;
|
||||
void (^resume)(void) = ^{ if (weakSelf) sciResumeStoryPlayback(weakSelf); };
|
||||
|
||||
UIAlertController *sheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Mark seen") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn);
|
||||
resume();
|
||||
}]];
|
||||
if (pk) {
|
||||
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen");
|
||||
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen");
|
||||
NSString *t = inList ? removeLabel : addLabel;
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:t style:inList ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
if (inList) {
|
||||
[SCIExcludedStoryUsers removePK:pk];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
|
||||
if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
|
||||
} else {
|
||||
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
|
||||
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
|
||||
}
|
||||
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
resume();
|
||||
}]];
|
||||
}
|
||||
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:^(UIAlertAction *_) {
|
||||
resume();
|
||||
}]];
|
||||
sheet.popoverPresentationController.sourceView = btn;
|
||||
sheet.popoverPresentationController.sourceRect = btn.bounds;
|
||||
[host presentViewController:sheet animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// ============ Mark seen handler ============
|
||||
|
||||
%new - (void)sciMarkSeenTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
if (sender) {
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
|
||||
}
|
||||
|
||||
@try {
|
||||
// Story path
|
||||
UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController");
|
||||
if (storyVC) {
|
||||
id sectionCtrl = sciFindSectionController(storyVC);
|
||||
id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil;
|
||||
if (!storyItem) storyItem = sciGetCurrentStoryItem(self);
|
||||
IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem);
|
||||
|
||||
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; }
|
||||
|
||||
sciAllowSeenForPK(media);
|
||||
sciSeenBypassActive = YES;
|
||||
|
||||
SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:);
|
||||
if ([storyVC respondsToSelector:delegateSel]) {
|
||||
typedef void (*Func)(id, SEL, id, id);
|
||||
((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media);
|
||||
}
|
||||
if (sectionCtrl) {
|
||||
SEL markSel = NSSelectorFromString(@"markItemAsSeen:");
|
||||
if ([sectionCtrl respondsToSelector:markSel])
|
||||
((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media);
|
||||
}
|
||||
id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager));
|
||||
id vm = sciCall(storyVC, @selector(currentViewModel));
|
||||
if (seenManager && vm) {
|
||||
SEL setSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:");
|
||||
if ([seenManager respondsToSelector:setSel]) {
|
||||
id mediaPK = sciCall(media, @selector(pk));
|
||||
id reelPK = sciCall(vm, NSSelectorFromString(@"reelPK"));
|
||||
if (!reelPK) reelPK = sciCall(vm, @selector(pk));
|
||||
if (mediaPK && reelPK) {
|
||||
typedef void (*SetFunc)(id, SEL, id, id);
|
||||
((SetFunc)objc_msgSend)(seenManager, setSel, mediaPK, reelPK);
|
||||
}
|
||||
}
|
||||
}
|
||||
sciSeenBypassActive = NO;
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked as seen") subtitle:SCILocalized(@"Will sync when leaving stories")];
|
||||
|
||||
// Advance to next story if enabled (skip when triggered programmatically via exclude)
|
||||
if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) {
|
||||
__block id secCtrl = sectionCtrl;
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciAdvanceBypassActive = YES;
|
||||
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
|
||||
if ([secCtrl respondsToSelector:advSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1);
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
__strong __typeof(weakSelf) strongSelf = weakSelf;
|
||||
UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil;
|
||||
id sc2 = vc2 ? sciFindSectionController(vc2) : nil;
|
||||
if (sc2) {
|
||||
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
if ([sc2 respondsToSelector:resumeSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
|
||||
}
|
||||
sciAdvanceBypassActive = NO;
|
||||
});
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// DM visual message path
|
||||
UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController");
|
||||
if (dmVC) {
|
||||
extern BOOL dmVisualMsgsViewedButtonEnabled;
|
||||
BOOL wasEnabled = dmVisualMsgsViewedButtonEnabled;
|
||||
dmVisualMsgsViewedButtonEnabled = YES;
|
||||
|
||||
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
|
||||
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
|
||||
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
|
||||
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
|
||||
Ivar erIvar = class_getInstanceVariable([dmVC class], "_eventResponders");
|
||||
NSArray *responders = erIvar ? object_getIvar(dmVC, erIvar) : nil;
|
||||
|
||||
if (responders && msg) {
|
||||
for (id resp in responders) {
|
||||
SEL beginSel = @selector(visualMessageViewerController:didBeginPlaybackForVisualMessage:atIndex:);
|
||||
if ([resp respondsToSelector:beginSel]) {
|
||||
typedef void (*Fn)(id, SEL, id, id, NSInteger);
|
||||
((Fn)objc_msgSend)(resp, beginSel, dmVC, msg, 0);
|
||||
}
|
||||
SEL endSel = @selector(visualMessageViewerController:didEndPlaybackForVisualMessage:atIndex:mediaCurrentTime:forNavType:);
|
||||
if ([resp respondsToSelector:endSel]) {
|
||||
typedef void (*Fn)(id, SEL, id, id, NSInteger, CGFloat, NSInteger);
|
||||
((Fn)objc_msgSend)(resp, endSel, dmVC, msg, 0, 0.0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SEL dismissSel = NSSelectorFromString(@"_didTapHeaderViewDismissButton:");
|
||||
if ([dmVC respondsToSelector:dismissSel])
|
||||
((void(*)(id,SEL,id))objc_msgSend)(dmVC, dismissSel, nil);
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
dmVisualMsgsViewedButtonEnabled = wasEnabled;
|
||||
});
|
||||
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")];
|
||||
return;
|
||||
}
|
||||
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")];
|
||||
} @catch (NSException *e) {
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]];
|
||||
}
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// ============ Chrome alpha sync ============
|
||||
|
||||
static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) return;
|
||||
UIView *cur = self_;
|
||||
while (cur) {
|
||||
for (UIView *sib in cur.superview.subviews) {
|
||||
if (![sib isKindOfClass:overlayCls]) continue;
|
||||
UIView *seen = [sib viewWithTag:1339];
|
||||
UIView *dl = [sib viewWithTag:1340];
|
||||
UIView *audio = [sib viewWithTag:1341];
|
||||
if (seen) seen.alpha = alpha;
|
||||
if (dl) dl.alpha = alpha;
|
||||
if (audio) audio.alpha = alpha;
|
||||
return;
|
||||
}
|
||||
cur = cur.superview;
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGStoryFullscreenHeaderView
|
||||
- (void)setAlpha:(CGFloat)alpha {
|
||||
%orig;
|
||||
sciSyncStoryButtonsAlpha((UIView *)self, alpha);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,46 @@
|
||||
// Shared helpers for StoryOverlayButtons.xm and DMOverlayButtons.xm.
|
||||
|
||||
#import "StoryHelpers.h"
|
||||
|
||||
// Disjoint tag spaces so viewWithTag: can't cross-hit between surfaces.
|
||||
#define SCI_STORY_EYE_TAG 1339
|
||||
#define SCI_STORY_ACTION_TAG 1340
|
||||
#define SCI_STORY_AUDIO_TAG 1341
|
||||
#define SCI_DM_ACTION_TAG 1342
|
||||
#define SCI_DM_EYE_TAG 1343
|
||||
#define SCI_DM_AUDIO_TAG 1344
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// From StoryAudioToggle.xm.
|
||||
void sciToggleStoryAudio(void);
|
||||
BOOL sciIsStoryAudioEnabled(void);
|
||||
void sciInitStoryAudioState(void);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
extern BOOL dmVisualMsgsViewedButtonEnabled;
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
// Context detection / view lookup.
|
||||
BOOL sciOverlayIsInDMContext(UIView *overlay);
|
||||
UIView * _Nullable sciFindOverlayInView(UIView *root);
|
||||
|
||||
// DM disappearing-media actions.
|
||||
NSURL * _Nullable sciDMMediaURL(UIViewController *dmVC, BOOL *outIsVideo);
|
||||
void sciDMExpandMedia(UIViewController *dmVC);
|
||||
void sciDMShareMedia(UIViewController *dmVC);
|
||||
void sciDMDownloadMedia(UIViewController *dmVC);
|
||||
void sciDMMarkCurrentAsViewed(UIViewController *dmVC);
|
||||
|
||||
// Opens RyukGram settings on the Messages tab.
|
||||
void sciOpenMessagesSettings(UIView *source);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,163 @@
|
||||
#import "OverlayHelpers.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
|
||||
// MARK: - Context detection
|
||||
|
||||
BOOL sciOverlayIsInDMContext(UIView *overlay) {
|
||||
Class dmCls = NSClassFromString(@"IGDirectVisualMessageViewerController");
|
||||
if (!dmCls) return NO;
|
||||
|
||||
UIResponder *r = overlay.nextResponder;
|
||||
while (r) {
|
||||
if ([r isKindOfClass:dmCls]) return YES;
|
||||
r = r.nextResponder;
|
||||
}
|
||||
|
||||
// Fallback: _gestureDelegate ivar is the DM VC in DM contexts.
|
||||
static Ivar gdIvar = NULL;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
Class c = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (c) gdIvar = class_getInstanceVariable(c, "_gestureDelegate");
|
||||
});
|
||||
if (gdIvar) {
|
||||
id d = object_getIvar(overlay, gdIvar);
|
||||
if (d && [d isKindOfClass:dmCls]) return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
UIView *sciFindOverlayInView(UIView *root) {
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls || !root) return nil;
|
||||
if ([root isKindOfClass:overlayCls]) return root;
|
||||
for (UIView *sub in root.subviews) {
|
||||
UIView *found = sciFindOverlayInView(sub);
|
||||
if (found) return found;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// MARK: - DM media URL
|
||||
|
||||
NSURL *sciDMMediaURL(UIViewController *dmVC, BOOL *outIsVideo) {
|
||||
if (!dmVC) return nil;
|
||||
|
||||
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
|
||||
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
|
||||
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
|
||||
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
|
||||
if (!msg) return nil;
|
||||
|
||||
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
|
||||
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
|
||||
Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil;
|
||||
id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil;
|
||||
if (!visMedia) return nil;
|
||||
|
||||
@try {
|
||||
id rawVideo = [msg valueForKey:@"rawVideo"];
|
||||
if (rawVideo) {
|
||||
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
|
||||
if (url) { if (outIsVideo) *outIsVideo = YES; return url; }
|
||||
}
|
||||
} @catch (__unused NSException *e) {}
|
||||
|
||||
Ivar pi = class_getInstanceVariable([visMedia class], "_photo_photo");
|
||||
id photo = pi ? object_getIvar(visMedia, pi) : nil;
|
||||
if (photo) {
|
||||
if (outIsVideo) *outIsVideo = NO;
|
||||
return [SCIUtils getPhotoUrl:photo];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// MARK: - DM actions
|
||||
|
||||
// Strong refs — SCIDownloadDelegate needs to outlive the download.
|
||||
static SCIDownloadDelegate *sciDMShareDelegate = nil;
|
||||
static SCIDownloadDelegate *sciDMDownloadDelegate = nil;
|
||||
|
||||
void sciDMExpandMedia(UIViewController *dmVC) {
|
||||
BOOL isVideo = NO;
|
||||
NSURL *url = sciDMMediaURL(dmVC, &isVideo);
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
|
||||
if (isVideo) [SCIMediaViewer showWithVideoURL:url photoURL:nil caption:nil];
|
||||
else [SCIMediaViewer showWithVideoURL:nil photoURL:url caption:nil];
|
||||
}
|
||||
|
||||
void sciDMShareMedia(UIViewController *dmVC) {
|
||||
BOOL isVideo = NO;
|
||||
NSURL *url = sciDMMediaURL(dmVC, &isVideo);
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
|
||||
sciDMShareDelegate = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:YES];
|
||||
[sciDMShareDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
|
||||
}
|
||||
|
||||
void sciDMDownloadMedia(UIViewController *dmVC) {
|
||||
BOOL isVideo = NO;
|
||||
NSURL *url = sciDMMediaURL(dmVC, &isVideo);
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; }
|
||||
sciDMDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:YES];
|
||||
[sciDMDownloadDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil];
|
||||
}
|
||||
|
||||
// Flips dmVisualMsgsViewedButtonEnabled for ~1s so VisualMsgModifier lets the
|
||||
// begin/end playback callbacks through, then restores.
|
||||
void sciDMMarkCurrentAsViewed(UIViewController *dmVC) {
|
||||
if (!dmVC) return;
|
||||
|
||||
BOOL wasEnabled = dmVisualMsgsViewedButtonEnabled;
|
||||
dmVisualMsgsViewedButtonEnabled = YES;
|
||||
|
||||
Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource");
|
||||
id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil;
|
||||
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
|
||||
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
|
||||
Ivar erIvar = class_getInstanceVariable([dmVC class], "_eventResponders");
|
||||
NSArray *responders = erIvar ? object_getIvar(dmVC, erIvar) : nil;
|
||||
|
||||
if (responders && msg) {
|
||||
for (id resp in responders) {
|
||||
SEL beginSel = @selector(visualMessageViewerController:didBeginPlaybackForVisualMessage:atIndex:);
|
||||
if ([resp respondsToSelector:beginSel]) {
|
||||
typedef void (*Fn)(id, SEL, id, id, NSInteger);
|
||||
((Fn)objc_msgSend)(resp, beginSel, dmVC, msg, 0);
|
||||
}
|
||||
SEL endSel = @selector(visualMessageViewerController:didEndPlaybackForVisualMessage:atIndex:mediaCurrentTime:forNavType:);
|
||||
if ([resp respondsToSelector:endSel]) {
|
||||
typedef void (*Fn)(id, SEL, id, id, NSInteger, CGFloat, NSInteger);
|
||||
((Fn)objc_msgSend)(resp, endSel, dmVC, msg, 0, 0.0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SEL dismissSel = NSSelectorFromString(@"_didTapHeaderViewDismissButton:");
|
||||
if ([dmVC respondsToSelector:dismissSel]) {
|
||||
((void(*)(id,SEL,id))objc_msgSend)(dmVC, dismissSel, nil);
|
||||
}
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
dmVisualMsgsViewedButtonEnabled = wasEnabled;
|
||||
});
|
||||
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")];
|
||||
}
|
||||
|
||||
// MARK: - Settings shortcut
|
||||
|
||||
void sciOpenMessagesSettings(UIView *source) {
|
||||
UIWindow *win = source.window;
|
||||
if (!win) {
|
||||
for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) {
|
||||
if (![scene isKindOfClass:[UIWindowScene class]]) continue;
|
||||
for (UIWindow *w in ((UIWindowScene *)scene).windows) {
|
||||
if (w.isKeyWindow) { win = w; break; }
|
||||
}
|
||||
if (win) break;
|
||||
}
|
||||
}
|
||||
if (!win) return;
|
||||
[SCIUtils showSettingsVC:win atTopLevelEntry:SCILocalized(@"Messages")];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// Story interaction pipeline — confirm gate + seen/advance per policy table.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIStoryInteraction) {
|
||||
SCIStoryInteractionLike,
|
||||
SCIStoryInteractionEmojiReaction,
|
||||
SCIStoryInteractionTextReply,
|
||||
};
|
||||
|
||||
void sciStoryInteraction(SCIStoryInteraction type,
|
||||
void (^action)(void),
|
||||
void (^_Nullable uiRevert)(void),
|
||||
void (^_Nullable uiReapply)(void));
|
||||
|
||||
// Side-effects only (seen/advance). No confirm, no action.
|
||||
void sciStoryInteractionSideEffects(SCIStoryInteraction type);
|
||||
@@ -0,0 +1,131 @@
|
||||
#import "SCIStoryInteractionPipeline.h"
|
||||
#import "StoryHelpers.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/message.h>
|
||||
#import <mach/mach_time.h>
|
||||
|
||||
extern __weak UIViewController *sciActiveStoryVC;
|
||||
extern BOOL sciAdvanceBypassActive;
|
||||
|
||||
#pragma mark - Policy table
|
||||
|
||||
typedef struct {
|
||||
NSString *confirmPref;
|
||||
NSString *seenPref;
|
||||
NSString *advancePref;
|
||||
NSTimeInterval advanceDelay;
|
||||
} SCIStoryPolicy;
|
||||
|
||||
static SCIStoryPolicy sciPolicyForType(SCIStoryInteraction type) {
|
||||
switch (type) {
|
||||
case SCIStoryInteractionLike:
|
||||
return (SCIStoryPolicy){
|
||||
@"story_like_confirm",
|
||||
@"seen_on_story_like",
|
||||
@"advance_on_story_like",
|
||||
0.3
|
||||
};
|
||||
case SCIStoryInteractionEmojiReaction:
|
||||
return (SCIStoryPolicy){
|
||||
@"emoji_reaction_confirm",
|
||||
@"seen_on_story_reply",
|
||||
@"advance_on_story_reply",
|
||||
0.4
|
||||
};
|
||||
case SCIStoryInteractionTextReply:
|
||||
return (SCIStoryPolicy){
|
||||
nil,
|
||||
@"seen_on_story_reply",
|
||||
@"advance_on_story_reply",
|
||||
0.4
|
||||
};
|
||||
}
|
||||
return (SCIStoryPolicy){ nil, nil, nil, 0.3 };
|
||||
}
|
||||
|
||||
#pragma mark - Side effects
|
||||
|
||||
static UIView *sciFindOverlay(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
Class cls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!cls) return nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:cls]) return v;
|
||||
for (UIView *s in v.subviews) [stack addObject:s];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciMarkSeen(NSString *prefKey) {
|
||||
if (!prefKey || ![SCIUtils getBoolPref:prefKey]) return;
|
||||
UIView *overlay = sciFindOverlay(sciActiveStoryVC);
|
||||
if (!overlay) return;
|
||||
SEL sel = NSSelectorFromString(@"sciStoryMarkSeenTapped:");
|
||||
if ([overlay respondsToSelector:sel])
|
||||
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
|
||||
}
|
||||
|
||||
static uint64_t sciLastAdvanceTime = 0;
|
||||
|
||||
static void sciAdvance(NSString *prefKey, NSTimeInterval delay) {
|
||||
if (!prefKey || ![SCIUtils getBoolPref:prefKey]) return;
|
||||
UIViewController *vc = sciActiveStoryVC;
|
||||
if (!vc) return;
|
||||
id ctrl = sciFindSectionController(vc);
|
||||
if (!ctrl) return;
|
||||
|
||||
uint64_t now = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
|
||||
if (now - sciLastAdvanceTime < 500000000ULL) return;
|
||||
sciLastAdvanceTime = now;
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciAdvanceBypassActive = YES;
|
||||
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
|
||||
if ([ctrl respondsToSelector:advSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(ctrl, advSel, 1);
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
id c2 = vc ? sciFindSectionController(vc) : nil;
|
||||
if (c2) {
|
||||
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
if ([c2 respondsToSelector:resumeSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(c2, resumeSel, 0);
|
||||
}
|
||||
sciAdvanceBypassActive = NO;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static void sciFireSideEffects(SCIStoryPolicy policy) {
|
||||
sciMarkSeen(policy.seenPref);
|
||||
sciAdvance(policy.advancePref, policy.advanceDelay);
|
||||
}
|
||||
|
||||
#pragma mark - Pipeline
|
||||
|
||||
void sciStoryInteraction(SCIStoryInteraction type,
|
||||
void (^action)(void),
|
||||
void (^_Nullable uiRevert)(void),
|
||||
void (^_Nullable uiReapply)(void)) {
|
||||
SCIStoryPolicy policy = sciPolicyForType(type);
|
||||
|
||||
if (policy.confirmPref && [SCIUtils getBoolPref:policy.confirmPref]) {
|
||||
if (uiRevert) uiRevert();
|
||||
[SCIUtils showConfirmation:^{
|
||||
if (uiReapply) uiReapply();
|
||||
if (action) action();
|
||||
sciFireSideEffects(policy);
|
||||
}];
|
||||
return;
|
||||
}
|
||||
|
||||
if (action) action();
|
||||
sciFireSideEffects(policy);
|
||||
}
|
||||
|
||||
void sciStoryInteractionSideEffects(SCIStoryInteraction type) {
|
||||
sciFireSideEffects(sciPolicyForType(type));
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Tweak.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "SCIExcludedThreads.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
@@ -197,7 +198,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
|
||||
if (w.isKeyWindow) { win = w; break; }
|
||||
}
|
||||
}
|
||||
[SCIUtils showSettingsVC:win atTopLevelEntry:@"Messages"];
|
||||
[SCIUtils showSettingsVC:win atTopLevelEntry:SCILocalized(@"Messages")];
|
||||
}];
|
||||
[items addObject:openSettings];
|
||||
|
||||
@@ -309,11 +310,13 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
|
||||
BOOL navInList = navThreadId && [SCIExcludedThreads isInList:navThreadId];
|
||||
|
||||
if ([SCIUtils getBoolPref:@"remove_lastseen"] && !navExcluded) {
|
||||
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"eye"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
|
||||
SCIChromeButton *inner = nil;
|
||||
UIBarButtonItem *seenButton = SCIChromeBarButtonItem(@"eye", 18, self, @selector(seenButtonHandler:), &inner);
|
||||
seenButton.accessibilityIdentifier = @"sci-seen-btn";
|
||||
if (sciIsSeenToggleMode())
|
||||
[seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
|
||||
seenButton.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
UIColor *tint = UIColor.labelColor;
|
||||
if (sciIsSeenToggleMode()) tint = dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
|
||||
inner.iconTint = tint;
|
||||
inner.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
[new_items addObject:seenButton];
|
||||
}
|
||||
|
||||
@@ -328,25 +331,23 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
|
||||
BOOL showAddBtn = blockSelected && !navInList;
|
||||
if (showListButton && (showRemoveBtn || showAddBtn)) {
|
||||
SEL action = showRemoveBtn ? @selector(sciUnexcludeButtonHandler:) : @selector(sciAddToListHandler:);
|
||||
UIBarButtonItem *listBtn = [[UIBarButtonItem alloc]
|
||||
initWithImage:[UIImage systemImageNamed:showRemoveBtn ? @"eye.slash.fill" : @"eye.slash"]
|
||||
style:UIBarButtonItemStylePlain
|
||||
target:self
|
||||
action:action];
|
||||
NSString *sym = showRemoveBtn ? @"eye.slash.fill" : @"eye.slash";
|
||||
SCIChromeButton *inner = nil;
|
||||
UIBarButtonItem *listBtn = SCIChromeBarButtonItem(sym, 18, self, action, &inner);
|
||||
listBtn.accessibilityIdentifier = @"sci-unex-btn";
|
||||
listBtn.tintColor = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
|
||||
listBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
inner.iconTint = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
|
||||
inner.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
[new_items addObject:listBtn];
|
||||
}
|
||||
|
||||
// Replay toggle: in eye menu when eye button exists, standalone button otherwise
|
||||
BOOL eyeButtonOn = [SCIUtils getBoolPref:@"remove_lastseen"];
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded && !eyeButtonOn) {
|
||||
UIBarButtonItem *replayBtn = [[UIBarButtonItem alloc]
|
||||
initWithImage:[UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"]
|
||||
style:UIBarButtonItemStylePlain target:self action:@selector(sciReplayToggleHandler:)];
|
||||
NSString *sym = dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill";
|
||||
SCIChromeButton *inner = nil;
|
||||
UIBarButtonItem *replayBtn = SCIChromeBarButtonItem(sym, 18, self, @selector(sciReplayToggleHandler:), &inner);
|
||||
replayBtn.accessibilityIdentifier = @"sci-visual-btn";
|
||||
replayBtn.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
|
||||
inner.iconTint = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
|
||||
[new_items addObject:replayBtn];
|
||||
}
|
||||
|
||||
@@ -355,10 +356,14 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
|
||||
|
||||
// ============ MESSAGES SEEN BUTTON ============
|
||||
|
||||
%new - (void)seenButtonHandler:(UIBarButtonItem *)sender {
|
||||
%new - (void)seenButtonHandler:(id)sender {
|
||||
UIBarButtonItem *barItem = [sender isKindOfClass:[UIBarButtonItem class]] ? (UIBarButtonItem *)sender : nil;
|
||||
SCIChromeButton *inner = [sender isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)sender : SCIChromeButtonForBarItem(barItem);
|
||||
if (sciIsSeenToggleMode()) {
|
||||
dmSeenToggleEnabled = !dmSeenToggleEnabled;
|
||||
[sender setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
|
||||
UIColor *tint = dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
|
||||
if (inner) inner.iconTint = tint;
|
||||
else [barItem setTintColor:tint];
|
||||
if (dmSeenToggleEnabled) {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
@@ -377,13 +382,19 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
|
||||
// Rebuild menu so toggle text updates
|
||||
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
NSString *tid = sciThreadIdForVC(navNearestVC);
|
||||
sender.menu = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window);
|
||||
UIMenu *rebuilt = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window);
|
||||
if (inner) inner.menu = rebuilt;
|
||||
else if (barItem) barItem.menu = rebuilt;
|
||||
}
|
||||
|
||||
%new - (void)sciReplayToggleHandler:(UIBarButtonItem *)sender {
|
||||
%new - (void)sciReplayToggleHandler:(id)sender {
|
||||
UIBarButtonItem *barItem = [sender isKindOfClass:[UIBarButtonItem class]] ? (UIBarButtonItem *)sender : nil;
|
||||
SCIChromeButton *inner = [sender isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)sender : SCIChromeButtonForBarItem(barItem);
|
||||
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
|
||||
sender.image = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
|
||||
sender.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
|
||||
NSString *sym = dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill";
|
||||
UIColor *tint = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
|
||||
if (inner) { inner.symbolName = sym; inner.iconTint = tint; }
|
||||
else if (barItem) { barItem.image = [UIImage systemImageNamed:sym]; barItem.tintColor = tint; }
|
||||
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
|
||||
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Mark seen + advance when replying or reacting to a story.
|
||||
// Story reply + emoji reaction hooks. Routes through the interaction pipeline.
|
||||
|
||||
#import "SCIStoryInteractionPipeline.h"
|
||||
#import "../../Utils.h"
|
||||
#import "StoryHelpers.h"
|
||||
#import <objc/message.h>
|
||||
@@ -9,106 +10,43 @@
|
||||
extern __weak UIViewController *sciActiveStoryVC;
|
||||
extern BOOL sciAdvanceBypassActive;
|
||||
|
||||
static UIView *sciFindOverlayForStoryVC(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) return nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:overlayCls]) return v;
|
||||
for (UIView *s in v.subviews) [stack addObject:s];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciMarkSeenOnReply(void) {
|
||||
if (![SCIUtils getBoolPref:@"seen_on_story_reply"]) return;
|
||||
UIView *overlay = sciFindOverlayForStoryVC(sciActiveStoryVC);
|
||||
if (!overlay) return;
|
||||
SEL sel = @selector(sciMarkSeenTapped:);
|
||||
if ([overlay respondsToSelector:sel])
|
||||
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
|
||||
}
|
||||
|
||||
static uint64_t sciLastReplyAdvanceTime = 0;
|
||||
|
||||
static void sciAdvanceOnReply(void) {
|
||||
if (![SCIUtils getBoolPref:@"advance_on_story_reply"]) return;
|
||||
UIViewController *storyVC = sciActiveStoryVC;
|
||||
if (!storyVC) return;
|
||||
id sectionCtrl = sciFindSectionController(storyVC);
|
||||
if (!sectionCtrl) return;
|
||||
|
||||
// Dedup across multiple hooks firing for the same event
|
||||
uint64_t now = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW);
|
||||
if (now - sciLastReplyAdvanceTime < 500000000ULL) return;
|
||||
sciLastReplyAdvanceTime = now;
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciAdvanceBypassActive = YES;
|
||||
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
|
||||
if ([sectionCtrl respondsToSelector:advSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sectionCtrl, advSel, 1);
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
id sc2 = storyVC ? sciFindSectionController(storyVC) : nil;
|
||||
if (sc2) {
|
||||
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
if ([sc2 respondsToSelector:resumeSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
|
||||
}
|
||||
sciAdvanceBypassActive = NO;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static void sciOnStoryReply(void) {
|
||||
sciMarkSeenOnReply();
|
||||
sciAdvanceOnReply();
|
||||
}
|
||||
|
||||
// Text reply — IGDirectComposer is shared with DMs, gate by active story VC.
|
||||
%hook IGDirectComposer
|
||||
- (void)_didTapSend:(id)arg {
|
||||
%orig;
|
||||
if (sciActiveStoryVC) sciOnStoryReply();
|
||||
if (sciActiveStoryVC) sciStoryInteraction(SCIStoryInteractionTextReply, nil, nil, nil);
|
||||
}
|
||||
- (void)_send {
|
||||
%orig;
|
||||
if (sciActiveStoryVC) sciOnStoryReply();
|
||||
if (sciActiveStoryVC) sciStoryInteraction(SCIStoryInteractionTextReply, nil, nil, nil);
|
||||
}
|
||||
%end
|
||||
|
||||
// Composer emoji reaction buttons (forwarded to the Swift footer delegate)
|
||||
// Composer emoji reaction buttons
|
||||
static void (*orig_footerEmojiQuick)(id, SEL, id, id);
|
||||
static void new_footerEmojiQuick(id self, SEL _cmd, id inputView, id btn) {
|
||||
orig_footerEmojiQuick(self, _cmd, inputView, btn);
|
||||
sciOnStoryReply();
|
||||
sciStoryInteraction(SCIStoryInteractionEmojiReaction,
|
||||
^{ orig_footerEmojiQuick(self, _cmd, inputView, btn); }, nil, nil);
|
||||
}
|
||||
|
||||
static void (*orig_footerEmojiReaction)(id, SEL, id, id);
|
||||
static void new_footerEmojiReaction(id self, SEL _cmd, id inputView, id btn) {
|
||||
orig_footerEmojiReaction(self, _cmd, inputView, btn);
|
||||
sciOnStoryReply();
|
||||
sciStoryInteraction(SCIStoryInteractionEmojiReaction,
|
||||
^{ orig_footerEmojiReaction(self, _cmd, inputView, btn); }, nil, nil);
|
||||
}
|
||||
|
||||
// Swipe-up quick reactions tray
|
||||
// Swipe-up quick reactions. qrCtrl → qrDelegate internally, gate only qrCtrl.
|
||||
static void (*orig_qrCtrlDidTapEmoji)(id, SEL, id, id, id);
|
||||
static void new_qrCtrlDidTapEmoji(id self, SEL _cmd, id view, id sourceBtn, id emoji) {
|
||||
orig_qrCtrlDidTapEmoji(self, _cmd, view, sourceBtn, emoji);
|
||||
sciOnStoryReply();
|
||||
sciStoryInteraction(SCIStoryInteractionEmojiReaction,
|
||||
^{ orig_qrCtrlDidTapEmoji(self, _cmd, view, sourceBtn, emoji); }, nil, nil);
|
||||
}
|
||||
|
||||
static void (*orig_qrDelegateDidTapEmoji)(id, SEL, id, id, id);
|
||||
static void new_qrDelegateDidTapEmoji(id self, SEL _cmd, id ctrl, id sourceBtn, id emoji) {
|
||||
orig_qrDelegateDidTapEmoji(self, _cmd, ctrl, sourceBtn, emoji);
|
||||
sciOnStoryReply();
|
||||
}
|
||||
|
||||
// Swift classes aren't guaranteed to be registered at %ctor time — install
|
||||
// lazily on first overlay appearance as a fallback.
|
||||
static void sciInstallReplyHooks(void) {
|
||||
static BOOL installed = NO;
|
||||
if (installed) return;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Story audio mute/unmute toggle. Posts mute-switch-state-changed to toggle
|
||||
// IG's audio. Reads _audioEnabled on IGAudioStatusAnnouncer for icon state.
|
||||
// Story audio mute/unmute toggle.
|
||||
// Flips IGAudioStatusAnnouncer private state then fans out to listeners
|
||||
// via the two IGUltralightAnnouncer sub-forwarders (426 dropped the old
|
||||
// mute-switch notification).
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import "StoryHelpers.h"
|
||||
@@ -12,14 +14,34 @@ extern "C" void sciRefreshAllVisibleOverlays(UIViewController *);
|
||||
|
||||
static id sciAudioAnnouncer = nil;
|
||||
|
||||
static id sciReadIvar(id obj, const char *name) {
|
||||
if (!obj) return nil;
|
||||
Ivar iv = class_getInstanceVariable([obj class], name);
|
||||
if (!iv) return nil;
|
||||
return object_getIvar(obj, iv);
|
||||
}
|
||||
|
||||
static BOOL sciIGAudioEnabled(void) {
|
||||
if (!sciAudioAnnouncer) return NO;
|
||||
SEL s = NSSelectorFromString(@"isAudioEnabledForSoundBehavior:");
|
||||
if ([sciAudioAnnouncer respondsToSelector:s]) {
|
||||
typedef BOOL (*Fn)(id, SEL, NSInteger);
|
||||
return ((Fn)objc_msgSend)(sciAudioAnnouncer, s, 1);
|
||||
}
|
||||
Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled");
|
||||
if (!ivar) return NO;
|
||||
ptrdiff_t offset = ivar_getOffset(ivar);
|
||||
return *(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset);
|
||||
}
|
||||
|
||||
static void sciWriteAudioEnabled(BOOL value) {
|
||||
if (!sciAudioAnnouncer) return;
|
||||
Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled");
|
||||
if (!ivar) return;
|
||||
ptrdiff_t offset = ivar_getOffset(ivar);
|
||||
*(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset) = value;
|
||||
}
|
||||
|
||||
// ============ Volume KVO ============
|
||||
|
||||
@interface _SciVolumeObserver : NSObject
|
||||
@@ -41,12 +63,29 @@ extern "C" {
|
||||
BOOL sciStoryAudioBypass = NO;
|
||||
|
||||
void sciToggleStoryAudio(void) {
|
||||
if (!sciAudioAnnouncer) return;
|
||||
|
||||
BOOL on = sciIGAudioEnabled();
|
||||
BOOL wanted = !on;
|
||||
sciStoryAudioBypass = YES;
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"mute-switch-state-changed"
|
||||
object:nil
|
||||
userInfo:@{@"mute-state": @(on ? 0 : 1)}];
|
||||
|
||||
sciWriteAudioEnabled(wanted);
|
||||
|
||||
// 2 = user-enabled, 1 = user-disabled.
|
||||
Ivar stickIv = class_getInstanceVariable([sciAudioAnnouncer class], "_stickySoundState");
|
||||
if (stickIv) {
|
||||
ptrdiff_t off = ivar_getOffset(stickIv);
|
||||
NSInteger *p = (NSInteger *)((char *)(__bridge void *)sciAudioAnnouncer + off);
|
||||
*p = wanted ? 2 : 1;
|
||||
}
|
||||
|
||||
SEL notify = NSSelectorFromString(@"audioStatusDidChangeIsAudioEnabled:forReason:");
|
||||
typedef void (*NotifyFn)(id, SEL, BOOL, NSInteger);
|
||||
id subA = sciReadIvar(sciAudioAnnouncer, "_announcerForDefaultBehaviors");
|
||||
id subB = sciReadIvar(sciAudioAnnouncer, "_announcerForIgnoreUserPreferenceAndMatchDeviceState");
|
||||
if (subA) ((NotifyFn)objc_msgSend)(subA, notify, wanted, 0);
|
||||
if (subB) ((NotifyFn)objc_msgSend)(subB, notify, wanted, 0);
|
||||
|
||||
sciStoryAudioBypass = NO;
|
||||
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// Story like button hook. Routes through the interaction pipeline.
|
||||
|
||||
#import "SCIStoryInteractionPipeline.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static void (*orig_sciStoryLikeTap)(id, SEL, id);
|
||||
static void new_sciStoryLikeTap(id self, SEL _cmd, id button) {
|
||||
BOOL isSelected = [button isKindOfClass:[UIButton class]] ? [(UIButton *)button isSelected] : NO;
|
||||
if (!isSelected) { orig_sciStoryLikeTap(self, _cmd, button); return; }
|
||||
|
||||
UIButton *btn = (UIButton *)button;
|
||||
SEL setLiked = NSSelectorFromString(@"setIsLiked:animated:");
|
||||
|
||||
sciStoryInteraction(SCIStoryInteractionLike,
|
||||
^{ orig_sciStoryLikeTap(self, _cmd, button); },
|
||||
^{
|
||||
[UIView performWithoutAnimation:^{
|
||||
[btn setSelected:NO];
|
||||
if ([btn respondsToSelector:setLiked])
|
||||
((void(*)(id, SEL, BOOL, BOOL))objc_msgSend)(btn, setLiked, NO, NO);
|
||||
}];
|
||||
},
|
||||
^{
|
||||
[btn setSelected:YES];
|
||||
if ([btn respondsToSelector:setLiked])
|
||||
((void(*)(id, SEL, BOOL, BOOL))objc_msgSend)(btn, setLiked, YES, YES);
|
||||
});
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = NSClassFromString(@"_TtC22IGStoryLikesController38IGStoryLikesInteractionControllingImpl");
|
||||
if (!cls) cls = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(@"handleStoryLikeTapWith:");
|
||||
if (!class_getInstanceMethod(cls, sel)) sel = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
|
||||
if (!class_getInstanceMethod(cls, sel)) return;
|
||||
MSHookMessageEx(cls, sel, (IMP)new_sciStoryLikeTap, (IMP *)&orig_sciStoryLikeTap);
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../SCIImageCache.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import "StoryHelpers.h"
|
||||
#import <objc/runtime.h>
|
||||
@@ -358,21 +359,15 @@ static NSDictionary *sciMentionUserInfo(id mention) {
|
||||
avatar.tintColor = [UIColor tertiaryLabelColor];
|
||||
|
||||
if (picURL) {
|
||||
NSURL *url = [picURL copy];
|
||||
NSInteger row = indexPath.row;
|
||||
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
|
||||
NSData *data = [NSData dataWithContentsOfURL:url];
|
||||
if (!data) return;
|
||||
UIImage *img = [UIImage imageWithData:data];
|
||||
[SCIImageCache loadImageFromURL:picURL completion:^(UIImage *img) {
|
||||
if (!img) return;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UITableViewCell *c = [tableView cellForRowAtIndexPath:
|
||||
[NSIndexPath indexPathForRow:row inSection:0]];
|
||||
if (!c) return;
|
||||
UIImageView *av = [c.contentView viewWithTag:kAvTag];
|
||||
if (av) { av.image = img; av.tintColor = nil; }
|
||||
});
|
||||
});
|
||||
UITableViewCell *c = [tableView cellForRowAtIndexPath:
|
||||
[NSIndexPath indexPathForRow:row inSection:0]];
|
||||
if (!c) return;
|
||||
UIImageView *av = [c.contentView viewWithTag:kAvTag];
|
||||
if (av) { av.image = img; av.tintColor = nil; }
|
||||
}];
|
||||
}
|
||||
|
||||
[followBtn removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
// Story overlay buttons — action / audio / eye (tags 1339–1341).
|
||||
// Early-exits in DM context; DMOverlayButtons.xm handles that surface.
|
||||
|
||||
#import "OverlayHelpers.h"
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
#import "../../SCIChrome.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import "../../ActionButton/SCIActionMenu.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
|
||||
extern "C" BOOL sciSeenBypassActive;
|
||||
extern "C" BOOL sciAdvanceBypassActive;
|
||||
extern "C" void sciAllowSeenForPK(id);
|
||||
extern "C" BOOL sciStorySeenToggleEnabled;
|
||||
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC);
|
||||
extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC);
|
||||
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
|
||||
extern "C" NSDictionary *sciOwnerInfoForView(UIView *view);
|
||||
extern "C" void sciShowStoryMentions(UIViewController *, UIView *);
|
||||
|
||||
// MARK: - Playback control
|
||||
|
||||
static void sciPauseStoryPlayback(UIView *sourceView) {
|
||||
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
|
||||
if (!storyVC) return;
|
||||
id sc = sciFindSectionController(storyVC);
|
||||
|
||||
SEL pauseSel = NSSelectorFromString(@"pauseWithReason:");
|
||||
if (sc && [sc respondsToSelector:pauseSel]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, pauseSel, 10);
|
||||
return;
|
||||
}
|
||||
if ([storyVC respondsToSelector:pauseSel]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, pauseSel, 10);
|
||||
}
|
||||
}
|
||||
|
||||
static void sciResumeStoryPlayback(UIView *sourceView) {
|
||||
UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController");
|
||||
if (!storyVC) return;
|
||||
id sc = sciFindSectionController(storyVC);
|
||||
|
||||
SEL resumeSel1 = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
SEL resumeSel2 = NSSelectorFromString(@"tryResumePlayback");
|
||||
if (sc && [sc respondsToSelector:resumeSel1]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, resumeSel1, 0);
|
||||
return;
|
||||
}
|
||||
if ([storyVC respondsToSelector:resumeSel2]) {
|
||||
((void(*)(id, SEL))objc_msgSend)(storyVC, resumeSel2);
|
||||
return;
|
||||
}
|
||||
if ([storyVC respondsToSelector:resumeSel1]) {
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, resumeSel1, 0);
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Overlay hook
|
||||
|
||||
%group StoryOverlayGroup
|
||||
|
||||
%hook IGStoryFullscreenOverlayView
|
||||
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!self.superview) return;
|
||||
|
||||
// Strip stale tags up-front so nothing flashes when this overlay
|
||||
// turns out to belong to a DM viewer.
|
||||
UIView *sA = [self viewWithTag:SCI_STORY_ACTION_TAG]; if (sA) [sA removeFromSuperview];
|
||||
UIView *sE = [self viewWithTag:SCI_STORY_EYE_TAG]; if (sE) [sE removeFromSuperview];
|
||||
UIView *sU = [self viewWithTag:SCI_STORY_AUDIO_TAG]; if (sU) [sU removeFromSuperview];
|
||||
|
||||
// Defer one tick — responder chain isn't complete yet, so the DM
|
||||
// context check needs to run after the current runloop iteration.
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
__strong __typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf || !strongSelf.superview) return;
|
||||
if (sciOverlayIsInDMContext(strongSelf)) return;
|
||||
((void(*)(id, SEL))objc_msgSend)(strongSelf, @selector(sciInstallStoryOverlayButtons));
|
||||
});
|
||||
}
|
||||
|
||||
%new - (void)sciInstallStoryOverlayButtons {
|
||||
if (!self.superview) return;
|
||||
|
||||
// --- Action button (tag 1340) ---
|
||||
UIView *staleAction = [self viewWithTag:SCI_STORY_ACTION_TAG];
|
||||
if (staleAction) {
|
||||
@try { [staleAction removeObserver:self forKeyPath:@"highlighted"]; } @catch (__unused id e) {}
|
||||
[staleAction removeFromSuperview];
|
||||
}
|
||||
if ([SCIUtils getBoolPref:@"stories_action_button"]) {
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:18 diameter:36];
|
||||
btn.tag = SCI_STORY_ACTION_TAG;
|
||||
[self addSubview:btn];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
|
||||
SCIActionMediaProvider provider = ^id (UIView *sourceView) {
|
||||
sciPauseStoryPlayback(sourceView);
|
||||
id item = sciGetCurrentStoryItem(sourceView);
|
||||
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) return item;
|
||||
id extracted = sciExtractMediaFromItem(item);
|
||||
return extracted ?: (id)kCFNull;
|
||||
};
|
||||
|
||||
[SCIActionButton configureButton:btn
|
||||
context:SCIActionContextStories
|
||||
prefKey:@"stories_action_default"
|
||||
mediaProvider:provider];
|
||||
|
||||
// Resume playback when the native UIMenu dismisses.
|
||||
[btn addObserver:self forKeyPath:@"highlighted"
|
||||
options:NSKeyValueObservingOptionNew context:NULL];
|
||||
|
||||
// Reel items provider — used by SCIMediaActions for "download all".
|
||||
static const void *kStoryReelItemsProvider = &kStoryReelItemsProvider;
|
||||
objc_setAssociatedObject(btn, kStoryReelItemsProvider, ^NSArray *(UIView *src) {
|
||||
UIViewController *storyVC = sciFindVC(src, @"IGStoryViewerViewController");
|
||||
if (!storyVC) return nil;
|
||||
id vm = sciCall(storyVC, @selector(currentViewModel));
|
||||
if (!vm) return nil;
|
||||
|
||||
for (NSString *sel in @[@"items", @"storyItems", @"reelItems", @"mediaItems", @"allItems"]) {
|
||||
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
|
||||
@try {
|
||||
id val = ((id(*)(id,SEL))objc_msgSend)(vm, NSSelectorFromString(sel));
|
||||
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) return val;
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
|
||||
Class mc = NSClassFromString(@"IGMedia");
|
||||
unsigned int cnt = 0;
|
||||
Ivar *ivs = class_copyIvarList(object_getClass(vm), &cnt);
|
||||
for (unsigned int i = 0; i < cnt; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivs[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(vm, ivs[i]);
|
||||
if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) {
|
||||
id first = [(NSArray *)val firstObject];
|
||||
if (mc && [first isKindOfClass:mc]) { free(ivs); return val; }
|
||||
IGMedia *extracted = sciExtractMediaFromItem(first);
|
||||
if (extracted) { free(ivs); return val; }
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivs) free(ivs);
|
||||
return nil;
|
||||
}, OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
}
|
||||
|
||||
// --- Audio toggle (tag 1341) ---
|
||||
UIView *staleAudio = [self viewWithTag:SCI_STORY_AUDIO_TAG];
|
||||
if (staleAudio) [staleAudio removeFromSuperview];
|
||||
sciInitStoryAudioState();
|
||||
if ([SCIUtils getBoolPref:@"story_audio_toggle"]) {
|
||||
NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:icon pointSize:14 diameter:28];
|
||||
btn.tag = SCI_STORY_AUDIO_TAG;
|
||||
[btn addTarget:self action:@selector(sciStoryAudioToggleTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:btn];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12],
|
||||
[btn.widthAnchor constraintEqualToConstant:28],
|
||||
[btn.heightAnchor constraintEqualToConstant:28]
|
||||
]];
|
||||
}
|
||||
|
||||
// --- Eye / mark-seen (tag 1339) ---
|
||||
// layoutSubviews can fire between the tick-0 strip and now, creating
|
||||
// the eye with fallback constraints before the action exists. Drop it
|
||||
// so the refresh rebuilds it anchored to the action button.
|
||||
UIView *staleEye = [self viewWithTag:SCI_STORY_EYE_TAG];
|
||||
if (staleEye) [staleEye removeFromSuperview];
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
|
||||
}
|
||||
|
||||
// MARK: - Action button menu-dismiss resume
|
||||
|
||||
%new - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
|
||||
change:(NSDictionary *)change context:(void *)context {
|
||||
if ([keyPath isEqualToString:@"highlighted"]) {
|
||||
BOOL highlighted = [change[NSKeyValueChangeNewKey] boolValue];
|
||||
if (!highlighted) sciResumeStoryPlayback(self);
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Audio toggle
|
||||
|
||||
%new - (void)sciStoryAudioToggleTapped:(SCIChromeButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[haptic impactOccurred];
|
||||
sciToggleStoryAudio();
|
||||
sender.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
}
|
||||
|
||||
%new - (void)sciRefreshStoryAudioButton {
|
||||
SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:SCI_STORY_AUDIO_TAG];
|
||||
if (![btn isKindOfClass:[SCIChromeButton class]]) return;
|
||||
btn.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash";
|
||||
}
|
||||
|
||||
// MARK: - Seen eye button
|
||||
|
||||
// Visible only when no_seen_receipt is on and the owner isn't excluded.
|
||||
%new - (void)sciRefreshSeenButton {
|
||||
BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"];
|
||||
if (!seenBlockingOn) { UIView *old = [self viewWithTag:SCI_STORY_EYE_TAG]; if (old) [old removeFromSuperview]; return; }
|
||||
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(self);
|
||||
NSString *ownerPK = ownerInfo[@"pk"] ?: @"";
|
||||
BOOL excluded = ownerPK.length && [SCIExcludedStoryUsers isUserPKExcluded:ownerPK];
|
||||
SCIChromeButton *existing = (SCIChromeButton *)[self viewWithTag:SCI_STORY_EYE_TAG];
|
||||
if (![existing isKindOfClass:[SCIChromeButton class]]) existing = nil;
|
||||
|
||||
if (excluded) { if (existing) [existing removeFromSuperview]; return; }
|
||||
|
||||
BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"];
|
||||
NSString *symName;
|
||||
UIColor *tint;
|
||||
if (toggleMode) {
|
||||
symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
|
||||
tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
|
||||
} else {
|
||||
symName = @"eye"; tint = [UIColor whiteColor];
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
existing.symbolName = symName;
|
||||
existing.iconTint = tint;
|
||||
return;
|
||||
}
|
||||
|
||||
SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:symName pointSize:18 diameter:36];
|
||||
btn.tag = SCI_STORY_EYE_TAG;
|
||||
btn.iconTint = tint;
|
||||
[btn addTarget:self action:@selector(sciStorySeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
// Long-press → context menu (positions itself next to the button).
|
||||
UIContextMenuInteraction *ix = [[UIContextMenuInteraction alloc] initWithDelegate:(id<UIContextMenuInteractionDelegate>)self];
|
||||
[btn addInteraction:ix];
|
||||
[self addSubview:btn];
|
||||
|
||||
UIView *anchor = [self viewWithTag:SCI_STORY_ACTION_TAG];
|
||||
if (anchor) {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
} else {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Owner / audio refresh on layout
|
||||
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
static char kLastPKKey;
|
||||
static char kLastExclKey;
|
||||
static char kLastAudioKey;
|
||||
|
||||
UIButton *audioBtn = (UIButton *)[self viewWithTag:SCI_STORY_AUDIO_TAG];
|
||||
if (audioBtn) {
|
||||
BOOL audioOn = sciIsStoryAudioEnabled();
|
||||
NSNumber *prevAudio = objc_getAssociatedObject(self, &kLastAudioKey);
|
||||
if (!prevAudio || [prevAudio boolValue] != audioOn) {
|
||||
objc_setAssociatedObject(self, &kLastAudioKey, @(audioOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshStoryAudioButton));
|
||||
}
|
||||
}
|
||||
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return;
|
||||
NSDictionary *info = sciOwnerInfoForView(self);
|
||||
NSString *pk = info[@"pk"] ?: @"";
|
||||
BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk];
|
||||
NSString *prev = objc_getAssociatedObject(self, &kLastPKKey);
|
||||
NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey);
|
||||
BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded);
|
||||
if (!changed) return;
|
||||
objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton));
|
||||
}
|
||||
|
||||
// MARK: - Seen button tap handlers
|
||||
|
||||
%new - (void)sciStorySeenButtonTapped:(SCIChromeButton *)sender {
|
||||
if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) {
|
||||
sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled;
|
||||
sender.symbolName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye";
|
||||
sender.iconTint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
|
||||
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")];
|
||||
return;
|
||||
}
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciStoryMarkSeenTapped:), sender);
|
||||
}
|
||||
|
||||
// Long-press menu — rebuilt per display so owner/exclusion is always fresh.
|
||||
%new - (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
configurationForMenuAtLocation:(CGPoint)location {
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
return [UIContextMenuConfiguration
|
||||
configurationWithIdentifier:nil
|
||||
previewProvider:nil
|
||||
actionProvider:^UIMenu * _Nullable(NSArray<UIMenuElement *> * _Nonnull suggested) {
|
||||
__strong __typeof(weakSelf) strongSelf = weakSelf;
|
||||
if (!strongSelf) return nil;
|
||||
|
||||
NSDictionary *ownerInfo = sciOwnerInfoForView(strongSelf);
|
||||
NSString *pk = ownerInfo[@"pk"];
|
||||
NSString *username = ownerInfo[@"username"] ?: @"";
|
||||
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
|
||||
BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk];
|
||||
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
|
||||
NSMutableArray<UIMenuElement *> *items = [NSMutableArray array];
|
||||
[items addObject:[UIAction actionWithTitle:SCILocalized(@"Mark seen")
|
||||
image:[UIImage systemImageNamed:@"eye"]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(strongSelf, @selector(sciStoryMarkSeenTapped:), nil);
|
||||
}]];
|
||||
if (pk) {
|
||||
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen");
|
||||
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen");
|
||||
NSString *t = inList ? removeLabel : addLabel;
|
||||
NSString *img = inList ? @"minus.circle" : @"eye.slash";
|
||||
UIAction *excl = [UIAction actionWithTitle:t
|
||||
image:[UIImage systemImageNamed:img]
|
||||
identifier:nil
|
||||
handler:^(UIAction *a) {
|
||||
if (inList) {
|
||||
[SCIExcludedStoryUsers removePK:pk];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
|
||||
if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
|
||||
} else {
|
||||
[SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
|
||||
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
|
||||
}
|
||||
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}];
|
||||
if (inList) excl.attributes = UIMenuElementAttributesDestructive;
|
||||
[items addObject:excl];
|
||||
}
|
||||
return [UIMenu menuWithTitle:@"" children:items];
|
||||
}];
|
||||
}
|
||||
|
||||
%new - (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
willDisplayMenuForConfiguration:(UIContextMenuConfiguration *)configuration
|
||||
animator:(id<UIContextMenuInteractionAnimating>)animator {
|
||||
sciPauseStoryPlayback(self);
|
||||
}
|
||||
|
||||
%new - (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
willEndForConfiguration:(UIContextMenuConfiguration *)configuration
|
||||
animator:(id<UIContextMenuInteractionAnimating>)animator {
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
void (^resume)(void) = ^{ if (weakSelf) sciResumeStoryPlayback(weakSelf); };
|
||||
if (animator) [animator addCompletion:resume];
|
||||
else resume();
|
||||
}
|
||||
|
||||
%new - (void)sciStoryMarkSeenTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
if (sender) {
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
|
||||
}
|
||||
|
||||
@try {
|
||||
UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController");
|
||||
if (!storyVC) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")]; return; }
|
||||
|
||||
id sectionCtrl = sciFindSectionController(storyVC);
|
||||
id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil;
|
||||
if (!storyItem) storyItem = sciGetCurrentStoryItem(self);
|
||||
IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem);
|
||||
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; }
|
||||
|
||||
sciAllowSeenForPK(media);
|
||||
sciSeenBypassActive = YES;
|
||||
|
||||
SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:);
|
||||
if ([storyVC respondsToSelector:delegateSel]) {
|
||||
typedef void (*Func)(id, SEL, id, id);
|
||||
((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media);
|
||||
}
|
||||
if (sectionCtrl) {
|
||||
SEL markSel = NSSelectorFromString(@"markItemAsSeen:");
|
||||
if ([sectionCtrl respondsToSelector:markSel])
|
||||
((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media);
|
||||
}
|
||||
id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager));
|
||||
id vm = sciCall(storyVC, @selector(currentViewModel));
|
||||
if (seenManager && vm) {
|
||||
SEL setSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:");
|
||||
if ([seenManager respondsToSelector:setSel]) {
|
||||
id mediaPK = sciCall(media, @selector(pk));
|
||||
id reelPK = sciCall(vm, NSSelectorFromString(@"reelPK"));
|
||||
if (!reelPK) reelPK = sciCall(vm, @selector(pk));
|
||||
if (mediaPK && reelPK) {
|
||||
typedef void (*SetFunc)(id, SEL, id, id);
|
||||
((SetFunc)objc_msgSend)(seenManager, setSel, mediaPK, reelPK);
|
||||
}
|
||||
}
|
||||
}
|
||||
sciSeenBypassActive = NO;
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked as seen") subtitle:SCILocalized(@"Will sync when leaving stories")];
|
||||
|
||||
if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) {
|
||||
__block id secCtrl = sectionCtrl;
|
||||
__weak __typeof(self) weakSelf = self;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciAdvanceBypassActive = YES;
|
||||
SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:");
|
||||
if ([secCtrl respondsToSelector:advSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1);
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
__strong __typeof(weakSelf) strongSelf = weakSelf;
|
||||
UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil;
|
||||
id sc2 = vc2 ? sciFindSectionController(vc2) : nil;
|
||||
if (sc2) {
|
||||
SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:");
|
||||
if ([sc2 respondsToSelector:resumeSel])
|
||||
((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0);
|
||||
}
|
||||
sciAdvanceBypassActive = NO;
|
||||
});
|
||||
});
|
||||
}
|
||||
} @catch (NSException *e) {
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]];
|
||||
}
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// MARK: - Chrome alpha sync (story only)
|
||||
|
||||
static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) {
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) return;
|
||||
UIView *cur = self_;
|
||||
while (cur) {
|
||||
for (UIView *sib in cur.superview.subviews) {
|
||||
if (![sib isKindOfClass:overlayCls]) continue;
|
||||
UIView *seen = [sib viewWithTag:SCI_STORY_EYE_TAG];
|
||||
UIView *act = [sib viewWithTag:SCI_STORY_ACTION_TAG];
|
||||
UIView *audio = [sib viewWithTag:SCI_STORY_AUDIO_TAG];
|
||||
if (seen) seen.alpha = alpha;
|
||||
if (act) act.alpha = alpha;
|
||||
if (audio) audio.alpha = alpha;
|
||||
return;
|
||||
}
|
||||
cur = cur.superview;
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGStoryFullscreenHeaderView
|
||||
- (void)setAlpha:(CGFloat)alpha {
|
||||
%orig;
|
||||
sciSyncStoryButtonsAlpha((UIView *)self, alpha);
|
||||
}
|
||||
%end
|
||||
|
||||
%end // StoryOverlayGroup
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"stories_action_button"] ||
|
||||
[SCIUtils getBoolPref:@"story_audio_toggle"] ||
|
||||
[SCIUtils getBoolPref:@"no_seen_receipt"]) {
|
||||
%init(StoryOverlayGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// Force IG into dark appearance regardless of iOS setting.
|
||||
|
||||
#import "../../Utils.h"
|
||||
|
||||
%group ForceDarkModeGroup
|
||||
|
||||
%hook UIWindow
|
||||
- (void)makeKeyAndVisible {
|
||||
%orig;
|
||||
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
|
||||
}
|
||||
- (void)becomeKeyWindow {
|
||||
%orig;
|
||||
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
|
||||
}
|
||||
%end
|
||||
|
||||
%end
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"theme_force_dark"]) {
|
||||
%init(ForceDarkModeGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Replace IG's dark-gray surfaces with pure black.
|
||||
//
|
||||
// Swaps any near-black fill (RGB all < 0.13, alpha >= 0.9) for #000000.
|
||||
// RyukGram's own surfaces opt out by painting above the threshold or with
|
||||
// alpha < 0.9 — see SCIOLEDSurface.xm.
|
||||
|
||||
#import "../../Utils.h"
|
||||
|
||||
static inline BOOL sciOLEDShouldReplace(UIColor *color) {
|
||||
if (!color) return NO;
|
||||
CGFloat r = 0, g = 0, b = 0, a = 0;
|
||||
if (![color getRed:&r green:&g blue:&b alpha:&a]) {
|
||||
CGFloat w = 0;
|
||||
if ([color getWhite:&w alpha:&a]) {
|
||||
return (a >= 0.9 && w < 0.13);
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
return (a >= 0.9 && r < 0.13 && g < 0.13 && b < 0.13);
|
||||
}
|
||||
|
||||
%group FullOLEDGroup
|
||||
|
||||
%hook UIView
|
||||
- (void)setBackgroundColor:(UIColor *)color {
|
||||
if (sciOLEDShouldReplace(color)) {
|
||||
%orig([UIColor blackColor]);
|
||||
return;
|
||||
}
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook CAGradientLayer
|
||||
- (void)setColors:(NSArray *)colors {
|
||||
if (colors.count >= 1) {
|
||||
BOOL allDark = YES;
|
||||
for (id raw in colors) {
|
||||
CGColorRef cg = (__bridge CGColorRef)raw;
|
||||
if (!cg) { allDark = NO; break; }
|
||||
UIColor *c = [UIColor colorWithCGColor:cg];
|
||||
if (!sciOLEDShouldReplace(c)) { allDark = NO; break; }
|
||||
}
|
||||
if (allDark) {
|
||||
id black = (id)[UIColor blackColor].CGColor;
|
||||
NSMutableArray *flat = [NSMutableArray arrayWithCapacity:colors.count];
|
||||
for (NSUInteger i = 0; i < colors.count; i++) [flat addObject:black];
|
||||
%orig(flat);
|
||||
return;
|
||||
}
|
||||
}
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%end
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"theme_full_oled"]) {
|
||||
%init(FullOLEDGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
// Keyboard appearance override for IG's text inputs.
|
||||
// Modes: "off" / "dark" / "oled".
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
|
||||
static inline BOOL sciKeyboardOLED(void) {
|
||||
return [[[NSUserDefaults standardUserDefaults] stringForKey:@"theme_keyboard"] isEqualToString:@"oled"];
|
||||
}
|
||||
|
||||
%group KeyboardThemeDarkGroup
|
||||
|
||||
%hook UITextField
|
||||
- (BOOL)becomeFirstResponder {
|
||||
self.keyboardAppearance = UIKeyboardAppearanceDark;
|
||||
return %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UITextView
|
||||
- (BOOL)becomeFirstResponder {
|
||||
self.keyboardAppearance = UIKeyboardAppearanceDark;
|
||||
return %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%end
|
||||
|
||||
%group KeyboardThemeOLEDGroup
|
||||
|
||||
%hook UIKBBackdropView
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
self.backgroundColor = [UIColor blackColor];
|
||||
for (UIView *sub in self.subviews) sub.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UIKBKeyplaneChargedView
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
self.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
%end
|
||||
|
||||
%end
|
||||
|
||||
%ctor {
|
||||
NSString *mode = [[NSUserDefaults standardUserDefaults] stringForKey:@"theme_keyboard"];
|
||||
if ([mode isEqualToString:@"dark"] || [mode isEqualToString:@"oled"]) {
|
||||
%init(KeyboardThemeDarkGroup);
|
||||
if (sciKeyboardOLED()) {
|
||||
%init(KeyboardThemeOLEDGroup);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Pure-black DM thread background + incoming message bubbles.
|
||||
// IGDirectThreadBackgroundImageView / IGDirectMessageBubbleView declared in InstagramHeaders.h.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
|
||||
%group OLEDChatThemeGroup
|
||||
|
||||
%hook IGDirectThreadBackgroundImageView
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
self.image = nil;
|
||||
self.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
- (void)setImage:(UIImage *)image {
|
||||
%orig(nil);
|
||||
self.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
- (void)setBackgroundColor:(UIColor *)color {
|
||||
%orig([UIColor blackColor]);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGDirectMessageBubbleView
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
CGFloat r = 0, g = 0, b = 0, a = 0;
|
||||
if ([self.backgroundColor getRed:&r green:&g blue:&b alpha:&a]) {
|
||||
// Leave tinted outgoing bubbles (blue/purple) alone.
|
||||
if (a >= 0.9 && r < 0.2 && g < 0.2 && b < 0.2) {
|
||||
self.backgroundColor = [UIColor blackColor];
|
||||
}
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
%end
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"theme_oled_chat"]) {
|
||||
%init(OLEDChatThemeGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Keep RyukGram's table-view surfaces visible under Full OLED.
|
||||
//
|
||||
// Grouped-inset cells default to #1C1C1E which Full OLED blackens. Repaint
|
||||
// SCI*-owned cells at ~#121212 (alpha 0.89 passes the hook's a >= 0.9 gate)
|
||||
// on attach, so settings + Profile Analyzer stay readable on black.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
static inline BOOL sciOLEDSurfaceInRyukGram(UIView *view) {
|
||||
UIResponder *r = view;
|
||||
while (r) {
|
||||
const char *name = class_getName([r class]);
|
||||
if (name && name[0] == 'S' && name[1] == 'C' && name[2] == 'I') return YES;
|
||||
r = r.nextResponder;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
static UIColor *sciOLEDSurfaceTone(void) {
|
||||
static UIColor *tone;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ tone = [UIColor colorWithWhite:0.08 alpha:0.89]; });
|
||||
return tone;
|
||||
}
|
||||
|
||||
%group OLEDSurfaceGroup
|
||||
|
||||
%hook UITableViewCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!self.superview) return;
|
||||
if (!sciOLEDSurfaceInRyukGram((UIView *)self)) return;
|
||||
UIColor *tone = sciOLEDSurfaceTone();
|
||||
UIBackgroundConfiguration *bg = [UIBackgroundConfiguration listGroupedCellConfiguration];
|
||||
bg.backgroundColor = tone;
|
||||
self.backgroundConfiguration = bg;
|
||||
self.backgroundColor = tone;
|
||||
self.contentView.backgroundColor = tone;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UITableViewHeaderFooterView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!self.superview) return;
|
||||
if (!sciOLEDSurfaceInRyukGram((UIView *)self)) return;
|
||||
self.backgroundConfiguration = [UIBackgroundConfiguration clearConfiguration];
|
||||
}
|
||||
%end
|
||||
|
||||
%end
|
||||
|
||||
%ctor {
|
||||
if ([SCIUtils getBoolPref:@"theme_full_oled"]) {
|
||||
%init(OLEDSurfaceGroup);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@
|
||||
@interface IGExploreGridViewController : IGViewController
|
||||
@end
|
||||
|
||||
@interface IGExploreViewController : IGViewController
|
||||
@end
|
||||
|
||||
@interface IGExploreSearchTitleView : UIView
|
||||
@end
|
||||
|
||||
@interface UIImage ()
|
||||
- (NSString *)ig_imageName;
|
||||
@end
|
||||
@@ -164,6 +170,15 @@
|
||||
- (void)addLongPressGestureRecognizer; // new
|
||||
@end
|
||||
|
||||
@interface IGSundialViewerPhotoCell : UIView
|
||||
@end
|
||||
|
||||
@interface IGSundialViewerCarouselPhotoCell : UIView
|
||||
@end
|
||||
|
||||
@interface IGSundialViewerCarouselCell : UIView
|
||||
@end
|
||||
|
||||
@interface IGImageProgressView : UIView
|
||||
@property(retain, nonatomic) IGImageSpecifier *imageSpecifier;
|
||||
@end
|
||||
@@ -538,6 +553,14 @@
|
||||
// IG's UINavigationBar subclass — hosts the iOS 26 liquid-glass platter layout.
|
||||
@interface IGNavigationBar : UINavigationBar @end
|
||||
|
||||
// DM thread background + message bubble views — OLED chat theme.
|
||||
@interface IGDirectThreadBackgroundImageView : UIImageView @end
|
||||
@interface IGDirectMessageBubbleView : UIView @end
|
||||
|
||||
// UIKit-private keyboard classes — OLED keyboard theme.
|
||||
@interface UIKBBackdropView : UIView @end
|
||||
@interface UIKBKeyplaneChargedView : UIView @end
|
||||
|
||||
// Story tray list adapter — drives data source updates for the home feed tray.
|
||||
@interface IGListAdapter : NSObject
|
||||
- (void)performUpdatesAnimated:(BOOL)animated completion:(void (^)(BOOL))completion;
|
||||
@@ -674,4 +697,39 @@ typedef FLEXAlertAction * _Nonnull (^FLEXAlertActionHandler)(void(^handler)(NSAr
|
||||
- (void)showExplorer;
|
||||
- (void)hideExplorer;
|
||||
- (void)toggleExplorer;
|
||||
@end
|
||||
|
||||
// IGLive classes — discovered via runtime ivar/method dump.
|
||||
@interface IGLiveFeedbackController : NSObject
|
||||
- (void)start;
|
||||
- (void)stop;
|
||||
@end
|
||||
|
||||
@interface IGLiveCommentsContainerViewController : UIViewController
|
||||
- (void)setIsHidden:(BOOL)hidden;
|
||||
- (void)setDisabled:(BOOL)disabled;
|
||||
@end
|
||||
|
||||
// Story/reel sticker views — data accessors resolved at runtime.
|
||||
@interface IGQuizStickerView : UIView
|
||||
@end
|
||||
|
||||
@interface IGPollStickerView : UIView
|
||||
@end
|
||||
|
||||
@interface IGPollStickerV2View : UIView
|
||||
@end
|
||||
|
||||
@interface IGSliderStickerView : UIView
|
||||
@end
|
||||
|
||||
// Composer sticker tray data source — hooked to inject the quiz model.
|
||||
@interface IGStoryStickerDataSourceImpl : NSObject
|
||||
- (NSArray *)items;
|
||||
@end
|
||||
|
||||
@interface IGQuizStickerTrayModel : NSObject
|
||||
@property (nonatomic) BOOL isBoostEligible;
|
||||
@property (nonatomic, copy) id stickerSection;
|
||||
@property (nonatomic, copy) NSArray *prompts;
|
||||
@end
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user