Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 51c1dc59cf | |||
| 86eaa95019 | |||
| 9b2c7dc202 | |||
| d17fba5778 | |||
| 06b2626714 | |||
| 7952877545 | |||
| 3693f3e93a | |||
| de3e13f60a | |||
| d8a8b6c0fe | |||
| 89c5a25512 | |||
| f2f310fce5 | |||
| ceb89e65d2 | |||
| d03da10941 | |||
| ae6f70e47c | |||
| fee6a026b4 | |||
| 7300fe893e | |||
| 84b4405b84 | |||
| bdb0b5d2e3 | |||
| 0643f5e691 | |||
| 6e96140895 | |||
| e634359acc | |||
| a93929bbb2 | |||
| 3490531941 | |||
| 7782ca34b3 | |||
| 2687f99cfb | |||
| 5282d67103 | |||
| b99c20a254 | |||
| bf541bc483 |
@@ -1,8 +1,3 @@
|
||||
# Inspired heavily by the following workflows
|
||||
# https://github.com/arichornlover/uYouEnhanced/blob/main/.github/workflows/buildapp.yml
|
||||
# https://github.com/ISnackable/YTCubePlus/blob/main/.github/workflows/Build.yml
|
||||
# https://github.com/BandarHL/BHTwitter/actions/workflows/build.yml
|
||||
|
||||
name: Build and Package RyukGram
|
||||
|
||||
on:
|
||||
@@ -88,7 +83,10 @@ jobs:
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build RyukGram tweak for sideloading (as IPA)
|
||||
- name: Setup FFmpegKit
|
||||
run: cd main && ./scripts/setup-ffmpegkit.sh
|
||||
|
||||
- name: Build sideloaded IPA (rootless deb → cyan inject)
|
||||
run: |
|
||||
pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip
|
||||
|
||||
@@ -96,22 +94,22 @@ jobs:
|
||||
curl -Lo ipapatch https://github.com/asdfzxcvbn/ipapatch/releases/download/v2.1.3/ipapatch.macos-arm64
|
||||
chmod +x ipapatch
|
||||
export PATH=.:$PATH
|
||||
ls -la
|
||||
|
||||
./build.sh sideload
|
||||
ls -la packages
|
||||
env:
|
||||
THEOS: ${{ github.workspace }}/theos
|
||||
|
||||
- name: Rename IPA to include version info
|
||||
- name: Rename IPA
|
||||
run: |
|
||||
cd main/packages
|
||||
mv "$(ls -t | head -n1)" "RyukGram_sideloaded_v${VERSION}.ipa"
|
||||
IPA=$(ls -t *.ipa | grep -iv instagram | head -n1)
|
||||
[ -n "$IPA" ] && mv "$IPA" "RyukGram_sideloaded_v${VERSION}.ipa"
|
||||
|
||||
- name: Pass package name to upload action
|
||||
id: package_name
|
||||
run: |
|
||||
echo "package=$(ls -t main/packages | head -n1)" >> "$GITHUB_OUTPUT"
|
||||
echo "package=$(ls -t main/packages/RyukGram_sideloaded_v*.ipa | head -n1 | xargs basename)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload Artifact
|
||||
if: ${{ inputs.upload_artifact }}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: Build RyukGram tweak for Rootless
|
||||
name: Build RyukGram tweak (rootless + rootful)
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -12,7 +12,7 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
jobs:
|
||||
build:
|
||||
name: Build RyukGram Rootless
|
||||
name: Build RyukGram
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -65,28 +65,41 @@ jobs:
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build RyukGram tweak for Rootless
|
||||
- name: Setup FFmpegKit
|
||||
run: cd main && ./scripts/setup-ffmpegkit.sh
|
||||
|
||||
- name: Build rootless
|
||||
run: |
|
||||
cd main
|
||||
ls -la
|
||||
./build.sh rootless
|
||||
ls -la packages
|
||||
cd packages
|
||||
DEB="$(ls -t *-rootless.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "com.faroukbmiled.ryukgram_${VERSION}+debug-rootless.deb"
|
||||
ls -la
|
||||
env:
|
||||
THEOS: ${{ github.workspace }}/theos
|
||||
|
||||
- name: Rename deb to include version info
|
||||
- name: Build rootful
|
||||
run: |
|
||||
cd main/packages
|
||||
mv "$(ls -t | head -n1)" "com.faroukbmiled.ryukgram_${VERSION}+debug-rootless.deb"
|
||||
cd main
|
||||
./build.sh rootful
|
||||
cd packages
|
||||
DEB="$(ls -t *-rootful.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "com.faroukbmiled.ryukgram_${VERSION}+debug-rootful.deb"
|
||||
ls -la
|
||||
env:
|
||||
THEOS: ${{ github.workspace }}/theos
|
||||
|
||||
- name: Pass package name to upload action
|
||||
id: package_name
|
||||
run: |
|
||||
echo "package=$(ls -t main/packages | head -n1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload Artifact
|
||||
- name: Upload rootless artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.package_name.outputs.package }}
|
||||
path: ${{ github.workspace }}/main/packages/${{ steps.package_name.outputs.package }}
|
||||
name: com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootless.deb
|
||||
path: ${{ github.workspace }}/main/packages/com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootless.deb
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload rootful artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootful.deb
|
||||
path: ${{ github.workspace }}/main/packages/com.faroukbmiled.ryukgram_${{ env.VERSION }}+debug-rootful.deb
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -65,34 +65,41 @@ jobs:
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build dylib
|
||||
run: |
|
||||
export THEOS="${{ github.workspace }}/theos"
|
||||
./build.sh dylib
|
||||
ls -la packages/
|
||||
- name: Setup FFmpegKit
|
||||
run: ./scripts/setup-ffmpegkit.sh
|
||||
|
||||
- name: Build rootless deb
|
||||
run: |
|
||||
export THEOS="${{ github.workspace }}/theos"
|
||||
./build.sh rootless
|
||||
cd packages
|
||||
mv "$(ls -t *.deb | head -n1)" "RyukGram_${VERSION}_rootless.deb"
|
||||
DEB="$(ls -t *-rootless.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "RyukGram_${VERSION}_rootless.deb"
|
||||
ls -la
|
||||
|
||||
- name: Upload dylib artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_v${{ steps.version.outputs.version }}_dylib
|
||||
path: packages/RyukGram.dylib
|
||||
if-no-files-found: error
|
||||
- name: Build rootful deb
|
||||
run: |
|
||||
export THEOS="${{ github.workspace }}/theos"
|
||||
./build.sh rootful
|
||||
cd packages
|
||||
DEB="$(ls -t *-rootful.deb | head -n1)"
|
||||
[ -n "$DEB" ] && mv "$DEB" "RyukGram_${VERSION}_rootful.deb"
|
||||
ls -la
|
||||
|
||||
- name: Upload deb artifact
|
||||
- name: Upload rootless artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_v${{ steps.version.outputs.version }}_rootless
|
||||
path: packages/RyukGram_${{ env.VERSION }}_rootless.deb
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload rootful artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: RyukGram_v${{ steps.version.outputs.version }}_rootful
|
||||
path: packages/RyukGram_${{ env.VERSION }}_rootful.deb
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Check if release
|
||||
id: check_release
|
||||
run: |
|
||||
@@ -105,34 +112,37 @@ jobs:
|
||||
|
||||
- name: Generate release notes
|
||||
if: steps.check_release.outputs.should_release == 'true'
|
||||
id: notes
|
||||
run: |
|
||||
BODY=$(git log -1 --pretty=%b)
|
||||
|
||||
PREV_TAG=$(git tag --sort=-creatordate | grep -v "v${VERSION}$" | head -n1 || true)
|
||||
{
|
||||
echo "## Changelog"
|
||||
echo ""
|
||||
if [ -n "$BODY" ]; then
|
||||
echo '```'
|
||||
echo "$BODY"
|
||||
echo '```'
|
||||
fi
|
||||
git log -1 --pretty=%B | tail -n +2 | sed -e '/^<!--/,/-->$/d' -e '/^\[release\]/d'
|
||||
echo ""
|
||||
echo "**[All commits](https://github.com/${{ github.repository }}/commits/main)**"
|
||||
echo "## Downloads"
|
||||
echo ""
|
||||
echo "| File | Description |"
|
||||
echo "|------|-------------|"
|
||||
echo "| \`RyukGram_rootless.deb\` | Rootless .deb (Dopamine/palera1n). Also works for sideloading via Feather/cyan. |"
|
||||
echo "| \`RyukGram_rootful.deb\` | Rootful .deb (unc0ver/checkra1n). |"
|
||||
echo ""
|
||||
if [ -n "$PREV_TAG" ]; then
|
||||
echo "**Full changelog:** [\`${PREV_TAG}\`...\`v${VERSION}\`](https://github.com/${{ github.repository }}/compare/${PREV_TAG}...v${VERSION})"
|
||||
else
|
||||
echo "**[All commits](https://github.com/${{ github.repository }}/commits/main)**"
|
||||
fi
|
||||
} > /tmp/release_notes.md
|
||||
|
||||
- name: Create Release
|
||||
if: steps.check_release.outputs.should_release == 'true'
|
||||
id: create_release
|
||||
run: |
|
||||
# Strip [release] from commit subject for the release title
|
||||
SUBJECT=$(git log -1 --pretty=%s | sed 's/\[release\]//g' | xargs)
|
||||
TITLE="${SUBJECT:-RyukGram v${VERSION}}"
|
||||
gh release create "v${VERSION}" \
|
||||
--repo "${{ github.repository }}" \
|
||||
--title "$TITLE" \
|
||||
--notes-file /tmp/release_notes.md \
|
||||
packages/RyukGram.dylib \
|
||||
"packages/RyukGram_${VERSION}_rootless.deb"
|
||||
"packages/RyukGram_${VERSION}_rootless.deb" \
|
||||
"packages/RyukGram_${VERSION}_rootful.deb"
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
@@ -38,3 +38,15 @@ CLAUDE.md
|
||||
upstream-scinsta
|
||||
*.ipa
|
||||
*.dylib
|
||||
deploy.sh
|
||||
PENDING_CHANGES.md
|
||||
PENDING_CHANGES.md.bk
|
||||
wrapper/
|
||||
scripts/*.py
|
||||
scripts/__pycache__/
|
||||
|
||||
# FFmpegKit frameworks
|
||||
modules/ffmpegkit/
|
||||
|
||||
# External reference tweaks
|
||||
exp_flags/
|
||||
|
||||
@@ -7,9 +7,9 @@ include $(THEOS)/makefiles/common.mk
|
||||
TWEAK_NAME = RyukGram
|
||||
|
||||
$(TWEAK_NAME)_FILES = $(shell find src -type f \( -iname \*.x -o -iname \*.xm -o -iname \*.m \)) $(wildcard modules/JGProgressHUD/*.m) modules/fishhook/fishhook.c
|
||||
$(TWEAK_NAME)_FRAMEWORKS = UIKit Foundation CoreGraphics Photos CoreServices SystemConfiguration SafariServices Security QuartzCore
|
||||
$(TWEAK_NAME)_FRAMEWORKS = UIKit Foundation CoreGraphics Photos CoreServices SystemConfiguration SafariServices Security QuartzCore AVFoundation UniformTypeIdentifiers CoreLocation MapKit
|
||||
$(TWEAK_NAME)_PRIVATE_FRAMEWORKS = Preferences
|
||||
$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Wno-unsupported-availability-guard -Wno-unused-value -Wno-deprecated-declarations -Wno-nullability-completeness -Wno-unused-function -Wno-incompatible-pointer-types
|
||||
$(TWEAK_NAME)_CFLAGS = -fobjc-arc -Wno-unsupported-availability-guard -Wno-unused-value -Wno-deprecated-declarations -Wno-nullability-completeness -Wno-unused-function -Wno-incompatible-pointer-types -include src/SCIPrefix.h
|
||||
$(TWEAK_NAME)_LOGOSFLAGS = --c warnings=none
|
||||
|
||||
CCFLAGS += -std=c++11
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# RyukGram
|
||||
A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com/SoCuul/SCInsta) with additional features and fixes.\
|
||||
`Version v1.1.4` | `Tested on Instagram 423.1.0`
|
||||
`Version v1.2.0` | `Tested on Instagram 425.0.0`
|
||||
|
||||
---
|
||||
|
||||
@@ -13,7 +13,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
>[!IMPORTANT]
|
||||
> Which type of device are you planning on installing this tweak on?
|
||||
> - Jailbroken/TrollStore device -> [Download pre-built tweak](https://github.com/faroukbmiled/RyukGram/releases/latest)
|
||||
> - Standard iOS device -> Sideload the dylib using Feather or similar
|
||||
> - Standard iOS device -> Sideload the .deb using Feather or similar
|
||||
|
||||
# Features
|
||||
> Features marked with **\*** are new or improved in RyukGram
|
||||
@@ -21,10 +21,20 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
### General
|
||||
- Hide ads
|
||||
- Hide Meta AI
|
||||
- Hide metrics (likes, comments, shares counts)
|
||||
- Disable app haptics
|
||||
- Copy description
|
||||
- Copy comment text from long-press menu **\***
|
||||
- Download GIF comments **\***
|
||||
- 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) **\***
|
||||
- Open links in external browser **\***
|
||||
- Strip tracking from browser links **\***
|
||||
- Do not save recent searches
|
||||
- Use detailed (native) color picker
|
||||
- Enable liquid glass buttons
|
||||
- Enable liquid glass surfaces — floating tab bar, dynamic sizing, and other UI elements **\***
|
||||
- Enable teen app icons
|
||||
- IG Notes:
|
||||
- Hide notes tray
|
||||
@@ -39,19 +49,28 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
|
||||
### 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 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) **\***
|
||||
- Disable background refresh, home button refresh, and home button scroll **\***
|
||||
- Disable reels tab button refresh **\***
|
||||
- Hide repost button in feed **\***
|
||||
|
||||
### Reels
|
||||
- Modify tap controls
|
||||
- Always show progress scrubber
|
||||
- Disable auto-unmuting reels (properly blocks mute switch, volume buttons, and announcer broadcasts) **\***
|
||||
- Confirm reel refresh
|
||||
- Unlock password-locked reels **\***
|
||||
- Hide reels header
|
||||
- Hide repost button in reels **\***
|
||||
- Hide reels blend button
|
||||
- Disable scrolling reels
|
||||
- Prevent doom scrolling (limit maximum viewable reels)
|
||||
@@ -62,32 +81,68 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Playback toggle synced with overlay during hold/zoom
|
||||
- Works across IG A/B test variants
|
||||
|
||||
### Saving
|
||||
- Download feed posts (photo + video)
|
||||
- Download reels
|
||||
- Download stories
|
||||
### Action buttons **\***
|
||||
- Context-aware action menu on feed, reels, and stories (expand, repost, download, copy caption, etc.) **\***
|
||||
- Configurable default tap action per context **\***
|
||||
- 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 **\***
|
||||
- Save profile picture
|
||||
- Download buttons on media — tap a button directly on feed posts, reels sidebar, and story overlay **\***
|
||||
- Download method — choose between download button or long-press gesture **\***
|
||||
- Save action — choose between share sheet or save directly to Photos **\***
|
||||
- Download confirmation — optional confirmation dialog before downloading **\***
|
||||
- Non-blocking download HUD — pill-style progress at the top, tap to cancel **\***
|
||||
- Debug fallback — if IG updates break downloads, shows diagnostic info instead of crashing **\***
|
||||
- *Customize finger count for long-press*
|
||||
- *Customize hold time for long-press*
|
||||
- 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 **\***
|
||||
|
||||
### Saving
|
||||
- Enhanced HD downloads — up to 1080p via DASH + FFmpegKit **\***
|
||||
- Quality picker with preview playback **\***
|
||||
- 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 **\***
|
||||
|
||||
### Stories and messages
|
||||
- Keep deleted messages
|
||||
- Manually mark messages as seen
|
||||
- Keep deleted messages (preserves unsent messages with visual indicator and notification pill) **\***
|
||||
- Hide trailing action buttons on preserved messages
|
||||
- Warn before clearing on refresh — optional confirmation when pulling to refresh the DMs tab if preserved messages would be cleared **\***
|
||||
- Manually mark messages as seen (button or toggle mode) **\***
|
||||
- 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) **\***
|
||||
- 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 **\***
|
||||
- Disable typing status
|
||||
- Unlimited replay of direct stories
|
||||
- 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 **\***
|
||||
- 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 **\***
|
||||
- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\***
|
||||
- Long-press the story seen button for quick actions **\***
|
||||
- Per-user story seen-receipt 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 **\***
|
||||
- Story download button — download directly from the story overlay **\***
|
||||
- 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 **\***
|
||||
- Disable instants creation
|
||||
|
||||
### Navigation
|
||||
@@ -98,11 +153,15 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Hide explore tab
|
||||
- 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) **\***
|
||||
|
||||
### Confirm actions
|
||||
- Confirm like: Posts/Stories
|
||||
- Confirm like: Reels
|
||||
- Confirm follow
|
||||
- Confirm unfollow **\***
|
||||
- Confirm repost
|
||||
- Confirm call
|
||||
- Confirm voice messages
|
||||
@@ -112,9 +171,48 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
|
||||
- Confirm changing direct message theme
|
||||
- Confirm sticker interaction
|
||||
|
||||
### Fake location **\***
|
||||
- Overrides CoreLocation app-wide so any IG feature reading a coord (Friends Map, posts, etc.) gets your chosen location
|
||||
- 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
|
||||
|
||||
### 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) **\***
|
||||
- Quick-access via long-press on feed tab **\***
|
||||
|
||||
### Backup & Restore **\***
|
||||
- Export RyukGram settings as a JSON file
|
||||
- Import settings from a JSON file
|
||||
- Searchable, collapsible, editable 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).
|
||||
|
||||
### Optimization
|
||||
- Automatically clears unneeded cache folders, reducing the size of your Instagram installation
|
||||
|
||||
# Translating RyukGram
|
||||
Want to see RyukGram in your language? Open a PR — it takes about 30 minutes of copy-paste.
|
||||
|
||||
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`.
|
||||
|
||||
Partial translations are welcome — untranslated keys automatically fall back to English at runtime. Ship what you've got, iterate from there.
|
||||
|
||||
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.
|
||||
|
||||
## 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).
|
||||
- "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.
|
||||
|
||||
# Opening Tweak Settings
|
||||
|
||||
| | |
|
||||
@@ -148,3 +246,6 @@ $ ./build.sh <sideload/rootless/rootful>
|
||||
- [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
|
||||
- [@euoradan](https://t.me/euoradan) (Radan) — experimental Instagram feature flag research
|
||||
- [@erupts0](https://github.com/erupts0) (John) — testing and feature suggestions
|
||||
- [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension) — base for the bundled Safari extension
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
set -e
|
||||
|
||||
# Auto-detect THEOS if not set
|
||||
# Auto-detect THEOS if not set
|
||||
if [ -z "$THEOS" ]; then
|
||||
if [ -d "$HOME/theos" ]; then
|
||||
export THEOS="$HOME/theos"
|
||||
@@ -15,12 +15,89 @@ fi
|
||||
CMAKE_OSX_ARCHITECTURES="arm64e;arm64"
|
||||
CMAKE_OSX_SYSROOT="iphoneos"
|
||||
|
||||
# Copy Localization resources (*.lproj) into a RyukGram.bundle.
|
||||
# Arg 1: destination bundle directory (created if missing).
|
||||
copy_localization_into_bundle() {
|
||||
local DEST="$1"
|
||||
local SRC="src/Localization/Resources"
|
||||
[ -d "$SRC" ] || return 0
|
||||
mkdir -p "$DEST"
|
||||
for lproj in "$SRC"/*.lproj; do
|
||||
[ -d "$lproj" ] || continue
|
||||
cp -R "$lproj" "$DEST/"
|
||||
done
|
||||
}
|
||||
|
||||
# Collect all FFmpegKit frameworks for injection
|
||||
ffmpegkit_frameworks() {
|
||||
local fws=""
|
||||
if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
for fw in modules/ffmpegkit/*.framework; do
|
||||
fws="$fws $fw"
|
||||
done
|
||||
fi
|
||||
echo "$fws"
|
||||
}
|
||||
|
||||
# Inject RyukGram.bundle into a .deb:
|
||||
# - Always: localization lproj resources.
|
||||
# - Optional: FFmpegKit frameworks (renamed *_sci to avoid collisions).
|
||||
# Path: Library/Application Support/RyukGram.bundle/ — jailbreak dlopens by full
|
||||
# path, Feather copies .bundle without injecting load commands for sideload.
|
||||
# Arg 1: path to .deb (cwd must be packages/)
|
||||
inject_bundle_into_deb() {
|
||||
local BASE_DEB="$1"
|
||||
local TMPDIR=$(mktemp -d)
|
||||
dpkg-deb -R "$BASE_DEB" "$TMPDIR"
|
||||
local DYLIB_DIR=$(find "$TMPDIR" -name "RyukGram.dylib" -exec dirname {} \; | head -1)
|
||||
[ -n "$DYLIB_DIR" ] || { rm -rf "$TMPDIR"; return; }
|
||||
|
||||
local PREFIX=""
|
||||
[[ "$DYLIB_DIR" == *"/var/jb/"* ]] && PREFIX="var/jb/"
|
||||
|
||||
local BUNDLE_DIR="$TMPDIR/${PREFIX}Library/Application Support/RyukGram.bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
( cd .. && copy_localization_into_bundle "$BUNDLE_DIR" )
|
||||
|
||||
if [ -d "../modules/ffmpegkit/ffmpegkit.framework" ]; then
|
||||
for fw in ../modules/ffmpegkit/*.framework; do
|
||||
cp -R "$fw" "$BUNDLE_DIR/"
|
||||
done
|
||||
|
||||
local LIBS="libavutil libavcodec libavformat libavfilter libavdevice libswresample libswscale"
|
||||
for lib in $LIBS; do
|
||||
mv "$BUNDLE_DIR/${lib}.framework" "$BUNDLE_DIR/${lib}_sci.framework"
|
||||
install_name_tool -id "@rpath/${lib}_sci.framework/${lib}" \
|
||||
"$BUNDLE_DIR/${lib}_sci.framework/${lib}"
|
||||
done
|
||||
for target in "$BUNDLE_DIR/ffmpegkit.framework/ffmpegkit" \
|
||||
"$BUNDLE_DIR"/libav*_sci.framework/libav* \
|
||||
"$BUNDLE_DIR"/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_DIR/ffmpegkit.framework/ffmpegkit" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
dpkg-deb -b "$TMPDIR" "$BASE_DEB"
|
||||
rm -rf "$TMPDIR"
|
||||
}
|
||||
|
||||
# Build just the dylib (for Feather/manual injection)
|
||||
if [ "$1" == "dylib" ];
|
||||
then
|
||||
|
||||
make clean 2>/dev/null || true
|
||||
rm -rf .theos
|
||||
# --fast: incremental build (no clean)
|
||||
if [ "$2" != "--fast" ]; then
|
||||
make clean 2>/dev/null || true
|
||||
rm -rf .theos
|
||||
fi
|
||||
|
||||
echo -e '\033[1m\033[32mBuilding RyukGram dylib\033[0m'
|
||||
|
||||
@@ -29,7 +106,10 @@ then
|
||||
mkdir -p packages
|
||||
cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib
|
||||
|
||||
echo -e "\033[1m\033[32mDone!\033[0m\n\nDylib at: $(pwd)/packages/RyukGram.dylib"
|
||||
# Ship localization bundle next to the dylib so Feather/manual installs work.
|
||||
copy_localization_into_bundle "packages/RyukGram.bundle"
|
||||
|
||||
echo -e "\033[1m\033[32mDone!\033[0m\n\nDylib at: $(pwd)/packages/RyukGram.dylib\nBundle at: $(pwd)/packages/RyukGram.bundle"
|
||||
|
||||
# Build sideloaded IPA
|
||||
elif [ "$1" == "sideload" ];
|
||||
@@ -88,9 +168,19 @@ then
|
||||
rm -rf .theos
|
||||
|
||||
# Check for decrypted Instagram IPA
|
||||
ipaFile="$(find ./packages/ -name '*com.burbn.instagram*.ipa' -type f -exec basename {} \; 2>/dev/null || true)"
|
||||
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
|
||||
echo -e '\033[1m\033[0;31m./packages/com.burbn.instagram.ipa not found.\nPlease put a decrypted Instagram IPA in its path.\033[0m'
|
||||
# Auto-move any Instagram IPA from cwd into packages/
|
||||
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
|
||||
echo -e "\033[1m\033[32mMoving $(basename "$cwdIpa") → packages/\033[0m"
|
||||
mv "$cwdIpa" packages/
|
||||
ipaFile="$(basename "$cwdIpa")"
|
||||
fi
|
||||
fi
|
||||
if [ -z "${ipaFile}" ]; then
|
||||
echo -e '\033[1m\033[0;31mDecrypted Instagram IPA not found.\nPlace a *com.burbn.instagram*.ipa in ./ or ./packages/.\033[0m'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -128,24 +218,72 @@ then
|
||||
exit
|
||||
fi
|
||||
|
||||
TWEAKPATH=".theos/obj/debug/RyukGram.dylib"
|
||||
if [ "$2" == "--devquick" ];
|
||||
then
|
||||
# Exclude RyukGram.dylib from IPA for livecontainer quick builds
|
||||
TWEAKPATH=""
|
||||
# Build RyukGram.bundle with renamed frameworks for cyan injection
|
||||
BUNDLE_PATH="packages/RyukGram.bundle"
|
||||
rm -rf "$BUNDLE_PATH"
|
||||
mkdir -p "$BUNDLE_PATH"
|
||||
copy_localization_into_bundle "$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
|
||||
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
|
||||
|
||||
# Create IPA file
|
||||
TWEAKPATH=".theos/obj/debug/RyukGram.dylib"
|
||||
if [ "$2" == "--devquick" ]; then TWEAKPATH=""; fi
|
||||
|
||||
BUNDLE_ARG=""
|
||||
[ -d "$BUNDLE_PATH" ] && BUNDLE_ARG="$BUNDLE_PATH"
|
||||
|
||||
# Create IPA: cyan injects dylib + copies RyukGram.bundle to app root
|
||||
echo -e '\033[1m\033[32mCreating the IPA file...\033[0m'
|
||||
rm -f packages/RyukGram-sideloaded.ipa
|
||||
cyan -i "packages/${ipaFile}" -o packages/RyukGram-sideloaded.ipa -f $TWEAKPATH $FLEXPATH -c $COMPRESSION -m 15.0 -du
|
||||
cyan -i "packages/${ipaFile}" -o packages/RyukGram-sideloaded.ipa -f $TWEAKPATH $FLEXPATH $BUNDLE_ARG -c $COMPRESSION -m 15.0 -du
|
||||
|
||||
# Inject Safari "Open in Instagram" extension into Payload/*.app/PlugIns/
|
||||
# before ipapatch re-signs, so instagram.com links open the app.
|
||||
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-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 -${COMPRESSION} ../repacked.ipa Payload )
|
||||
mv "$INJECT_TMP/../repacked.ipa" packages/RyukGram-sideloaded.ipa
|
||||
fi
|
||||
rm -rf "$INJECT_TMP"
|
||||
fi
|
||||
|
||||
# Patch IPA for sideloading
|
||||
ipapatch --input "packages/RyukGram-sideloaded.ipa" --inplace --noconfirm
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the ipa file at: $(pwd)/packages"
|
||||
|
||||
# Build rootless .deb
|
||||
# Build rootless .deb with FFmpegKit
|
||||
elif [ "$1" == "rootless" ];
|
||||
then
|
||||
|
||||
@@ -157,9 +295,20 @@ then
|
||||
export THEOS_PACKAGE_SCHEME=rootless
|
||||
make package
|
||||
|
||||
echo -e '\033[1m\033[32mInjecting RyukGram.bundle (localization + FFmpegKit) into deb\033[0m'
|
||||
cd packages
|
||||
BASE_DEB="$(ls -t *.deb | head -n1)"
|
||||
if [ -n "$BASE_DEB" ]; then
|
||||
inject_bundle_into_deb "$BASE_DEB"
|
||||
NEW_NAME="${BASE_DEB%.deb}-rootless.deb"
|
||||
mv "$BASE_DEB" "$NEW_NAME"
|
||||
fi
|
||||
cd ..
|
||||
[ -d "modules/ffmpegkit/ffmpegkit.framework" ] || echo -e '\033[0;33mFFmpegKit not found — deb built without FFmpegKit.\033[0m'
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the deb file at: $(pwd)/packages"
|
||||
|
||||
# Build rootful .deb
|
||||
# Build rootful .deb with FFmpegKit
|
||||
elif [ "$1" == "rootful" ];
|
||||
then
|
||||
|
||||
@@ -171,6 +320,17 @@ then
|
||||
unset THEOS_PACKAGE_SCHEME
|
||||
make package
|
||||
|
||||
echo -e '\033[1m\033[32mInjecting RyukGram.bundle (localization + FFmpegKit) into deb\033[0m'
|
||||
cd packages
|
||||
BASE_DEB="$(ls -t *.deb | head -n1)"
|
||||
if [ -n "$BASE_DEB" ]; then
|
||||
inject_bundle_into_deb "$BASE_DEB"
|
||||
NEW_NAME="${BASE_DEB%.deb}-rootful.deb"
|
||||
mv "$BASE_DEB" "$NEW_NAME"
|
||||
fi
|
||||
cd ..
|
||||
[ -d "modules/ffmpegkit/ffmpegkit.framework" ] || echo -e '\033[0;33mFFmpegKit not found — deb built without FFmpegKit.\033[0m'
|
||||
|
||||
echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the deb file at: $(pwd)/packages"
|
||||
|
||||
else
|
||||
@@ -182,7 +342,7 @@ else
|
||||
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'
|
||||
echo ' rootful - Build a rootful .deb package'
|
||||
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.1.4
|
||||
Version: 1.2.0
|
||||
Architecture: iphoneos-arm
|
||||
Description: A feature-rich tweak for Instagram on iOS, based on SCInsta
|
||||
Homepage: https://github.com/faroukbmiled/RyukGram
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extension_name": {
|
||||
"message": "Open in Instagram",
|
||||
"description": "The display name for the extension."
|
||||
},
|
||||
"extension_description": {
|
||||
"message": "Opens instagram.com links (profiles, posts, reels, stories, tags) in the Instagram app.",
|
||||
"description": "Description of what the extension does."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// Redirect instagram.com web links into the native app.
|
||||
// Shipped inside RyukGram as a Safari web extension.
|
||||
|
||||
(function () {
|
||||
if (window.top !== window.self) return;
|
||||
if (sessionStorage.getItem("__sciOpenedApp")) return;
|
||||
|
||||
function urlFromLocation() {
|
||||
const path = window.location.pathname.split("/").filter(Boolean);
|
||||
if (path.length === 0) return null;
|
||||
|
||||
if (path[0] === "p" || path[0] === "reel") {
|
||||
const meta = document.querySelector("meta[property='al:ios:url']");
|
||||
if (meta && meta.getAttribute("content")) return meta.getAttribute("content");
|
||||
return path[1] ? `instagram://media?id=${path[1]}` : null;
|
||||
}
|
||||
|
||||
if (path[0] === "stories" && path[1]) {
|
||||
return `instagram://story?username=${path[1]}`;
|
||||
}
|
||||
|
||||
if (path[0] === "explore" && path[1] === "tags" && path[2]) {
|
||||
return `instagram://tag?name=${path[2]}`;
|
||||
}
|
||||
|
||||
if (path.length === 1) {
|
||||
return `instagram://user?username=${path[0]}`;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function openInApp() {
|
||||
const target = urlFromLocation();
|
||||
if (!target) return;
|
||||
sessionStorage.setItem("__sciOpenedApp", "1");
|
||||
window.location.href = target;
|
||||
}
|
||||
|
||||
openInApp();
|
||||
})();
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 9.5 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"default_locale": "en",
|
||||
|
||||
"name": "__MSG_extension_name__",
|
||||
"description": "__MSG_extension_description__",
|
||||
"version": "1.0",
|
||||
|
||||
"icons": {
|
||||
"48": "images/icon-48.png",
|
||||
"96": "images/icon-96.png",
|
||||
"128": "images/icon-128.png",
|
||||
"256": "images/icon-256.png",
|
||||
"512": "images/icon-512.png"
|
||||
},
|
||||
|
||||
"background": {
|
||||
"service_worker": "background.js"
|
||||
},
|
||||
|
||||
"content_scripts": [{
|
||||
"js": [ "content.js" ],
|
||||
"matches": [ "*://*.instagram.com/*" ]
|
||||
}],
|
||||
|
||||
"action": {
|
||||
"default_popup": "popup.html",
|
||||
"default_icon": {
|
||||
"16": "images/toolbar-icon-16.png",
|
||||
"19": "images/toolbar-icon-19.png",
|
||||
"32": "images/toolbar-icon-32.png",
|
||||
"38": "images/toolbar-icon-38.png",
|
||||
"48": "images/toolbar-icon-48.png",
|
||||
"72": "images/toolbar-icon-72.png"
|
||||
}
|
||||
},
|
||||
|
||||
"permissions": [ ]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 220px;
|
||||
padding: 14px 16px;
|
||||
margin: 0;
|
||||
font-family: -apple-system, system-ui, sans-serif;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="stylesheet" href="popup.css">
|
||||
<script type="module" src="popup.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="title">Open in RyukGram</div>
|
||||
<div class="subtitle">instagram.com links open in the app.</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/bin/bash
|
||||
# Downloads pre-built FFmpegKit frameworks for iOS arm64.
|
||||
# Called by build.sh before compilation if modules/ffmpegkit/ is empty.
|
||||
|
||||
set -e
|
||||
|
||||
DEST="$(dirname "$0")/../modules/ffmpegkit"
|
||||
MARKER="$DEST/.fetched"
|
||||
|
||||
# Skip if already fetched
|
||||
if [ -f "$MARKER" ]; then
|
||||
echo "[ffmpegkit] Already fetched, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# FFmpegKit 6.0 LTS — min-gpl variant (smallest, has x264)
|
||||
VERSION="6.0"
|
||||
VARIANT="min-gpl"
|
||||
URL="https://github.com/arthenica/ffmpeg-kit/releases/download/v${VERSION}/ffmpeg-kit-${VARIANT}-${VERSION}-ios-xcframework.zip"
|
||||
|
||||
echo "[ffmpegkit] Downloading FFmpegKit ${VERSION} (${VARIANT})..."
|
||||
TMPZIP=$(mktemp /tmp/ffmpegkit-XXXXXX.zip)
|
||||
curl -L -o "$TMPZIP" "$URL"
|
||||
|
||||
echo "[ffmpegkit] Extracting..."
|
||||
TMPDIR=$(mktemp -d /tmp/ffmpegkit-extract-XXXXXX)
|
||||
unzip -q "$TMPZIP" -d "$TMPDIR"
|
||||
|
||||
# XCFrameworks contain ios-arm64 slices — extract the .framework from each
|
||||
echo "[ffmpegkit] Installing frameworks..."
|
||||
for xcfw in "$TMPDIR"/*.xcframework; do
|
||||
NAME=$(basename "$xcfw" .xcframework)
|
||||
# Find the ios-arm64 framework slice
|
||||
ARM64_DIR=$(find "$xcfw" -type d -name "ios-arm64" -o -name "ios-arm64_armv7" 2>/dev/null | head -1)
|
||||
if [ -z "$ARM64_DIR" ]; then
|
||||
# Try the plain ios directory
|
||||
ARM64_DIR=$(find "$xcfw" -type d -name "*.framework" | head -1)
|
||||
ARM64_DIR=$(dirname "$ARM64_DIR")
|
||||
fi
|
||||
|
||||
if [ -d "$ARM64_DIR/${NAME}.framework" ]; then
|
||||
cp -R "$ARM64_DIR/${NAME}.framework" "$DEST/"
|
||||
echo " + ${NAME}.framework"
|
||||
else
|
||||
echo " ! ${NAME}.framework not found in xcframework"
|
||||
fi
|
||||
done
|
||||
|
||||
# Cleanup
|
||||
rm -rf "$TMPZIP" "$TMPDIR"
|
||||
|
||||
touch "$MARKER"
|
||||
echo "[ffmpegkit] Done. Frameworks installed to modules/ffmpegkit/"
|
||||
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Downloads FFmpegKit xcframeworks and extracts arm64 device frameworks.
|
||||
# Output: modules/ffmpegkit/{ffmpegkit,libav*,libsw*}.framework/
|
||||
|
||||
set -e
|
||||
|
||||
DEST="$(cd "$(dirname "$0")/.." && pwd)/modules/ffmpegkit"
|
||||
URL="https://github.com/luthviar/ffmpeg-kit-ios-full/releases/download/6.0/ffmpeg-kit-ios-full.zip"
|
||||
|
||||
mkdir -p "$DEST"
|
||||
|
||||
# Already set up?
|
||||
if [ -f "$DEST/ffmpegkit.framework/ffmpegkit" ]; then
|
||||
echo "[ffmpegkit] Already present, skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "[ffmpegkit] Downloading ffmpeg-kit-ios-full..."
|
||||
TMPDIR=$(mktemp -d)
|
||||
curl -L -o "$TMPDIR/ffmpegkit.zip" "$URL"
|
||||
|
||||
echo "[ffmpegkit] Extracting arm64 device frameworks..."
|
||||
unzip -q "$TMPDIR/ffmpegkit.zip" -d "$TMPDIR"
|
||||
|
||||
# Copy the ios-arm64 slice from each xcframework
|
||||
for xcfw in "$TMPDIR"/ffmpeg-kit-ios-full/*.xcframework; do
|
||||
NAME=$(basename "$xcfw" .xcframework)
|
||||
ARM64="$xcfw/ios-arm64/$NAME.framework"
|
||||
if [ -d "$ARM64" ]; then
|
||||
cp -R "$ARM64" "$DEST/"
|
||||
echo "[ffmpegkit] $NAME.framework"
|
||||
fi
|
||||
done
|
||||
|
||||
rm -rf "$TMPDIR"
|
||||
echo "[ffmpegkit] Done — $(ls -d "$DEST"/*.framework | wc -l | tr -d ' ') frameworks installed."
|
||||
@@ -0,0 +1,37 @@
|
||||
// SCIActionButton — wires a UIButton to the RyukGram action menu system.
|
||||
// Tap fires the default action; long-press opens the full context menu.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "SCIMediaActions.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
typedef id _Nullable (^SCIActionMediaProvider)(UIView *sourceView);
|
||||
|
||||
@interface SCIActionButton : NSObject
|
||||
|
||||
/// Key for an optional dismiss callback block (void(^)(void)) stored on
|
||||
/// the button via objc_setAssociatedObject. Called when the context menu
|
||||
/// or UIMenu dismisses. Used by stories to resume playback.
|
||||
extern const void *kSCIDismissKey;
|
||||
|
||||
/// Configure an existing UIButton with RyukGram action-menu behavior.
|
||||
///
|
||||
/// `prefKey` is the NSUserDefaults key storing the default-tap choice
|
||||
/// (one of `menu`, `expand`, `download_share`, `download_photos`).
|
||||
+ (void)configureButton:(UIButton *)button
|
||||
context:(SCIActionContext)ctx
|
||||
prefKey:(NSString *)prefKey
|
||||
mediaProvider:(SCIActionMediaProvider)provider;
|
||||
|
||||
/// Build the deferred UIMenu for a given context + provider. Exposed so
|
||||
/// callers that already have their own UIButton wiring can reuse just the
|
||||
/// menu construction.
|
||||
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
|
||||
fromView:(UIView *)sourceView
|
||||
mediaProvider:(SCIActionMediaProvider)provider;
|
||||
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,176 @@
|
||||
#import "SCIActionButton.h"
|
||||
#import "SCIActionMenu.h"
|
||||
#import "SCIRepostSheet.h"
|
||||
#import "../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// Associated-object keys for per-button config.
|
||||
static const void *kSCICtxKey = &kSCICtxKey;
|
||||
static const void *kSCIProviderKey = &kSCIProviderKey;
|
||||
static const void *kSCIPrefKey = &kSCIPrefKey;
|
||||
const void *kSCIDismissKey = &kSCIDismissKey;
|
||||
|
||||
|
||||
@interface SCIActionButton () <UIContextMenuInteractionDelegate>
|
||||
@end
|
||||
|
||||
@implementation SCIActionButton
|
||||
|
||||
// Singleton delegate for UIContextMenuInteraction.
|
||||
+ (instancetype)shared {
|
||||
static SCIActionButton *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [SCIActionButton new]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
+ (UIMenu *)deferredMenuForContext:(SCIActionContext)ctx
|
||||
fromView:(UIView *)sourceView
|
||||
mediaProvider:(SCIActionMediaProvider)provider {
|
||||
__weak UIView *weakSource = sourceView;
|
||||
SCIActionMediaProvider capturedProvider = [provider copy];
|
||||
|
||||
UIDeferredMenuElement *deferred = [UIDeferredMenuElement
|
||||
elementWithUncachedProvider:^(void (^completion)(NSArray<UIMenuElement *> * _Nonnull)) {
|
||||
UIView *view = weakSource;
|
||||
id media = (view && capturedProvider) ? capturedProvider(view) : nil;
|
||||
NSArray *actions = [SCIMediaActions actionsForContext:ctx
|
||||
media:media
|
||||
fromView:view];
|
||||
UIMenu *built = [SCIActionMenu buildMenuWithActions:actions];
|
||||
completion(built.children);
|
||||
}];
|
||||
|
||||
return [UIMenu menuWithTitle:@""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:0
|
||||
children:@[deferred]];
|
||||
}
|
||||
|
||||
+ (void)configureButton:(UIButton *)button
|
||||
context:(SCIActionContext)ctx
|
||||
prefKey:(NSString *)prefKey
|
||||
mediaProvider:(SCIActionMediaProvider)provider {
|
||||
if (!button) return;
|
||||
|
||||
// Stash config on the button.
|
||||
objc_setAssociatedObject(button, kSCICtxKey, @(ctx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
objc_setAssociatedObject(button, kSCIProviderKey, [provider copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
objc_setAssociatedObject(button, kSCIPrefKey, [prefKey copy], OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
|
||||
// Read default tap mode fresh.
|
||||
NSString *defaultTap = [SCIUtils getStringPref:prefKey];
|
||||
if (!defaultTap.length) defaultTap = @"menu";
|
||||
|
||||
// Remove previous wiring to stay idempotent.
|
||||
[button removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
|
||||
for (id<UIInteraction> it in [button.interactions copy]) {
|
||||
if ([(id)it isKindOfClass:[UIContextMenuInteraction class]]) {
|
||||
[button removeInteraction:it];
|
||||
}
|
||||
}
|
||||
|
||||
if ([defaultTap isEqualToString:@"menu"]) {
|
||||
// Tap opens menu natively.
|
||||
button.menu = [self deferredMenuForContext:ctx fromView:button mediaProvider:provider];
|
||||
button.showsMenuAsPrimaryAction = YES;
|
||||
return;
|
||||
}
|
||||
|
||||
// Tap fires dedicated action; long-press opens menu.
|
||||
button.showsMenuAsPrimaryAction = NO;
|
||||
button.menu = nil;
|
||||
[button addTarget:[self shared]
|
||||
action:@selector(sciTapHandler:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
|
||||
UIContextMenuInteraction *interaction =
|
||||
[[UIContextMenuInteraction alloc] initWithDelegate:[self shared]];
|
||||
[button addInteraction:interaction];
|
||||
}
|
||||
|
||||
// Haptic + scale-bounce feedback.
|
||||
+ (void)bounceButton:(UIView *)view {
|
||||
UIImpactFeedbackGenerator *haptic =
|
||||
[[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1
|
||||
animations:^{ view.transform = CGAffineTransformMakeScale(0.82, 0.82); }
|
||||
completion:^(BOOL _) {
|
||||
[UIView animateWithDuration:0.1 animations:^{
|
||||
view.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
// Default-tap handler.
|
||||
- (void)sciTapHandler:(UIButton *)sender {
|
||||
[SCIActionButton bounceButton:sender];
|
||||
|
||||
NSNumber *ctxNum = objc_getAssociatedObject(sender, kSCICtxKey);
|
||||
SCIActionMediaProvider provider = objc_getAssociatedObject(sender, kSCIProviderKey);
|
||||
NSString *prefKey = objc_getAssociatedObject(sender, kSCIPrefKey);
|
||||
if (!ctxNum || !provider) return;
|
||||
|
||||
NSString *tap = [SCIUtils getStringPref:prefKey];
|
||||
if (!tap.length) tap = @"menu";
|
||||
id media = provider(sender);
|
||||
if (media == (id)kCFNull) return;
|
||||
|
||||
if ([tap isEqualToString:@"expand"]) {
|
||||
[SCIMediaActions expandMedia:media fromView:sender caption:nil];
|
||||
} else if ([tap isEqualToString:@"download_share"]) {
|
||||
[SCIMediaActions downloadAndShareMedia:media];
|
||||
} else if ([tap isEqualToString:@"download_photos"]) {
|
||||
[SCIMediaActions downloadAndSaveMedia:media];
|
||||
} else if ([tap isEqualToString:@"copy_link"]) {
|
||||
[SCIMediaActions copyURLForMedia:media];
|
||||
} else if ([tap isEqualToString:@"repost"]) {
|
||||
NSURL *vidURL = [SCIUtils getVideoUrlForMedia:(id)media];
|
||||
NSURL *imgURL = [SCIUtils getPhotoUrlForMedia:(id)media];
|
||||
[SCIRepostSheet repostWithVideoURL:vidURL photoURL:imgURL];
|
||||
} else if ([tap isEqualToString:@"view_mentions"]) {
|
||||
UIViewController *host = [SCIUtils nearestViewControllerForView:sender];
|
||||
if (host) {
|
||||
extern void sciShowStoryMentions(UIViewController *, UIView *);
|
||||
sciShowStoryMentions(host, sender);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - UIContextMenuInteractionDelegate
|
||||
|
||||
- (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
configurationForMenuAtLocation:(CGPoint)location {
|
||||
UIView *view = interaction.view;
|
||||
NSNumber *ctxNum = objc_getAssociatedObject(view, kSCICtxKey);
|
||||
SCIActionMediaProvider provider = objc_getAssociatedObject(view, kSCIProviderKey);
|
||||
if (!ctxNum || !provider) return nil;
|
||||
SCIActionContext ctx = (SCIActionContext)ctxNum.integerValue;
|
||||
|
||||
return [UIContextMenuConfiguration
|
||||
configurationWithIdentifier:nil
|
||||
previewProvider:nil
|
||||
actionProvider:^UIMenu * _Nullable(NSArray<UIMenuElement *> * _Nonnull suggested) {
|
||||
return [SCIActionButton deferredMenuForContext:ctx
|
||||
fromView:view
|
||||
mediaProvider:provider];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction
|
||||
willEndForConfiguration:(UIContextMenuConfiguration *)configuration
|
||||
animator:(id<UIContextMenuInteractionAnimating>)animator {
|
||||
UIView *view = interaction.view;
|
||||
void (^dismiss)(void) = objc_getAssociatedObject(view, kSCIDismissKey);
|
||||
if (dismiss) {
|
||||
if (animator) {
|
||||
[animator addCompletion:^{ dismiss(); }];
|
||||
} else {
|
||||
dismiss();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,48 @@
|
||||
// SCIActionMenu — reusable action menu model + UIMenu builder.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// One menu entry. Either a leaf (has handler) or a submenu (has children).
|
||||
@interface SCIAction : NSObject
|
||||
@property (nonatomic, copy, readonly) NSString *title;
|
||||
@property (nonatomic, copy, readonly, nullable) NSString *subtitle;
|
||||
@property (nonatomic, copy, readonly, nullable) NSString *systemIconName;
|
||||
@property (nonatomic, copy, readonly, nullable) void (^handler)(void);
|
||||
@property (nonatomic, copy, readonly, nullable) NSArray<SCIAction *> *children;
|
||||
@property (nonatomic, assign, readonly) BOOL destructive;
|
||||
@property (nonatomic, assign, readonly) BOOL isSeparator;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(nullable NSString *)icon
|
||||
handler:(void(^)(void))handler;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
subtitle:(nullable NSString *)subtitle
|
||||
icon:(nullable NSString *)icon
|
||||
destructive:(BOOL)destructive
|
||||
handler:(void(^)(void))handler;
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(nullable NSString *)icon
|
||||
children:(NSArray<SCIAction *> *)children;
|
||||
|
||||
/// A visual group break. Rendered as an inline submenu divider in UIMenu.
|
||||
+ (instancetype)separator;
|
||||
@end
|
||||
|
||||
|
||||
@interface SCIActionMenu : NSObject
|
||||
|
||||
/// Build a UIMenu from an array of SCIAction. Consecutive actions between
|
||||
/// `separator` markers are grouped into inline submenus so they render as
|
||||
/// divided sections (standard iOS menu aesthetic).
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions;
|
||||
|
||||
/// Build a UIMenu with a header title shown at the top of the menu.
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(nullable NSString *)title;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,132 @@
|
||||
#import "SCIActionMenu.h"
|
||||
|
||||
#pragma mark - SCIAction
|
||||
|
||||
@interface SCIAction ()
|
||||
@property (nonatomic, copy, readwrite) NSString *title;
|
||||
@property (nonatomic, copy, readwrite, nullable) NSString *subtitle;
|
||||
@property (nonatomic, copy, readwrite, nullable) NSString *systemIconName;
|
||||
@property (nonatomic, copy, readwrite, nullable) void (^handler)(void);
|
||||
@property (nonatomic, copy, readwrite, nullable) NSArray<SCIAction *> *children;
|
||||
@property (nonatomic, assign, readwrite) BOOL destructive;
|
||||
@property (nonatomic, assign, readwrite) BOOL isSeparator;
|
||||
@end
|
||||
|
||||
@implementation SCIAction
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(NSString *)icon
|
||||
handler:(void(^)(void))handler {
|
||||
return [self actionWithTitle:title subtitle:nil icon:icon destructive:NO handler:handler];
|
||||
}
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
subtitle:(NSString *)subtitle
|
||||
icon:(NSString *)icon
|
||||
destructive:(BOOL)destructive
|
||||
handler:(void(^)(void))handler {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.title = title ?: @"";
|
||||
a.subtitle = subtitle;
|
||||
a.systemIconName = icon;
|
||||
a.handler = handler;
|
||||
a.destructive = destructive;
|
||||
return a;
|
||||
}
|
||||
|
||||
+ (instancetype)actionWithTitle:(NSString *)title
|
||||
icon:(NSString *)icon
|
||||
children:(NSArray<SCIAction *> *)children {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.title = title ?: @"";
|
||||
a.systemIconName = icon;
|
||||
a.children = [children copy];
|
||||
return a;
|
||||
}
|
||||
|
||||
+ (instancetype)separator {
|
||||
SCIAction *a = [SCIAction new];
|
||||
a.isSeparator = YES;
|
||||
return a;
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
#pragma mark - SCIActionMenu
|
||||
|
||||
@implementation SCIActionMenu
|
||||
|
||||
+ (UIImage *)imageForIcon:(NSString *)name {
|
||||
if (!name.length) return nil;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightRegular];
|
||||
return [UIImage systemImageNamed:name withConfiguration:cfg];
|
||||
}
|
||||
|
||||
// Convert SCIAction to UIMenuElement.
|
||||
+ (UIMenuElement *)elementForAction:(SCIAction *)action {
|
||||
if (action.children.count) {
|
||||
NSMutableArray<UIMenuElement *> *kids = [NSMutableArray arrayWithCapacity:action.children.count];
|
||||
for (SCIAction *child in action.children) {
|
||||
UIMenuElement *el = [self elementForAction:child];
|
||||
if (el) [kids addObject:el];
|
||||
}
|
||||
return [UIMenu menuWithTitle:action.title
|
||||
image:[self imageForIcon:action.systemIconName]
|
||||
identifier:nil
|
||||
options:0
|
||||
children:kids];
|
||||
}
|
||||
|
||||
UIAction *ua = [UIAction actionWithTitle:action.title
|
||||
image:[self imageForIcon:action.systemIconName]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction * _Nonnull a) {
|
||||
if (action.handler) action.handler();
|
||||
}];
|
||||
|
||||
if (@available(iOS 15.0, *)) {
|
||||
if (action.subtitle.length) ua.subtitle = action.subtitle;
|
||||
}
|
||||
if (action.destructive) ua.attributes = UIMenuElementAttributesDestructive;
|
||||
return ua;
|
||||
}
|
||||
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions {
|
||||
return [self buildMenuWithActions:actions title:nil];
|
||||
}
|
||||
|
||||
+ (UIMenu *)buildMenuWithActions:(NSArray<SCIAction *> *)actions title:(NSString *)title {
|
||||
// Group actions between separators into inline submenus.
|
||||
NSMutableArray<UIMenuElement *> *top = [NSMutableArray array];
|
||||
NSMutableArray<UIMenuElement *> *currentGroup = [NSMutableArray array];
|
||||
|
||||
void (^flush)(void) = ^{
|
||||
if (currentGroup.count == 0) return;
|
||||
UIMenu *group = [UIMenu menuWithTitle:@""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:UIMenuOptionsDisplayInline
|
||||
children:[currentGroup copy]];
|
||||
[top addObject:group];
|
||||
[currentGroup removeAllObjects];
|
||||
};
|
||||
|
||||
for (SCIAction *a in actions) {
|
||||
if (a.isSeparator) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
UIMenuElement *el = [self elementForAction:a];
|
||||
if (el) [currentGroup addObject:el];
|
||||
}
|
||||
flush();
|
||||
|
||||
return [UIMenu menuWithTitle:title ?: @""
|
||||
image:nil
|
||||
identifier:nil
|
||||
options:0
|
||||
children:[top copy]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,98 @@
|
||||
// SCIMediaActions — shared media extraction + action handlers for the action menu.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
#import "../InstagramHeaders.h"
|
||||
#import "SCIActionMenu.h"
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
/// Where the action is being invoked from. Used to target settings entries
|
||||
/// and to pick context-specific language in HUDs.
|
||||
typedef NS_ENUM(NSInteger, SCIActionContext) {
|
||||
SCIActionContextFeed,
|
||||
SCIActionContextReels,
|
||||
SCIActionContextStories,
|
||||
};
|
||||
|
||||
@interface SCIMediaActions : NSObject
|
||||
|
||||
// MARK: - Media extraction
|
||||
|
||||
/// Return the post's caption string. Tries selectors first, falls back to
|
||||
/// reading `_fieldCache[@"caption"][@"text"]`.
|
||||
+ (nullable NSString *)captionForMedia:(id)media;
|
||||
|
||||
/// YES if the media is a carousel (multi-photo/video sidecar).
|
||||
+ (BOOL)isCarouselMedia:(id)media;
|
||||
|
||||
/// Ordered children of a carousel IGMedia. Empty array for non-carousels.
|
||||
+ (NSArray *)carouselChildrenForMedia:(id)media;
|
||||
|
||||
/// 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;
|
||||
|
||||
/// Cover/poster image URL for a video-type media (first frame). Works for
|
||||
/// reels, feed videos, and story videos.
|
||||
+ (nullable NSURL *)coverURLForMedia:(id)media;
|
||||
|
||||
// MARK: - Primary actions (each directly triggerable from a menu entry)
|
||||
|
||||
/// Present the media in the native QLPreview UI. Video URLs download first,
|
||||
/// images preview directly. Optional caption is shown as a subtitle.
|
||||
+ (void)expandMedia:(id)media
|
||||
fromView:(UIView *)sourceView
|
||||
caption:(nullable NSString *)caption;
|
||||
|
||||
/// Download the best URL for the media and hand off via share sheet.
|
||||
+ (void)downloadAndShareMedia:(id)media;
|
||||
|
||||
/// Download the best URL for the media and save to Photos (respects album pref).
|
||||
+ (void)downloadAndSaveMedia:(id)media;
|
||||
|
||||
/// Copy the direct CDN URL for the media to the clipboard.
|
||||
+ (void)copyURLForMedia:(id)media;
|
||||
|
||||
/// Copy the post caption to the clipboard.
|
||||
+ (void)copyCaptionForMedia:(id)media;
|
||||
|
||||
/// Trigger Instagram's native repost flow for the given context's currently
|
||||
/// visible UFI bar. Uses the existing button ivars to avoid reimplementing.
|
||||
+ (void)triggerRepostForContext:(SCIActionContext)ctx sourceView:(UIView *)sourceView;
|
||||
|
||||
/// Open the RyukGram settings page for the given context.
|
||||
+ (void)openSettingsForContext:(SCIActionContext)ctx fromView:(UIView *)sourceView;
|
||||
|
||||
// MARK: - Carousel bulk actions
|
||||
|
||||
/// Download every child of a carousel and share as a batch.
|
||||
+ (void)downloadAllAndShareMedia:(id)carouselMedia;
|
||||
|
||||
/// Download every child of a carousel and save to Photos.
|
||||
+ (void)downloadAllAndSaveMedia:(id)carouselMedia;
|
||||
|
||||
/// Copy newline-joined CDN URLs for every child of a carousel.
|
||||
+ (void)copyAllURLsForMedia:(id)carouselMedia;
|
||||
|
||||
// MARK: - Menu builders
|
||||
|
||||
// MARK: - Bulk URL download helpers
|
||||
|
||||
/// Download an array of URLs in parallel, show pill, call done with file URLs.
|
||||
+ (void)bulkDownloadURLs:(NSArray<NSURL *> *)urls
|
||||
title:(NSString *)title
|
||||
done:(void(^)(NSArray<NSURL *> *fileURLs))done;
|
||||
|
||||
/// Save an array of local file URLs to Photos (sequential, respects album pref).
|
||||
+ (void)bulkSaveFiles:(NSArray<NSURL *> *)files;
|
||||
|
||||
/// Build the full action menu for the given context + media + default tap.
|
||||
/// If `defaultTap` is provided and non-menu, the builder may reorder or skip
|
||||
/// its matching leaf so it's visible in the full menu.
|
||||
+ (NSArray<SCIAction *> *)actionsForContext:(SCIActionContext)ctx
|
||||
media:(nullable id)media
|
||||
fromView:(UIView *)sourceView;
|
||||
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
@@ -0,0 +1,24 @@
|
||||
// SCIMediaViewer — full-screen media viewer. Supports single items and carousels.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
/// One media item to display.
|
||||
@interface SCIMediaViewerItem : NSObject
|
||||
@property (nonatomic, strong) NSURL *videoURL; // nil for photos
|
||||
@property (nonatomic, strong) NSURL *photoURL; // nil for videos
|
||||
@property (nonatomic, copy) NSString *caption;
|
||||
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
|
||||
@end
|
||||
|
||||
@interface SCIMediaViewer : NSObject
|
||||
|
||||
/// Show a single media item.
|
||||
+ (void)showItem:(SCIMediaViewerItem *)item;
|
||||
|
||||
/// Show multiple items (carousel). Starts at the given index.
|
||||
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index;
|
||||
|
||||
/// Convenience: auto-detect video vs photo for a single item.
|
||||
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,437 @@
|
||||
#import "SCIMediaViewer.h"
|
||||
#import "../Utils.h"
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import <AVKit/AVKit.h>
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Data model
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@implementation SCIMediaViewerItem
|
||||
+ (instancetype)itemWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
|
||||
SCIMediaViewerItem *i = [SCIMediaViewerItem new];
|
||||
i.videoURL = videoURL;
|
||||
i.photoURL = photoURL;
|
||||
i.caption = caption;
|
||||
return i;
|
||||
}
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Single photo page
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIPhotoPageVC : UIViewController <UIScrollViewDelegate>
|
||||
@property (nonatomic, strong) NSURL *photoURL;
|
||||
@property (nonatomic, strong) UIScrollView *scrollView;
|
||||
@property (nonatomic, strong) UIImageView *imageView;
|
||||
@property (nonatomic, strong) UIActivityIndicatorView *spinner;
|
||||
@end
|
||||
|
||||
@implementation _SCIPhotoPageVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
|
||||
self.scrollView = [[UIScrollView alloc] initWithFrame:self.view.bounds];
|
||||
self.scrollView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
self.scrollView.delegate = self;
|
||||
self.scrollView.minimumZoomScale = 1.0;
|
||||
self.scrollView.maximumZoomScale = 5.0;
|
||||
self.scrollView.showsVerticalScrollIndicator = NO;
|
||||
self.scrollView.showsHorizontalScrollIndicator = NO;
|
||||
[self.view addSubview:self.scrollView];
|
||||
|
||||
self.imageView = [[UIImageView alloc] initWithFrame:self.scrollView.bounds];
|
||||
self.imageView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
self.imageView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.scrollView addSubview:self.imageView];
|
||||
|
||||
self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
|
||||
self.spinner.color = [UIColor whiteColor];
|
||||
self.spinner.center = self.view.center;
|
||||
self.spinner.autoresizingMask = UIViewAutoresizingFlexibleTopMargin | UIViewAutoresizingFlexibleBottomMargin
|
||||
| UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleRightMargin;
|
||||
[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;
|
||||
});
|
||||
});
|
||||
|
||||
// Double-tap to zoom
|
||||
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)];
|
||||
doubleTap.numberOfTapsRequired = 2;
|
||||
[self.scrollView addGestureRecognizer:doubleTap];
|
||||
}
|
||||
|
||||
- (UIView *)viewForZoomingInScrollView:(UIScrollView *)sv { return self.imageView; }
|
||||
|
||||
- (void)handleDoubleTap:(UITapGestureRecognizer *)gr {
|
||||
if (self.scrollView.zoomScale > 1.0) {
|
||||
[self.scrollView setZoomScale:1.0 animated:YES];
|
||||
} else {
|
||||
CGPoint pt = [gr locationInView:self.imageView];
|
||||
CGRect rect = CGRectMake(pt.x - 50, pt.y - 50, 100, 100);
|
||||
[self.scrollView zoomToRect:rect animated:YES];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIImage *)currentImage { return self.imageView.image; }
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Single video page
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIVideoPageVC : UIViewController
|
||||
@property (nonatomic, strong) NSURL *videoURL;
|
||||
@property (nonatomic, strong) AVPlayerViewController *playerVC;
|
||||
@end
|
||||
|
||||
@implementation _SCIVideoPageVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
|
||||
AVPlayer *player = [AVPlayer playerWithURL:self.videoURL];
|
||||
self.playerVC = [[AVPlayerViewController alloc] init];
|
||||
self.playerVC.player = player;
|
||||
self.playerVC.showsPlaybackControls = YES;
|
||||
|
||||
[self addChildViewController:self.playerVC];
|
||||
self.playerVC.view.frame = self.view.bounds;
|
||||
self.playerVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.playerVC.view];
|
||||
[self.playerVC didMoveToParentViewController:self];
|
||||
|
||||
[player play];
|
||||
}
|
||||
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
[super viewWillDisappear:animated];
|
||||
[self.playerVC.player pause];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Container VC (PageViewController-based)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@interface _SCIMediaViewerContainerVC : UIViewController <UIPageViewControllerDataSource, UIPageViewControllerDelegate>
|
||||
@property (nonatomic, strong) NSArray<SCIMediaViewerItem *> *items;
|
||||
@property (nonatomic, assign) NSUInteger currentIndex;
|
||||
@property (nonatomic, strong) UIPageViewController *pageVC;
|
||||
@property (nonatomic, strong) UIView *topBar;
|
||||
@property (nonatomic, strong) UIButton *closeBtn;
|
||||
@property (nonatomic, strong) UILabel *counterLabel;
|
||||
@property (nonatomic, strong) UIButton *shareBtn;
|
||||
@property (nonatomic, strong) UIView *bottomBar;
|
||||
@property (nonatomic, strong) UILabel *captionLabel;
|
||||
@property (nonatomic, assign) BOOL chromeVisible;
|
||||
@property (nonatomic, assign) BOOL captionExpanded;
|
||||
@end
|
||||
|
||||
@implementation _SCIMediaViewerContainerVC
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor blackColor];
|
||||
self.chromeVisible = YES;
|
||||
|
||||
// Page view controller
|
||||
self.pageVC = [[UIPageViewController alloc]
|
||||
initWithTransitionStyle:UIPageViewControllerTransitionStyleScroll
|
||||
navigationOrientation:UIPageViewControllerNavigationOrientationHorizontal
|
||||
options:nil];
|
||||
self.pageVC.dataSource = self.items.count > 1 ? self : nil;
|
||||
self.pageVC.delegate = self;
|
||||
|
||||
UIViewController *firstPage = [self viewControllerForIndex:self.currentIndex];
|
||||
if (firstPage) [self.pageVC setViewControllers:@[firstPage] direction:UIPageViewControllerNavigationDirectionForward animated:NO completion:nil];
|
||||
|
||||
[self addChildViewController:self.pageVC];
|
||||
self.pageVC.view.frame = self.view.bounds;
|
||||
self.pageVC.view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
[self.view addSubview:self.pageVC.view];
|
||||
[self.pageVC didMoveToParentViewController:self];
|
||||
|
||||
// Top bar
|
||||
self.topBar = [[UIView alloc] init];
|
||||
self.topBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.topBar];
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:17 weight:UIImageSymbolWeightSemibold];
|
||||
|
||||
self.closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[self.closeBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
self.closeBtn.tintColor = [UIColor whiteColor];
|
||||
self.closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.topBar addSubview:self.closeBtn];
|
||||
|
||||
self.shareBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
[self.shareBtn setImage:[UIImage systemImageNamed:@"square.and.arrow.up" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
self.shareBtn.tintColor = [UIColor whiteColor];
|
||||
self.shareBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.shareBtn addTarget:self action:@selector(shareTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.topBar addSubview:self.shareBtn];
|
||||
|
||||
self.counterLabel = [[UILabel alloc] init];
|
||||
self.counterLabel.textColor = [UIColor whiteColor];
|
||||
self.counterLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
|
||||
self.counterLabel.textAlignment = NSTextAlignmentCenter;
|
||||
self.counterLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.topBar addSubview:self.counterLabel];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.topBar.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor],
|
||||
[self.topBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.topBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.topBar.heightAnchor constraintEqualToConstant:44],
|
||||
[self.closeBtn.leadingAnchor constraintEqualToAnchor:self.topBar.leadingAnchor constant:16],
|
||||
[self.closeBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
[self.shareBtn.trailingAnchor constraintEqualToAnchor:self.topBar.trailingAnchor constant:-16],
|
||||
[self.shareBtn.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
[self.counterLabel.centerXAnchor constraintEqualToAnchor:self.topBar.centerXAnchor],
|
||||
[self.counterLabel.centerYAnchor constraintEqualToAnchor:self.topBar.centerYAnchor],
|
||||
]];
|
||||
|
||||
// Bottom bar (caption — tap to expand/collapse)
|
||||
self.bottomBar = [[UIView alloc] init];
|
||||
self.bottomBar.backgroundColor = [UIColor colorWithWhite:0 alpha:0.6];
|
||||
self.bottomBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self.view addSubview:self.bottomBar];
|
||||
|
||||
self.captionLabel = [[UILabel alloc] init];
|
||||
self.captionLabel.textColor = [UIColor whiteColor];
|
||||
self.captionLabel.font = [UIFont systemFontOfSize:14];
|
||||
self.captionLabel.numberOfLines = 3; // collapsed
|
||||
self.captionLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
self.captionLabel.userInteractionEnabled = YES;
|
||||
[self.bottomBar addSubview:self.captionLabel];
|
||||
|
||||
UITapGestureRecognizer *captionTap = [[UITapGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(toggleCaption)];
|
||||
[self.captionLabel addGestureRecognizer:captionTap];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.bottomBar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
|
||||
[self.bottomBar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
|
||||
[self.bottomBar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
|
||||
[self.captionLabel.topAnchor constraintEqualToAnchor:self.bottomBar.topAnchor constant:12],
|
||||
[self.captionLabel.leadingAnchor constraintEqualToAnchor:self.bottomBar.leadingAnchor constant:16],
|
||||
[self.captionLabel.trailingAnchor constraintEqualToAnchor:self.bottomBar.trailingAnchor constant:-16],
|
||||
[self.captionLabel.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-8],
|
||||
]];
|
||||
|
||||
// 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];
|
||||
}
|
||||
|
||||
- (void)updateChrome {
|
||||
SCIMediaViewerItem *item = self.items[self.currentIndex];
|
||||
|
||||
// Counter (hide for single items)
|
||||
if (self.items.count > 1) {
|
||||
self.counterLabel.text = [NSString stringWithFormat:@"%lu / %lu", (unsigned long)(self.currentIndex + 1), (unsigned long)self.items.count];
|
||||
self.counterLabel.hidden = NO;
|
||||
} else {
|
||||
self.counterLabel.hidden = YES;
|
||||
}
|
||||
|
||||
// Caption
|
||||
if (item.caption.length) {
|
||||
self.captionLabel.text = item.caption;
|
||||
self.bottomBar.hidden = NO;
|
||||
} else {
|
||||
self.bottomBar.hidden = YES;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)toggleChrome {
|
||||
self.chromeVisible = !self.chromeVisible;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
CGFloat a = self.chromeVisible ? 1.0 : 0.0;
|
||||
self.topBar.alpha = a;
|
||||
self.bottomBar.alpha = a;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)toggleCaption {
|
||||
self.captionExpanded = !self.captionExpanded;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
self.captionLabel.numberOfLines = self.captionExpanded ? 0 : 3;
|
||||
[self.view layoutIfNeeded];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)closeTapped {
|
||||
// Pause any playing video
|
||||
UIViewController *current = self.pageVC.viewControllers.firstObject;
|
||||
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[(((_SCIVideoPageVC *)current).playerVC.player) pause];
|
||||
}
|
||||
[self dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)shareTapped {
|
||||
SCIMediaViewerItem *item = self.items[self.currentIndex];
|
||||
NSMutableArray *shareItems = [NSMutableArray array];
|
||||
|
||||
UIViewController *current = self.pageVC.viewControllers.firstObject;
|
||||
if ([current isKindOfClass:[_SCIPhotoPageVC class]]) {
|
||||
UIImage *img = [(_SCIPhotoPageVC *)current currentImage];
|
||||
if (img) [shareItems addObject:img];
|
||||
}
|
||||
|
||||
// For videos or if no image loaded, share the URL
|
||||
if (!shareItems.count) {
|
||||
NSURL *url = item.videoURL ?: item.photoURL;
|
||||
if (url) [shareItems addObject:url];
|
||||
}
|
||||
|
||||
if (!shareItems.count) return;
|
||||
|
||||
UIActivityViewController *vc = [[UIActivityViewController alloc] initWithActivityItems:shareItems applicationActivities:nil];
|
||||
vc.popoverPresentationController.sourceView = self.shareBtn;
|
||||
[self presentViewController:vc animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// ─── Page data source ───
|
||||
|
||||
- (UIViewController *)viewControllerForIndex:(NSUInteger)idx {
|
||||
if (idx >= self.items.count) return nil;
|
||||
SCIMediaViewerItem *item = self.items[idx];
|
||||
|
||||
if (item.videoURL) {
|
||||
_SCIVideoPageVC *vc = [[_SCIVideoPageVC alloc] init];
|
||||
vc.videoURL = item.videoURL;
|
||||
vc.view.tag = (NSInteger)idx;
|
||||
return vc;
|
||||
} else if (item.photoURL) {
|
||||
_SCIPhotoPageVC *vc = [[_SCIPhotoPageVC alloc] init];
|
||||
vc.photoURL = item.photoURL;
|
||||
vc.view.tag = (NSInteger)idx;
|
||||
return vc;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerBeforeViewController:(UIViewController *)vc {
|
||||
NSInteger idx = vc.view.tag;
|
||||
if (idx <= 0) return nil;
|
||||
return [self viewControllerForIndex:idx - 1];
|
||||
}
|
||||
|
||||
- (UIViewController *)pageViewController:(UIPageViewController *)pvc viewControllerAfterViewController:(UIViewController *)vc {
|
||||
NSInteger idx = vc.view.tag;
|
||||
if (idx + 1 >= (NSInteger)self.items.count) return nil;
|
||||
return [self viewControllerForIndex:idx + 1];
|
||||
}
|
||||
|
||||
- (void)pageViewController:(UIPageViewController *)pvc didFinishAnimating:(BOOL)finished
|
||||
previousViewControllers:(NSArray<UIViewController *> *)prev transitionCompleted:(BOOL)completed {
|
||||
if (!completed) return;
|
||||
UIViewController *current = pvc.viewControllers.firstObject;
|
||||
self.currentIndex = (NSUInteger)current.view.tag;
|
||||
|
||||
// Pause previous video
|
||||
for (UIViewController *p in prev) {
|
||||
if ([p isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[((_SCIVideoPageVC *)p).playerVC.player pause];
|
||||
}
|
||||
}
|
||||
// Play new video
|
||||
if ([current isKindOfClass:[_SCIVideoPageVC class]]) {
|
||||
[((_SCIVideoPageVC *)current).playerVC.player play];
|
||||
}
|
||||
|
||||
[self updateChrome];
|
||||
}
|
||||
|
||||
- (BOOL)prefersStatusBarHidden { return YES; }
|
||||
- (BOOL)prefersHomeIndicatorAutoHidden { return YES; }
|
||||
|
||||
@end
|
||||
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
#pragma mark - Public API
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@implementation SCIMediaViewer
|
||||
|
||||
+ (void)presentNativeVideoPlayer:(NSURL *)url {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
AVPlayerViewController *playerVC = [[AVPlayerViewController alloc] init];
|
||||
playerVC.player = [AVPlayer playerWithURL:url];
|
||||
playerVC.modalPresentationStyle = UIModalPresentationFullScreen;
|
||||
[topMostController() presentViewController:playerVC animated:YES completion:^{
|
||||
[playerVC.player play];
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)showItem:(SCIMediaViewerItem *)item {
|
||||
if (!item) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
|
||||
|
||||
// Single video → native AVPlayerViewController directly (no wrapper)
|
||||
if (item.videoURL) {
|
||||
[self presentNativeVideoPlayer:item.videoURL];
|
||||
return;
|
||||
}
|
||||
|
||||
// Single photo → use our photo viewer container
|
||||
[self showItems:@[item] startIndex:0];
|
||||
}
|
||||
|
||||
+ (void)showItems:(NSArray<SCIMediaViewerItem *> *)items startIndex:(NSUInteger)index {
|
||||
if (!items.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to show")]; return; }
|
||||
if (index >= items.count) index = 0;
|
||||
|
||||
// Single video item → native player
|
||||
if (items.count == 1 && items[0].videoURL) {
|
||||
[self presentNativeVideoPlayer:items[0].videoURL];
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
_SCIMediaViewerContainerVC *vc = [[_SCIMediaViewerContainerVC alloc] init];
|
||||
vc.items = items;
|
||||
vc.currentIndex = index;
|
||||
vc.modalPresentationStyle = UIModalPresentationFullScreen;
|
||||
vc.modalTransitionStyle = UIModalTransitionStyleCrossDissolve;
|
||||
[topMostController() presentViewController:vc animated:YES completion:nil];
|
||||
});
|
||||
}
|
||||
|
||||
+ (void)showWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL caption:(NSString *)caption {
|
||||
[self showItem:[SCIMediaViewerItem itemWithVideoURL:videoURL photoURL:photoURL caption:caption]];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,10 @@
|
||||
// SCIRepostSheet — download media, save to Photos, open IG's creation flow.
|
||||
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface SCIRepostSheet : NSObject
|
||||
|
||||
/// Download media, save to Photos, open IG's creation flow.
|
||||
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,109 @@
|
||||
#import "SCIRepostSheet.h"
|
||||
#import "../Utils.h"
|
||||
#import "../Downloader/Download.h"
|
||||
#import "../PhotoAlbum.h"
|
||||
#import <Photos/Photos.h>
|
||||
|
||||
@implementation SCIRepostSheet
|
||||
|
||||
+ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL {
|
||||
NSURL *url = videoURL ?: photoURL;
|
||||
if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media URL")]; return; }
|
||||
|
||||
// Show pill
|
||||
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
|
||||
[pill resetState];
|
||||
[pill setText:SCILocalized(@"Preparing repost...")];
|
||||
[pill setSubtitle:nil];
|
||||
UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
|
||||
if (hostView) [pill showInView:hostView];
|
||||
|
||||
// Download to temp file
|
||||
NSString *ext = [[url lastPathComponent] pathExtension];
|
||||
if (!ext.length) ext = videoURL ? @"mp4" : @"jpg";
|
||||
NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"repost_%@.%@", [[NSUUID UUID] UUIDString], ext]];
|
||||
|
||||
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
|
||||
downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) {
|
||||
if (err || !loc) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Download failed")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
NSError *mv = nil;
|
||||
NSURL *fileURL = [NSURL fileURLWithPath:tmp];
|
||||
[[NSFileManager defaultManager] moveItemAtURL:loc toURL:fileURL error:&mv];
|
||||
if (mv) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Save failed")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to Photos and get the localIdentifier
|
||||
[self saveToPhotosAndOpenCreation:fileURL isVideo:(videoURL != nil) pill:pill];
|
||||
}];
|
||||
[task resume];
|
||||
}
|
||||
|
||||
+ (void)saveToPhotosAndOpenCreation:(NSURL *)fileURL isVideo:(BOOL)isVideo pill:(SCIDownloadPillView *)pill {
|
||||
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
|
||||
if (status != PHAuthorizationStatusAuthorized) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill showError:SCILocalized(@"Photos access denied")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
__block NSString *localId = nil;
|
||||
|
||||
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
|
||||
PHAssetCreationRequest *req;
|
||||
if (isVideo) {
|
||||
req = [PHAssetCreationRequest creationRequestForAssetFromVideoAtFileURL:fileURL];
|
||||
} else {
|
||||
UIImage *img = [UIImage imageWithContentsOfFile:fileURL.path];
|
||||
if (img) {
|
||||
req = [PHAssetCreationRequest creationRequestForAssetFromImage:img];
|
||||
} else {
|
||||
req = [PHAssetCreationRequest creationRequestForAsset];
|
||||
PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new];
|
||||
opts.shouldMoveFile = YES;
|
||||
[req addResourceWithType:PHAssetResourceTypePhoto fileURL:fileURL options:opts];
|
||||
}
|
||||
}
|
||||
localId = req.placeholderForCreatedAsset.localIdentifier;
|
||||
} completionHandler:^(BOOL success, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (!success || !localId.length) {
|
||||
[pill showError:SCILocalized(@"Failed to save")];
|
||||
[pill dismissAfterDelay:2.0];
|
||||
return;
|
||||
}
|
||||
|
||||
[pill showSuccess:SCILocalized(@"Opening creator...")];
|
||||
[pill dismissAfterDelay:1.0];
|
||||
|
||||
// Open IG's native creation flow with the saved asset
|
||||
NSString *urlStr = [NSString stringWithFormat:@"instagram://library?LocalIdentifier=%@",
|
||||
[localId stringByAddingPercentEncodingWithAllowedCharacters:
|
||||
[NSCharacterSet URLQueryAllowedCharacterSet]]];
|
||||
NSURL *igURL = [NSURL URLWithString:urlStr];
|
||||
if ([[UIApplication sharedApplication] canOpenURL:igURL]) {
|
||||
[[UIApplication sharedApplication] openURL:igURL options:@{} completionHandler:nil];
|
||||
} else {
|
||||
// Fallback: show share sheet
|
||||
[SCIUtils showShareVC:fileURL];
|
||||
}
|
||||
});
|
||||
}];
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -8,17 +8,34 @@
|
||||
#import "Manager.h"
|
||||
|
||||
@interface SCIDownloadPillView : UIView
|
||||
@property (nonatomic, strong) UIProgressView *progressRing;
|
||||
@property (nonatomic, strong) UIProgressView *progressBar;
|
||||
@property (nonatomic, strong) UILabel *textLabel;
|
||||
@property (nonatomic, strong) UILabel *subtitleLabel;
|
||||
@property (nonatomic, strong) UIButton *cancelButton;
|
||||
@property (nonatomic, strong) UIImageView *iconView;
|
||||
@property (nonatomic, copy) void (^onCancel)(void);
|
||||
|
||||
- (void)resetState;
|
||||
- (void)showInView:(UIView *)view;
|
||||
- (void)dismiss;
|
||||
- (void)dismissAfterDelay:(NSTimeInterval)delay;
|
||||
- (void)setProgress:(float)progress;
|
||||
- (void)setText:(NSString *)text;
|
||||
- (void)setSubtitle:(NSString *)text;
|
||||
- (void)showSuccess:(NSString *)text;
|
||||
- (void)showError:(NSString *)text;
|
||||
- (void)showBulkProgress:(NSUInteger)completed total:(NSUInteger)total;
|
||||
|
||||
// Multi-download ticket API. All methods are safe from any thread.
|
||||
// Tap-to-cancel pops the most recently pushed ticket.
|
||||
- (NSString *)beginTicketWithTitle:(NSString *)title onCancel:(void (^)(void))cancel;
|
||||
- (void)updateTicket:(NSString *)ticketId progress:(float)progress;
|
||||
- (void)updateTicket:(NSString *)ticketId text:(NSString *)text;
|
||||
- (void)finishTicket:(NSString *)ticketId successMessage:(NSString *)message;
|
||||
- (void)finishTicket:(NSString *)ticketId errorMessage:(NSString *)message;
|
||||
- (void)finishTicket:(NSString *)ticketId cancelled:(NSString *)message;
|
||||
|
||||
/// Shared singleton pill — reused across all downloads so only one shows at a time.
|
||||
+ (instancetype)shared;
|
||||
@end
|
||||
|
||||
@interface SCIDownloadDelegate : NSObject <SCIDownloadDelegateProtocol>
|
||||
@@ -33,6 +50,7 @@ typedef NS_ENUM(NSUInteger, DownloadAction) {
|
||||
|
||||
@property (nonatomic, strong) SCIDownloadManager *downloadManager;
|
||||
@property (nonatomic, strong) SCIDownloadPillView *pill;
|
||||
@property (nonatomic, copy) NSString *ticketId;
|
||||
|
||||
- (instancetype)initWithAction:(DownloadAction)action showProgress:(BOOL)showProgress;
|
||||
|
||||
|
||||
@@ -1,70 +1,146 @@
|
||||
#import "Download.h"
|
||||
#import "../PhotoAlbum.h"
|
||||
#import <Photos/Photos.h>
|
||||
|
||||
#pragma mark - Ticket slot
|
||||
|
||||
@interface SCIDownloadSlot : NSObject
|
||||
@property (nonatomic, copy) NSString *ticketId;
|
||||
@property (nonatomic, copy) NSString *title;
|
||||
@property (nonatomic, assign) float progress;
|
||||
@property (nonatomic, copy) void (^onCancel)(void);
|
||||
@property (nonatomic, assign) BOOL finished;
|
||||
@end
|
||||
@implementation SCIDownloadSlot @end
|
||||
|
||||
#pragma mark - SCIDownloadPillView
|
||||
|
||||
@interface SCIDownloadPillView ()
|
||||
@property (nonatomic, strong) NSMutableArray<SCIDownloadSlot *> *slots;
|
||||
@end
|
||||
|
||||
@implementation SCIDownloadPillView
|
||||
|
||||
+ (instancetype)shared {
|
||||
static SCIDownloadPillView *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [[SCIDownloadPillView alloc] init]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
- (instancetype)init {
|
||||
self = [super initWithFrame:CGRectZero];
|
||||
if (self) {
|
||||
self.backgroundColor = [UIColor colorWithWhite:0.1 alpha:0.92];
|
||||
self.layer.cornerRadius = 20;
|
||||
_slots = [NSMutableArray array];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(_sciAppDidBecomeActive)
|
||||
name:UIApplicationDidBecomeActiveNotification object:nil];
|
||||
[[NSNotificationCenter defaultCenter] addObserver:self
|
||||
selector:@selector(_sciAppDidEnterBackground)
|
||||
name:UIApplicationDidEnterBackgroundNotification object:nil];
|
||||
UIBlurEffect *blur = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemUltraThinMaterialDark];
|
||||
UIVisualEffectView *blurView = [[UIVisualEffectView alloc] initWithEffect:blur];
|
||||
blurView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
blurView.layer.cornerRadius = 16;
|
||||
blurView.clipsToBounds = YES;
|
||||
[self addSubview:blurView];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[blurView.topAnchor constraintEqualToAnchor:self.topAnchor],
|
||||
[blurView.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[blurView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||
[blurView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
]];
|
||||
|
||||
self.layer.cornerRadius = 16;
|
||||
self.clipsToBounds = YES;
|
||||
self.alpha = 0;
|
||||
|
||||
// Circular progress (using a small CAShapeLayer ring)
|
||||
_progressRing = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
|
||||
_progressRing.progressTintColor = [UIColor systemBlueColor];
|
||||
_progressRing.trackTintColor = [UIColor colorWithWhite:0.3 alpha:1.0];
|
||||
_progressRing.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_progressRing.layer.cornerRadius = 2;
|
||||
_progressRing.clipsToBounds = YES;
|
||||
[self addSubview:_progressRing];
|
||||
// Icon
|
||||
_iconView = [[UIImageView alloc] init];
|
||||
_iconView.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_iconView.tintColor = [UIColor whiteColor];
|
||||
_iconView.contentMode = UIViewContentModeScaleAspectFit;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
_iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
|
||||
[self addSubview:_iconView];
|
||||
|
||||
// Text
|
||||
_textLabel = [[UILabel alloc] init];
|
||||
_textLabel.text = @"Downloading 0%";
|
||||
_textLabel.text = SCILocalized(@"Downloading...");
|
||||
_textLabel.textColor = [UIColor whiteColor];
|
||||
_textLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
|
||||
_textLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
|
||||
_textLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_textLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:_textLabel];
|
||||
|
||||
// Subtitle
|
||||
_subtitleLabel = [[UILabel alloc] init];
|
||||
_subtitleLabel.text = @"Tap to cancel";
|
||||
_subtitleLabel.textColor = [UIColor colorWithWhite:0.6 alpha:1.0];
|
||||
_subtitleLabel.font = [UIFont systemFontOfSize:10 weight:UIFontWeightRegular];
|
||||
_subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
_subtitleLabel.textColor = [UIColor colorWithWhite:0.7 alpha:1.0];
|
||||
_subtitleLabel.font = [UIFont systemFontOfSize:11 weight:UIFontWeightRegular];
|
||||
_subtitleLabel.textAlignment = NSTextAlignmentCenter;
|
||||
_subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:_subtitleLabel];
|
||||
|
||||
// Tap gesture for cancel
|
||||
// Progress bar
|
||||
_progressBar = [[UIProgressView alloc] initWithProgressViewStyle:UIProgressViewStyleDefault];
|
||||
_progressBar.progressTintColor = [UIColor systemBlueColor];
|
||||
_progressBar.trackTintColor = [UIColor colorWithWhite:0.3 alpha:0.5];
|
||||
_progressBar.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
_progressBar.layer.cornerRadius = 1.5;
|
||||
_progressBar.clipsToBounds = YES;
|
||||
[self addSubview:_progressBar];
|
||||
|
||||
UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleTap)];
|
||||
[self addGestureRecognizer:tap];
|
||||
|
||||
// Layout: [progress bar]
|
||||
// [text centered]
|
||||
// [subtitle centered]
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[_progressRing.topAnchor constraintEqualToAnchor:self.topAnchor constant:12],
|
||||
[_progressRing.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16],
|
||||
[_progressRing.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
|
||||
[_progressRing.heightAnchor constraintEqualToConstant:4],
|
||||
[_iconView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:14],
|
||||
[_iconView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor constant:-2],
|
||||
[_iconView.widthAnchor constraintEqualToConstant:22],
|
||||
[_iconView.heightAnchor constraintEqualToConstant:22],
|
||||
|
||||
[_textLabel.topAnchor constraintEqualToAnchor:_progressRing.bottomAnchor constant:6],
|
||||
[_textLabel.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[_textLabel.topAnchor constraintEqualToAnchor:self.topAnchor constant:10],
|
||||
[_textLabel.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:10],
|
||||
[_textLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-14],
|
||||
|
||||
[_subtitleLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor constant:2],
|
||||
[_subtitleLabel.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[_subtitleLabel.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-10],
|
||||
[_subtitleLabel.topAnchor constraintEqualToAnchor:_textLabel.bottomAnchor constant:1],
|
||||
[_subtitleLabel.leadingAnchor constraintEqualToAnchor:_textLabel.leadingAnchor],
|
||||
[_subtitleLabel.trailingAnchor constraintEqualToAnchor:_textLabel.trailingAnchor],
|
||||
|
||||
[_progressBar.bottomAnchor constraintEqualToAnchor:self.bottomAnchor],
|
||||
[_progressBar.leadingAnchor constraintEqualToAnchor:self.leadingAnchor],
|
||||
[_progressBar.trailingAnchor constraintEqualToAnchor:self.trailingAnchor],
|
||||
[_progressBar.heightAnchor constraintEqualToConstant:3],
|
||||
|
||||
[_subtitleLabel.bottomAnchor constraintEqualToAnchor:_progressBar.topAnchor constant:-8],
|
||||
]];
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
- (void)handleTap {
|
||||
if (self.onCancel) self.onCancel();
|
||||
if (self.slots.count > 0) {
|
||||
SCIDownloadSlot *top = self.slots.lastObject;
|
||||
void (^cb)(void) = top.onCancel;
|
||||
top.onCancel = nil;
|
||||
if (cb) cb();
|
||||
return;
|
||||
}
|
||||
void (^cb)(void) = self.onCancel;
|
||||
self.onCancel = nil;
|
||||
if (cb) cb();
|
||||
}
|
||||
|
||||
- (void)resetState {
|
||||
self.progressBar.progress = 0;
|
||||
self.progressBar.hidden = NO;
|
||||
self.subtitleLabel.hidden = NO;
|
||||
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
self.textLabel.text = SCILocalized(@"Downloading...");
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor whiteColor];
|
||||
}
|
||||
|
||||
- (void)showInView:(UIView *)view {
|
||||
@@ -73,23 +149,32 @@
|
||||
[view addSubview:self];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[self.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor constant:4],
|
||||
[self.topAnchor constraintEqualToAnchor:view.safeAreaLayoutGuide.topAnchor constant:8],
|
||||
[self.centerXAnchor constraintEqualToAnchor:view.centerXAnchor],
|
||||
[self.widthAnchor constraintGreaterThanOrEqualToConstant:160],
|
||||
[self.widthAnchor constraintLessThanOrEqualToConstant:220],
|
||||
[self.widthAnchor constraintGreaterThanOrEqualToConstant:200],
|
||||
[self.widthAnchor constraintLessThanOrEqualToConstant:300],
|
||||
]];
|
||||
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
[UIView animateWithDuration:0.3 delay:0 usingSpringWithDamping:0.8 initialSpringVelocity:0.5
|
||||
options:UIViewAnimationOptionCurveEaseOut animations:^{
|
||||
self.alpha = 1;
|
||||
}];
|
||||
} completion:nil];
|
||||
}
|
||||
|
||||
- (void)dismiss {
|
||||
[UIView animateWithDuration:0.2 animations:^{
|
||||
self.alpha = 0;
|
||||
} completion:^(BOOL finished) {
|
||||
[self removeFromSuperview];
|
||||
}];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
// A new ticket raced in — keep the pill alive.
|
||||
if (self.slots.count > 0) return;
|
||||
if (self.alpha <= 0.01 && !self.superview) return;
|
||||
self.onCancel = nil;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
self.alpha = 0;
|
||||
self.transform = CGAffineTransformMakeScale(0.9, 0.9);
|
||||
} completion:^(BOOL finished) {
|
||||
[self removeFromSuperview];
|
||||
self.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
- (void)dismissAfterDelay:(NSTimeInterval)delay {
|
||||
@@ -99,13 +184,203 @@
|
||||
}
|
||||
|
||||
- (void)setProgress:(float)progress {
|
||||
[self.progressRing setProgress:progress animated:YES];
|
||||
self.progressBar.hidden = NO;
|
||||
[self.progressBar setProgress:progress animated:YES];
|
||||
}
|
||||
|
||||
- (void)setText:(NSString *)text {
|
||||
self.textLabel.text = text;
|
||||
}
|
||||
|
||||
- (void)setSubtitle:(NSString *)text {
|
||||
self.subtitleLabel.text = text;
|
||||
self.subtitleLabel.hidden = (text.length == 0);
|
||||
}
|
||||
|
||||
- (void)showSuccess:(NSString *)text {
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"checkmark.circle.fill" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor systemGreenColor];
|
||||
self.textLabel.text = text;
|
||||
self.subtitleLabel.hidden = YES;
|
||||
self.progressBar.hidden = YES;
|
||||
self.onCancel = nil;
|
||||
}
|
||||
|
||||
- (void)showError:(NSString *)text {
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"xmark.circle.fill" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor systemRedColor];
|
||||
self.textLabel.text = text;
|
||||
self.subtitleLabel.hidden = YES;
|
||||
self.progressBar.hidden = YES;
|
||||
self.onCancel = nil;
|
||||
}
|
||||
|
||||
- (void)showBulkProgress:(NSUInteger)completed total:(NSUInteger)total {
|
||||
self.textLabel.text = [NSString stringWithFormat:@"Downloading %lu of %lu", (unsigned long)completed + 1, (unsigned long)total];
|
||||
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
self.subtitleLabel.hidden = NO;
|
||||
self.progressBar.hidden = NO;
|
||||
[self.progressBar setProgress:(float)completed / (float)total animated:YES];
|
||||
}
|
||||
|
||||
#pragma mark - Ticket API
|
||||
|
||||
- (void)_onMain:(dispatch_block_t)block {
|
||||
if ([NSThread isMainThread]) block();
|
||||
else dispatch_async(dispatch_get_main_queue(), block);
|
||||
}
|
||||
|
||||
- (SCIDownloadSlot *)_slotForId:(NSString *)ticketId {
|
||||
if (!ticketId) return nil;
|
||||
for (SCIDownloadSlot *s in self.slots) {
|
||||
if ([s.ticketId isEqualToString:ticketId]) return s;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
- (void)_renderTop {
|
||||
SCIDownloadSlot *top = self.slots.lastObject;
|
||||
if (!top) return;
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:@"arrow.down.circle" withConfiguration:cfg];
|
||||
self.iconView.tintColor = [UIColor whiteColor];
|
||||
self.textLabel.text = top.title ?: @"Downloading...";
|
||||
self.progressBar.hidden = NO;
|
||||
[self.progressBar setProgress:top.progress animated:YES];
|
||||
self.subtitleLabel.hidden = NO;
|
||||
if (self.slots.count > 1) {
|
||||
self.subtitleLabel.text = [NSString stringWithFormat:@"%lu active — tap to cancel",
|
||||
(unsigned long)self.slots.count];
|
||||
} else {
|
||||
self.subtitleLabel.text = SCILocalized(@"Tap to cancel");
|
||||
}
|
||||
}
|
||||
|
||||
- (NSString *)beginTicketWithTitle:(NSString *)title onCancel:(void (^)(void))cancel {
|
||||
NSString *ticketId = [[NSUUID UUID] UUIDString];
|
||||
void (^cancelCopy)(void) = [cancel copy];
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *slot = [SCIDownloadSlot new];
|
||||
slot.ticketId = ticketId;
|
||||
slot.title = title ?: @"Downloading...";
|
||||
slot.progress = 0;
|
||||
slot.onCancel = cancelCopy;
|
||||
[self.slots addObject:slot];
|
||||
|
||||
// Reset visual state so the prior download's final frame doesn't leak in.
|
||||
[self.progressBar setProgress:0 animated:NO];
|
||||
self.alpha = 1;
|
||||
self.transform = CGAffineTransformIdentity;
|
||||
if (!self.superview) {
|
||||
UIView *host = [UIApplication sharedApplication].keyWindow ?: topMostController().view;
|
||||
if (host) [self showInView:host];
|
||||
}
|
||||
[self _renderTop];
|
||||
}];
|
||||
return ticketId;
|
||||
}
|
||||
|
||||
- (void)_sciAppDidBecomeActive {
|
||||
[self _onMain:^{
|
||||
if (self.slots.count == 0 && (self.superview || self.alpha > 0.01)) {
|
||||
self.alpha = 0;
|
||||
self.transform = CGAffineTransformIdentity;
|
||||
[self removeFromSuperview];
|
||||
} else if (self.slots.count > 0) {
|
||||
[self _renderTop];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// iOS suspends networking + ffmpeg on background — cancel active tickets so the
|
||||
// pill clears cleanly on return. User re-initiates the download.
|
||||
- (void)_sciAppDidEnterBackground {
|
||||
[self _onMain:^{
|
||||
for (SCIDownloadSlot *slot in [self.slots copy]) {
|
||||
void (^cb)(void) = slot.onCancel;
|
||||
slot.onCancel = nil;
|
||||
if (cb) cb();
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)dealloc {
|
||||
[[NSNotificationCenter defaultCenter] removeObserver:self];
|
||||
}
|
||||
|
||||
- (void)updateTicket:(NSString *)ticketId progress:(float)progress {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
if (!s || s.finished) return;
|
||||
s.progress = progress;
|
||||
if (self.slots.lastObject == s) [self.progressBar setProgress:progress animated:YES];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)updateTicket:(NSString *)ticketId text:(NSString *)text {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
if (!s || s.finished) return;
|
||||
s.title = text ?: s.title;
|
||||
if (self.slots.lastObject == s) self.textLabel.text = s.title;
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)_removeSlot:(SCIDownloadSlot *)slot
|
||||
finalText:(NSString *)finalText
|
||||
finalIcon:(NSString *)finalIcon
|
||||
iconColor:(UIColor *)iconColor {
|
||||
if (!slot || slot.finished) return;
|
||||
slot.finished = YES;
|
||||
slot.onCancel = nil;
|
||||
[self.slots removeObject:slot];
|
||||
|
||||
if (self.slots.count > 0) {
|
||||
[self _renderTop];
|
||||
return;
|
||||
}
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium];
|
||||
self.iconView.image = [UIImage systemImageNamed:finalIcon withConfiguration:cfg];
|
||||
self.iconView.tintColor = iconColor;
|
||||
self.textLabel.text = finalText;
|
||||
self.subtitleLabel.hidden = YES;
|
||||
self.progressBar.hidden = YES;
|
||||
[self dismissAfterDelay:1.2];
|
||||
}
|
||||
|
||||
- (void)finishTicket:(NSString *)ticketId successMessage:(NSString *)message {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
[self _removeSlot:s
|
||||
finalText:message ?: @"Done"
|
||||
finalIcon:@"checkmark.circle.fill"
|
||||
iconColor:[UIColor systemGreenColor]];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)finishTicket:(NSString *)ticketId errorMessage:(NSString *)message {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
[self _removeSlot:s
|
||||
finalText:message ?: @"Failed"
|
||||
finalIcon:@"xmark.circle.fill"
|
||||
iconColor:[UIColor systemRedColor]];
|
||||
}];
|
||||
}
|
||||
|
||||
- (void)finishTicket:(NSString *)ticketId cancelled:(NSString *)message {
|
||||
[self _onMain:^{
|
||||
SCIDownloadSlot *s = [self _slotForId:ticketId];
|
||||
[self _removeSlot:s
|
||||
finalText:message ?: @"Cancelled"
|
||||
finalIcon:@"xmark.circle.fill"
|
||||
iconColor:[UIColor systemOrangeColor]];
|
||||
}];
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
|
||||
@@ -126,33 +401,13 @@
|
||||
}
|
||||
|
||||
- (void)downloadFileWithURL:(NSURL *)url fileExtension:(NSString *)fileExtension hudLabel:(NSString *)hudLabel {
|
||||
// Dismiss any existing pill
|
||||
[self.pill dismiss];
|
||||
|
||||
self.pill = [[SCIDownloadPillView alloc] init];
|
||||
|
||||
if (hudLabel) {
|
||||
[self.pill setText:hudLabel];
|
||||
}
|
||||
|
||||
if (!self.showProgress) {
|
||||
self.pill.progressRing.hidden = YES;
|
||||
self.pill.subtitleLabel.text = nil;
|
||||
}
|
||||
SCIDownloadPillView *pill = [SCIDownloadPillView shared];
|
||||
self.pill = pill;
|
||||
|
||||
__weak typeof(self) weakSelf = self;
|
||||
self.pill.onCancel = ^{
|
||||
self.ticketId = [pill beginTicketWithTitle:hudLabel ?: @"Downloading..." onCancel:^{
|
||||
[weakSelf.downloadManager cancelDownload];
|
||||
};
|
||||
|
||||
// Show on keyWindow so it survives VC transitions (e.g. leaving stories)
|
||||
UIView *hostView = [UIApplication sharedApplication].keyWindow;
|
||||
if (!hostView) hostView = topMostController().view;
|
||||
if (!hostView) {
|
||||
NSLog(@"[SCInsta] Download: No valid view");
|
||||
return;
|
||||
}
|
||||
[self.pill showInView:hostView];
|
||||
}];
|
||||
|
||||
NSLog(@"[SCInsta] Download: Will start download for url \"%@\" with file extension: \".%@\"", url, fileExtension);
|
||||
[self.downloadManager downloadFileWithURL:url fileExtension:fileExtension];
|
||||
@@ -163,41 +418,30 @@
|
||||
}
|
||||
|
||||
- (void)downloadDidCancel {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.pill setText:@"Cancelled"];
|
||||
self.pill.subtitleLabel.text = nil;
|
||||
self.pill.progressRing.hidden = YES;
|
||||
[self.pill dismissAfterDelay:0.8];
|
||||
});
|
||||
[self.pill finishTicket:self.ticketId cancelled:@"Cancelled"];
|
||||
NSLog(@"[SCInsta] Download: Download cancelled");
|
||||
}
|
||||
|
||||
- (void)downloadDidProgress:(float)progress {
|
||||
if (self.showProgress) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.pill setProgress:progress];
|
||||
[self.pill setText:[NSString stringWithFormat:@"Downloading %d%%", (int)(progress * 100)]];
|
||||
});
|
||||
}
|
||||
if (!self.showProgress) return;
|
||||
[self.pill updateTicket:self.ticketId progress:progress];
|
||||
[self.pill updateTicket:self.ticketId text:[NSString stringWithFormat:@"Downloading %d%%", (int)(progress * 100)]];
|
||||
}
|
||||
|
||||
- (void)downloadDidFinishWithError:(NSError *)error {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (error && error.code != NSURLErrorCancelled) {
|
||||
NSLog(@"[SCInsta] Download: Download failed with error: \"%@\"", error);
|
||||
[self.pill setText:@"Download failed"];
|
||||
self.pill.subtitleLabel.text = nil;
|
||||
self.pill.progressRing.hidden = YES;
|
||||
[self.pill dismissAfterDelay:2.0];
|
||||
}
|
||||
});
|
||||
if (error && error.code != NSURLErrorCancelled) {
|
||||
NSLog(@"[SCInsta] Download: Download failed with error: \"%@\"", error);
|
||||
[self.pill finishTicket:self.ticketId errorMessage:SCILocalized(@"Download failed")];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)downloadDidFinishWithFileURL:(NSURL *)fileURL {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[self.pill dismiss];
|
||||
|
||||
NSLog(@"[SCInsta] Download: Finished with url: \"%@\"", [fileURL absoluteString]);
|
||||
// saveToPhotos finishes the ticket after the PH completion fires.
|
||||
if (self.action != saveToPhotos) {
|
||||
[self.pill finishTicket:self.ticketId successMessage:SCILocalized(@"Done")];
|
||||
}
|
||||
|
||||
switch (self.action) {
|
||||
case share:
|
||||
@@ -212,45 +456,37 @@
|
||||
[PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) {
|
||||
if (status != PHAuthorizationStatusAuthorized) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[SCIUtils showErrorHUDWithDescription:@"Photo library access denied"];
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
|
||||
NSString *ext = [[fileURL pathExtension] lowercaseString];
|
||||
BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext];
|
||||
|
||||
if (isVideo) {
|
||||
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
|
||||
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
|
||||
opts.shouldMoveFile = YES;
|
||||
[req addResourceWithType:PHAssetResourceTypeVideo fileURL:fileURL options:opts];
|
||||
req.creationDate = [NSDate date];
|
||||
} else {
|
||||
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
|
||||
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
|
||||
opts.shouldMoveFile = YES;
|
||||
[req addResourceWithType:PHAssetResourceTypePhoto fileURL:fileURL options:opts];
|
||||
req.creationDate = [NSDate date];
|
||||
}
|
||||
} completionHandler:^(BOOL success, NSError *error) {
|
||||
BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"];
|
||||
void (^onDone)(BOOL, NSError *) = ^(BOOL success, NSError *error) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (success) {
|
||||
SCIDownloadPillView *donePill = [[SCIDownloadPillView alloc] init];
|
||||
donePill.progressRing.hidden = YES;
|
||||
donePill.subtitleLabel.text = nil;
|
||||
[donePill setText:@"Saved to Photos"];
|
||||
UIView *hostView = topMostController().view;
|
||||
if (hostView) {
|
||||
[donePill showInView:hostView];
|
||||
[donePill dismissAfterDelay:1.5];
|
||||
}
|
||||
[self.pill finishTicket:self.ticketId
|
||||
successMessage:useAlbum ? SCILocalized(@"Saved to RyukGram") : SCILocalized(@"Saved to Photos")];
|
||||
} else {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Failed to save to Photos"];
|
||||
[self.pill finishTicket:self.ticketId errorMessage:SCILocalized(@"Failed to save")];
|
||||
}
|
||||
});
|
||||
}];
|
||||
};
|
||||
|
||||
if (useAlbum) {
|
||||
[SCIPhotoAlbum saveFileToAlbum:fileURL completion:onDone];
|
||||
} else {
|
||||
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
|
||||
NSString *ext = [[fileURL pathExtension] lowercaseString];
|
||||
BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext];
|
||||
PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset];
|
||||
PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init];
|
||||
opts.shouldMoveFile = YES;
|
||||
[req addResourceWithType:(isVideo ? PHAssetResourceTypeVideo : PHAssetResourceTypePhoto)
|
||||
fileURL:fileURL options:opts];
|
||||
req.creationDate = [NSDate date];
|
||||
} completionHandler:onDone];
|
||||
}
|
||||
}];
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
// Feed action button — hooks IGUFIInteractionCountsView.
|
||||
// Media lives on sibling cells (IGFeedItemPhotoCell, IGModernFeedVideoCell)
|
||||
// in the same collection view section, NOT on the UFI cell itself.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static const NSInteger kFeedActionBtnTag = 13370;
|
||||
static const void *kFeedPageIndexKey = &kFeedPageIndexKey;
|
||||
|
||||
// Read _currentMediaPK from IGFeedItemUFICell.
|
||||
static NSString *sciFeedCurrentMediaPK(UIView *button) {
|
||||
UIResponder *r = button;
|
||||
Class ufiCls = NSClassFromString(@"IGFeedItemUFICell");
|
||||
while (r && !(ufiCls && [r isKindOfClass:ufiCls])) r = [r nextResponder];
|
||||
if (!r) return nil;
|
||||
Ivar iv = class_getInstanceVariable(object_getClass(r), "_currentMediaPK");
|
||||
if (!iv) return nil;
|
||||
id val = object_getIvar(r, iv);
|
||||
return [val isKindOfClass:[NSString class]] ? val : nil;
|
||||
}
|
||||
|
||||
// Current carousel page index. Returns -1 if not found.
|
||||
static NSInteger sciFeedCarouselPageIndex(UIView *button) {
|
||||
// Walk up to collection view
|
||||
UIView *v = button;
|
||||
UICollectionViewCell *ufiCell = nil;
|
||||
UICollectionView *cv = nil;
|
||||
while (v) {
|
||||
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
|
||||
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
|
||||
ufiCell = (UICollectionViewCell *)v;
|
||||
}
|
||||
if ([v isKindOfClass:[UICollectionView class]]) {
|
||||
cv = (UICollectionView *)v;
|
||||
break;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
if (!ufiCell || !cv) return -1;
|
||||
|
||||
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
|
||||
if (!ufiPath) return -1;
|
||||
NSInteger section = ufiPath.section;
|
||||
|
||||
// Find IGFeedItemPageCell in same section
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
NSString *cls = NSStringFromClass([cell class]);
|
||||
if (![cls containsString:@"Page"]) continue;
|
||||
|
||||
// BFS for IGPageMediaView
|
||||
Class pmvCls = NSClassFromString(@"IGPageMediaView");
|
||||
if (pmvCls) {
|
||||
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
|
||||
int scanned = 0;
|
||||
UIView *pmv = nil;
|
||||
while (queue.count && scanned < 50) {
|
||||
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
|
||||
if ([cur isKindOfClass:pmvCls]) { pmv = cur; break; }
|
||||
for (UIView *s in cur.subviews) [queue addObject:s];
|
||||
}
|
||||
if (pmv && [pmv respondsToSelector:@selector(currentMediaItem)] && [pmv respondsToSelector:@selector(items)]) {
|
||||
@try {
|
||||
id current = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(currentMediaItem));
|
||||
NSArray *items = ((id(*)(id,SEL))objc_msgSend)(pmv, @selector(items));
|
||||
if (current && items.count) {
|
||||
NSUInteger idx = [items indexOfObjectIdenticalTo:current];
|
||||
if (idx != NSNotFound) return (NSInteger)idx;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: _currentIndex ivar on the page cell
|
||||
Ivar idxIvar = class_getInstanceVariable([cell class], "_currentIndex");
|
||||
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentPage");
|
||||
if (!idxIvar) idxIvar = class_getInstanceVariable([cell class], "_currentMediaIndex");
|
||||
if (idxIvar) {
|
||||
ptrdiff_t offset = ivar_getOffset(idxIvar);
|
||||
NSInteger idx = *(NSInteger *)((char *)(__bridge void *)cell + offset);
|
||||
return idx;
|
||||
}
|
||||
|
||||
// Fallback: compute page from scroll view content offset
|
||||
{
|
||||
NSMutableArray *sq = [NSMutableArray arrayWithObject:cell];
|
||||
int sc = 0;
|
||||
while (sq.count && sc < 100) {
|
||||
UIView *cur = sq.firstObject; [sq removeObjectAtIndex:0]; sc++;
|
||||
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
|
||||
UIScrollView *sv = (UIScrollView *)cur;
|
||||
CGFloat pageW = sv.bounds.size.width;
|
||||
// Horizontal paging scroll view
|
||||
if (pageW > 100 && sv.contentSize.width > pageW * 1.5) {
|
||||
NSInteger idx = (NSInteger)round(sv.contentOffset.x / pageW);
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
for (UIView *s in cur.subviews) [sq addObject:s];
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Resolve current carousel child using page index.
|
||||
static id sciFeedResolveCarouselChild(id parentMedia, UIView *button) {
|
||||
if (!parentMedia) return nil;
|
||||
if (![SCIMediaActions isCarouselMedia:parentMedia]) return parentMedia;
|
||||
|
||||
NSInteger idx = sciFeedCarouselPageIndex(button);
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
|
||||
if (idx >= 0 && (NSUInteger)idx < children.count) {
|
||||
return children[idx];
|
||||
}
|
||||
return parentMedia;
|
||||
}
|
||||
|
||||
// Extract IGMedia from sibling cells in the same collection view section.
|
||||
static IGMedia *sciFeedMediaFromButton(UIView *button) {
|
||||
if (!button) return nil;
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
if (!mediaClass) return nil;
|
||||
|
||||
// Walk up to find UFI cell and collection view
|
||||
UIView *v = button;
|
||||
UICollectionViewCell *ufiCell = nil;
|
||||
UICollectionView *cv = nil;
|
||||
|
||||
while (v) {
|
||||
if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]
|
||||
&& [NSStringFromClass([v class]) containsString:@"UFI"]) {
|
||||
ufiCell = (UICollectionViewCell *)v;
|
||||
}
|
||||
if ([v isKindOfClass:[UICollectionView class]]) {
|
||||
cv = (UICollectionView *)v;
|
||||
break;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
|
||||
if (!ufiCell || !cv) return nil;
|
||||
|
||||
// Get section
|
||||
NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell];
|
||||
if (!ufiPath) return nil;
|
||||
NSInteger section = ufiPath.section;
|
||||
|
||||
// Search sibling cells for IGMedia
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
if (cell == ufiCell) continue;
|
||||
|
||||
// Filter to media cell classes
|
||||
NSString *cls = NSStringFromClass([cell class]);
|
||||
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
|
||||
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
|
||||
|
||||
// Scan ivars for IGMedia
|
||||
unsigned int count = 0;
|
||||
Class c = object_getClass(cell);
|
||||
while (c && c != [UICollectionViewCell class]) {
|
||||
Ivar *ivars = class_copyIvarList(c, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(cell, ivars[i]);
|
||||
if (val && [val isKindOfClass:mediaClass]) {
|
||||
free(ivars);
|
||||
return (IGMedia *)val;
|
||||
}
|
||||
// Try .media selector on wrapper objects
|
||||
if (val && [val respondsToSelector:@selector(media)]) {
|
||||
id m = ((id(*)(id,SEL))objc_msgSend)(val, @selector(media));
|
||||
if (m && [m isKindOfClass:mediaClass]) {
|
||||
free(ivars);
|
||||
return (IGMedia *)m;
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
c = class_getSuperclass(c);
|
||||
}
|
||||
|
||||
// Try mediaCellFeedItem (video cells)
|
||||
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
|
||||
@try {
|
||||
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
|
||||
if (m && [m isKindOfClass:mediaClass]) {
|
||||
return (IGMedia *)m;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGUFIInteractionCountsView
|
||||
|
||||
- (void)updateUFIWithButtonsConfig:(id)config interactionCountProvider:(id)provider {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"feed_action_button"]) return;
|
||||
|
||||
UIButton *btn = (UIButton *)[self viewWithTag:kFeedActionBtnTag];
|
||||
if (!btn) {
|
||||
btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
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;
|
||||
[self addSubview:btn];
|
||||
|
||||
// Position: right side, left of bookmark. Shifted up 4pt to
|
||||
// align with the native like/comment/share icons.
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-44],
|
||||
[btn.centerYAnchor constraintEqualToAnchor:self.centerYAnchor constant:-6],
|
||||
[btn.widthAnchor constraintEqualToConstant:36],
|
||||
[btn.heightAnchor constraintEqualToConstant:36],
|
||||
]];
|
||||
}
|
||||
|
||||
// Reconfigure with fresh media provider.
|
||||
[SCIActionButton configureButton:btn
|
||||
context:SCIActionContextFeed
|
||||
prefKey:@"feed_action_default"
|
||||
mediaProvider:^id (UIView *sourceView) {
|
||||
id parentMedia = sciFeedMediaFromButton(sourceView);
|
||||
if (!parentMedia) return nil;
|
||||
|
||||
if ([SCIMediaActions isCarouselMedia:parentMedia]) {
|
||||
NSInteger idx = sciFeedCarouselPageIndex(sourceView);
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
|
||||
if (idx >= 0 && (NSUInteger)idx < children.count) {
|
||||
// Stash page index for the menu builder to find the parent.
|
||||
objc_setAssociatedObject(sourceView, kFeedPageIndexKey,
|
||||
@(idx), OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
return children[idx];
|
||||
}
|
||||
}
|
||||
return parentMedia;
|
||||
}];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,171 @@
|
||||
// Reels action button — injects a RyukGram action button above the reel's
|
||||
// vertical like/comment/share sidebar (IGSundialViewerVerticalUFI).
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../ActionButton/SCIActionButton.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static const NSInteger kReelActionBtnTag = 1337;
|
||||
|
||||
static UIView *sciFindSuperviewOfClass(UIView *view, NSString *className) {
|
||||
Class cls = NSClassFromString(className);
|
||||
if (!cls) return nil;
|
||||
UIView *current = view.superview;
|
||||
for (int depth = 0; current && depth < 20; depth++) {
|
||||
if ([current isKindOfClass:cls]) return current;
|
||||
current = current.superview;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id sciFindMediaIvar(UIView *view) {
|
||||
if (!view) return nil;
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
if (!mediaClass) return nil;
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList([view class], &count);
|
||||
id found = nil;
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(view, ivars[i]);
|
||||
if (val && [val isKindOfClass:mediaClass]) { found = val; break; }
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Resolve the current carousel child from _currentIndex.
|
||||
static id sciCurrentCarouselChildMedia(UIView *carouselCell, id parentMedia) {
|
||||
if (!carouselCell || !parentMedia) return parentMedia;
|
||||
|
||||
// Try _currentIndex ivar
|
||||
Ivar idxIvar = class_getInstanceVariable([carouselCell class], "_currentIndex");
|
||||
NSInteger currentIdx = 0;
|
||||
if (idxIvar) {
|
||||
ptrdiff_t offset = ivar_getOffset(idxIvar);
|
||||
currentIdx = *(NSInteger *)((char *)(__bridge void *)carouselCell + offset);
|
||||
}
|
||||
|
||||
// Fallback: _currentFractionalIndex
|
||||
if (!idxIvar || currentIdx == 0) {
|
||||
Ivar fracIvar = class_getInstanceVariable([carouselCell class], "_currentFractionalIndex");
|
||||
if (fracIvar) {
|
||||
ptrdiff_t fOffset = ivar_getOffset(fracIvar);
|
||||
double fracIdx = *(double *)((char *)(__bridge void *)carouselCell + fOffset);
|
||||
NSInteger roundedIdx = (NSInteger)round(fracIdx);
|
||||
if (roundedIdx > 0) currentIdx = roundedIdx;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: inner collection view content offset
|
||||
Ivar cvIvar = class_getInstanceVariable([carouselCell class], "_collectionView");
|
||||
if (cvIvar) {
|
||||
UICollectionView *cv = object_getIvar(carouselCell, cvIvar);
|
||||
if (cv) {
|
||||
CGFloat pageWidth = cv.bounds.size.width;
|
||||
if (pageWidth > 0) {
|
||||
NSInteger cvIdx = (NSInteger)round(cv.contentOffset.x / pageWidth);
|
||||
if (cvIdx > currentIdx) currentIdx = cvIdx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia];
|
||||
if (currentIdx >= 0 && (NSUInteger)currentIdx < children.count) {
|
||||
return children[currentIdx];
|
||||
}
|
||||
return parentMedia;
|
||||
}
|
||||
|
||||
// Media provider for reels. Returns current page's child for carousels.
|
||||
static id sciReelsMediaProvider(UIView *sourceView) {
|
||||
// Video reel
|
||||
UIView *videoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerVideoCell");
|
||||
if (videoCell) {
|
||||
id m = sciFindMediaIvar(videoCell);
|
||||
if (m) return m;
|
||||
}
|
||||
|
||||
// Photo reel
|
||||
UIView *photoCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerPhotoCell");
|
||||
if (photoCell) {
|
||||
id m = sciFindMediaIvar(photoCell);
|
||||
if (m) return m;
|
||||
}
|
||||
|
||||
// Carousel reel
|
||||
UIView *carouselCell = sciFindSuperviewOfClass(sourceView, @"IGSundialViewerCarouselCell");
|
||||
if (carouselCell) {
|
||||
id parentMedia = sciFindMediaIvar(carouselCell);
|
||||
if (parentMedia) {
|
||||
return sciCurrentCarouselChildMedia(carouselCell, parentMedia);
|
||||
}
|
||||
}
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGSundialViewerVerticalUFI
|
||||
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"reels_action_button"]) return;
|
||||
if (!self.superview) return;
|
||||
|
||||
UIButton *btn = (UIButton *)[self viewWithTag:kReelActionBtnTag];
|
||||
|
||||
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.
|
||||
CGFloat pad = 8;
|
||||
CGSize sz = CGSizeMake(base.size.width + pad * 2, base.size.height + pad * 2);
|
||||
UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz];
|
||||
UIImage *icon = [r imageWithActions:^(UIGraphicsImageRendererContext *ctx) {
|
||||
CGContextRef c = ctx.CGContext;
|
||||
CGContextSaveGState(c);
|
||||
CGContextSetShadowWithColor(c, CGSizeMake(0, 1), 3,
|
||||
[UIColor colorWithWhite:0 alpha:0.55].CGColor);
|
||||
UIImage *tinted = [base imageWithTintColor:[UIColor whiteColor]
|
||||
renderingMode:UIImageRenderingModeAlwaysOriginal];
|
||||
[tinted drawInRect:CGRectMake(pad, pad, base.size.width, base.size.height)];
|
||||
CGContextRestoreGState(c);
|
||||
}];
|
||||
|
||||
[btn setImage:icon forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor whiteColor];
|
||||
|
||||
self.clipsToBounds = NO;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[self addSubview:btn];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.topAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:40],
|
||||
[btn.heightAnchor constraintEqualToConstant:40]
|
||||
]];
|
||||
}
|
||||
|
||||
// Reconfigure with fresh media provider.
|
||||
[SCIActionButton configureButton:btn
|
||||
context:SCIActionContextReels
|
||||
prefKey:@"reels_action_default"
|
||||
mediaProvider:^id (UIView *sourceView) {
|
||||
return sciReelsMediaProvider(sourceView);
|
||||
}];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -18,17 +18,22 @@
|
||||
// Follow button on profile page
|
||||
%hook IGFollowController
|
||||
- (void)_didPressFollowButton {
|
||||
// Get user follow status (check if already following user)
|
||||
NSInteger UserFollowStatus = self.user.followStatus;
|
||||
|
||||
// Only show confirm dialog if user is not following
|
||||
if (UserFollowStatus == 2) {
|
||||
NSInteger status = self.user.followStatus;
|
||||
if (status == 2) {
|
||||
CONFIRMFOLLOW(%orig);
|
||||
}
|
||||
else {
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
// Unfollow from profile action sheet
|
||||
- (void)_performUnfollow {
|
||||
if ([SCIUtils getBoolPref:@"unfollow_confirm"]) {
|
||||
[SCIUtils showConfirmation:^(void) { %orig; } title:SCILocalized(@"Unfollow?")];
|
||||
} else {
|
||||
%orig;
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
// Follow button on discover people page
|
||||
|
||||
@@ -1,33 +1,28 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook IGDirectDisappearingModeSwipeHandler
|
||||
- (void)handleBottomSwipeableScrollUpdate {
|
||||
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return;
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
else %orig;
|
||||
}
|
||||
- (id)getSwipeableScrollHintTextInfo {
|
||||
if ([SCIUtils getBoolPref:@"disable_disappearing_mode_swipe"]) return nil;
|
||||
return %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGDirectThreadViewController
|
||||
- (void)swipeableScrollManagerDidEndDraggingAboveSwipeThreshold:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm shh mode triggered");
|
||||
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)shhModeTransitionButtonDidTap:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm shh mode triggered");
|
||||
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
- (void)messageListViewControllerDidToggleShhMode:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) {
|
||||
NSLog(@"[SCInsta] Confirm shh mode triggered");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
} else {
|
||||
return %orig;
|
||||
}
|
||||
else %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
- (void)messageListViewControllerDidReplayInShhMode:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"shh_mode_confirm"])
|
||||
[SCIUtils showConfirmation:^(void) { %orig; }];
|
||||
else %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
@@ -1,10 +1,32 @@
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// Demangled name: IGFeedPlayback.IGFeedPlaybackStrategy
|
||||
%hook _TtC14IGFeedPlayback22IGFeedPlaybackStrategy
|
||||
- (id)initWithShouldDisableAutoplay:(_Bool)autoplay {
|
||||
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) return %orig(true);
|
||||
// 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.
|
||||
|
||||
return %orig(autoplay);
|
||||
static id (*orig_initStrategy2)(id, SEL, BOOL, BOOL);
|
||||
static id new_initStrategy2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) {
|
||||
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
|
||||
return orig_initStrategy2(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) {
|
||||
if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES;
|
||||
return orig_initStrategy3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover);
|
||||
}
|
||||
|
||||
%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);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,185 @@
|
||||
// Story tray long-press actions — adds "View profile picture" to the action sheet.
|
||||
// Fetches HD profile pic via /api/v1/users/{pk}/info/.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static __weak id sciLongPressedTrayCell = nil;
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
static UIImage *sciProfileImageFromCell(id cell) {
|
||||
Ivar avIvar = class_getInstanceVariable([cell class], "_avatarView");
|
||||
if (!avIvar) return nil;
|
||||
UIView *avatarView = object_getIvar(cell, avIvar);
|
||||
if (!avatarView) return nil;
|
||||
Ivar imgIvar = class_getInstanceVariable([avatarView class], "_ownerImageView");
|
||||
if (!imgIvar) return nil;
|
||||
UIImageView *imgView = object_getIvar(avatarView, imgIvar);
|
||||
if ([imgView isKindOfClass:[UIImageView class]]) return imgView.image;
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSString *sciUsernameFromCell(id cell) {
|
||||
@try {
|
||||
Ivar mi = class_getInstanceVariable([cell class], "_model");
|
||||
if (!mi) return nil;
|
||||
id model = object_getIvar(cell, mi);
|
||||
id title = [model valueForKey:@"title"];
|
||||
if ([title isKindOfClass:[NSAttributedString class]])
|
||||
return [[(NSAttributedString *)title string] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]];
|
||||
} @catch (NSException *e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSString *sciFullNameFromCell(id cell) {
|
||||
@try {
|
||||
Ivar mi = class_getInstanceVariable([cell class], "_model");
|
||||
if (!mi) return nil;
|
||||
id model = object_getIvar(cell, mi);
|
||||
id owner = [model valueForKey:@"reelOwner"];
|
||||
if (!owner) return nil;
|
||||
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
|
||||
if (!ui) return nil;
|
||||
id igUser = object_getIvar(owner, ui);
|
||||
Ivar fi = NULL;
|
||||
for (Class c = [igUser class]; c && !fi; c = class_getSuperclass(c))
|
||||
fi = class_getInstanceVariable(c, "_fieldCache");
|
||||
if (!fi) return nil;
|
||||
id fc = object_getIvar(igUser, fi);
|
||||
if (![fc isKindOfClass:[NSDictionary class]]) return nil;
|
||||
id name = [(NSDictionary *)fc objectForKey:@"full_name"];
|
||||
if ([name isKindOfClass:[NSString class]] && [(NSString *)name length] > 0) return name;
|
||||
} @catch (NSException *e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSString *sciCaptionFromCell(id cell) {
|
||||
NSString *username = sciUsernameFromCell(cell);
|
||||
NSString *fullName = sciFullNameFromCell(cell);
|
||||
if (username && fullName) return [NSString stringWithFormat:@"%@\n%@", username, fullName];
|
||||
return username ?: fullName;
|
||||
}
|
||||
|
||||
static NSString *sciUserPKFromCell(id cell) {
|
||||
@try {
|
||||
Ivar mi = class_getInstanceVariable([cell class], "_model");
|
||||
if (!mi) return nil;
|
||||
id model = object_getIvar(cell, mi);
|
||||
id owner = [model valueForKey:@"reelOwner"];
|
||||
if (!owner) return nil;
|
||||
Ivar ui = class_getInstanceVariable([owner class], "_userReelOwner_user");
|
||||
if (!ui) return nil;
|
||||
id igUser = object_getIvar(owner, ui);
|
||||
Ivar pi = NULL;
|
||||
for (Class c = [igUser class]; c && !pi; c = class_getSuperclass(c))
|
||||
pi = class_getInstanceVariable(c, "_pk");
|
||||
if (!pi) return nil;
|
||||
return [object_getIvar(igUser, pi) description];
|
||||
} @catch (NSException *e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Fetch HD profile pic via API, fallback to local avatar
|
||||
static void sciShowHDProfilePic(NSString *pk, NSString *caption, UIImage *fallback) {
|
||||
NSString *path = [NSString stringWithFormat:@"users/%@/info/", pk];
|
||||
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *response, NSError *error) {
|
||||
if (error || !response) {
|
||||
if (fallback) {
|
||||
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
|
||||
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
|
||||
[d writeToFile:p atomically:YES];
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
NSDictionary *user = response[@"user"];
|
||||
NSString *hdURL = nil;
|
||||
|
||||
NSDictionary *hdInfo = user[@"hd_profile_pic_url_info"];
|
||||
if ([hdInfo isKindOfClass:[NSDictionary class]]) hdURL = hdInfo[@"url"];
|
||||
|
||||
if (!hdURL) {
|
||||
NSArray *versions = user[@"hd_profile_pic_versions"];
|
||||
if ([versions isKindOfClass:[NSArray class]] && versions.count > 0)
|
||||
hdURL = [versions.lastObject objectForKey:@"url"];
|
||||
}
|
||||
|
||||
if (!hdURL) hdURL = user[@"profile_pic_url"];
|
||||
|
||||
if (hdURL) {
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL URLWithString:hdURL] caption:caption];
|
||||
} else if (fallback) {
|
||||
NSData *d = UIImageJPEGRepresentation(fallback, 1.0);
|
||||
NSString *p = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"pfp_%@.jpg", pk]];
|
||||
[d writeToFile:p atomically:YES];
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:[NSURL fileURLWithPath:p] caption:caption];
|
||||
}
|
||||
}];
|
||||
}
|
||||
|
||||
// ── Capture long-pressed cell ──
|
||||
|
||||
static void (*orig_didLongPressCell)(id, SEL, UIGestureRecognizer *);
|
||||
static void hook_didLongPressCell(id self, SEL _cmd, UIGestureRecognizer *gesture) {
|
||||
if (gesture.state == UIGestureRecognizerStateBegan)
|
||||
sciLongPressedTrayCell = gesture.view;
|
||||
orig_didLongPressCell(self, _cmd, gesture);
|
||||
}
|
||||
|
||||
// ── Inject action into the sheet ──
|
||||
|
||||
static void (*orig_present)(id, SEL, id, BOOL, id);
|
||||
static void hook_present(id self, SEL _cmd, id vc, BOOL animated, id completion) {
|
||||
if (sciLongPressedTrayCell && [SCIUtils getBoolPref:@"story_tray_actions"]) {
|
||||
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
|
||||
NSArray *actions = actIvar ? object_getIvar(vc, actIvar) : nil;
|
||||
|
||||
if (actions) {
|
||||
id cell = sciLongPressedTrayCell;
|
||||
sciLongPressedTrayCell = nil;
|
||||
|
||||
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
|
||||
NSString *pk = sciUserPKFromCell(cell);
|
||||
if (actionCls && pk) {
|
||||
NSString *caption = sciCaptionFromCell(cell);
|
||||
UIImage *localPic = sciProfileImageFromCell(cell);
|
||||
|
||||
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
|
||||
void (^handler)(void) = ^{ sciShowHDProfilePic(pk, caption, localPic); };
|
||||
id action = ((InitFn)objc_msgSend)([actionCls alloc],
|
||||
@selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:),
|
||||
@"View profile picture", nil, (NSInteger)0, handler, nil, nil);
|
||||
|
||||
if (action) {
|
||||
NSMutableArray *newActions = [actions mutableCopy];
|
||||
[newActions insertObject:action atIndex:0];
|
||||
object_setIvar(vc, actIvar, [newActions copy]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sciLongPressedTrayCell) sciLongPressedTrayCell = nil;
|
||||
orig_present(self, _cmd, vc, animated, completion);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class scCls = NSClassFromString(@"IGStorySectionController");
|
||||
if (scCls) {
|
||||
SEL sel = NSSelectorFromString(@"_didLongPressCell:");
|
||||
if (class_getInstanceMethod(scCls, sel))
|
||||
MSHookMessageEx(scCls, sel, (IMP)hook_didLongPressCell, (IMP *)&orig_didLongPressCell);
|
||||
}
|
||||
|
||||
MSHookMessageEx([UIViewController class], @selector(presentViewController:animated:completion:),
|
||||
(IMP)hook_present, (IMP *)&orig_present);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
// Copy comment text + download GIF from comment long-press menu
|
||||
#import "../../Utils.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static SCIDownloadDelegate *sciGifDl = nil;
|
||||
|
||||
static DownloadAction sciGifDownloadAction(void) {
|
||||
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
|
||||
return [method isEqualToString:@"photos"] ? saveToPhotos : share;
|
||||
}
|
||||
|
||||
static id (*orig_commentCtxMenu)(id, SEL, id, id, CGPoint);
|
||||
static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint point) {
|
||||
UIContextMenuConfiguration *config = orig_commentCtxMenu(self, _cmd, cv, indexPath, point);
|
||||
if (!config) return config;
|
||||
|
||||
Ivar commentIvar = class_getInstanceVariable([self class], "_longPressedComment");
|
||||
id comment = commentIvar ? object_getIvar(self, commentIvar) : nil;
|
||||
if (!comment) return config;
|
||||
|
||||
NSString *text = nil;
|
||||
@try { text = ((id(*)(id,SEL))objc_msgSend)(comment, @selector(text)); } @catch (__unused id e) {}
|
||||
|
||||
NSString *gifId = nil;
|
||||
@try {
|
||||
SEL sel = NSSelectorFromString(@"gifMediaId");
|
||||
if ([comment respondsToSelector:sel])
|
||||
gifId = ((id(*)(id,SEL))objc_msgSend)(comment, sel);
|
||||
} @catch (__unused id e) {}
|
||||
|
||||
NSString *gifURL = nil;
|
||||
if (gifId.length) {
|
||||
Ivar attIvar = class_getInstanceVariable([comment class], "_commentAttachment");
|
||||
id att = attIvar ? object_getIvar(comment, attIvar) : nil;
|
||||
if (att) {
|
||||
Ivar urlIvar = class_getInstanceVariable([att class], "_image_imageURL");
|
||||
if (urlIvar) {
|
||||
id url = object_getIvar(att, urlIvar);
|
||||
if ([url isKindOfClass:[NSString class]]) gifURL = url;
|
||||
else if ([url isKindOfClass:[NSURL class]]) gifURL = [(NSURL *)url absoluteString];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BOOL hasText = text.length > 0;
|
||||
BOOL hasGif = gifURL.length > 0;
|
||||
if (!hasText && !hasGif) return config;
|
||||
|
||||
id origProvider = [config valueForKey:@"actionProvider"];
|
||||
id<NSCopying> origIdent = [config valueForKey:@"identifier"];
|
||||
UIContextMenuContentPreviewProvider origPreview = [config valueForKey:@"previewProvider"];
|
||||
|
||||
UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray<UIMenuElement *> *suggested) {
|
||||
UIMenu *base = origProvider ? ((UIMenu *(^)(NSArray *))origProvider)(suggested)
|
||||
: [UIMenu menuWithChildren:suggested];
|
||||
NSMutableArray *extra = [NSMutableArray array];
|
||||
|
||||
if (hasText && [SCIUtils getBoolPref:@"copy_comment"]) {
|
||||
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Copy")
|
||||
image:[UIImage systemImageNamed:@"doc.on.doc"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
[UIPasteboard generalPasteboard].string = text;
|
||||
}]];
|
||||
}
|
||||
|
||||
if (hasGif && [SCIUtils getBoolPref:@"download_gif_comment"]) {
|
||||
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Download GIF")
|
||||
image:[UIImage systemImageNamed:@"arrow.down.circle"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
NSURL *url = [NSURL URLWithString:gifURL];
|
||||
if (!url) return;
|
||||
sciGifDl = [[SCIDownloadDelegate alloc] initWithAction:sciGifDownloadAction() showProgress:YES];
|
||||
[sciGifDl downloadFileWithURL:url fileExtension:@"gif" hudLabel:nil];
|
||||
}]];
|
||||
}
|
||||
|
||||
if (!extra.count) return base;
|
||||
NSMutableArray *kids = [base.children mutableCopy] ?: [NSMutableArray array];
|
||||
NSUInteger insertIdx = kids.count > 0 ? kids.count - 1 : 0;
|
||||
UIMenu *ourMenu = [UIMenu menuWithTitle:@"" image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:extra];
|
||||
[kids insertObject:ourMenu atIndex:insertIdx];
|
||||
return [base menuByReplacingChildren:kids];
|
||||
};
|
||||
|
||||
return [UIContextMenuConfiguration configurationWithIdentifier:origIdent
|
||||
previewProvider:origPreview
|
||||
actionProvider:wrapped];
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void _commentActionsInit(void) {
|
||||
Class cls = NSClassFromString(@"IGCommentThreadViewController");
|
||||
if (!cls) return;
|
||||
SEL s = @selector(collectionView:contextMenuConfigurationForItemAtIndexPath:point:);
|
||||
if (class_getInstanceMethod(cls, s))
|
||||
MSHookMessageEx(cls, s, (IMP)new_commentCtxMenu, (IMP *)&orig_commentCtxMenu);
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
|
||||
// Notify user
|
||||
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
|
||||
HUD.textLabel.text = @"Copied text to clipboard";
|
||||
HUD.textLabel.text = SCILocalized(@"Copied text to clipboard");
|
||||
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
|
||||
|
||||
[HUD showInView:topMostController().view];
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
UIColorPickerViewController *colorPickerController = [[UIColorPickerViewController alloc] init];
|
||||
|
||||
colorPickerController.delegate = (id<UIColorPickerViewControllerDelegate>)self; // cast to suppress warnings
|
||||
colorPickerController.title = @"Select color";
|
||||
colorPickerController.title = SCILocalized(@"Select color");
|
||||
colorPickerController.modalPresentationStyle = UIModalPresentationPopover;
|
||||
colorPickerController.supportsAlpha = NO;
|
||||
colorPickerController.selectedColor = self.color;
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
// Disable feed refresh — background refresh and home tab refresh.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static BOOL sciDisableBgRefresh(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_bg_refresh"];
|
||||
}
|
||||
|
||||
static BOOL sciDisableHomeRefresh(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_home_refresh"];
|
||||
}
|
||||
|
||||
static BOOL sciDisableHomeScroll(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_home_scroll"];
|
||||
}
|
||||
|
||||
static BOOL sciDisableReelsRefresh(void) {
|
||||
return [SCIUtils getBoolPref:@"disable_reels_tab_refresh"];
|
||||
}
|
||||
|
||||
// Returns 999999s when disabled (effectively never), -1 to keep IG's value.
|
||||
static double sciOverrideInterval(void) {
|
||||
if (sciDisableBgRefresh()) return 999999;
|
||||
return -1;
|
||||
}
|
||||
|
||||
// MARK: - Refresh-utility class-method overrides
|
||||
// IGMainFeedRefreshUtility recomputes the intervals at runtime, ignoring the
|
||||
// init args on IGMainFeedNetworkSource — override the 4 class methods too.
|
||||
|
||||
static double (*orig_wsRefresh)(id, SEL, id, id);
|
||||
static double new_wsRefresh(id self, SEL _cmd, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_wsRefresh(self, _cmd, ls, store);
|
||||
}
|
||||
|
||||
static double (*orig_wsBgRefresh)(id, SEL, id, id);
|
||||
static double new_wsBgRefresh(id self, SEL _cmd, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_wsBgRefresh(self, _cmd, ls, store);
|
||||
}
|
||||
|
||||
static double (*orig_peakWsRefresh)(id, SEL, double, id, id);
|
||||
static double new_peakWsRefresh(id self, SEL _cmd, double iv, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_peakWsRefresh(self, _cmd, iv, ls, store);
|
||||
}
|
||||
|
||||
static double (*orig_peakWsBgRefresh)(id, SEL, id, id);
|
||||
static double new_peakWsBgRefresh(id self, SEL _cmd, id ls, id store) {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : orig_peakWsBgRefresh(self, _cmd, ls, store);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class c = NSClassFromString(@"IGMainFeedViewModelUtility.IGMainFeedRefreshUtility");
|
||||
if (!c) return;
|
||||
Class meta = object_getClass(c);
|
||||
|
||||
SEL s1 = NSSelectorFromString(@"warmStartRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s1))
|
||||
MSHookMessageEx(meta, s1, (IMP)new_wsRefresh, (IMP *)&orig_wsRefresh);
|
||||
|
||||
SEL s2 = NSSelectorFromString(@"warmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s2))
|
||||
MSHookMessageEx(meta, s2, (IMP)new_wsBgRefresh, (IMP *)&orig_wsBgRefresh);
|
||||
|
||||
SEL s3 = NSSelectorFromString(@"onPeakWarmStartRefreshIntervalWithWarmStartFetchInterval:launcherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s3))
|
||||
MSHookMessageEx(meta, s3, (IMP)new_peakWsRefresh, (IMP *)&orig_peakWsRefresh);
|
||||
|
||||
SEL s4 = NSSelectorFromString(@"onPeakWarmStartBackgroundRefreshIntervalWithLauncherSet:feedRefreshInstructionsStore:");
|
||||
if (class_getInstanceMethod(meta, s4))
|
||||
MSHookMessageEx(meta, s4, (IMP)new_peakWsBgRefresh, (IMP *)&orig_peakWsBgRefresh);
|
||||
}
|
||||
|
||||
// MARK: - Background refresh
|
||||
|
||||
%hook IGMainFeedNetworkSource
|
||||
|
||||
- (instancetype)initWithDeps:(id)a1
|
||||
posts:(id)a2
|
||||
nextMaxID:(id)a3
|
||||
initialPaginationSource:(id)a4
|
||||
contentCoordinator:(id)a5
|
||||
dataSourceSupplementaryItemsProvider:(id)a6
|
||||
disableAutomaticRefresh:(BOOL)disable
|
||||
disableSerialization:(BOOL)a8
|
||||
sessionId:(id)a9
|
||||
analyticsModule:(id)a10
|
||||
serializationSuffix:(id)a11
|
||||
disableFlashFeedTLI:(BOOL)a12
|
||||
disableFlashFeedOnColdStart:(BOOL)a13
|
||||
disableResponseDeferral:(BOOL)a14
|
||||
hidesStoriesTray:(BOOL)a15
|
||||
isSecondaryFeed:(BOOL)a16
|
||||
collectionViewBackgroundColorOverride:(id)a17
|
||||
minWarmStartFetchInterval:(double)a18
|
||||
peakMinWarmStartFetchInterval:(double)a19
|
||||
minimumWarmStartBackgroundedInterval:(double)a20
|
||||
peakMinimumWarmStartBackgroundedInterval:(double)a21
|
||||
supplementalFeedHoistedMediaID:(id)a22
|
||||
headerTitleOverride:(id)a23
|
||||
isInFollowingTab:(BOOL)a24
|
||||
useShimmerLoadingWhenNoStoriesTray:(BOOL)a25 {
|
||||
|
||||
double override = sciOverrideInterval();
|
||||
if (sciDisableBgRefresh()) disable = YES;
|
||||
if (override > 0) { a18 = override; a19 = override; a20 = override; a21 = override; }
|
||||
|
||||
return %orig(a1, a2, a3, a4, a5, a6, disable, a8, a9, a10, a11, a12, a13, a14, a15, a16, a17, a18, a19, a20, a21, a22, a23, a24, a25);
|
||||
}
|
||||
|
||||
// Getter overrides for instances created before the class hooks landed.
|
||||
- (double)minWarmStartFetchInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
- (double)peakMinWarmStartFetchInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
- (double)minimumWarmStartBackgroundedInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
- (double)peakMinimumWarmStartBackgroundedInterval {
|
||||
double o = sciOverrideInterval();
|
||||
return o > 0 ? o : %orig;
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// MARK: - Hot start refresh
|
||||
|
||||
%hook IGMainFeedViewController
|
||||
|
||||
- (void)hotStartRefresh {
|
||||
if (sciDisableBgRefresh()) return;
|
||||
%orig;
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// MARK: - Home tab refresh
|
||||
|
||||
%hook IGTabBarController
|
||||
|
||||
- (void)_timelineButtonPressed {
|
||||
BOOL noRefresh = sciDisableHomeRefresh();
|
||||
BOOL noScroll = sciDisableHomeScroll();
|
||||
|
||||
if (!noRefresh && !noScroll) { %orig; return; }
|
||||
|
||||
UIViewController *selected = nil;
|
||||
if ([self respondsToSelector:@selector(selectedViewController)])
|
||||
selected = [self valueForKey:@"selectedViewController"];
|
||||
|
||||
BOOL onFeedTab = NO;
|
||||
if (selected) {
|
||||
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
|
||||
? [(UINavigationController *)selected topViewController] : selected;
|
||||
onFeedTab = [NSStringFromClass([top class]) containsString:@"MainFeed"];
|
||||
}
|
||||
|
||||
if (!onFeedTab) { %orig; return; }
|
||||
if (noScroll) return;
|
||||
|
||||
// noRefresh only — scroll to top without refreshing.
|
||||
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
|
||||
? [(UINavigationController *)selected topViewController] : selected;
|
||||
|
||||
NSMutableArray *queue = [NSMutableArray arrayWithObject:top.view];
|
||||
int scanned = 0;
|
||||
while (queue.count && scanned < 30) {
|
||||
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
|
||||
if ([cur isKindOfClass:[UICollectionView class]]) {
|
||||
UIScrollView *sv = (UIScrollView *)cur;
|
||||
[sv setContentOffset:CGPointMake(0, -sv.adjustedContentInset.top) animated:YES];
|
||||
return;
|
||||
}
|
||||
for (UIView *s in cur.subviews) [queue addObject:s];
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reels tab refresh
|
||||
|
||||
- (void)_discoverVideoButtonPressed {
|
||||
if (!sciDisableReelsRefresh()) { %orig; return; }
|
||||
|
||||
UIViewController *selected = nil;
|
||||
if ([self respondsToSelector:@selector(selectedViewController)])
|
||||
selected = [self valueForKey:@"selectedViewController"];
|
||||
|
||||
BOOL onReelsTab = NO;
|
||||
if (selected) {
|
||||
UIViewController *top = [selected isKindOfClass:[UINavigationController class]]
|
||||
? [(UINavigationController *)selected topViewController] : selected;
|
||||
NSString *cls = NSStringFromClass([top class]);
|
||||
onReelsTab = [cls containsString:@"Sundial"] || [cls containsString:@"Reels"]
|
||||
|| [cls containsString:@"DiscoverVideo"];
|
||||
}
|
||||
|
||||
if (!onReelsTab) { %orig; return; }
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,33 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook UIImpactFeedbackGenerator
|
||||
- (void)impactOccurred {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
|
||||
}
|
||||
- (void)impactOccurredWithIntensity:(CGFloat)intensity {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(intensity);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UINotificationFeedbackGenerator
|
||||
- (void)notificationOccurred:(UINotificationFeedbackType)notificationType {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig(notificationType);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UISelectionFeedbackGenerator
|
||||
- (void)selectionChanged {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook CHHapticEngine
|
||||
- (BOOL)startAndReturnError:(NSError **)outError {
|
||||
if (![SCIUtils getBoolPref:@"disable_haptics"]) {
|
||||
return %orig(outError);
|
||||
}
|
||||
else {
|
||||
return NO;
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,99 @@
|
||||
// Rewrite Instagram share links — replace domain + optionally strip tracking params.
|
||||
// Waits for IG's async clipboard write via changeCount, then rewrites once.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static NSString *sciRewriteIGURL(NSString *url) {
|
||||
if (!url.length) return url;
|
||||
|
||||
// Domain replacement
|
||||
if ([SCIUtils getBoolPref:@"embed_links"]) {
|
||||
NSString *domain = [SCIUtils getStringPref:@"embed_link_domain"];
|
||||
if (!domain.length) domain = @"kkinstagram.com";
|
||||
if (![url containsString:domain]) {
|
||||
NSArray *igDomains = @[@"www.instagram.com", @"instagram.com", @"www.instagr.am", @"instagr.am"];
|
||||
for (NSString *d in igDomains) {
|
||||
NSRange r = [url rangeOfString:d];
|
||||
if (r.location != NSNotFound) {
|
||||
NSString *target = [d hasPrefix:@"www."]
|
||||
? [NSString stringWithFormat:@"www.%@", domain] : domain;
|
||||
url = [url stringByReplacingCharactersInRange:r withString:target];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip tracking params
|
||||
if ([SCIUtils getBoolPref:@"strip_tracking_params"]) {
|
||||
NSURLComponents *comps = [NSURLComponents componentsWithString:url];
|
||||
if (comps.queryItems.count) {
|
||||
NSArray *strip = @[@"igsh", @"ig_rid", @"utm_source", @"utm_medium", @"utm_campaign"];
|
||||
NSMutableArray *clean = [NSMutableArray array];
|
||||
for (NSURLQueryItem *q in comps.queryItems) {
|
||||
if (![strip containsObject:q.name]) [clean addObject:q];
|
||||
}
|
||||
comps.queryItems = clean.count ? clean : nil;
|
||||
NSString *result = comps.string;
|
||||
if (result) url = result;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
static BOOL sciShouldRewrite(void) {
|
||||
return [SCIUtils getBoolPref:@"embed_links"] || [SCIUtils getBoolPref:@"strip_tracking_params"];
|
||||
}
|
||||
|
||||
// Rewrite clipboard once after IG writes
|
||||
static void sciPollAndRewrite(NSInteger countBefore, int polls, double interval) {
|
||||
__block BOOL done = NO;
|
||||
for (int i = 0; i < polls; i++) {
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)((interval + i * interval) * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
if (done) return;
|
||||
if ([UIPasteboard generalPasteboard].changeCount == countBefore) return;
|
||||
NSString *clip = [UIPasteboard generalPasteboard].string;
|
||||
if (!clip || ![clip containsString:@"instagram"]) return;
|
||||
NSString *rewritten = sciRewriteIGURL(clip);
|
||||
if (![rewritten isEqualToString:clip]) {
|
||||
[UIPasteboard generalPasteboard].string = rewritten;
|
||||
done = YES;
|
||||
} else {
|
||||
done = YES;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============ Hooks ============
|
||||
|
||||
static void (*orig_copyLink)(id, SEL, id);
|
||||
static void new_copyLink(id self, SEL _cmd, id vc) {
|
||||
if (!sciShouldRewrite()) { orig_copyLink(self, _cmd, vc); return; }
|
||||
NSInteger countBefore = [UIPasteboard generalPasteboard].changeCount;
|
||||
orig_copyLink(self, _cmd, vc);
|
||||
sciPollAndRewrite(countBefore, 30, 0.05);
|
||||
}
|
||||
|
||||
static void (*orig_shareMore)(id, SEL, id);
|
||||
static void new_shareMore(id self, SEL _cmd, id vc) {
|
||||
if (!sciShouldRewrite()) { orig_shareMore(self, _cmd, vc); return; }
|
||||
NSInteger countBefore = [UIPasteboard generalPasteboard].changeCount;
|
||||
orig_shareMore(self, _cmd, vc);
|
||||
sciPollAndRewrite(countBefore, 120, 0.1);
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void _embedLinksInit(void) {
|
||||
Class cls = NSClassFromString(@"IGExternalShareOptionsViewController");
|
||||
if (!cls) return;
|
||||
SEL copy = NSSelectorFromString(@"_shareToClipboardFromVC:");
|
||||
if (class_getInstanceMethod(cls, copy))
|
||||
MSHookMessageEx(cls, copy, (IMP)new_copyLink, (IMP *)&orig_copyLink);
|
||||
SEL more = NSSelectorFromString(@"_shareToMoreFromVC:");
|
||||
if (class_getInstanceMethod(cls, more))
|
||||
MSHookMessageEx(cls, more, (IMP)new_shareMore, (IMP *)&orig_shareMore);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
// Fake location — overrides CLLocationManager so any IG location read returns our coord.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import <CoreLocation/CoreLocation.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static BOOL sciFakeLocOn(void) {
|
||||
return [SCIUtils getBoolPref:@"fake_location_enabled"];
|
||||
}
|
||||
|
||||
static CLLocation *sciFakeLocation(void) {
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
double lat = [[d objectForKey:@"fake_location_lat"] doubleValue];
|
||||
double lon = [[d objectForKey:@"fake_location_lon"] doubleValue];
|
||||
return [[CLLocation alloc] initWithCoordinate:CLLocationCoordinate2DMake(lat, lon)
|
||||
altitude:35
|
||||
horizontalAccuracy:5
|
||||
verticalAccuracy:5
|
||||
timestamp:[NSDate date]];
|
||||
}
|
||||
|
||||
static void sciFeedFake(CLLocationManager *mgr) {
|
||||
id<CLLocationManagerDelegate> d = mgr.delegate;
|
||||
if (![d respondsToSelector:@selector(locationManager:didUpdateLocations:)]) return;
|
||||
CLLocation *loc = sciFakeLocation();
|
||||
NSArray *locs = @[ loc ];
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[d locationManager:mgr didUpdateLocations:locs];
|
||||
});
|
||||
}
|
||||
|
||||
%hook CLLocationManager
|
||||
|
||||
- (CLLocation *)location {
|
||||
if (sciFakeLocOn()) return sciFakeLocation();
|
||||
return %orig;
|
||||
}
|
||||
|
||||
- (void)startUpdatingLocation {
|
||||
%orig;
|
||||
if (sciFakeLocOn()) sciFeedFake(self);
|
||||
}
|
||||
|
||||
- (void)requestLocation {
|
||||
if (sciFakeLocOn()) { sciFeedFake(self); return; }
|
||||
%orig;
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,260 @@
|
||||
// Quick fake-location toggle injected into IG's Friends Map (DMs > Maps).
|
||||
|
||||
#import "../../Utils.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 UIViewController *sciTopMost(void) {
|
||||
UIWindow *win = nil;
|
||||
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
|
||||
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
|
||||
for (UIWindow *w in ((UIWindowScene *)sc).windows) if (w.isKeyWindow) { win = w; break; }
|
||||
if (win) break;
|
||||
}
|
||||
UIViewController *v = win.rootViewController;
|
||||
while (v.presentedViewController) v = v.presentedViewController;
|
||||
return v;
|
||||
}
|
||||
|
||||
static void sciRefreshMapButton(UIView *mapView);
|
||||
static void sciAddMapButton(UIView *mapView);
|
||||
static void sciRemoveMapButton(UIView *mapView);
|
||||
static UIMenu *sciBuildMapMenu(void);
|
||||
|
||||
static void sciWalkMapViews(UIView *root, Class mapCls, void (^block)(UIView *)) {
|
||||
if (!root) return;
|
||||
if (mapCls && [root isKindOfClass:mapCls]) block(root);
|
||||
for (UIView *s in root.subviews) sciWalkMapViews(s, mapCls, block);
|
||||
}
|
||||
|
||||
static void sciRefreshActiveMapButton(void) {
|
||||
Class mapCls = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
|
||||
for (UIScene *sc in [UIApplication sharedApplication].connectedScenes) {
|
||||
if (![sc isKindOfClass:[UIWindowScene class]]) continue;
|
||||
for (UIWindow *w in ((UIWindowScene *)sc).windows) {
|
||||
sciWalkMapViews(w, mapCls, ^(UIView *mv) {
|
||||
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
|
||||
sciRemoveMapButton(mv);
|
||||
} else {
|
||||
sciAddMapButton(mv);
|
||||
sciRefreshMapButton(mv);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void sciOpenPickerForCurrent(void) {
|
||||
UIViewController *top = sciTopMost();
|
||||
if (!top) return;
|
||||
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
|
||||
[[d objectForKey:@"fake_location_lon"] doubleValue]);
|
||||
vc.titleText = SCILocalized(@"Set location");
|
||||
vc.onPick = ^(double lat, double lon, NSString *name) {
|
||||
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
|
||||
[u setObject:@(lat) forKey:@"fake_location_lat"];
|
||||
[u setObject:@(lon) forKey:@"fake_location_lon"];
|
||||
[u setObject:(name ?: @"") forKey:@"fake_location_name"];
|
||||
if (![u boolForKey:@"fake_location_enabled"]) [u setBool:YES forKey:@"fake_location_enabled"];
|
||||
sciRefreshActiveMapButton();
|
||||
};
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
|
||||
nav.modalPresentationStyle = UIModalPresentationPageSheet;
|
||||
[top presentViewController:nav animated:YES completion:nil];
|
||||
}
|
||||
|
||||
static void sciOpenPickerForNewPreset(void) {
|
||||
UIViewController *top = sciTopMost();
|
||||
if (!top) return;
|
||||
SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new];
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:@"fake_location_lat"] doubleValue],
|
||||
[[d objectForKey:@"fake_location_lon"] doubleValue]);
|
||||
vc.titleText = SCILocalized(@"Add preset");
|
||||
vc.onPick = ^(double lat, double lon, NSString *name) {
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Save preset")
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"Name"); tf.text = name; }];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) {
|
||||
NSString *n = alert.textFields.firstObject.text.length ? alert.textFields.firstObject.text : name;
|
||||
NSUserDefaults *u = [NSUserDefaults standardUserDefaults];
|
||||
NSArray *raw = [u objectForKey:@"fake_location_presets"];
|
||||
NSMutableArray *presets = [raw isKindOfClass:[NSArray class]] ? [raw mutableCopy] : [NSMutableArray array];
|
||||
[presets addObject:@{@"name": n ?: @"", @"lat": @(lat), @"lon": @(lon)}];
|
||||
[u setObject:presets forKey:@"fake_location_presets"];
|
||||
sciRefreshActiveMapButton();
|
||||
}]];
|
||||
[sciTopMost() presentViewController:alert animated:YES completion:nil];
|
||||
};
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
|
||||
nav.modalPresentationStyle = UIModalPresentationPageSheet;
|
||||
[top presentViewController:nav animated:YES completion:nil];
|
||||
}
|
||||
|
||||
static UIMenu *sciBuildMapMenu(void) {
|
||||
NSUserDefaults *d = [NSUserDefaults standardUserDefaults];
|
||||
BOOL enabled = [d boolForKey:@"fake_location_enabled"];
|
||||
NSString *name = [d objectForKey:@"fake_location_name"] ?: @"(unset)";
|
||||
|
||||
// Header section: current location (disabled), enable/disable, change location
|
||||
UIAction *header = [UIAction actionWithTitle:[NSString stringWithFormat:SCILocalized(@"Current: %@"), name]
|
||||
image:[UIImage systemImageNamed:@"mappin.and.ellipse"]
|
||||
identifier:nil handler:^(__unused UIAction *a) {}];
|
||||
header.attributes = UIMenuElementAttributesDisabled;
|
||||
|
||||
UIAction *toggle = [UIAction actionWithTitle:enabled ? SCILocalized(@"Disable") : SCILocalized(@"Enable")
|
||||
image:[UIImage systemImageNamed:enabled ? @"location.slash.fill" : @"location.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *a) {
|
||||
[d setBool:!enabled forKey:@"fake_location_enabled"];
|
||||
sciRefreshActiveMapButton();
|
||||
}];
|
||||
if (enabled) toggle.attributes = UIMenuElementAttributesDestructive;
|
||||
|
||||
UIAction *change = [UIAction actionWithTitle:SCILocalized(@"Change location")
|
||||
image:[UIImage systemImageNamed:@"map"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *a) { sciOpenPickerForCurrent(); }];
|
||||
|
||||
UIMenu *headerSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:@[header, toggle, change]];
|
||||
|
||||
// Presets + Add
|
||||
NSMutableArray<UIMenuElement *> *presetItems = [NSMutableArray array];
|
||||
NSArray *presets = [d objectForKey:@"fake_location_presets"];
|
||||
if ([presets isKindOfClass:[NSArray class]]) {
|
||||
for (NSDictionary *p in presets) {
|
||||
if (![p isKindOfClass:[NSDictionary class]]) continue;
|
||||
NSString *pname = p[@"name"] ?: @"Preset";
|
||||
BOOL active = [p[@"name"] isEqualToString:name];
|
||||
UIAction *act = [UIAction actionWithTitle:pname
|
||||
image:[UIImage systemImageNamed:@"mappin.circle.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *x) {
|
||||
[d setObject:p[@"lat"] forKey:@"fake_location_lat"];
|
||||
[d setObject:p[@"lon"] forKey:@"fake_location_lon"];
|
||||
[d setObject:p[@"name"] ?: @"" forKey:@"fake_location_name"];
|
||||
if (![d boolForKey:@"fake_location_enabled"]) [d setBool:YES forKey:@"fake_location_enabled"];
|
||||
sciRefreshActiveMapButton();
|
||||
}];
|
||||
if (active) act.state = UIMenuElementStateOn;
|
||||
[presetItems addObject:act];
|
||||
}
|
||||
}
|
||||
[presetItems addObject:[UIAction actionWithTitle:SCILocalized(@"Add location")
|
||||
image:[UIImage systemImageNamed:@"plus.circle.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *x) { sciOpenPickerForNewPreset(); }]];
|
||||
UIMenu *presetSection = [UIMenu menuWithTitle:SCILocalized(@"Saved locations") image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:presetItems];
|
||||
|
||||
// Settings
|
||||
UIAction *openSettings = [UIAction actionWithTitle:SCILocalized(@"Settings…")
|
||||
image:[UIImage systemImageNamed:@"gearshape.fill"]
|
||||
identifier:nil
|
||||
handler:^(__unused UIAction *x) {
|
||||
UIViewController *top = sciTopMost();
|
||||
if (!top) return;
|
||||
SCIFakeLocationSettingsVC *vc = [SCIFakeLocationSettingsVC new];
|
||||
UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc];
|
||||
nav.modalPresentationStyle = UIModalPresentationFormSheet;
|
||||
[top presentViewController:nav animated:YES completion:nil];
|
||||
}];
|
||||
UIMenu *settingsSection = [UIMenu menuWithTitle:@"" image:nil identifier:nil
|
||||
options:UIMenuOptionsDisplayInline children:@[openSettings]];
|
||||
|
||||
return [UIMenu menuWithTitle:SCILocalized(@"Fake location") image:nil identifier:nil options:0
|
||||
children:@[headerSection, presetSection, settingsSection]];
|
||||
}
|
||||
|
||||
static void sciRemoveMapButton(UIView *mapView) {
|
||||
UIView *btn = [mapView viewWithTag:kSciMapBtnTag];
|
||||
if (btn) [btn removeFromSuperview];
|
||||
}
|
||||
|
||||
static void sciAddMapButton(UIView *mapView) {
|
||||
if (!mapView) return;
|
||||
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];
|
||||
[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],
|
||||
]];
|
||||
}
|
||||
|
||||
static void sciRefreshMapButton(UIView *mapView) {
|
||||
UIButton *btn = (UIButton *)[mapView viewWithTag:kSciMapBtnTag];
|
||||
if (!btn) 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();
|
||||
}
|
||||
|
||||
static void (*orig_mapLayout)(UIView *, SEL);
|
||||
static void new_mapLayout(UIView *self, SEL _cmd) {
|
||||
orig_mapLayout(self, _cmd);
|
||||
if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) {
|
||||
sciRemoveMapButton(self);
|
||||
return;
|
||||
}
|
||||
sciAddMapButton(self);
|
||||
sciRefreshMapButton(self);
|
||||
UIView *btn = [self viewWithTag:kSciMapBtnTag];
|
||||
if (btn) [self bringSubviewToFront:btn];
|
||||
}
|
||||
|
||||
static void sciInstallMapHooks(void) {
|
||||
static BOOL installed = NO;
|
||||
if (installed) return;
|
||||
Class c = NSClassFromString(@"IGFriendsMapCoreUI.IGFriendsMapView");
|
||||
if (!c) return;
|
||||
installed = YES;
|
||||
SEL sel = @selector(layoutSubviews);
|
||||
if (class_getInstanceMethod(c, sel))
|
||||
MSHookMessageEx(c, sel, (IMP)new_mapLayout, (IMP *)&orig_mapLayout);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
sciInstallMapHooks();
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciInstallMapHooks();
|
||||
});
|
||||
[[NSNotificationCenter defaultCenter] addObserverForName:@"SCIFakeLocationMapBtnPrefChanged"
|
||||
object:nil
|
||||
queue:[NSOperationQueue mainQueue]
|
||||
usingBlock:^(__unused NSNotification *n) {
|
||||
sciRefreshActiveMapButton();
|
||||
}];
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// Date format hooks — replace IG's relative timestamps with a custom format.
|
||||
// Each NSDate formatter selector is independently toggleable via prefs
|
||||
// (date_fmt_<name>) so users can apply the format surface-by-surface.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "SCIDateFormatEntries.h"
|
||||
#import <substrate.h>
|
||||
|
||||
static NSDictionary *sciDateFormats(BOOL sec) {
|
||||
return sec ? @{
|
||||
@"short": @"MMM d",
|
||||
@"medium": @"MMM d, yyyy",
|
||||
@"full": @"MMM d, yyyy 'at' h:mm:ss a",
|
||||
@"time_12": @"MMM d 'at' h:mm:ss a",
|
||||
@"time_24": @"MMM d 'at' HH:mm:ss",
|
||||
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm:ss a",
|
||||
@"day_slash": @"dd/MM/yyyy h:mm:ss a",
|
||||
@"month_slash": @"MM/dd/yyyy h:mm:ss a",
|
||||
@"euro": @"dd.MM.yyyy HH:mm:ss",
|
||||
@"iso": @"yyyy-MM-dd",
|
||||
@"iso_time": @"yyyy-MM-dd HH:mm:ss",
|
||||
} : @{
|
||||
@"short": @"MMM d",
|
||||
@"medium": @"MMM d, yyyy",
|
||||
@"full": @"MMM d, yyyy 'at' h:mm a",
|
||||
@"time_12": @"MMM d 'at' h:mm a",
|
||||
@"time_24": @"MMM d 'at' HH:mm",
|
||||
@"dd_mmm": @"dd-MMM-yyyy 'at' h:mm a",
|
||||
@"day_slash": @"dd/MM/yyyy h:mm a",
|
||||
@"month_slash": @"MM/dd/yyyy h:mm a",
|
||||
@"euro": @"dd.MM.yyyy HH:mm",
|
||||
@"iso": @"yyyy-MM-dd",
|
||||
@"iso_time": @"yyyy-MM-dd HH:mm",
|
||||
};
|
||||
}
|
||||
|
||||
static NSString *sciFormat(NSDate *date) {
|
||||
NSString *fmt = [SCIUtils getStringPref:@"feed_date_format"];
|
||||
if (!fmt.length || [fmt isEqualToString:@"default"]) return nil;
|
||||
BOOL sec = [[NSUserDefaults standardUserDefaults] boolForKey:@"feed_date_show_seconds"];
|
||||
NSString *pattern = sciDateFormats(sec)[fmt];
|
||||
if (!pattern) return nil;
|
||||
static NSDateFormatter *df = nil;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ df = [NSDateFormatter new]; });
|
||||
df.dateFormat = pattern;
|
||||
return [df stringFromDate:date];
|
||||
}
|
||||
|
||||
// Per-arity hook generators. When the entry's pref is on, return the custom
|
||||
// format; otherwise forward to orig with the original arguments.
|
||||
|
||||
#define SCI_HOOK0(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK1(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK2(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1, a2); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK3(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1, a2, a3); \
|
||||
}
|
||||
|
||||
#define SCI_HOOK4(NAME, SEL_, LABEL, PREF) \
|
||||
static NSString *(*orig_##NAME)(NSDate *, SEL, NSInteger, NSInteger, NSInteger, NSInteger); \
|
||||
static NSString *hook_##NAME(NSDate *self, SEL _cmd, NSInteger a1, NSInteger a2, NSInteger a3, NSInteger a4) { \
|
||||
if ([SCIUtils getBoolPref:@PREF]) { \
|
||||
NSString *r = sciFormat(self); \
|
||||
if (r) return r; \
|
||||
} \
|
||||
return orig_##NAME(self, _cmd, a1, a2, a3, a4); \
|
||||
}
|
||||
|
||||
#define SCI_EMIT_HOOK(NAME, SEL_, LABEL, ARITY, PREF) SCI_HOOK##ARITY(NAME, SEL_, LABEL, PREF)
|
||||
SCI_DATE_FORMAT_ENTRIES(SCI_EMIT_HOOK)
|
||||
|
||||
#define SCI_INSTALL_HOOK(NAME, SEL_, LABEL, ARITY, PREF) do { \
|
||||
SEL s = sel_registerName(SEL_); \
|
||||
if ([[NSDate class] instancesRespondToSelector:s]) \
|
||||
MSHookMessageEx([NSDate class], s, (IMP)hook_##NAME, (IMP *)&orig_##NAME); \
|
||||
} while (0);
|
||||
|
||||
%ctor {
|
||||
SCI_DATE_FORMAT_ENTRIES(SCI_INSTALL_HOOK)
|
||||
}
|
||||
@@ -135,22 +135,35 @@
|
||||
// Write with meta ai in message composer
|
||||
%hook IGDirectComposer
|
||||
- (id)initWithLayoutSpecProvider:(id)arg1
|
||||
userLauncherSetProviding:(id)arg2
|
||||
userSession:(id)arg2
|
||||
userLauncherSet:(id)arg3
|
||||
config:(IGDirectComposerConfig *)config
|
||||
style:(id)arg4
|
||||
text:(id)arg5
|
||||
style:(id)arg5
|
||||
text:(id)arg6
|
||||
{
|
||||
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5);
|
||||
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6);
|
||||
}
|
||||
|
||||
- (id)initWithLayoutSpecProvider:(id)arg1
|
||||
userLauncherSetProviding:(id)arg2
|
||||
userSession:(id)arg2
|
||||
userLauncherSet:(id)arg3
|
||||
config:(IGDirectComposerConfig *)config
|
||||
style:(id)arg4
|
||||
text:(id)arg5
|
||||
shouldUpdateModeLater:(BOOL)arg6
|
||||
style:(id)arg5
|
||||
text:(id)arg6
|
||||
shouldUpdateModeLater:(BOOL)arg7
|
||||
{
|
||||
return %orig(arg1, arg2, [self patchConfig:config], arg4, arg5, arg6);
|
||||
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
|
||||
}
|
||||
|
||||
- (id)_initializeWithLayoutSpecProvider:(id)arg1
|
||||
userSession:(id)arg2
|
||||
userLauncherSet:(id)arg3
|
||||
config:(IGDirectComposerConfig *)config
|
||||
style:(id)arg5
|
||||
text:(id)arg6
|
||||
shouldUpdateModeLater:(BOOL)arg7
|
||||
{
|
||||
return %orig(arg1, arg2, arg3, [self patchConfig:config], arg5, arg6, arg7);
|
||||
}
|
||||
|
||||
- (void)setConfig:(IGDirectComposerConfig *)config {
|
||||
@@ -178,6 +191,20 @@
|
||||
}
|
||||
%end
|
||||
|
||||
// Demangled name: IGAIRewrite.IGAIRewriteStoryRepliesPresenter
|
||||
%hook _TtC11IGAIRewrite32IGAIRewriteStoryRepliesPresenter
|
||||
- (BOOL)shouldShowAIRewriteButton:(id)arg1 input:(id)arg2 {
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
NSLog(@"[SCInsta] Hiding meta ai: disable ai rewrite story reply presenter");
|
||||
|
||||
return NO;
|
||||
}
|
||||
|
||||
return %orig(arg1, arg2);
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Direct sticker tray picker view
|
||||
%hook IGStickerTrayListAdapterDataSource
|
||||
- (id)objectsForListAdapter:(id)arg1 {
|
||||
@@ -346,6 +373,24 @@
|
||||
// Reels/Sundial
|
||||
|
||||
// Suggested AI searches in comment section
|
||||
%hook IGCommentConfig
|
||||
- (id)initWithUserSession:(id)session
|
||||
commentThreadConfiguration:(IGCommentThreadConfiguration *)threadConfig
|
||||
sponsoredSupportConfiguration:(id)supportConfig
|
||||
CTAPresenterContext:(id)context
|
||||
replyText:(id)text
|
||||
loggingDelegate:(id)loggingDelegate
|
||||
presentingViewController:(id)vc
|
||||
childCommentThreadDelegate:(id)threadDelegate
|
||||
{
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
[threadConfig setValue:@(YES) forKey:@"disableMetaAICarousel"];
|
||||
}
|
||||
return %orig(session, threadConfig, supportConfig, context, text, loggingDelegate, vc, threadDelegate);
|
||||
}
|
||||
%end
|
||||
|
||||
// Suggested AI searches in comment section (workaround if setting comment thread config fails)
|
||||
%hook IGCommentThreadAICarousel
|
||||
- (id)initWithLauncherSet:(id)arg1 hasSearchPrefix:(BOOL)arg2 {
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
@@ -383,7 +428,7 @@
|
||||
NSLog(@"[SCInsta] Hiding meta ai: ai images add to story suggestion");
|
||||
|
||||
if ([SCIUtils getBoolPref:@"hide_meta_ai"]) {
|
||||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(10), @(11) ]];
|
||||
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"NOT (SELF IN %@)", @[ @(9), @(10), @(11) ]];
|
||||
newTools = [tools filteredArrayUsingPredicate:predicate];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
#import "../../Utils.h"
|
||||
|
||||
%hook IGSundialViewerVerticalUFI
|
||||
- (void)setNumLikes:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumReshares:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumComments:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumReposts:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
- (void)setNumSaves:(NSInteger)num {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? 0 : num);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGUFIButtonWithCountsView
|
||||
- (void)setCountString:(id)string showButton:(BOOL)showButton {
|
||||
return %orig([SCIUtils getBoolPref:@"hide_metrics"] ? @"" : string, showButton);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,69 @@
|
||||
// Hide suggested stories from the tray. Only filters when suggested items
|
||||
// are present — skips clean inputs to avoid IGListKit diff cascade.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static BOOL sciIsSuggestedTrayItem(id obj) {
|
||||
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO;
|
||||
|
||||
@try {
|
||||
if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) 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);
|
||||
if (!igUser) return NO;
|
||||
|
||||
Ivar fcIvar = NULL;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
for (id obj in objects) {
|
||||
if (sciIsSuggestedTrayItem(obj)) { hasSuggested = YES; break; }
|
||||
}
|
||||
if (!hasSuggested) return objects;
|
||||
|
||||
NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count];
|
||||
for (id obj in objects) {
|
||||
if (!sciIsSuggestedTrayItem(obj)) [filtered addObject:obj];
|
||||
}
|
||||
return [filtered copy];
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class dsCls = NSClassFromString(@"IGStoryTrayListAdapterDataSource");
|
||||
if (!dsCls) return;
|
||||
|
||||
SEL sel = NSSelectorFromString(@"objectsForListAdapter:");
|
||||
if (class_getInstanceMethod(dsCls, sel))
|
||||
MSHookMessageEx(dsCls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// View highlight cover — opens the cover image in the full-screen media viewer.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// Find the IGStoryTrayCell with an active long-press gesture
|
||||
static UIView *sciFindLongPressedCell(UIView *root) {
|
||||
Class cellCls = NSClassFromString(@"IGStoryTrayCell");
|
||||
if (!cellCls) return nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
if ([v isKindOfClass:cellCls]) {
|
||||
for (UIGestureRecognizer *gr in v.gestureRecognizers) {
|
||||
if ([gr isKindOfClass:[UILongPressGestureRecognizer class]] &&
|
||||
(gr.state == UIGestureRecognizerStateBegan || gr.state == UIGestureRecognizerStateChanged))
|
||||
return v;
|
||||
}
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Find the IGImageView inside a specific cell
|
||||
static UIImage *sciCoverImageFromCell(UIView *cell) {
|
||||
if (!cell) return nil;
|
||||
Class igImageView = NSClassFromString(@"IGImageView");
|
||||
if (!igImageView) igImageView = [UIImageView class];
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:cell];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
if ([v isKindOfClass:igImageView] && [v isKindOfClass:[UIImageView class]]) {
|
||||
UIImage *img = [(UIImageView *)v image];
|
||||
if (img && img.size.width > 10) return img;
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciViewCoverImage(UIImage *image) {
|
||||
if (!image) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find cover image")];
|
||||
return;
|
||||
}
|
||||
|
||||
// Save to temp and open in the media viewer
|
||||
NSData *data = UIImageJPEGRepresentation(image, 1.0);
|
||||
if (!data) return;
|
||||
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"cover_%@.jpg", [[NSUUID UUID] UUIDString]]];
|
||||
[data writeToFile:tmpPath atomically:YES];
|
||||
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:tmpURL caption:nil];
|
||||
}
|
||||
|
||||
// Stored reference to the long-pressed cell (captured at presentation time)
|
||||
static __weak UIView *sciLongPressedHighlightCell = nil;
|
||||
|
||||
static void (*orig_present)(id, SEL, id, BOOL, id);
|
||||
static void new_present(id self, SEL _cmd, id vc, BOOL animated, id completion) {
|
||||
if ([SCIUtils getBoolPref:@"download_highlight_cover"] &&
|
||||
[NSStringFromClass([vc class]) containsString:@"ActionSheet"] &&
|
||||
[NSStringFromClass([self class]) containsString:@"Profile"]) {
|
||||
|
||||
// Capture the long-pressed cell NOW while the gesture is still active
|
||||
UIView *cell = sciFindLongPressedCell([(UIViewController *)self view]);
|
||||
sciLongPressedHighlightCell = cell;
|
||||
|
||||
if (cell) {
|
||||
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
|
||||
NSArray *actions = actIvar ? object_getIvar(vc, actIvar) : nil;
|
||||
if (actions && actions.count >= 2 && actions.count <= 6) {
|
||||
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
|
||||
if (actionCls) {
|
||||
void (^handler)(void) = ^{
|
||||
UIImage *cover = sciCoverImageFromCell(sciLongPressedHighlightCell);
|
||||
sciViewCoverImage(cover);
|
||||
};
|
||||
|
||||
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
|
||||
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
|
||||
id newAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
|
||||
@"View cover", nil, 0, handler, nil, nil);
|
||||
|
||||
if (newAction) {
|
||||
NSMutableArray *newActions = [actions mutableCopy];
|
||||
[newActions addObject:newAction];
|
||||
object_setIvar(vc, actIvar, [newActions copy]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
orig_present(self, _cmd, vc, animated, completion);
|
||||
}
|
||||
|
||||
__attribute__((constructor)) static void _highlightInit(void) {
|
||||
MSHookMessageEx([UIViewController class], @selector(presentViewController:animated:completion:),
|
||||
(IMP)new_present, (IMP *)&orig_present);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
// Force launch into a chosen tab. Ignored while messages_only is active.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/message.h>
|
||||
|
||||
static NSString *sciSelectorForLaunchPref(NSString *p) {
|
||||
if ([p isEqualToString:@"feed"]) return @"_timelineButtonPressed";
|
||||
if ([p isEqualToString:@"explore"]) return @"_exploreButtonPressed";
|
||||
if ([p isEqualToString:@"reels"]) return @"_discoverVideoButtonPressed";
|
||||
if ([p isEqualToString:@"inbox"]) return @"_directInboxButtonPressed";
|
||||
if ([p isEqualToString:@"profile"]) return @"_profileButtonPressed";
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGTabBarController
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
if (![SCIUtils getBoolPref:@"messages_only"]) {
|
||||
static BOOL fired = NO;
|
||||
if (!fired) {
|
||||
fired = YES;
|
||||
NSString *pref = [SCIUtils getStringPref:@"launch_tab"];
|
||||
NSString *selName = sciSelectorForLaunchPref(pref);
|
||||
if (selName) {
|
||||
SEL s = NSSelectorFromString(selName);
|
||||
if ([self respondsToSelector:s])
|
||||
((void(*)(id, SEL))objc_msgSend)(self, s);
|
||||
}
|
||||
}
|
||||
}
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,198 @@
|
||||
// Media zoom — long press on feed media to expand in full-screen viewer.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../ActionButton/SCIMediaActions.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
// IGFeedItemPageVideoCell declared in InstagramHeaders.h
|
||||
|
||||
static const void *kZoomGestureKey = &kZoomGestureKey;
|
||||
|
||||
static BOOL sciZoomEnabled(void) {
|
||||
return [SCIUtils getBoolPref:@"feed_media_zoom"];
|
||||
}
|
||||
|
||||
// Walk up to the feed's outer collection view (skip carousel inner CVs)
|
||||
static UICollectionView *sciFeedCollectionView(UIView *view) {
|
||||
UIView *v = view;
|
||||
while (v) {
|
||||
if ([v isKindOfClass:[UICollectionView class]]) {
|
||||
NSString *cls = NSStringFromClass([v class]);
|
||||
if (![cls containsString:@"Carousel"] && ![cls containsString:@"Page"])
|
||||
return (UICollectionView *)v;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSInteger sciFeedSectionForView(UIView *view, UICollectionView *cv) {
|
||||
UIView *v = view;
|
||||
while (v) {
|
||||
if ([v isKindOfClass:[UICollectionViewCell class]]) {
|
||||
NSIndexPath *ip = [cv indexPathForCell:(UICollectionViewCell *)v];
|
||||
if (ip) return ip.section;
|
||||
}
|
||||
v = v.superview;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
// Extract IGMedia from sibling cells in the same section
|
||||
static IGMedia *sciZoomFeedMedia(UIView *view) {
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
if (!mediaClass) return nil;
|
||||
|
||||
UICollectionView *cv = sciFeedCollectionView(view);
|
||||
if (!cv) return nil;
|
||||
|
||||
NSInteger section = sciFeedSectionForView(view, cv);
|
||||
if (section < 0) return nil;
|
||||
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
|
||||
NSString *cls = NSStringFromClass([cell class]);
|
||||
if (![cls containsString:@"Photo"] && ![cls containsString:@"Video"]
|
||||
&& ![cls containsString:@"Media"] && ![cls containsString:@"Page"]) continue;
|
||||
|
||||
unsigned int count = 0;
|
||||
Class c = object_getClass(cell);
|
||||
while (c && c != [UICollectionViewCell class]) {
|
||||
Ivar *ivars = class_copyIvarList(c, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(cell, ivars[i]);
|
||||
if (val && [val isKindOfClass:mediaClass]) { free(ivars); return (IGMedia *)val; }
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
c = class_getSuperclass(c);
|
||||
}
|
||||
|
||||
if ([cell respondsToSelector:@selector(mediaCellFeedItem)]) {
|
||||
id m = ((id(*)(id,SEL))objc_msgSend)(cell, @selector(mediaCellFeedItem));
|
||||
if (m && [m isKindOfClass:mediaClass]) return (IGMedia *)m;
|
||||
}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Carousel page index from the horizontal scroll view in the Page cell
|
||||
static NSInteger sciZoomPageIndex(UIView *view) {
|
||||
UICollectionView *cv = sciFeedCollectionView(view);
|
||||
if (!cv) return 0;
|
||||
|
||||
NSInteger section = sciFeedSectionForView(view, cv);
|
||||
if (section < 0) return 0;
|
||||
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
NSIndexPath *path = [cv indexPathForCell:cell];
|
||||
if (!path || path.section != section) continue;
|
||||
if (![NSStringFromClass([cell class]) containsString:@"Page"]) continue;
|
||||
|
||||
NSMutableArray *queue = [NSMutableArray arrayWithObject:cell];
|
||||
int scanned = 0;
|
||||
while (queue.count && scanned < 100) {
|
||||
UIView *cur = queue.firstObject; [queue removeObjectAtIndex:0]; scanned++;
|
||||
if ([cur isKindOfClass:[UIScrollView class]] && cur != cv) {
|
||||
UIScrollView *sv = (UIScrollView *)cur;
|
||||
CGFloat pageW = sv.bounds.size.width;
|
||||
if (pageW > 100 && sv.contentSize.width > pageW * 1.5)
|
||||
return (NSInteger)round(sv.contentOffset.x / pageW);
|
||||
}
|
||||
for (UIView *s in cur.subviews) [queue addObject:s];
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void sciZoomFired(UILongPressGestureRecognizer *g) {
|
||||
if (g.state != UIGestureRecognizerStateBegan) return;
|
||||
if (!sciZoomEnabled()) return;
|
||||
|
||||
UIView *view = g.view;
|
||||
IGMedia *media = sciZoomFeedMedia(view);
|
||||
if (!media) return;
|
||||
|
||||
NSString *caption = [SCIMediaActions captionForMedia:media];
|
||||
|
||||
if ([SCIMediaActions isCarouselMedia:media]) {
|
||||
NSArray *children = [SCIMediaActions carouselChildrenForMedia:media];
|
||||
NSMutableArray *items = [NSMutableArray array];
|
||||
for (id child in children) {
|
||||
NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)child];
|
||||
NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)child];
|
||||
if (!v && !p) p = [SCIMediaActions bestURLForMedia:child];
|
||||
if (v || p) [items addObject:[SCIMediaViewerItem itemWithVideoURL:v photoURL:p caption:caption]];
|
||||
}
|
||||
if (items.count) {
|
||||
NSInteger idx = sciZoomPageIndex(view);
|
||||
if (idx < 0 || idx >= (NSInteger)items.count) idx = 0;
|
||||
[SCIMediaViewer showItems:items startIndex:idx];
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
|
||||
if (!videoUrl && !photoUrl) photoUrl = [SCIMediaActions bestURLForMedia:media];
|
||||
if (!videoUrl && !photoUrl) return;
|
||||
|
||||
[SCIMediaViewer showWithVideoURL:videoUrl photoURL:photoUrl caption:caption];
|
||||
}
|
||||
|
||||
// MARK: - Gesture setup
|
||||
|
||||
@interface _SCIZoomTarget : NSObject @end
|
||||
@implementation _SCIZoomTarget
|
||||
- (void)fired:(UILongPressGestureRecognizer *)g { sciZoomFired(g); }
|
||||
@end
|
||||
|
||||
static void sciAddZoomGesture(UIView *view) {
|
||||
if (objc_getAssociatedObject(view, kZoomGestureKey)) return;
|
||||
|
||||
_SCIZoomTarget *target = [_SCIZoomTarget new];
|
||||
objc_setAssociatedObject(view, kZoomGestureKey, target, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
|
||||
UILongPressGestureRecognizer *gesture = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:target action:@selector(fired:)];
|
||||
gesture.minimumPressDuration = 0.5;
|
||||
[view addGestureRecognizer:gesture];
|
||||
}
|
||||
|
||||
// MARK: - Hooks
|
||||
|
||||
%hook IGFeedPhotoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (self.superview) sciAddZoomGesture(self);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGModernFeedVideoCell.IGModernFeedVideoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (((UIView *)self).superview) sciAddZoomGesture((UIView *)self);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGFeedItemPagePhotoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (self.superview) sciAddZoomGesture((UIView *)self);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGFeedItemPageVideoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (self.superview) sciAddZoomGesture((UIView *)self);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,65 @@
|
||||
// Messages-only mode — no-op the tab creators we don't want, force inbox at launch.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; }
|
||||
|
||||
%hook IGTabBarController
|
||||
|
||||
// Block tab creation entirely so they never enter the buttons array (no gaps).
|
||||
- (void)_createAndConfigureTimelineButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureReelsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureExploreButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureCameraButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureDynamicTabButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureNewsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
- (void)_createAndConfigureStreamsButtonIfNeeded { if (sciMsgOnly()) return; %orig; }
|
||||
|
||||
// Force initial selection to inbox once after the tab bar has fully laid out.
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
static BOOL launched = NO;
|
||||
if (sciMsgOnly() && !launched) {
|
||||
launched = YES;
|
||||
SEL s = NSSelectorFromString(@"_directInboxButtonPressed");
|
||||
if ([self respondsToSelector:s])
|
||||
((void(*)(id, SEL))objc_msgSend)(self, s);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
Class c = [self class];
|
||||
Ivar ibIv = class_getInstanceVariable(c, "_directInboxButton");
|
||||
Ivar pbIv = class_getInstanceVariable(c, "_profileButton");
|
||||
UIButton *inbox = ibIv ? object_getIvar(self, ibIv) : nil;
|
||||
UIButton *profile = pbIv ? object_getIvar(self, pbIv) : nil;
|
||||
BOOL profileActive = [which isEqualToString:@"profile"];
|
||||
if ([inbox respondsToSelector:@selector(setSelected:)]) inbox.selected = !profileActive;
|
||||
if ([profile respondsToSelector:@selector(setSelected:)]) profile.selected = profileActive;
|
||||
|
||||
// No-op on classic tab bar (selector only exists on IGLiquidGlassInteractiveTabBar).
|
||||
Ivar tbIv = class_getInstanceVariable(c, "_tabBar");
|
||||
id tabBar = tbIv ? object_getIvar(self, tbIv) : nil;
|
||||
NSInteger idx = profileActive ? 1 : 0;
|
||||
SEL setIdx = NSSelectorFromString(@"setSelectedTabBarItemIndex:animateIndicator:");
|
||||
if ([tabBar respondsToSelector:setIdx])
|
||||
((void(*)(id, SEL, NSInteger, BOOL))objc_msgSend)(tabBar, setIdx, idx, YES);
|
||||
}
|
||||
|
||||
- (void)_directInboxButtonPressed {
|
||||
%orig;
|
||||
if (sciMsgOnly())
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"inbox");
|
||||
}
|
||||
- (void)_profileButtonPressed {
|
||||
%orig;
|
||||
if (sciMsgOnly())
|
||||
((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciSyncTabBarSelection:), @"profile");
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -13,6 +13,11 @@ BOOL isSurfaceShown(IGMainAppSurfaceIntent *surface) {
|
||||
isShown = NO;
|
||||
}
|
||||
|
||||
// Messages
|
||||
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"DIRECT"] && [SCIUtils getBoolPref:@"hide_messages_tab"]) {
|
||||
isShown = NO;
|
||||
}
|
||||
|
||||
// Explore
|
||||
else if ([[surface tabStringFromSurfaceIntent] isEqualToString:@"SEARCH"] && [SCIUtils getBoolPref:@"hide_explore_tab"]) {
|
||||
isShown = NO;
|
||||
@@ -97,4 +102,19 @@ NSArray *filterSurfacesArray(NSArray *surfaces) {
|
||||
- (void)setIsTabSwipingEnabled:(BOOL)arg1 {
|
||||
return;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGHomeFeedHeaderView
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"hide_messages_tab"]) {
|
||||
UIButton *rightButton = [self valueForKey:@"rightButton"];
|
||||
if (rightButton) {
|
||||
NSLog(@"[SCInsta] Hiding messages tab (on feed)");
|
||||
|
||||
[rightButton removeFromSuperview];
|
||||
}
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -38,13 +38,13 @@
|
||||
|
||||
// Recent dm message recipients search bar
|
||||
%hook IGDirectRecipientRecentSearchStorage
|
||||
- (id)initWithDiskManager:(id)arg1 directCache:(id)arg2 userStore:(id)arg3 currentUser:(id)arg4 featureSets:(id)arg5 {
|
||||
- (id)initWithDiskManager:(id)arg1 directRepo:(id)arg2 userMap:(id)arg3 currentUser:(id)arg4 launcherSet:(id)arg5 {
|
||||
if ([SCIUtils getBoolPref:@"no_recent_searches"]) {
|
||||
NSLog(@"[SCInsta] Disabling recent searches");
|
||||
|
||||
return nil;
|
||||
}
|
||||
|
||||
return %orig;
|
||||
return %orig(arg1, arg2, arg3, arg4, arg5);
|
||||
}
|
||||
%end
|
||||
@@ -64,7 +64,7 @@
|
||||
// Section header
|
||||
if ([obj isKindOfClass:%c(IGLabelItemViewModel)]) {
|
||||
// Suggested for you
|
||||
if ([[obj labelTitle] isEqualToString:@"Suggested for you"]) {
|
||||
if ([[obj valueForKey:@"tag"] intValue] == 2) { // 2 == Suggested Users
|
||||
if ([SCIUtils getBoolPref:@"no_suggested_users"]) {
|
||||
NSLog(@"[SCInsta] Hiding suggested users (header: activity feed)");
|
||||
|
||||
|
||||
@@ -130,12 +130,12 @@ static char targetStaticRef[] = "target";
|
||||
[rightButton sizeToFit];
|
||||
|
||||
[rightButton addAction:[UIAction actionWithHandler:^(__kindof UIAction * _Nonnull action) {
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Enter Emoji Text"
|
||||
message:@"Click the Apply button after this to see the emoji"
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Enter Emoji Text")
|
||||
message:SCILocalized(@"Click the Apply button after this to see the emoji")
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
|
||||
[alert addTextFieldWithConfigurationHandler:^(UITextField *textField) {
|
||||
textField.placeholder = @"Type emoji...";
|
||||
textField.placeholder = SCILocalized(@"Type emoji...");
|
||||
}];
|
||||
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"OK"
|
||||
@@ -145,7 +145,7 @@ static char targetStaticRef[] = "target";
|
||||
[self applySCICustomTheme:@"Emoji"];
|
||||
}]];
|
||||
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel"
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel")
|
||||
style:UIAlertActionStyleCancel
|
||||
handler:nil]];
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// Open links in external browser + strip IG tracking from URLs
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
// Extract the real URL from l.instagram.com redirects and strip tracking params
|
||||
static NSURL *sciCleanBrowserURL(NSURL *url) {
|
||||
if (![SCIUtils getBoolPref:@"strip_browser_tracking"]) return url;
|
||||
if (!url) return url;
|
||||
|
||||
NSString *urlStr = url.absoluteString;
|
||||
|
||||
// Unwrap l.instagram.com/?u=ENCODED_URL&e=TRACKING redirects
|
||||
if ([url.host isEqualToString:@"l.instagram.com"]) {
|
||||
NSURLComponents *comps = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO];
|
||||
for (NSURLQueryItem *q in comps.queryItems) {
|
||||
if ([q.name isEqualToString:@"u"] && q.value.length) {
|
||||
NSString *decoded = [q.value stringByRemovingPercentEncoding];
|
||||
if (decoded) urlStr = decoded;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strip common tracking params from the destination URL
|
||||
NSURLComponents *comps = [NSURLComponents componentsWithString:urlStr];
|
||||
if (comps.queryItems.count) {
|
||||
NSSet *trackingParams = [NSSet setWithArray:@[
|
||||
@"utm_source", @"utm_medium", @"utm_campaign", @"utm_content",
|
||||
@"utm_term", @"utm_id", @"fbclid", @"igshid", @"igsh",
|
||||
@"ig_rid", @"campaign_id", @"ad_id", @"aem"
|
||||
]];
|
||||
NSMutableArray *clean = [NSMutableArray array];
|
||||
for (NSURLQueryItem *q in comps.queryItems) {
|
||||
if (![trackingParams containsObject:q.name]) [clean addObject:q];
|
||||
}
|
||||
comps.queryItems = clean.count ? clean : nil;
|
||||
}
|
||||
|
||||
NSURL *result = comps.URL;
|
||||
return result ?: url;
|
||||
}
|
||||
|
||||
%hook IGBrowserNavigationController
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
id session = ((id(*)(id,SEL))objc_msgSend)(self, @selector(browserSession));
|
||||
Ivar urlIvar = session ? class_getInstanceVariable([session class], "_urlRequest") : nil;
|
||||
NSURLRequest *req = urlIvar ? object_getIvar(session, urlIvar) : nil;
|
||||
NSURL *url = req.URL;
|
||||
|
||||
if (url && [SCIUtils getBoolPref:@"open_links_external"]) {
|
||||
NSURL *cleaned = sciCleanBrowserURL(url);
|
||||
[[UIApplication sharedApplication] openURL:cleaned options:@{} completionHandler:nil];
|
||||
[(UIViewController *)self dismissViewControllerAnimated:NO completion:nil];
|
||||
return;
|
||||
}
|
||||
|
||||
// For in-app browser: replace the URL request with the cleaned version
|
||||
if (url && [SCIUtils getBoolPref:@"strip_browser_tracking"]) {
|
||||
NSURL *cleaned = sciCleanBrowserURL(url);
|
||||
if (![cleaned isEqual:url]) {
|
||||
NSURLRequest *cleanReq = [NSURLRequest requestWithURL:cleaned];
|
||||
object_setIvar(session, urlIvar, cleanReq);
|
||||
}
|
||||
}
|
||||
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,250 @@
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../../modules/JGProgressHUD/JGProgressHUD.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// Profile page copy button: hooks IG's native nav header builder to insert
|
||||
// a copy button alongside IG's own buttons, then opens a menu to copy
|
||||
// username/name/bio.
|
||||
|
||||
// IGProfileViewController declared in InstagramHeaders.h
|
||||
|
||||
static id sci_safeValueForKey(id obj, NSString *key) {
|
||||
@try { return [obj valueForKey:key]; }
|
||||
@catch (__unused NSException *e) { return nil; }
|
||||
}
|
||||
|
||||
static id sci_valueForAnyKey(id obj, NSArray<NSString *> *keys) {
|
||||
for (NSString *k in keys) {
|
||||
id v = sci_safeValueForKey(obj, k);
|
||||
if (v && v != [NSNull null]) return v;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id sci_findUserOnVC(UIViewController *vc) {
|
||||
id user = sci_valueForAnyKey(vc, @[@"user", @"userGQL", @"profileUser", @"loggedInUser", @"currentUser"]);
|
||||
if (user) return user;
|
||||
|
||||
Class userCls = NSClassFromString(@"IGUser");
|
||||
Class c = [vc class];
|
||||
while (c && c != [NSObject class]) {
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList(c, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
id v = object_getIvar(vc, ivars[i]);
|
||||
if (userCls && [v isKindOfClass:userCls]) {
|
||||
free(ivars);
|
||||
return v;
|
||||
}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
c = class_getSuperclass(c);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static UIViewController *sci_findProfileVC(UIView *view) {
|
||||
Class profileCls = NSClassFromString(@"IGProfileViewController");
|
||||
UIResponder *r = view;
|
||||
while (r) {
|
||||
if (profileCls && [r isKindOfClass:profileCls]) return (UIViewController *)r;
|
||||
r = [r nextResponder];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sci_copyAndToast(NSString *value, NSString *label) {
|
||||
if (value.length == 0) return;
|
||||
[UIPasteboard generalPasteboard].string = value;
|
||||
|
||||
JGProgressHUD *HUD = [[JGProgressHUD alloc] init];
|
||||
HUD.textLabel.text = [NSString stringWithFormat:@"Copied %@", label];
|
||||
HUD.indicatorView = [[JGProgressHUDSuccessIndicatorView alloc] init];
|
||||
UIView *host = nil;
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { host = w; break; }
|
||||
}
|
||||
if (host) {
|
||||
[HUD showInView:host];
|
||||
[HUD dismissAfterDelay:1.5];
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton target for the copy button so we don't have to track lifetime.
|
||||
@interface SCIProfileCopyTarget : NSObject
|
||||
+ (instancetype)shared;
|
||||
- (void)handleTap:(UIButton *)sender;
|
||||
@end
|
||||
|
||||
@implementation SCIProfileCopyTarget
|
||||
+ (instancetype)shared {
|
||||
static SCIProfileCopyTarget *s;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{ s = [[SCIProfileCopyTarget alloc] init]; });
|
||||
return s;
|
||||
}
|
||||
|
||||
- (void)handleTap:(UIButton *)sender {
|
||||
UIViewController *vc = sci_findProfileVC(sender);
|
||||
if (!vc) {
|
||||
NSLog(@"[SCInsta] copy button: no IGProfileViewController in responder chain");
|
||||
return;
|
||||
}
|
||||
|
||||
id user = sci_findUserOnVC(vc);
|
||||
if (!user) {
|
||||
NSLog(@"[SCInsta] copy button: no IGUser found on %@", vc.class);
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *username = [sci_valueForAnyKey(user, @[@"username"]) description];
|
||||
NSString *fullName = [sci_valueForAnyKey(user, @[@"fullName", @"fullname", @"name"]) description];
|
||||
NSString *biography = [sci_valueForAnyKey(user, @[@"biography", @"bio", @"profileBiography"]) description];
|
||||
|
||||
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
|
||||
username, fullName, (unsigned long)biography.length);
|
||||
|
||||
UIAlertController *menu = [UIAlertController alertControllerWithTitle:SCILocalized(@"Copy from profile")
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
|
||||
if (username.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:[NSString stringWithFormat:@"Copy username (@%@)", username]
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]];
|
||||
}
|
||||
if (fullName.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy name")
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]];
|
||||
}
|
||||
if (biography.length) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy bio")
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]];
|
||||
}
|
||||
|
||||
NSMutableArray *parts = [NSMutableArray array];
|
||||
if (username.length) [parts addObject:[NSString stringWithFormat:@"Username: @%@", username]];
|
||||
if (fullName.length) [parts addObject:[NSString stringWithFormat:@"Name: %@", fullName]];
|
||||
if (biography.length) [parts addObject:[NSString stringWithFormat:@"Bio:\n%@", biography]];
|
||||
|
||||
if (parts.count >= 2) {
|
||||
NSString *combined = [parts componentsJoinedByString:@"\n\n"];
|
||||
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy all")
|
||||
style:UIAlertActionStyleDefault
|
||||
handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]];
|
||||
}
|
||||
|
||||
if (menu.actions.count == 0) {
|
||||
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Nothing to copy") style:UIAlertActionStyleDefault handler:nil]];
|
||||
}
|
||||
|
||||
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
|
||||
if (sender) {
|
||||
menu.popoverPresentationController.sourceView = sender;
|
||||
menu.popoverPresentationController.sourceRect = sender.bounds;
|
||||
}
|
||||
|
||||
[vc presentViewController:menu animated:YES completion:nil];
|
||||
}
|
||||
@end
|
||||
|
||||
static UIView *sci_buildCopyButton(void) {
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
btn.accessibilityIdentifier = @"sci-profile-copy-button";
|
||||
btn.accessibilityLabel = @"Copy profile info";
|
||||
UIImageSymbolConfiguration *cfg =
|
||||
[UIImageSymbolConfiguration configurationWithPointSize:16
|
||||
weight:UIImageSymbolWeightRegular];
|
||||
UIImage *icon = [[UIImage systemImageNamed:@"doc.on.doc"] imageByApplyingSymbolConfiguration:cfg];
|
||||
[btn setImage:icon forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor labelColor];
|
||||
btn.frame = CGRectMake(0, 0, 24, 44);
|
||||
[btn addTarget:[SCIProfileCopyTarget shared]
|
||||
action:@selector(handleTap:)
|
||||
forControlEvents:UIControlEventTouchUpInside];
|
||||
return btn;
|
||||
}
|
||||
|
||||
static void (*orig_configureHeaderView)(id, SEL, id, id, id, BOOL);
|
||||
|
||||
static void hooked_configureHeaderView(id self, SEL _cmd,
|
||||
id titleView,
|
||||
id leftButtons,
|
||||
id rightButtons,
|
||||
BOOL titleIsCentered) {
|
||||
if (![SCIUtils getBoolPref:@"profile_copy_button"]) {
|
||||
orig_configureHeaderView(self, _cmd, titleView, leftButtons, rightButtons, titleIsCentered);
|
||||
return;
|
||||
}
|
||||
|
||||
// Own profile (centered title) → inject on the left to avoid crowding the
|
||||
// plus/notifications/burger cluster. Other profiles → inject on the right.
|
||||
NSArray *lb = [leftButtons isKindOfClass:[NSArray class]] ? (NSArray *)leftButtons : nil;
|
||||
NSArray *rb = [rightButtons isKindOfClass:[NSArray class]] ? (NSArray *)rightButtons : nil;
|
||||
BOOL isOwnProfile = titleIsCentered;
|
||||
|
||||
BOOL alreadyHas = NO;
|
||||
for (id wrapper in (isOwnProfile ? lb : rb)) {
|
||||
UIView *v = sci_safeValueForKey(wrapper, @"view");
|
||||
if ([v isKindOfClass:[UIView class]] &&
|
||||
[v.accessibilityIdentifier isEqualToString:@"sci-profile-copy-button"]) {
|
||||
alreadyHas = YES;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
NSArray *patchedLeft = leftButtons;
|
||||
NSArray *patchedRight = rightButtons;
|
||||
|
||||
if (!alreadyHas) {
|
||||
Class wrapperCls = NSClassFromString(@"IGProfileNavigationHeaderViewButtonSwift.IGProfileNavigationHeaderViewButton");
|
||||
// Mirror an existing button's type so IG lays ours out the same way
|
||||
id sample = rb.firstObject ?: lb.firstObject;
|
||||
NSInteger type = 0;
|
||||
id typeVal = sci_safeValueForKey(sample, @"type");
|
||||
if ([typeVal respondsToSelector:@selector(integerValue)]) {
|
||||
type = [typeVal integerValue];
|
||||
}
|
||||
|
||||
UIView *btn = sci_buildCopyButton();
|
||||
id wrapper = nil;
|
||||
if (wrapperCls) {
|
||||
wrapper = [wrapperCls alloc];
|
||||
SEL initSel = @selector(initWithType:view:);
|
||||
if ([wrapper respondsToSelector:initSel]) {
|
||||
id (*ctor)(id, SEL, NSInteger, id) =
|
||||
(id (*)(id, SEL, NSInteger, id))objc_msgSend;
|
||||
wrapper = ctor(wrapper, initSel, type, btn);
|
||||
}
|
||||
}
|
||||
|
||||
if (wrapper) {
|
||||
if (isOwnProfile) {
|
||||
NSMutableArray *m = lb ? [lb mutableCopy] : [NSMutableArray array];
|
||||
[m addObject:wrapper];
|
||||
patchedLeft = m;
|
||||
} else if (rb) {
|
||||
NSMutableArray *m = [rb mutableCopy];
|
||||
[m insertObject:wrapper atIndex:0];
|
||||
patchedRight = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
orig_configureHeaderView(self, _cmd, titleView, patchedLeft, patchedRight, titleIsCentered);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = objc_getClass("IGProfileNavigationSwift.IGProfileNavigationHeaderView");
|
||||
if (!cls) return;
|
||||
SEL sel = @selector(configureWithTitleView:leftButtons:rightButtons:titleIsCentered:);
|
||||
if (![cls instancesRespondToSelector:sel]) return;
|
||||
MSHookMessageEx(cls, sel,
|
||||
(IMP)hooked_configureHeaderView,
|
||||
(IMP *)&orig_configureHeaderView);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Single source of truth for date-format hook entries.
|
||||
// Format: X(name, selector_cstring, label, arity, pref_key)
|
||||
// Entries sharing a pref_key are toggled together; label is shown in the
|
||||
// picker for the first entry sharing a given pref_key (use "" for others).
|
||||
|
||||
#define SCI_DATE_FORMAT_ENTRIES(X) \
|
||||
X(mixed, "formattedDateInMixedFormat", "Feed posts", 0, "date_fmt_mixed") \
|
||||
X(rel, "formattedDateRelativeToNow", "Notes, comments, stories",0, "date_fmt_notes_comments_stories") \
|
||||
X(shortRel, "shortenedFormattedDateRelativeToNow", "", 0, "date_fmt_notes_comments_stories") \
|
||||
X(shortRelHs, "shortenedFormattedDateRelativeToNowHideSeconds:", "DMs", 1, "date_fmt_dms")
|
||||
|
||||
// Kept for future use — other NSDate relative formatters IG uses across
|
||||
// surfaces. Enable by adding to SCI_DATE_FORMAT_ENTRIES above.
|
||||
//
|
||||
// X(partialRel, "partiallyShortenedFormattedDateRelativeToNow", "Partially shortened relative", 0, "date_fmt_partialRel")
|
||||
// X(shortRelYears, "shortenedFormattedDateRelativeToNowIncludeYears", "Shortened relative (incl. years)", 0, "date_fmt_shortRelYears")
|
||||
// X(shortRelOpts, "shortenedFormattedDateRelativeToNowWithOptions:", "Shortened relative (options)", 1, "date_fmt_shortRelOpts")
|
||||
// X(shortRelFloor, "shortenedFormattedDateRelativeToNowWithFloorDaysWeeks:", "Shortened rel. (floor days/weeks)", 1, "date_fmt_shortRelFloor")
|
||||
// X(mixedShortRelMDY, "formattedDateInMixedShortenedRelativeAndMonthDayYearFormatWithThreshold:", "Mixed shortened + M/D/Y", 1, "date_fmt_mixedShortRelMDY")
|
||||
// X(relHs, "formattedDateRelativeToNowHideSeconds:", "Relative (hide seconds)", 1, "date_fmt_relHs")
|
||||
// X(relYearsHs, "formattedDateRelativeToNowIncludingYearsHideSeconds:", "Rel. incl. years (hide seconds)", 1, "date_fmt_relYearsHs")
|
||||
// X(partialRelHsOpts, "partiallyShortenedFormattedDateRelativeToNowHideSeconds:options:", "Partial rel. (hide secs, opts)", 2, "date_fmt_partialRelHsOpts")
|
||||
// X(relHsFloor, "formattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:", "Relative (hide secs, floor)", 2, "date_fmt_relHsFloor")
|
||||
// X(shortRelHsFloor, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:", "Shortened rel. (hide secs, floor)", 2, "date_fmt_shortRelHsFloor")
|
||||
// X(shortRelHsFloorOpts, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:options:", "Shortened rel. (hide secs, floor, opts)", 3, "date_fmt_shortRelHsFloorOpts")
|
||||
// X(shortRelHsFloorYearsOpts, "shortenedFormattedDateRelativeToNowHideSeconds:shouldFloorDaysWeeks:includeYears:options:","Shortened rel. (full signature)", 4, "date_fmt_shortRelHsFloorYearsOpts")
|
||||
@@ -30,14 +30,16 @@
|
||||
}
|
||||
%end
|
||||
|
||||
// Quick access to tweak settings by holding on home tab button
|
||||
// Quick access to tweak settings by holding on the home tab button.
|
||||
// In messages-only mode the home tab is gone — fall back to the inbox tab.
|
||||
%hook IGTabBarButton
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
// Only work on home/feed tab
|
||||
if (![self.accessibilityIdentifier isEqualToString:@"mainfeed-tab"]) return;
|
||||
|
||||
BOOL msgOnly = [SCIUtils getBoolPref:@"messages_only"];
|
||||
NSString *target = msgOnly ? SCILocalized(@"direct-inbox-tab") : SCILocalized(@"mainfeed-tab");
|
||||
if (![self.accessibilityIdentifier isEqualToString:target]) return;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"settings_shortcut"]) {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = 0.3;
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
// Legacy download gestures — off by default, kept for users who prefer the
|
||||
// old multi-finger long-press workflow over the action button menu.
|
||||
//
|
||||
// The modern flow lives in:
|
||||
// src/ActionButton/ — menu + handlers
|
||||
// src/Features/ActionButton/ — per-context button injection
|
||||
// src/Features/StoriesAndMessages/OverlayButtons.xm — stories action button
|
||||
//
|
||||
// This file only contains:
|
||||
// 1. Long-press gesture recognizers on feed/story/reel media views, gated
|
||||
// by `dw_legacy_gesture`. When on, they reuse the old sciDownload* path
|
||||
// and save via the user's `dw_save_action` preference.
|
||||
// 2. The profile-picture long-press gesture (always on when `save_profile`).
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
#import "../../ActionButton/SCIMediaViewer.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
static SCIDownloadDelegate *imageDownloadDelegate;
|
||||
@@ -12,220 +27,25 @@ static DownloadAction sciGetDownloadAction() {
|
||||
return share;
|
||||
}
|
||||
|
||||
static void initDownloaders () {
|
||||
// Re-init each time to pick up the current save action preference
|
||||
static void initDownloaders() {
|
||||
DownloadAction action = sciGetDownloadAction();
|
||||
DownloadAction imgAction = (action == saveToPhotos) ? saveToPhotos : quickLook;
|
||||
imageDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO];
|
||||
videoDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES];
|
||||
}
|
||||
|
||||
// Helper: run a download block with optional confirmation dialog
|
||||
static void sciConfirmAndDownload(NSString *title, void(^downloadBlock)(void)) {
|
||||
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
|
||||
[SCIUtils showConfirmation:downloadBlock title:title];
|
||||
} else {
|
||||
downloadBlock();
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: recursively search within a view tree for downloadable media (bounded to one post)
|
||||
static BOOL sciFindAndDownloadMediaInView(UIView *root) {
|
||||
if (!root) return NO;
|
||||
|
||||
// Check for video media via mediaCellFeedItem
|
||||
if ([root respondsToSelector:@selector(mediaCellFeedItem)]) {
|
||||
IGMedia *media = [root performSelector:@selector(mediaCellFeedItem)];
|
||||
if (media) {
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
|
||||
if (videoUrl) {
|
||||
initDownloaders();
|
||||
[videoDownloadDelegate downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil];
|
||||
return YES;
|
||||
}
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
|
||||
if (photoUrl) {
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for IGFeedPhotoView with delegate chain
|
||||
if ([root isKindOfClass:NSClassFromString(@"IGFeedPhotoView")] && [root respondsToSelector:@selector(delegate)]) {
|
||||
id delegate = [root performSelector:@selector(delegate)];
|
||||
if ([delegate isKindOfClass:NSClassFromString(@"IGFeedItemPhotoCell")]) {
|
||||
@try {
|
||||
Ivar cfgIvar = class_getInstanceVariable([delegate class], "_configuration");
|
||||
if (cfgIvar) {
|
||||
id cfg = object_getIvar(delegate, cfgIvar);
|
||||
if (cfg) {
|
||||
Ivar photoIvar = class_getInstanceVariable([cfg class], "_photo");
|
||||
if (photoIvar) {
|
||||
IGPhoto *photo = object_getIvar(cfg, photoIvar);
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
|
||||
if (photoUrl) {
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} @catch (NSException *e) {}
|
||||
}
|
||||
if ([delegate isKindOfClass:NSClassFromString(@"IGFeedItemPagePhotoCell")]) {
|
||||
@try {
|
||||
if ([delegate respondsToSelector:@selector(pagePhotoPost)]) {
|
||||
id pagePhotoPost = [delegate performSelector:@selector(pagePhotoPost)];
|
||||
if (pagePhotoPost && [pagePhotoPost respondsToSelector:@selector(photo)]) {
|
||||
IGPhoto *photo = [pagePhotoPost performSelector:@selector(photo)];
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
|
||||
if (photoUrl) {
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
|
||||
return YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
} @catch (NSException *e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Recurse into subviews
|
||||
for (UIView *sub in root.subviews) {
|
||||
if (sciFindAndDownloadMediaInView(sub)) return YES;
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Helper: find IGMedia from a cell using runtime ivar scanning
|
||||
// Avoids property getters which can cause EXC_BAD_ACCESS on certain IG versions
|
||||
static IGMedia * _Nullable sciGetMediaFromView(UIView *view) {
|
||||
if (!view) return nil;
|
||||
|
||||
unsigned int ivarCount = 0;
|
||||
Ivar *ivars = class_copyIvarList([view class], &ivarCount);
|
||||
if (!ivars) return nil;
|
||||
|
||||
IGMedia *found = nil;
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
|
||||
for (unsigned int i = 0; i < ivarCount; i++) {
|
||||
const char *name = ivar_getName(ivars[i]);
|
||||
if (!name) continue;
|
||||
|
||||
NSString *ivarName = [NSString stringWithUTF8String:name];
|
||||
NSString *lower = [ivarName lowercaseString];
|
||||
|
||||
if ([lower containsString:@"video"] || [lower containsString:@"media"] || [lower containsString:@"item"]) {
|
||||
id value = object_getIvar(view, ivars[i]);
|
||||
if (value && mediaClass && [value isKindOfClass:mediaClass]) {
|
||||
found = (IGMedia *)value;
|
||||
NSLog(@"[SCInsta] Found IGMedia in ivar '%@' of %@", ivarName, NSStringFromClass([view class]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
free(ivars);
|
||||
return found;
|
||||
}
|
||||
|
||||
// Helper: walk superview chain to find a view of a given class
|
||||
static UIView * _Nullable sciFindSuperviewOfClass(UIView *view, NSString *className) {
|
||||
Class cls = NSClassFromString(className);
|
||||
if (!cls) return nil;
|
||||
UIView *current = view.superview;
|
||||
int depth = 0;
|
||||
while (current && depth < 15) {
|
||||
if ([current isKindOfClass:cls]) return current;
|
||||
current = current.superview;
|
||||
depth++;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Helper: show debug ivar dump when media extraction fails (survives IG updates)
|
||||
static void sciShowDebugIvarDump(UIView *cell) {
|
||||
NSMutableString *debug = [NSMutableString stringWithFormat:@"No IGMedia found in %@\n\nIvars:\n", NSStringFromClass([cell class])];
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList([cell class], &count);
|
||||
for (unsigned int i = 0; i < count && i < 50; i++) {
|
||||
const char *name = ivar_getName(ivars[i]);
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (name) [debug appendFormat:@"%s (%s)\n", name, type ? type : "?"];
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
|
||||
NSLog(@"[SCInsta] Debug: %@", debug);
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"RyukGram Debug"
|
||||
message:debug
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"Copy & Close" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
|
||||
[[UIPasteboard generalPasteboard] setString:debug];
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"Close" style:UIAlertActionStyleCancel handler:nil]];
|
||||
UIViewController *topVC = topMostController();
|
||||
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
|
||||
});
|
||||
}
|
||||
|
||||
// Whether download buttons (not long-press) are enabled
|
||||
static BOOL sciUseDownloadButtons() {
|
||||
return [[SCIUtils getStringPref:@"dw_method"] isEqualToString:@"button"];
|
||||
static BOOL sciLegacyGestureEnabled() {
|
||||
return [SCIUtils getBoolPref:@"dw_legacy_gesture"];
|
||||
}
|
||||
|
||||
|
||||
/* * Feed * */
|
||||
/* * Feed (legacy gesture) * */
|
||||
|
||||
// Download feed images
|
||||
%hook IGFeedPhotoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"dw_feed_posts"]) return;
|
||||
|
||||
if (sciUseDownloadButtons()) {
|
||||
[self sciAddDownloadButton];
|
||||
} else {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
}
|
||||
%new - (void)sciAddDownloadButton {
|
||||
if ([self viewWithTag:1338]) return;
|
||||
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1338;
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
|
||||
[btn setImage:[UIImage systemImageNamed:@"arrow.down.to.line" withConfiguration:config] forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor whiteColor];
|
||||
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
btn.layer.cornerRadius = 12;
|
||||
btn.clipsToBounds = YES;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:self action:@selector(sciDownloadBtnTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:btn];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:10],
|
||||
[btn.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:24],
|
||||
[btn.heightAnchor constraintEqualToConstant:24]
|
||||
]];
|
||||
}
|
||||
%new - (void)sciDownloadBtnTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.75, 0.75); }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
|
||||
|
||||
sciConfirmAndDownload(@"Download photo?", ^{
|
||||
[self handleLongPress:nil];
|
||||
});
|
||||
if (!sciLegacyGestureEnabled()) return;
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
@@ -237,75 +57,30 @@ static BOOL sciUseDownloadButtons() {
|
||||
if (sender && sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
IGPhoto *photo;
|
||||
|
||||
if ([self.delegate isKindOfClass:%c(IGFeedItemPhotoCell)]) {
|
||||
IGFeedItemPhotoCellConfiguration *_configuration = MSHookIvar<IGFeedItemPhotoCellConfiguration *>(self.delegate, "_configuration");
|
||||
if (!_configuration) return;
|
||||
photo = MSHookIvar<IGPhoto *>(_configuration, "_photo");
|
||||
}
|
||||
else if ([self.delegate isKindOfClass:%c(IGFeedItemPagePhotoCell)]) {
|
||||
} else if ([self.delegate isKindOfClass:%c(IGFeedItemPagePhotoCell)]) {
|
||||
IGFeedItemPagePhotoCell *pagePhotoCell = self.delegate;
|
||||
photo = pagePhotoCell.pagePhotoPost.photo;
|
||||
}
|
||||
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrl:photo];
|
||||
if (!photoUrl) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from post"];
|
||||
return;
|
||||
}
|
||||
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from post")]; return; }
|
||||
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl
|
||||
fileExtension:[[photoUrl lastPathComponent]pathExtension]
|
||||
fileExtension:[[photoUrl lastPathComponent] pathExtension]
|
||||
hudLabel:nil];
|
||||
}
|
||||
%end
|
||||
|
||||
// Download feed videos
|
||||
%hook IGModernFeedVideoCell.IGModernFeedVideoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"dw_feed_posts"]) return;
|
||||
|
||||
if (sciUseDownloadButtons()) {
|
||||
[self sciAddDownloadButton];
|
||||
} else {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
}
|
||||
%new - (void)sciAddDownloadButton {
|
||||
UIView *selfView = (UIView *)self;
|
||||
if ([selfView viewWithTag:1338]) return;
|
||||
|
||||
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
btn.tag = 1338;
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold];
|
||||
[btn setImage:[UIImage systemImageNamed:@"arrow.down.to.line" withConfiguration:config] forState:UIControlStateNormal];
|
||||
btn.tintColor = [UIColor whiteColor];
|
||||
btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
btn.layer.cornerRadius = 12;
|
||||
btn.clipsToBounds = YES;
|
||||
btn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[btn addTarget:self action:@selector(sciDownloadBtnTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[selfView addSubview:btn];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[btn.leadingAnchor constraintEqualToAnchor:selfView.leadingAnchor constant:10],
|
||||
[btn.bottomAnchor constraintEqualToAnchor:selfView.bottomAnchor constant:-10],
|
||||
[btn.widthAnchor constraintEqualToConstant:24],
|
||||
[btn.heightAnchor constraintEqualToConstant:24]
|
||||
]];
|
||||
}
|
||||
%new - (void)sciDownloadBtnTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.75, 0.75); }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
|
||||
|
||||
sciConfirmAndDownload(@"Download video?", ^{
|
||||
[self handleLongPress:nil];
|
||||
});
|
||||
if (!sciLegacyGestureEnabled()) return;
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
@@ -317,10 +92,7 @@ static BOOL sciUseDownloadButtons() {
|
||||
if (sender && sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:[self mediaCellFeedItem]];
|
||||
if (!videoUrl) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from post"];
|
||||
return;
|
||||
}
|
||||
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from post")]; return; }
|
||||
|
||||
initDownloaders();
|
||||
[videoDownloadDelegate downloadFileWithURL:videoUrl
|
||||
@@ -330,277 +102,50 @@ static BOOL sciUseDownloadButtons() {
|
||||
%end
|
||||
|
||||
|
||||
/* * Stories (legacy gesture) * */
|
||||
|
||||
/* * Reels * */
|
||||
|
||||
// Download reels (photos) — long press only when gesture mode selected
|
||||
%hook IGSundialViewerPhotoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"dw_reels"] && !sciUseDownloadButtons()) {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
|
||||
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
|
||||
if (sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
@try {
|
||||
IGPhoto *_photo = nil;
|
||||
@try {
|
||||
_photo = MSHookIvar<IGPhoto *>(self, "_photo");
|
||||
} @catch (NSException *e) {}
|
||||
|
||||
if (!_photo) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not access reel photo"];
|
||||
return;
|
||||
}
|
||||
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrl:_photo];
|
||||
if (!photoUrl) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from reel"];
|
||||
return;
|
||||
}
|
||||
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl
|
||||
fileExtension:[[photoUrl lastPathComponent]pathExtension]
|
||||
hudLabel:nil];
|
||||
} @catch (NSException *exception) {
|
||||
NSLog(@"[SCInsta] Reel photo download error: %@", exception);
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Reel photo download failed: %@", exception.reason]];
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
// Download reels (videos) — long press only when gesture mode selected
|
||||
%hook IGSundialViewerVideoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"dw_reels"] && !sciUseDownloadButtons()) {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
|
||||
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
|
||||
if (sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
@try {
|
||||
IGMedia *media = sciGetMediaFromView(self);
|
||||
if (!media) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not access reel media"];
|
||||
return;
|
||||
}
|
||||
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
|
||||
if (!videoUrl) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from reel"];
|
||||
return;
|
||||
}
|
||||
|
||||
initDownloaders();
|
||||
[videoDownloadDelegate downloadFileWithURL:videoUrl
|
||||
fileExtension:[[videoUrl lastPathComponent] pathExtension]
|
||||
hudLabel:nil];
|
||||
} @catch (NSException *exception) {
|
||||
NSLog(@"[SCInsta] Reel download error: %@", exception);
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Reel download failed: %@", exception.reason]];
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
// Download button on reels vertical UFI (like/comment/share sidebar)
|
||||
%hook IGSundialViewerVerticalUFI
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"dw_reels"]) return;
|
||||
if (!sciUseDownloadButtons()) return;
|
||||
if (!self.superview) return;
|
||||
|
||||
// Add to superview so we're not clipped by the narrow 29pt UFI
|
||||
UIView *parent = self.superview;
|
||||
if ([parent viewWithTag:1337]) return;
|
||||
|
||||
UIButton *downloadBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
downloadBtn.tag = 1337;
|
||||
|
||||
// Match IG reel sidebar style: outline icon, semi-transparent white
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold];
|
||||
UIImage *icon = [UIImage systemImageNamed:@"arrow.down" withConfiguration:config];
|
||||
[downloadBtn setImage:icon forState:UIControlStateNormal];
|
||||
downloadBtn.tintColor = [UIColor colorWithWhite:1.0 alpha:0.9];
|
||||
|
||||
downloadBtn.layer.shadowColor = [UIColor blackColor].CGColor;
|
||||
downloadBtn.layer.shadowOffset = CGSizeMake(0, 1);
|
||||
downloadBtn.layer.shadowOpacity = 0.5;
|
||||
downloadBtn.layer.shadowRadius = 3;
|
||||
|
||||
downloadBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[downloadBtn addTarget:self action:@selector(sciDownloadTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[parent addSubview:downloadBtn];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[downloadBtn.centerXAnchor constraintEqualToAnchor:self.centerXAnchor],
|
||||
[downloadBtn.bottomAnchor constraintEqualToAnchor:self.topAnchor constant:-10],
|
||||
[downloadBtn.widthAnchor constraintEqualToConstant:40],
|
||||
[downloadBtn.heightAnchor constraintEqualToConstant:40]
|
||||
]];
|
||||
}
|
||||
|
||||
%new - (void)sciDownloadTapped:(UIButton *)sender {
|
||||
NSLog(@"[SCInsta] Reel download button tapped");
|
||||
|
||||
// Haptic + visual feedback
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{
|
||||
sender.transform = CGAffineTransformMakeScale(0.75, 0.75);
|
||||
} completion:^(BOOL finished) {
|
||||
[UIView animateWithDuration:0.1 animations:^{
|
||||
sender.transform = CGAffineTransformIdentity;
|
||||
}];
|
||||
}];
|
||||
|
||||
sciConfirmAndDownload(@"Download reel?", ^{
|
||||
// Find IGSundialViewerVideoCell in superview chain
|
||||
UIView *videoCell = sciFindSuperviewOfClass(self, @"IGSundialViewerVideoCell");
|
||||
|
||||
if (videoCell) {
|
||||
IGMedia *media = sciGetMediaFromView(videoCell);
|
||||
if (media) {
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
|
||||
if (videoUrl) {
|
||||
initDownloaders();
|
||||
[videoDownloadDelegate downloadFileWithURL:videoUrl
|
||||
fileExtension:[[videoUrl lastPathComponent] pathExtension]
|
||||
hudLabel:nil];
|
||||
return;
|
||||
}
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract video URL from reel"];
|
||||
return;
|
||||
}
|
||||
sciShowDebugIvarDump(videoCell);
|
||||
return;
|
||||
}
|
||||
|
||||
// Try photo reel
|
||||
UIView *photoView = sciFindSuperviewOfClass(self, @"IGSundialViewerPhotoView");
|
||||
if (photoView) {
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList([photoView class], &count);
|
||||
Class photoClass = NSClassFromString(@"IGPhoto");
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *name = ivar_getName(ivars[i]);
|
||||
if (!name) continue;
|
||||
NSString *ivarName = [NSString stringWithUTF8String:name];
|
||||
if ([[ivarName lowercaseString] containsString:@"photo"]) {
|
||||
id value = object_getIvar(photoView, ivars[i]);
|
||||
if (value && photoClass && [value isKindOfClass:photoClass]) {
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrl:(IGPhoto *)value];
|
||||
if (photoUrl) {
|
||||
free(ivars);
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl
|
||||
fileExtension:[[photoUrl lastPathComponent] pathExtension]
|
||||
hudLabel:nil];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
sciShowDebugIvarDump(photoView);
|
||||
return;
|
||||
}
|
||||
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not find reel cell in view hierarchy"];
|
||||
});
|
||||
}
|
||||
%end
|
||||
|
||||
|
||||
/* * Stories * */
|
||||
|
||||
// Download story (images)
|
||||
%hook IGStoryPhotoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"dw_story"]) {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
|
||||
return;
|
||||
if (!sciLegacyGestureEnabled()) return;
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
|
||||
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
|
||||
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
|
||||
if (sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:[self item]];
|
||||
if (!photoUrl) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract photo url from story"];
|
||||
|
||||
return;
|
||||
}
|
||||
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from story")]; return; }
|
||||
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl
|
||||
fileExtension:[[photoUrl lastPathComponent]pathExtension]
|
||||
fileExtension:[[photoUrl lastPathComponent] pathExtension]
|
||||
hudLabel:nil];
|
||||
}
|
||||
%end
|
||||
|
||||
// Download story (videos)
|
||||
%hook IGStoryModernVideoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"dw_story"]) {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
|
||||
return;
|
||||
if (!sciLegacyGestureEnabled()) return;
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
|
||||
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
|
||||
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
|
||||
if (sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:self.item];
|
||||
|
||||
if (!videoUrl) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from story"];
|
||||
|
||||
return;
|
||||
}
|
||||
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from story")]; return; }
|
||||
|
||||
initDownloaders();
|
||||
[videoDownloadDelegate downloadFileWithURL:videoUrl
|
||||
@@ -609,35 +154,26 @@ static BOOL sciUseDownloadButtons() {
|
||||
}
|
||||
%end
|
||||
|
||||
// Download story (videos, legacy)
|
||||
%hook IGStoryVideoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"dw_story"]) {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
|
||||
return;
|
||||
if (!sciLegacyGestureEnabled()) return;
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
|
||||
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
|
||||
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
|
||||
if (sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
NSURL *videoUrl;
|
||||
|
||||
IGStoryFullscreenSectionController *captionDelegate = self.captionDelegate;
|
||||
if (captionDelegate) {
|
||||
videoUrl = [SCIUtils getVideoUrlForMedia:captionDelegate.currentStoryItem];
|
||||
}
|
||||
else {
|
||||
// Direct messages video player
|
||||
} else {
|
||||
id parentVC = [SCIUtils nearestViewControllerForView:self];
|
||||
if (!parentVC || ![parentVC isKindOfClass:%c(IGDirectVisualMessageViewerController)]) return;
|
||||
|
||||
@@ -653,11 +189,7 @@ static BOOL sciUseDownloadButtons() {
|
||||
videoUrl = [SCIUtils getVideoUrl:rawVideo];
|
||||
}
|
||||
|
||||
if (!videoUrl) {
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract video url from story"];
|
||||
|
||||
return;
|
||||
}
|
||||
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from story")]; return; }
|
||||
|
||||
initDownloaders();
|
||||
[videoDownloadDelegate downloadFileWithURL:videoUrl
|
||||
@@ -667,17 +199,176 @@ static BOOL sciUseDownloadButtons() {
|
||||
%end
|
||||
|
||||
|
||||
/* * Reels (legacy gesture) * */
|
||||
|
||||
%hook IGSundialViewerPhotoView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!sciLegacyGestureEnabled()) return;
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
|
||||
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
|
||||
if (sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
@try {
|
||||
IGPhoto *_photo = MSHookIvar<IGPhoto *>(self, "_photo");
|
||||
if (!_photo) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not access reel photo")]; return; }
|
||||
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrl:_photo];
|
||||
if (!photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo url from reel")]; return; }
|
||||
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:photoUrl
|
||||
fileExtension:[[photoUrl lastPathComponent] pathExtension]
|
||||
hudLabel:nil];
|
||||
} @catch (NSException *exception) {
|
||||
NSLog(@"[SCInsta] Reel photo download error: %@", exception);
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGSundialViewerVideoCell
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!sciLegacyGestureEnabled()) return;
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
longPress.minimumPressDuration = [SCIUtils getDoublePref:@"dw_finger_duration"];
|
||||
longPress.numberOfTouchesRequired = [SCIUtils getDoublePref:@"dw_finger_count"];
|
||||
[self addGestureRecognizer:longPress];
|
||||
}
|
||||
%new - (void)handleLongPress:(UILongPressGestureRecognizer *)sender {
|
||||
if (sender.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
@try {
|
||||
// Runtime ivar scan: the exact name varies across IG releases.
|
||||
unsigned int ivarCount = 0;
|
||||
Ivar *ivars = class_copyIvarList([self class], &ivarCount);
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
IGMedia *media = nil;
|
||||
for (unsigned int i = 0; i < ivarCount; i++) {
|
||||
const char *name = ivar_getName(ivars[i]);
|
||||
if (!name) continue;
|
||||
NSString *lower = [[NSString stringWithUTF8String:name] lowercaseString];
|
||||
if ([lower containsString:@"video"] || [lower containsString:@"media"] || [lower containsString:@"item"]) {
|
||||
id val = object_getIvar(self, ivars[i]);
|
||||
if (val && mediaClass && [val isKindOfClass:mediaClass]) { media = val; break; }
|
||||
}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
|
||||
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not access reel media")]; return; }
|
||||
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
|
||||
if (!videoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video url from reel")]; return; }
|
||||
|
||||
initDownloaders();
|
||||
[videoDownloadDelegate downloadFileWithURL:videoUrl
|
||||
fileExtension:[[videoUrl lastPathComponent] pathExtension]
|
||||
hudLabel:nil];
|
||||
} @catch (NSException *exception) {
|
||||
NSLog(@"[SCInsta] Reel download error: %@", exception);
|
||||
}
|
||||
}
|
||||
%end
|
||||
|
||||
|
||||
/* * Profile pictures * */
|
||||
|
||||
// Get profile info by walking up to IGProfileViewController
|
||||
static NSString *sciProfileCaption(UIView *view) {
|
||||
Class profileCls = NSClassFromString(@"IGProfileViewController");
|
||||
Class userCls = NSClassFromString(@"IGUser");
|
||||
UIResponder *r = view;
|
||||
while (r) {
|
||||
if (profileCls && [r isKindOfClass:profileCls]) {
|
||||
id user = nil;
|
||||
for (NSString *key in @[@"user", @"userGQL", @"profileUser"]) {
|
||||
@try { user = [(UIViewController *)r valueForKey:key]; } @catch (__unused id e) {}
|
||||
if (user) break;
|
||||
}
|
||||
if (!user && userCls) {
|
||||
unsigned int cnt = 0;
|
||||
Ivar *ivars = class_copyIvarList([r class], &cnt);
|
||||
for (unsigned int i = 0; i < cnt; i++) {
|
||||
id v = object_getIvar(r, ivars[i]);
|
||||
if (v && [v isKindOfClass:userCls]) { user = v; break; }
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
}
|
||||
if (user) {
|
||||
NSString *name = nil, *username = nil, *bio = nil;
|
||||
@try { username = [user valueForKey:@"username"]; } @catch (__unused id e) {}
|
||||
@try { name = [user valueForKey:@"fullName"]; } @catch (__unused id e) {}
|
||||
if (!name) @try { name = [user valueForKey:@"name"]; } @catch (__unused id e) {}
|
||||
@try { bio = [user valueForKey:@"biography"]; } @catch (__unused id e) {}
|
||||
|
||||
NSMutableString *caption = [NSMutableString string];
|
||||
if (name.length) [caption appendString:name];
|
||||
if (username.length) {
|
||||
if (caption.length) [caption appendString:@"\n"];
|
||||
[caption appendFormat:@"@%@", username];
|
||||
}
|
||||
if (bio.length) {
|
||||
if (caption.length) [caption appendString:@"\n\n"];
|
||||
[caption appendString:bio];
|
||||
}
|
||||
return caption.length ? caption : nil;
|
||||
}
|
||||
}
|
||||
r = [r nextResponder];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Profile photo zoom — intercepts IG's profile pic long press
|
||||
%hook IGProfilePhotoCoinFlipUI.IGProfilePhotoCoinFlipView
|
||||
|
||||
- (void)viewLongPressedWithGesture:(UILongPressGestureRecognizer *)gesture {
|
||||
if (![SCIUtils getBoolPref:@"zoom_profile_photo"]) { %orig; return; }
|
||||
if (gesture.state != UIGestureRecognizerStateBegan) { %orig; return; }
|
||||
|
||||
// Find the IGProfilePictureImageView inside us
|
||||
UIView *source = gesture.view;
|
||||
NSMutableArray *q = [NSMutableArray arrayWithObject:source];
|
||||
int scanned = 0;
|
||||
while (q.count && scanned < 30) {
|
||||
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
|
||||
if ([cur isKindOfClass:NSClassFromString(@"IGProfilePictureImageView")]) {
|
||||
IGImageView *imgView = MSHookIvar<IGImageView *>(cur, "_imageView");
|
||||
if (imgView) {
|
||||
IGImageSpecifier *spec = imgView.imageSpecifier;
|
||||
NSURL *url = spec ? spec.url : nil;
|
||||
if (url) {
|
||||
NSString *caption = sciProfileCaption(cur);
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:url caption:caption];
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (UIView *s in cur.subviews) [q addObject:s];
|
||||
}
|
||||
|
||||
%orig;
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
|
||||
%hook IGProfilePictureImageView
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"save_profile"]) {
|
||||
if ([SCIUtils getBoolPref:@"save_profile"] || [SCIUtils getBoolPref:@"zoom_profile_photo"]) {
|
||||
[self addLongPressGestureRecognizer];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
%new - (void)addLongPressGestureRecognizer {
|
||||
UILongPressGestureRecognizer *longPress = [[UILongPressGestureRecognizer alloc] initWithTarget:self action:@selector(handleLongPress:)];
|
||||
@@ -695,6 +386,14 @@ static BOOL sciUseDownloadButtons() {
|
||||
NSURL *imageUrl = imageSpecifier.url;
|
||||
if (!imageUrl) return;
|
||||
|
||||
// Zoom: open in full-screen viewer with profile info
|
||||
if ([SCIUtils getBoolPref:@"zoom_profile_photo"]) {
|
||||
NSString *caption = sciProfileCaption(self);
|
||||
[SCIMediaViewer showWithVideoURL:nil photoURL:imageUrl caption:caption];
|
||||
return;
|
||||
}
|
||||
|
||||
// Legacy: direct download
|
||||
initDownloaders();
|
||||
[imageDownloadDelegate downloadFileWithURL:imageUrl
|
||||
fileExtension:[[imageUrl lastPathComponent] pathExtension]
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
// Follow indicator — shows whether the profile user follows you.
|
||||
// Fetches via /api/v1/friendships/show/{pk}/, renders inside the stats container.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../Networking/SCIInstagramAPI.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
// IGProfileViewController declared in InstagramHeaders.h
|
||||
|
||||
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) {
|
||||
return objc_getAssociatedObject(vc, &kFollowStatusKey);
|
||||
}
|
||||
static void sciSetFollowStatus(id vc, NSNumber *status) {
|
||||
objc_setAssociatedObject(vc, &kFollowStatusKey, status, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
|
||||
}
|
||||
|
||||
static void sciRenderBadge(UIViewController *vc) {
|
||||
NSNumber *status = sciGetFollowStatus(vc);
|
||||
if (!status) return;
|
||||
BOOL followedBy = [status boolValue];
|
||||
|
||||
UIView *statContainer = nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
if ([NSStringFromClass([v class]) containsString:@"StatButtonContainerView"]) {
|
||||
statContainer = v;
|
||||
break;
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
if (!statContainer) return;
|
||||
|
||||
UIView *old = [statContainer viewWithTag:kFollowBadgeTag];
|
||||
if (old) [old removeFromSuperview];
|
||||
|
||||
UILabel *badge = [[UILabel alloc] init];
|
||||
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];
|
||||
}
|
||||
|
||||
%hook IGProfileViewController
|
||||
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"follow_indicator"]) return;
|
||||
|
||||
// Already fetched — just re-render
|
||||
if (sciGetFollowStatus(self)) {
|
||||
sciRenderBadge(self);
|
||||
return;
|
||||
}
|
||||
|
||||
id igUser = nil;
|
||||
@try { igUser = [self valueForKey:@"user"]; } @catch (NSException *e) {}
|
||||
if (!igUser) return;
|
||||
|
||||
NSString *profilePK = sciPKFromUser(igUser);
|
||||
NSString *myPK = sciCurrentUserPK();
|
||||
if (!profilePK || !myPK || [profilePK isEqualToString:myPK]) return;
|
||||
|
||||
__weak UIViewController *weakSelf = self;
|
||||
NSString *path = [NSString stringWithFormat:@"friendships/show/%@/", profilePK];
|
||||
[SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *response, NSError *error) {
|
||||
if (error || !response) return;
|
||||
BOOL followedBy = [response[@"followed_by"] boolValue];
|
||||
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UIViewController *vc = weakSelf;
|
||||
if (!vc) return;
|
||||
sciSetFollowStatus(vc, @(followedBy));
|
||||
sciRenderBadge(vc);
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,40 @@
|
||||
// Copy note text on long press — long-press the note bubble to copy text.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
|
||||
// IGDirectNotesThoughtBubbleView declared in InstagramHeaders.h
|
||||
|
||||
%hook IGDirectNotesThoughtBubbleView
|
||||
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
if (![SCIUtils getBoolPref:@"profile_note_copy"]) return;
|
||||
|
||||
// Only add once
|
||||
static const NSInteger kCopyGestureTag = 99791;
|
||||
for (UIGestureRecognizer *gr in self.gestureRecognizers) {
|
||||
if (gr.view.tag == kCopyGestureTag) return;
|
||||
}
|
||||
self.tag = kCopyGestureTag;
|
||||
|
||||
UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc]
|
||||
initWithTarget:self action:@selector(sciCopyNoteLongPress:)];
|
||||
lp.minimumPressDuration = 0.5;
|
||||
[self addGestureRecognizer:lp];
|
||||
}
|
||||
|
||||
%new - (void)sciCopyNoteLongPress:(UILongPressGestureRecognizer *)gesture {
|
||||
if (gesture.state != UIGestureRecognizerStateBegan) return;
|
||||
|
||||
Ivar textIvar = class_getInstanceVariable([self class], "_noteText");
|
||||
if (!textIvar) return;
|
||||
NSString *text = object_getIvar(self, textIvar);
|
||||
if (!text.length) return;
|
||||
|
||||
[[UIPasteboard generalPasteboard] setString:text];
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note copied")];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -112,31 +112,60 @@ static void sciSetPlayViewOpacity(id cell, CGFloat opacity) {
|
||||
}
|
||||
}
|
||||
|
||||
// Force unmute by calling _didTapSoundButton on the section controller
|
||||
// Swallow IG's "no sound" toast and remember the media so we don't retry it.
|
||||
static NSString * const SCINoSoundToastText = @"This reel has no sound.";
|
||||
static BOOL sciSuppressNoSoundToast = NO;
|
||||
static BOOL sciSawNoSoundDuringUnmute = NO;
|
||||
static NSMutableSet<NSString *> *sciNoAudioMediaIds = nil;
|
||||
|
||||
static NSString *sciMediaIdFor(id media) {
|
||||
if (!media) return nil;
|
||||
for (NSString *k in @[@"pk", @"mediaPk", @"mediaID", @"mpk"]) {
|
||||
@try {
|
||||
id v = [media valueForKey:k];
|
||||
if (v) return [NSString stringWithFormat:@"%@", v];
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciForceUnmuteCell(id videoCell) {
|
||||
if (!videoCell) return;
|
||||
Ivar delegateIvar = class_getInstanceVariable([videoCell class], "_delegate");
|
||||
if (!delegateIvar) return;
|
||||
id sectionCtrl = object_getIvar(videoCell, delegateIvar);
|
||||
if (!sectionCtrl) return;
|
||||
|
||||
Ivar mediaIvar = class_getInstanceVariable([sectionCtrl class], "_media");
|
||||
id media = mediaIvar ? object_getIvar(sectionCtrl, mediaIvar) : nil;
|
||||
NSString *mediaId = sciMediaIdFor(media);
|
||||
if (mediaId && [sciNoAudioMediaIds containsObject:mediaId]) return;
|
||||
|
||||
SEL isAudioSel = NSSelectorFromString(@"isAudioEnabled");
|
||||
if (![sectionCtrl respondsToSelector:isAudioSel]) return;
|
||||
BOOL audioOn = ((BOOL(*)(id,SEL))objc_msgSend)(sectionCtrl, isAudioSel);
|
||||
if (audioOn) return;
|
||||
|
||||
SEL tapSel = NSSelectorFromString(@"_didTapSoundButton");
|
||||
if ([sectionCtrl respondsToSelector:tapSel]) {
|
||||
((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel);
|
||||
if (![sectionCtrl respondsToSelector:tapSel]) return;
|
||||
|
||||
sciSuppressNoSoundToast = YES;
|
||||
sciSawNoSoundDuringUnmute = NO;
|
||||
((void(*)(id,SEL))objc_msgSend)(sectionCtrl, tapSel);
|
||||
sciSuppressNoSoundToast = NO;
|
||||
|
||||
if (sciSawNoSoundDuringUnmute && mediaId) {
|
||||
if (!sciNoAudioMediaIds) sciNoAudioMediaIds = [NSMutableSet new];
|
||||
[sciNoAudioMediaIds addObject:mediaId];
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGSundialViewerVideoCell
|
||||
// Video playing/unpausing — use hidden (IG sets hidden=NO on next pause)
|
||||
// hidden=YES on play; IG resets it on the next pause.
|
||||
- (void)sundialVideoPlaybackViewDidStartPlaying:(id)view {
|
||||
%orig;
|
||||
if (sciIsPausePlayMode()) {
|
||||
sciHidePlayView(self);
|
||||
// Force unmute if in reels tab — this fires when the video ACTUALLY starts
|
||||
// playing, guaranteed to have a ready section controller
|
||||
if (sciIsInReelsTab) sciForceUnmuteCell(self);
|
||||
}
|
||||
}
|
||||
@@ -226,7 +255,7 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciIsInReelsTab = YES;
|
||||
// Force unmute first reel — retry until the cell is ready
|
||||
// Retry-until-ready: the first reel's cell may not be wired up yet.
|
||||
if (sciIsPausePlayMode()) {
|
||||
id feedVC = self;
|
||||
for (int i = 0; i < 10; i++) {
|
||||
@@ -256,6 +285,30 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) {
|
||||
}
|
||||
%end
|
||||
|
||||
%hook UILabel
|
||||
- (void)setText:(NSString *)text {
|
||||
if (sciSuppressNoSoundToast && [text isEqualToString:SCINoSoundToastText]) {
|
||||
sciSawNoSoundDuringUnmute = YES;
|
||||
%orig(@"");
|
||||
self.hidden = YES;
|
||||
// Container view is attached to a window after we return — detach the
|
||||
// topmost non-window ancestor on the next tick to remove the outline.
|
||||
__weak UILabel *weakSelf = self;
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
UILabel *s = weakSelf;
|
||||
if (!s) return;
|
||||
UIView *top = s;
|
||||
while (top.superview && ![top.superview isKindOfClass:[UIWindow class]]) {
|
||||
top = top.superview;
|
||||
}
|
||||
[top removeFromSuperview];
|
||||
});
|
||||
return;
|
||||
}
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ RUNTIME HOOKS ============
|
||||
|
||||
%ctor {
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
// Password-locked reels use IGMediaOverlayProfileWithPasswordView as a blur overlay.
|
||||
// The password is stored in the _asnwer ivar (IG typo). We read it at runtime,
|
||||
// then provide buttons to auto-fill + submit or reveal + copy the password.
|
||||
|
||||
#define SCI_PW_BTN_TAG 1342
|
||||
|
||||
static NSString * _Nullable sciGetPassword(id overlayView) {
|
||||
Class cls = [overlayView class];
|
||||
while (cls && cls != [UIView class]) {
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList(cls, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *name = ivar_getName(ivars[i]);
|
||||
if (name && strcmp(name, "_asnwer") == 0) {
|
||||
id value = object_getIvar(overlayView, ivars[i]);
|
||||
free(ivars);
|
||||
if ([value isKindOfClass:[NSString class]] && [(NSString *)value length] > 0)
|
||||
return (NSString *)value;
|
||||
return nil;
|
||||
}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
cls = class_getSuperclass(cls);
|
||||
}
|
||||
|
||||
// Fallback: scan for any password-related string ivar
|
||||
cls = [overlayView class];
|
||||
while (cls && cls != [UIView class]) {
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList(cls, &count);
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id value = object_getIvar(overlayView, ivars[i]);
|
||||
if (![value isKindOfClass:[NSString class]] || [(NSString *)value length] == 0) continue;
|
||||
const char *name = ivar_getName(ivars[i]);
|
||||
if (!name) continue;
|
||||
NSString *lower = [[NSString stringWithUTF8String:name] lowercaseString];
|
||||
if ([lower containsString:@"answer"] || [lower containsString:@"asnwer"] ||
|
||||
[lower containsString:@"password"] || [lower containsString:@"secret"]) {
|
||||
free(ivars);
|
||||
return (NSString *)value;
|
||||
}
|
||||
} @catch(id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
cls = class_getSuperclass(cls);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static UITextField * _Nullable sciFindTextField(UIView *root) {
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:[UITextField class]]) return (UITextField *)v;
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static UIView * _Nullable sciFindSubmitButton(UIView *root) {
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:root];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([NSStringFromClass([v class]) containsString:@"IGDSMediaTextButton"]) return v;
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
%hook IGMediaOverlayProfileWithPasswordView
|
||||
|
||||
- (void)didMoveToSuperview {
|
||||
%orig;
|
||||
if (!self.superview) return;
|
||||
if (![SCIUtils getBoolPref:@"unlock_password_reels"]) return;
|
||||
[self sciAddButtons];
|
||||
}
|
||||
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
if (![SCIUtils getBoolPref:@"unlock_password_reels"]) return;
|
||||
[self sciAddButtons];
|
||||
}
|
||||
|
||||
%new - (void)sciAddButtons {
|
||||
if ([self viewWithTag:SCI_PW_BTN_TAG]) return;
|
||||
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightBold];
|
||||
|
||||
UIButton *unlockBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
unlockBtn.tag = SCI_PW_BTN_TAG;
|
||||
[unlockBtn setImage:[UIImage systemImageNamed:@"lock.open.fill" withConfiguration:config] forState:UIControlStateNormal];
|
||||
unlockBtn.tintColor = [UIColor colorWithRed:1.0 green:0.85 blue:0.0 alpha:1.0];
|
||||
unlockBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.5];
|
||||
unlockBtn.layer.cornerRadius = 20;
|
||||
unlockBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[unlockBtn addTarget:self action:@selector(sciUnlockTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:unlockBtn];
|
||||
|
||||
UIButton *eyeBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
eyeBtn.tag = SCI_PW_BTN_TAG + 1;
|
||||
[eyeBtn setImage:[UIImage systemImageNamed:@"eye.fill" withConfiguration:config] forState:UIControlStateNormal];
|
||||
eyeBtn.tintColor = [UIColor whiteColor];
|
||||
eyeBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.5];
|
||||
eyeBtn.layer.cornerRadius = 20;
|
||||
eyeBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[eyeBtn addTarget:self action:@selector(sciShowPasswordTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:eyeBtn];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[unlockBtn.topAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.topAnchor constant:200],
|
||||
[unlockBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
|
||||
[unlockBtn.widthAnchor constraintEqualToConstant:40],
|
||||
[unlockBtn.heightAnchor constraintEqualToConstant:40],
|
||||
|
||||
[eyeBtn.topAnchor constraintEqualToAnchor:unlockBtn.bottomAnchor constant:12],
|
||||
[eyeBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16],
|
||||
[eyeBtn.widthAnchor constraintEqualToConstant:40],
|
||||
[eyeBtn.heightAnchor constraintEqualToConstant:40],
|
||||
]];
|
||||
}
|
||||
|
||||
%new - (void)sciUnlockTapped {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
|
||||
NSString *password = sciGetPassword(self);
|
||||
if (!password) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
|
||||
return;
|
||||
}
|
||||
|
||||
UITextField *textField = sciFindTextField(self);
|
||||
if (!textField) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No text field found")];
|
||||
return;
|
||||
}
|
||||
|
||||
textField.text = password;
|
||||
[textField sendActionsForControlEvents:UIControlEventEditingChanged];
|
||||
[[NSNotificationCenter defaultCenter] postNotificationName:UITextFieldTextDidChangeNotification object:textField];
|
||||
|
||||
if (textField.delegate) {
|
||||
if ([textField.delegate respondsToSelector:@selector(textField:shouldChangeCharactersInRange:replacementString:)])
|
||||
[textField.delegate textField:textField shouldChangeCharactersInRange:NSMakeRange(0, 0) replacementString:password];
|
||||
if ([textField.delegate respondsToSelector:@selector(textFieldDidChangeSelection:)])
|
||||
[textField.delegate textFieldDidChangeSelection:textField];
|
||||
}
|
||||
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
UIView *submitBtn = sciFindSubmitButton(self);
|
||||
if (submitBtn && [submitBtn isKindOfClass:[UIControl class]]) {
|
||||
[(UIControl *)submitBtn setHidden:NO];
|
||||
[(UIControl *)submitBtn sendActionsForControlEvents:UIControlEventTouchUpInside];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
%new - (void)sciShowPasswordTapped {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight];
|
||||
[haptic impactOccurred];
|
||||
|
||||
NSString *password = sciGetPassword(self);
|
||||
if (!password) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
|
||||
return;
|
||||
}
|
||||
|
||||
[[UIPasteboard generalPasteboard] setString:password];
|
||||
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Password")
|
||||
message:password
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copied!") style:UIAlertActionStyleCancel handler:nil]];
|
||||
UIViewController *topVC = topMostController();
|
||||
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -28,27 +28,46 @@
|
||||
}
|
||||
%end
|
||||
|
||||
static BOOL sciReelRefreshBypassing = NO;
|
||||
|
||||
%hook IGSundialFeedViewController
|
||||
- (void)_refreshReelsWithParamsForNetworkRequest:(NSInteger)arg1 userDidPullToRefresh:(BOOL)arg2 {
|
||||
if ([SCIUtils getBoolPref:@"prevent_doom_scrolling"]) {
|
||||
IGRefreshControl *_refreshControl = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
|
||||
[self refreshControlDidEndFinishLoadingAnimation:_refreshControl];
|
||||
|
||||
IGRefreshControl *rc = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
|
||||
[self refreshControlDidEndFinishLoadingAnimation:rc];
|
||||
return;
|
||||
}
|
||||
|
||||
if ([SCIUtils getBoolPref:@"refresh_reel_confirm"]) {
|
||||
NSLog(@"[SCInsta] Reel refresh triggered");
|
||||
|
||||
[SCIUtils showConfirmation:^(void) { %orig(arg1, arg2); }
|
||||
cancelHandler:^(void) {
|
||||
IGRefreshControl *_refreshControl = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
|
||||
[self refreshControlDidEndFinishLoadingAnimation:_refreshControl];
|
||||
}
|
||||
title:@"Refresh Reels"];
|
||||
} else {
|
||||
return %orig(arg1, arg2);
|
||||
if (![(UIViewController *)self isViewLoaded] || sciReelRefreshBypassing || ![SCIUtils getBoolPref:@"refresh_reel_confirm"]) {
|
||||
%orig(arg1, arg2);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the refresh control state so pull-to-refresh can trigger again
|
||||
IGRefreshControl *rc = MSHookIvar<IGRefreshControl *>(self, "_refreshControl");
|
||||
Ivar stateIvar = class_getInstanceVariable([rc class], "_refreshState");
|
||||
if (stateIvar) {
|
||||
ptrdiff_t off = ivar_getOffset(stateIvar);
|
||||
*(NSInteger *)((char *)(__bridge void *)rc + off) = 0;
|
||||
}
|
||||
if ([rc respondsToSelector:@selector(endRefreshing)])
|
||||
((void(*)(id,SEL))objc_msgSend)(rc, @selector(endRefreshing));
|
||||
[self refreshControlDidEndFinishLoadingAnimation:rc];
|
||||
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Refresh Reels?")
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
__weak id weakSelf = self;
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Refresh") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
sciReelRefreshBypassing = YES;
|
||||
SEL rSel = @selector(_refreshReelsWithParamsForNetworkRequest:userDidPullToRefresh:);
|
||||
((void(*)(id,SEL,NSInteger,BOOL))objc_msgSend)(weakSelf, rSel, arg1, arg2);
|
||||
sciReelRefreshBypassing = NO;
|
||||
}]];
|
||||
|
||||
UIViewController *presenter = (UIViewController *)self;
|
||||
[presenter presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
%end
|
||||
|
||||
@@ -67,15 +86,16 @@
|
||||
}
|
||||
}
|
||||
- (void)_muteSwitchStateChanged:(id)changed {
|
||||
if (![SCIUtils getBoolPref:@"disable_auto_unmuting_reels"]) {
|
||||
extern BOOL sciStoryAudioBypass;
|
||||
if (sciStoryAudioBypass || ![SCIUtils getBoolPref:@"disable_auto_unmuting_reels"]) {
|
||||
%orig(changed);
|
||||
}
|
||||
}
|
||||
// Block the announcer from broadcasting "audio enabled" state changes
|
||||
- (void)_announceForDeviceStateChangesIfNeededForAudioEnabled:(BOOL)enabled reason:(NSInteger)reason {
|
||||
// When pause/play mode is on, allow unmute (our force-unmute needs this path)
|
||||
extern BOOL sciStoryAudioBypass;
|
||||
BOOL pausePlayMode = [[SCIUtils getStringPref:@"reels_tap_control"] isEqualToString:@"pause"];
|
||||
if ([SCIUtils getBoolPref:@"disable_auto_unmuting_reels"] && enabled && !pausePlayMode) {
|
||||
if ([SCIUtils getBoolPref:@"disable_auto_unmuting_reels"] && enabled && !pausePlayMode && !sciStoryAudioBypass) {
|
||||
return;
|
||||
}
|
||||
%orig;
|
||||
|
||||
@@ -1,34 +1,27 @@
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
// Story seen receipt blocking + visual seen state blocking
|
||||
#import "StoryHelpers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// === State ===
|
||||
static BOOL sciSeenBypassActive = NO;
|
||||
static NSMutableSet *sciAllowedSeenPKs = nil;
|
||||
BOOL sciSeenBypassActive = NO;
|
||||
BOOL sciAdvanceBypassActive = NO;
|
||||
BOOL sciStorySeenToggleEnabled = NO; // toggle-mode session bypass
|
||||
NSMutableSet *sciAllowedSeenPKs = nil;
|
||||
|
||||
// === Helpers ===
|
||||
typedef id (*SCIMsgSend)(id, SEL);
|
||||
typedef id (*SCIMsgSend1)(id, SEL, id);
|
||||
extern BOOL sciIsCurrentStoryOwnerExcluded(void);
|
||||
extern BOOL sciIsObjectStoryOwnerExcluded(id obj);
|
||||
|
||||
static id sciCall(id obj, SEL sel) {
|
||||
if (!obj || ![obj respondsToSelector:sel]) return nil;
|
||||
return ((SCIMsgSend)objc_msgSend)(obj, sel);
|
||||
}
|
||||
static id sciCall1(id obj, SEL sel, id arg1) {
|
||||
if (!obj || ![obj respondsToSelector:sel]) return nil;
|
||||
return ((SCIMsgSend1)objc_msgSend)(obj, sel, arg1);
|
||||
static BOOL sciStorySeenToggleBypass(void) {
|
||||
return [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"] && sciStorySeenToggleEnabled;
|
||||
}
|
||||
|
||||
static void sciAllowSeenForPK(id media) {
|
||||
void sciAllowSeenForPK(id media) {
|
||||
if (!media) return;
|
||||
id pk = sciCall(media, @selector(pk));
|
||||
if (!pk) return;
|
||||
if (!sciAllowedSeenPKs) sciAllowedSeenPKs = [NSMutableSet set];
|
||||
NSString *pkStr = [NSString stringWithFormat:@"%@", pk];
|
||||
[sciAllowedSeenPKs addObject:pkStr];
|
||||
NSLog(@"[SCInsta] Allow-listed PK: %@", pkStr);
|
||||
[sciAllowedSeenPKs addObject:[NSString stringWithFormat:@"%@", pk]];
|
||||
}
|
||||
|
||||
static BOOL sciIsPKAllowed(id media) {
|
||||
@@ -40,137 +33,35 @@ static BOOL sciIsPKAllowed(id media) {
|
||||
|
||||
static BOOL sciShouldBlockSeenNetwork() {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (sciIsCurrentStoryOwnerExcluded()) return NO;
|
||||
return [SCIUtils getBoolPref:@"no_seen_receipt"];
|
||||
}
|
||||
|
||||
static BOOL sciShouldBlockSeenVisual() {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (sciIsCurrentStoryOwnerExcluded()) return NO;
|
||||
return [SCIUtils getBoolPref:@"no_seen_receipt"] && [SCIUtils getBoolPref:@"no_seen_visual"];
|
||||
}
|
||||
|
||||
static UIViewController * _Nullable sciFindVC(UIResponder *start, NSString *className) {
|
||||
Class cls = NSClassFromString(className);
|
||||
if (!cls) return nil;
|
||||
UIResponder *r = start;
|
||||
while (r) {
|
||||
if ([r isKindOfClass:cls]) return (UIViewController *)r;
|
||||
r = [r nextResponder];
|
||||
}
|
||||
return nil;
|
||||
// Per-instance gating for tray/item/ring hooks where the "current" story
|
||||
// VC may not be the owner of the model in question.
|
||||
static BOOL sciShouldBlockSeenVisualForObj(id obj) {
|
||||
if (sciSeenBypassActive) return NO;
|
||||
if (sciStorySeenToggleBypass()) return NO;
|
||||
if (![SCIUtils getBoolPref:@"no_seen_receipt"] || ![SCIUtils getBoolPref:@"no_seen_visual"]) return NO;
|
||||
if (sciIsObjectStoryOwnerExcluded(obj)) return NO;
|
||||
return YES;
|
||||
}
|
||||
|
||||
static IGMedia * _Nullable sciExtractMediaFromItem(id item) {
|
||||
if (!item) return nil;
|
||||
Class mediaClass = NSClassFromString(@"IGMedia");
|
||||
if (!mediaClass) return nil;
|
||||
NSArray *trySelectors = @[@"media", @"mediaItem", @"storyItem", @"item",
|
||||
@"feedItem", @"igMedia", @"model", @"backingModel",
|
||||
@"storyMedia", @"mediaModel"];
|
||||
for (NSString *selName in trySelectors) {
|
||||
id val = sciCall(item, NSSelectorFromString(selName));
|
||||
if (val && [val isKindOfClass:mediaClass]) return (IGMedia *)val;
|
||||
}
|
||||
unsigned int iCount = 0;
|
||||
Ivar *ivars = class_copyIvarList([item class], &iCount);
|
||||
for (unsigned int i = 0; i < iCount; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (type && type[0] == '@') {
|
||||
id val = object_getIvar(item, ivars[i]);
|
||||
if (val && [val isKindOfClass:mediaClass]) { free(ivars); return (IGMedia *)val; }
|
||||
}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
return nil;
|
||||
}
|
||||
|
||||
static id _Nullable sciGetCurrentStoryItem(UIResponder *start) {
|
||||
UIViewController *storyVC = sciFindVC(start, @"IGStoryViewerViewController");
|
||||
if (!storyVC) return nil;
|
||||
id vm = sciCall(storyVC, @selector(currentViewModel));
|
||||
if (!vm) return nil;
|
||||
return sciCall1(storyVC, @selector(currentStoryItemForViewModel:), vm);
|
||||
}
|
||||
|
||||
// Find section controller: VC -> collectionView -> visibleCell -> containerView -> delegate
|
||||
static id _Nullable sciFindSectionController(UIViewController *storyVC) {
|
||||
Class sectionClass = NSClassFromString(@"IGStoryFullscreenSectionController");
|
||||
if (!sectionClass || !storyVC) return nil;
|
||||
|
||||
// Find collection view in VC ivars
|
||||
unsigned int count = 0;
|
||||
Ivar *ivars = class_copyIvarList([storyVC class], &count);
|
||||
UICollectionView *cv = nil;
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
id val = object_getIvar(storyVC, ivars[i]);
|
||||
if (val && [val isKindOfClass:[UICollectionView class]]) { cv = val; break; }
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
if (!cv) return nil;
|
||||
|
||||
// Scan visible cells -> containerView -> delegate
|
||||
for (UICollectionViewCell *cell in cv.visibleCells) {
|
||||
unsigned int cCount = 0;
|
||||
Ivar *cIvars = class_copyIvarList([cell class], &cCount);
|
||||
for (unsigned int i = 0; i < cCount; i++) {
|
||||
const char *type = ivar_getTypeEncoding(cIvars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
id val = object_getIvar(cell, cIvars[i]);
|
||||
if (!val) continue;
|
||||
// Check val's ivars for section controller (L4: cell.containerView.delegate)
|
||||
unsigned int vCount = 0;
|
||||
Ivar *vIvars = class_copyIvarList([val class], &vCount);
|
||||
for (unsigned int j = 0; j < vCount; j++) {
|
||||
const char *type2 = ivar_getTypeEncoding(vIvars[j]);
|
||||
if (!type2 || type2[0] != '@') continue;
|
||||
id val2 = object_getIvar(val, vIvars[j]);
|
||||
if (val2 && [val2 isKindOfClass:sectionClass]) { free(vIvars); free(cIvars); return val2; }
|
||||
}
|
||||
if (vIvars) free(vIvars);
|
||||
}
|
||||
if (cIvars) free(cIvars);
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Story downloaders
|
||||
static SCIDownloadDelegate *sciStoryVideoDl = nil;
|
||||
static SCIDownloadDelegate *sciStoryImageDl = nil;
|
||||
|
||||
static void sciInitStoryDownloaders() {
|
||||
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
|
||||
DownloadAction action = [method isEqualToString:@"photos"] ? saveToPhotos : share;
|
||||
DownloadAction imgAction = [method isEqualToString:@"photos"] ? saveToPhotos : quickLook;
|
||||
sciStoryVideoDl = [[SCIDownloadDelegate alloc] initWithAction:action showProgress:YES];
|
||||
sciStoryImageDl = [[SCIDownloadDelegate alloc] initWithAction:imgAction showProgress:NO];
|
||||
}
|
||||
|
||||
static void sciDownloadMedia(IGMedia *media) {
|
||||
sciInitStoryDownloaders();
|
||||
NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:media];
|
||||
if (videoUrl) {
|
||||
[sciStoryVideoDl downloadFileWithURL:videoUrl fileExtension:[[videoUrl lastPathComponent] pathExtension] hudLabel:nil];
|
||||
return;
|
||||
}
|
||||
NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:media];
|
||||
if (photoUrl) {
|
||||
[sciStoryImageDl downloadFileWithURL:photoUrl fileExtension:[[photoUrl lastPathComponent] pathExtension] hudLabel:nil];
|
||||
return;
|
||||
}
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not extract URL from story"];
|
||||
}
|
||||
|
||||
// ============ BLOCK NETWORK SEEN ============
|
||||
|
||||
// network seen blocking
|
||||
%hook IGStorySeenStateUploader
|
||||
- (void)uploadSeenStateWithMedia:(id)arg1 {
|
||||
// Allow if: bypass active, or this specific media was manually marked
|
||||
if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !sciIsPKAllowed(arg1)) return;
|
||||
%orig;
|
||||
}
|
||||
- (void)uploadSeenState {
|
||||
// Batch upload — allow if bypass or any manual PKs are pending
|
||||
if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !(sciAllowedSeenPKs && sciAllowedSeenPKs.count > 0)) return;
|
||||
%orig;
|
||||
}
|
||||
@@ -182,27 +73,22 @@ static void sciDownloadMedia(IGMedia *media) {
|
||||
if (!sciSeenBypassActive && sciShouldBlockSeenNetwork() && !sciIsPKAllowed(arg1)) return;
|
||||
%orig;
|
||||
}
|
||||
// NEVER block networker — returning nil breaks the uploader permanently
|
||||
- (id)networker { return %orig; }
|
||||
%end
|
||||
|
||||
// ============ BLOCK VISUAL SEEN ============
|
||||
|
||||
// visual seen blocking + story auto-advance
|
||||
%hook IGStoryFullscreenSectionController
|
||||
// Visual seen blocking
|
||||
- (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; }
|
||||
|
||||
// Stop auto-advance: block timer-triggered advances, allow manual taps
|
||||
- (void)storyPlayerMediaViewDidPlayToEnd:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
|
||||
if (!sciAdvanceBypassActive && [SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
|
||||
%orig;
|
||||
}
|
||||
- (void)advanceToNextReelForAutoScroll {
|
||||
if ([SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
|
||||
if (!sciAdvanceBypassActive && [SCIUtils getBoolPref:@"stop_story_auto_advance"]) return;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
@@ -215,16 +101,16 @@ static void sciDownloadMedia(IGMedia *media) {
|
||||
%end
|
||||
|
||||
%hook IGStoryTrayViewModel
|
||||
- (void)markAsSeen { if (sciShouldBlockSeenVisual()) return; %orig; }
|
||||
- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(YES); return; } %orig; }
|
||||
- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisual()) return YES; return %orig; }
|
||||
- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)isSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; }
|
||||
- (void)markAsSeen { if (sciShouldBlockSeenVisualForObj(self)) return; %orig; }
|
||||
- (void)setHasUnseenMedia:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(YES); return; } %orig; }
|
||||
- (BOOL)hasUnseenMedia { if (sciShouldBlockSeenVisualForObj(self)) return YES; return %orig; }
|
||||
- (void)setIsSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)isSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; }
|
||||
%end
|
||||
|
||||
%hook IGStoryItem
|
||||
- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)hasSeen { if (sciShouldBlockSeenVisual()) return NO; return %orig; }
|
||||
- (void)setHasSeen:(BOOL)arg1 { if (sciShouldBlockSeenVisualForObj(self)) { %orig(NO); return; } %orig; }
|
||||
- (BOOL)hasSeen { if (sciShouldBlockSeenVisualForObj(self)) return NO; return %orig; }
|
||||
%end
|
||||
|
||||
%hook IGStoryGradientRingView
|
||||
@@ -233,152 +119,133 @@ static void sciDownloadMedia(IGMedia *media) {
|
||||
- (void)updateRingForSeenState:(BOOL)arg1 { if (sciShouldBlockSeenVisual()) { %orig(NO); return; } %orig; }
|
||||
%end
|
||||
|
||||
// ============ OVERLAY BUTTONS ============
|
||||
// ============ 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.
|
||||
|
||||
%hook IGStoryFullscreenOverlayView
|
||||
- (void)didMoveToSuperview {
|
||||
__weak UIViewController *sciActiveStoryVC = nil;
|
||||
|
||||
%hook IGStoryViewerViewController
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
if (!self.superview) return;
|
||||
|
||||
if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) {
|
||||
UIButton *dlBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
dlBtn.tag = 1340;
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
[dlBtn setImage:[UIImage systemImageNamed:@"arrow.down" withConfiguration:config] forState:UIControlStateNormal];
|
||||
dlBtn.tintColor = [UIColor whiteColor];
|
||||
dlBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
dlBtn.layer.cornerRadius = 18;
|
||||
dlBtn.clipsToBounds = YES;
|
||||
dlBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[dlBtn addTarget:self action:@selector(sciStoryDownloadTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:dlBtn];
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[dlBtn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[dlBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[dlBtn.widthAnchor constraintEqualToConstant:36],
|
||||
[dlBtn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
}
|
||||
|
||||
if ([SCIUtils getBoolPref:@"no_seen_receipt"] && ![self viewWithTag:1339]) {
|
||||
UIButton *seenBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
seenBtn.tag = 1339;
|
||||
UIImageSymbolConfiguration *config = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
|
||||
[seenBtn setImage:[UIImage systemImageNamed:@"eye" withConfiguration:config] forState:UIControlStateNormal];
|
||||
seenBtn.tintColor = [UIColor whiteColor];
|
||||
seenBtn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4];
|
||||
seenBtn.layer.cornerRadius = 18;
|
||||
seenBtn.clipsToBounds = YES;
|
||||
seenBtn.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[seenBtn addTarget:self action:@selector(sciMarkSeenTapped:) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self addSubview:seenBtn];
|
||||
UIView *dlBtn = [self viewWithTag:1340];
|
||||
if (dlBtn) {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[seenBtn.centerYAnchor constraintEqualToAnchor:dlBtn.centerYAnchor],
|
||||
[seenBtn.trailingAnchor constraintEqualToAnchor:dlBtn.leadingAnchor constant:-10],
|
||||
[seenBtn.widthAnchor constraintEqualToConstant:36],
|
||||
[seenBtn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
} else {
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[seenBtn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
|
||||
[seenBtn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12],
|
||||
[seenBtn.widthAnchor constraintEqualToConstant:36],
|
||||
[seenBtn.heightAnchor constraintEqualToConstant:36]
|
||||
]];
|
||||
}
|
||||
}
|
||||
sciActiveStoryVC = self;
|
||||
}
|
||||
|
||||
// ============ STORY DOWNLOAD ============
|
||||
|
||||
%new - (void)sciStoryDownloadTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
|
||||
@try {
|
||||
id item = sciGetCurrentStoryItem(self);
|
||||
IGMedia *media = sciExtractMediaFromItem(item);
|
||||
if (media) {
|
||||
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
|
||||
[SCIUtils showConfirmation:^{ sciDownloadMedia(media); } title:@"Download story?"];
|
||||
} else {
|
||||
sciDownloadMedia(media);
|
||||
}
|
||||
return;
|
||||
}
|
||||
[SCIUtils showErrorHUDWithDescription:@"Could not find story media"];
|
||||
} @catch (NSException *e) {
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ MARK SEEN ============
|
||||
|
||||
%new - (void)sciMarkSeenTapped:(UIButton *)sender {
|
||||
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
|
||||
[haptic impactOccurred];
|
||||
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; }
|
||||
completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }];
|
||||
|
||||
@try {
|
||||
UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController");
|
||||
if (!storyVC) { [SCIUtils showErrorHUDWithDescription:@"Story VC not found"]; return; }
|
||||
|
||||
// Get current story media
|
||||
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:@"Could not find story media"]; return; }
|
||||
|
||||
// Add this media PK to the permanent allow list
|
||||
// When Instagram's deferred upload eventually fires, our hooks will let this PK through
|
||||
sciAllowSeenForPK(media);
|
||||
|
||||
// Also set bypass for immediate calls
|
||||
sciSeenBypassActive = YES;
|
||||
|
||||
// Trigger the visual seen update via VC delegate
|
||||
SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:);
|
||||
if ([storyVC respondsToSelector:delegateSel]) {
|
||||
typedef void (*Func)(id, SEL, id, id);
|
||||
((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media);
|
||||
}
|
||||
|
||||
// Trigger the section controller's mark flow
|
||||
if (sectionCtrl) {
|
||||
SEL markSel = NSSelectorFromString(@"markItemAsSeen:");
|
||||
if ([sectionCtrl respondsToSelector:markSel]) {
|
||||
((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media);
|
||||
}
|
||||
}
|
||||
|
||||
// Update the session seen state manager
|
||||
id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager));
|
||||
id vm = sciCall(storyVC, @selector(currentViewModel));
|
||||
if (seenManager && vm) {
|
||||
SEL setSeenSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:");
|
||||
if ([seenManager respondsToSelector:setSeenSel]) {
|
||||
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, setSeenSel, mediaPK, reelPK);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sciSeenBypassActive = NO;
|
||||
|
||||
[SCIUtils showToastForDuration:2.0 title:@"Marked as seen" subtitle:@"Will sync when leaving stories"];
|
||||
} @catch (NSException *e) {
|
||||
sciSeenBypassActive = NO;
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
|
||||
}
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
if (sciActiveStoryVC == (UIViewController *)self) sciActiveStoryVC = nil;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
static UIView *sciFindStoryOverlayView(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
Class targetCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!targetCls) return nil;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:targetCls]) return v;
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static void sciMarkActiveStorySeen(void) {
|
||||
if (![SCIUtils getBoolPref:@"seen_on_story_like"]) return;
|
||||
UIView *overlay = sciFindStoryOverlayView(sciActiveStoryVC);
|
||||
if (!overlay) return;
|
||||
SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:");
|
||||
if ([overlay respondsToSelector:sel])
|
||||
((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil);
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
sciAdvanceBypassActive = NO;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static void sciOnStoryLike(void) {
|
||||
sciMarkActiveStorySeen();
|
||||
sciAdvanceOnStoryLike();
|
||||
}
|
||||
|
||||
static void (*orig_didLikeSundial)(id, SEL, id);
|
||||
static void new_didLikeSundial(id self, SEL _cmd, id pk) {
|
||||
orig_didLikeSundial(self, _cmd, pk);
|
||||
sciOnStoryLike();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class overlayCtl = NSClassFromString(@"IGSundialViewerControlsOverlayController");
|
||||
if (overlayCtl) {
|
||||
SEL didLike = NSSelectorFromString(@"didLikeSundialWithMediaPK:");
|
||||
if (class_getInstanceMethod(overlayCtl, didLike))
|
||||
MSHookMessageEx(overlayCtl, didLike, (IMP)new_didLikeSundial, (IMP *)&orig_didLikeSundial);
|
||||
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(overlayCtl, setLiked))
|
||||
MSHookMessageEx(overlayCtl, setLiked, (IMP)new_overlaySetIsLiked, (IMP *)&orig_overlaySetIsLiked);
|
||||
}
|
||||
|
||||
Class likesImpl = NSClassFromString(@"IGStoryLikesInteractionControllingImpl");
|
||||
if (likesImpl) {
|
||||
SEL handleTap = NSSelectorFromString(@"handleStoryLikeTapWithButton:");
|
||||
if (class_getInstanceMethod(likesImpl, handleTap))
|
||||
MSHookMessageEx(likesImpl, handleTap, (IMP)new_handleLikeTap, (IMP *)&orig_handleLikeTap);
|
||||
}
|
||||
|
||||
Class likeBtn = NSClassFromString(@"IGSundialViewerUFI.IGSundialLikeButton");
|
||||
if (likeBtn) {
|
||||
SEL setLiked = @selector(setIsLiked:animated:);
|
||||
if (class_getInstanceMethod(likeBtn, setLiked))
|
||||
MSHookMessageEx(likeBtn, setLiked, (IMP)new_likeButtonSetIsLiked, (IMP *)&orig_likeButtonSetIsLiked);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
|
||||
// Defined in SeenButtons.x
|
||||
extern __weak IGDirectThreadViewController *sciActiveThreadVC;
|
||||
extern BOOL sciAutoTypingEnabled(void);
|
||||
extern void sciDoAutoSeen(IGDirectThreadViewController *threadVC);
|
||||
|
||||
%hook IGDirectTypingStatusService
|
||||
- (void)updateOutgoingStatusIsActive:(_Bool)active threadKey:(id)key threadMetadata:(id)metadata typingStatusType:(long long)type {
|
||||
// Mark the visible thread as seen on the first typing event — runs even
|
||||
// when typing-status broadcasting is blocked below.
|
||||
if (active && sciAutoTypingEnabled()) {
|
||||
IGDirectThreadViewController *vc = sciActiveThreadVC;
|
||||
if (vc) sciDoAutoSeen(vc);
|
||||
}
|
||||
|
||||
if ([SCIUtils getBoolPref:@"disable_typing_status"]) return;
|
||||
|
||||
return %orig(active, key, metadata, type);
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
// Download voice messages from DMs. Detects audio messages via the
|
||||
// menuConfiguration hook, then injects a Download item into the long-press
|
||||
// PrismMenu. Tries to convert to .m4a; falls back to the source extension
|
||||
// (e.g. .ogg from web users) if AVFoundation can't decode the format.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import "../../Downloader/Download.h"
|
||||
|
||||
typedef id (*SCIMsgSendId)(id, SEL);
|
||||
static inline id sciDAF(id obj, SEL sel) {
|
||||
if (!obj || ![obj respondsToSelector:sel]) return nil;
|
||||
return ((SCIMsgSendId)objc_msgSend)(obj, sel);
|
||||
}
|
||||
|
||||
static BOOL sciAudioMenuPending = NO;
|
||||
static id sciLastAudioViewModel = nil;
|
||||
|
||||
// Demangled: IGDirectMessageMenuConfiguration.IGDirectMessageMenuConfiguration
|
||||
%hook _TtC32IGDirectMessageMenuConfiguration32IGDirectMessageMenuConfiguration
|
||||
|
||||
+ (id)menuConfigurationWithEligibleOptions:(id)options
|
||||
messageViewModel:(id)arg2
|
||||
contentType:(id)arg3
|
||||
isSticker:(_Bool)arg4
|
||||
isMusicSticker:(_Bool)arg5
|
||||
directNuxManager:(id)arg6
|
||||
sessionUserDefaults:(id)arg7
|
||||
launcherSet:(id)arg8
|
||||
userSession:(id)arg9
|
||||
tapHandler:(id)arg10
|
||||
{
|
||||
if ([SCIUtils getBoolPref:@"download_audio_message"] &&
|
||||
[arg3 isKindOfClass:[NSString class]] && [arg3 isEqualToString:@"voice_media"]) {
|
||||
sciAudioMenuPending = YES;
|
||||
sciLastAudioViewModel = arg2;
|
||||
}
|
||||
return %orig;
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// PrismMenu uses Swift classes with mangled names — hook via MSHookMessageEx in %ctor.
|
||||
|
||||
static id (*orig_prismMenuView_init3)(id, SEL, NSArray *, id, BOOL);
|
||||
|
||||
static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id header, BOOL edr) {
|
||||
if (!sciAudioMenuPending) return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
|
||||
sciAudioMenuPending = NO;
|
||||
|
||||
if (![SCIUtils getBoolPref:@"download_audio_message"])
|
||||
return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
|
||||
|
||||
Class builderClass = NSClassFromString(@"IGDSPrismMenuItemBuilder");
|
||||
Class elementClass = NSClassFromString(@"IGDSPrismMenuElement");
|
||||
if (!builderClass || !elementClass || elements.count == 0)
|
||||
return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
|
||||
|
||||
typedef id (*InitFn)(id, SEL, id);
|
||||
typedef id (*WithFn)(id, SEL, id);
|
||||
typedef id (*BuildFn)(id, SEL);
|
||||
|
||||
id capturedVM = sciLastAudioViewModel;
|
||||
void (^handler)(void) = ^{
|
||||
if (!capturedVM) return;
|
||||
|
||||
// vm -> audio (IGDirectAudio) -> _server_audio (IGAudio) -> playbackURL
|
||||
id directAudio = nil;
|
||||
@try { directAudio = [capturedVM valueForKey:@"audio"]; } @catch (NSException *e) {}
|
||||
if (!directAudio) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not get audio data. Try again after refreshing the chat.")];
|
||||
return;
|
||||
}
|
||||
|
||||
Ivar serverAudioIvar = class_getInstanceVariable([directAudio class], "_server_audio");
|
||||
id serverAudio = serverAudioIvar ? object_getIvar(directAudio, serverAudioIvar) : nil;
|
||||
if (!serverAudio) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Audio not loaded yet. Play the message first and try again.")];
|
||||
return;
|
||||
}
|
||||
|
||||
NSURL *playbackURL = sciDAF(serverAudio, @selector(playbackURL));
|
||||
if (!playbackURL) playbackURL = sciDAF(serverAudio, @selector(fallbackURL));
|
||||
if (!playbackURL) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio URL found. Try again after refreshing the chat.")];
|
||||
return;
|
||||
}
|
||||
|
||||
UIView *topView = [UIApplication sharedApplication].keyWindow;
|
||||
SCIDownloadPillView *pill = [[SCIDownloadPillView alloc] init];
|
||||
[pill setText:SCILocalized(@"Downloading audio...")];
|
||||
[pill showInView:topView];
|
||||
|
||||
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
|
||||
downloadTaskWithURL:playbackURL
|
||||
completionHandler:^(NSURL *tempURL, NSURLResponse *response, NSError *error) {
|
||||
if (error || !tempURL) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill dismiss];
|
||||
[SCIUtils showErrorHUDWithDescription:error.localizedDescription ?: @"Download failed. Try again."];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to convert to .m4a; on failure (e.g. Ogg/Opus) keep the source extension.
|
||||
NSString *urlExt = [[playbackURL.path pathExtension] lowercaseString];
|
||||
if (urlExt.length == 0) urlExt = @"m4a";
|
||||
|
||||
NSString *mediaId = sciDAF(serverAudio, @selector(mediaId)) ?: @"voice_message";
|
||||
NSString *srcPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"tmp_%@.%@", mediaId, urlExt]];
|
||||
NSURL *srcURL = [NSURL fileURLWithPath:srcPath];
|
||||
[[NSFileManager defaultManager] removeItemAtURL:srcURL error:nil];
|
||||
[[NSFileManager defaultManager] moveItemAtURL:tempURL toURL:srcURL error:nil];
|
||||
|
||||
void (^present)(NSURL *) = ^(NSURL *url) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
[pill setText:SCILocalized(@"Done!")];
|
||||
[pill dismissAfterDelay:0.5];
|
||||
[SCIUtils showShareVC:url];
|
||||
});
|
||||
};
|
||||
|
||||
NSString *m4aPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"audio_%@.m4a", mediaId]];
|
||||
NSURL *m4aURL = [NSURL fileURLWithPath:m4aPath];
|
||||
[[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil];
|
||||
|
||||
AVAsset *asset = [AVAsset assetWithURL:srcURL];
|
||||
AVAssetExportSession *exp = [AVAssetExportSession
|
||||
exportSessionWithAsset:asset presetName:AVAssetExportPresetAppleM4A];
|
||||
exp.outputURL = m4aURL;
|
||||
exp.outputFileType = AVFileTypeAppleM4A;
|
||||
|
||||
[exp exportAsynchronouslyWithCompletionHandler:^{
|
||||
if (exp.status == AVAssetExportSessionStatusCompleted) {
|
||||
[[NSFileManager defaultManager] removeItemAtURL:srcURL error:nil];
|
||||
present(m4aURL);
|
||||
return;
|
||||
}
|
||||
// Conversion failed — keep the original with its real extension.
|
||||
[[NSFileManager defaultManager] removeItemAtURL:m4aURL error:nil];
|
||||
NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"audio_%@.%@", mediaId, urlExt]];
|
||||
NSURL *outURL = [NSURL fileURLWithPath:outPath];
|
||||
[[NSFileManager defaultManager] removeItemAtURL:outURL error:nil];
|
||||
if (![[NSFileManager defaultManager] moveItemAtURL:srcURL toURL:outURL error:nil]) {
|
||||
present(srcURL);
|
||||
return;
|
||||
}
|
||||
present(outURL);
|
||||
}];
|
||||
}];
|
||||
[task resume];
|
||||
};
|
||||
|
||||
id builder = ((InitFn)objc_msgSend)([builderClass alloc], @selector(initWithTitle:), @"Download");
|
||||
builder = ((WithFn)objc_msgSend)(builder, @selector(withImage:), [UIImage systemImageNamed:@"arrow.down.circle"]);
|
||||
builder = ((WithFn)objc_msgSend)(builder, @selector(withHandler:), handler);
|
||||
id menuItem = ((BuildFn)objc_msgSend)(builder, @selector(build));
|
||||
if (!menuItem) return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
|
||||
|
||||
// Wrap in IGDSPrismMenuElement: clone _subtype from a sibling, attach the menuItem.
|
||||
id templateEl = elements[0];
|
||||
id newElement = [[templateEl class] new];
|
||||
Ivar subtypeIvar = class_getInstanceVariable([templateEl class], "_subtype");
|
||||
Ivar itemIvar = class_getInstanceVariable([templateEl class], "_item_menuItem");
|
||||
if (!newElement || !subtypeIvar || !itemIvar)
|
||||
return orig_prismMenuView_init3(self, _cmd, elements, header, edr);
|
||||
|
||||
ptrdiff_t offset = ivar_getOffset(subtypeIvar);
|
||||
*(uint64_t *)((uint8_t *)(__bridge void *)newElement + offset) =
|
||||
*(uint64_t *)((uint8_t *)(__bridge void *)templateEl + offset);
|
||||
object_setIvar(newElement, itemIvar, menuItem);
|
||||
|
||||
NSMutableArray *newElements = [NSMutableArray arrayWithObject:newElement];
|
||||
[newElements addObjectsFromArray:elements];
|
||||
return orig_prismMenuView_init3(self, _cmd, newElements, header, edr);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class prismMenuView = objc_getClass("IGDSPrismMenu.IGDSPrismMenuView");
|
||||
if (prismMenuView) {
|
||||
SEL sel = @selector(initWithMenuElements:headerText:edrEnabled:);
|
||||
if ([prismMenuView instancesRespondToSelector:sel])
|
||||
MSHookMessageEx(prismMenuView, sel, (IMP)new_prismMenuView_init3, (IMP *)&orig_prismMenuView_init3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
// Per-chat exclusion list. Injects an Add/Remove item into the inbox row
|
||||
// context menu, and tracks the currently-visible thread for the gating sites
|
||||
// in SeenButtons / OverlayButtons / VisualMsgModifier. Storage lives in
|
||||
// SCIExcludedThreads.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "SCIExcludedThreads.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
static id sci_safeKey(id obj, NSString *k) {
|
||||
@try { return [obj valueForKey:k]; } @catch (__unused id e) { return nil; }
|
||||
}
|
||||
|
||||
// Build a persistence-ready dict from a live IGDirectInboxThreadCellViewModel.
|
||||
static NSDictionary *sci_entryFromVM(id vm) {
|
||||
if (!vm) return nil;
|
||||
NSString *tid = sci_safeKey(vm, @"threadId");
|
||||
NSString *name = sci_safeKey(vm, @"threadName");
|
||||
NSNumber *grp = sci_safeKey(vm, @"isGroupThread");
|
||||
if (tid.length == 0) return nil;
|
||||
|
||||
NSMutableArray *users = [NSMutableArray array];
|
||||
id active = sci_safeKey(vm, @"recentlyActiveUsers");
|
||||
if ([active isKindOfClass:[NSArray class]]) {
|
||||
for (id u in (NSArray *)active) {
|
||||
id pk = sci_safeKey(u, @"pk");
|
||||
id un = sci_safeKey(u, @"username");
|
||||
id fn = sci_safeKey(u, @"fullName");
|
||||
NSMutableDictionary *d = [NSMutableDictionary dictionary];
|
||||
if (pk) d[@"pk"] = [NSString stringWithFormat:@"%@", pk];
|
||||
if (un) d[@"username"] = [NSString stringWithFormat:@"%@", un];
|
||||
if (fn) d[@"fullName"] = [NSString stringWithFormat:@"%@", fn];
|
||||
if (d.count) [users addObject:d];
|
||||
}
|
||||
}
|
||||
return @{
|
||||
@"threadId": tid,
|
||||
@"threadName": name ?: @"",
|
||||
@"isGroup": @([grp boolValue]),
|
||||
@"users": users,
|
||||
};
|
||||
}
|
||||
|
||||
// Inbox row context menu — wrap IG's UIContextMenuConfiguration to append our
|
||||
// add/remove item without losing any of IG's own actions.
|
||||
static id (*orig_ctxMenuCfg)(id, SEL, id);
|
||||
static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
|
||||
id cfg = orig_ctxMenuCfg(self, _cmd, indexPath);
|
||||
if (![SCIExcludedThreads isFeatureEnabled]) return cfg;
|
||||
if (![cfg isKindOfClass:[UIContextMenuConfiguration class]]) return cfg;
|
||||
|
||||
id adapter = sci_safeKey(self, @"listAdapter");
|
||||
if (!adapter || ![indexPath respondsToSelector:@selector(section)]) return cfg;
|
||||
NSInteger section = [(NSIndexPath *)indexPath section];
|
||||
SEL secSel = NSSelectorFromString(@"sectionControllerForSection:");
|
||||
if (![adapter respondsToSelector:secSel]) return cfg;
|
||||
id secCtrl = ((id(*)(id,SEL,NSInteger))objc_msgSend)(adapter, secSel, section);
|
||||
id vm = sci_safeKey(secCtrl, @"viewModel");
|
||||
if (!vm) vm = sci_safeKey(secCtrl, @"item");
|
||||
NSDictionary *entry = sci_entryFromVM(vm);
|
||||
if (!entry) return cfg;
|
||||
NSString *tid = entry[@"threadId"];
|
||||
|
||||
// actionProvider / previewProvider aren't public on UIContextMenuConfiguration
|
||||
UIContextMenuConfiguration *orig = (UIContextMenuConfiguration *)cfg;
|
||||
UIContextMenuActionProvider origProvider = sci_safeKey(orig, @"actionProvider");
|
||||
id<NSCopying> origIdent = sci_safeKey(orig, @"identifier");
|
||||
UIContextMenuContentPreviewProvider origPreview = sci_safeKey(orig, @"previewProvider");
|
||||
|
||||
UIContextMenuActionProvider wrapped = ^UIMenu *(NSArray<UIMenuElement *> *suggested) {
|
||||
UIMenu *base = origProvider ? origProvider(suggested) : [UIMenu menuWithChildren:suggested];
|
||||
BOOL inList = [SCIExcludedThreads isInList:tid];
|
||||
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
|
||||
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude chat");
|
||||
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude chat");
|
||||
NSString *title = inList ? removeLabel : addLabel;
|
||||
UIImage *img = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
|
||||
UIAction *toggle = [UIAction actionWithTitle:title image:img identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
if (inList) {
|
||||
[SCIExcludedThreads removeThreadId:tid];
|
||||
} else {
|
||||
[SCIExcludedThreads addOrUpdateEntry:entry];
|
||||
}
|
||||
}];
|
||||
NSMutableArray *kids = [base.children mutableCopy] ?: [NSMutableArray array];
|
||||
[kids addObject:toggle];
|
||||
return [base menuByReplacingChildren:kids];
|
||||
};
|
||||
|
||||
return [UIContextMenuConfiguration configurationWithIdentifier:origIdent
|
||||
previewProvider:origPreview
|
||||
actionProvider:wrapped];
|
||||
}
|
||||
|
||||
// Active thread tracking. Set on viewWillAppear so visual-message viewMode
|
||||
// reads it before the chat finishes loading. Only cleared on a real leave —
|
||||
// a visual viewer modal pushed on top mustn't drop context.
|
||||
%hook IGDirectThreadViewController
|
||||
- (void)viewWillAppear:(BOOL)animated {
|
||||
%orig;
|
||||
NSString *tid = sci_safeKey(self, @"threadId");
|
||||
if (tid) [SCIExcludedThreads setActiveThreadId:tid];
|
||||
}
|
||||
- (void)viewDidDisappear:(BOOL)animated {
|
||||
%orig;
|
||||
if (self.isMovingFromParentViewController || self.isBeingDismissed || self.parentViewController == nil) {
|
||||
NSString *cur = [SCIExcludedThreads activeThreadId];
|
||||
NSString *mine = sci_safeKey(self, @"threadId");
|
||||
if (cur && mine && [cur isEqualToString:mine]) {
|
||||
[SCIExcludedThreads setActiveThreadId:nil];
|
||||
}
|
||||
}
|
||||
}
|
||||
- (void)dealloc {
|
||||
NSString *cur = [SCIExcludedThreads activeThreadId];
|
||||
NSString *mine = sci_safeKey(self, @"threadId");
|
||||
if (cur && mine && [cur isEqualToString:mine]) {
|
||||
[SCIExcludedThreads setActiveThreadId:nil];
|
||||
}
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%ctor {
|
||||
Class cls = NSClassFromString(@"IGDirectInboxViewController");
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(@"networkingCoordinator_contextMenuConfigurationForThreadCellAtIndexPath:");
|
||||
if (class_getInstanceMethod(cls, sel))
|
||||
MSHookMessageEx(cls, sel, (IMP)new_ctxMenuCfg, (IMP *)&orig_ctxMenuCfg);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Per-user story seen-receipt exclusions. Excluded users' stories behave
|
||||
// normally (your view appears in their viewer list). Provides owner detection
|
||||
// helpers, 3-dot menu injection, and overlay refresh utilities.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "StoryHelpers.h"
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
NSDictionary *sciOwnerInfoFromObject(id obj);
|
||||
|
||||
// ============ Active story VC tracking ============
|
||||
|
||||
__weak UIViewController *sciActiveStoryViewerVC = nil;
|
||||
|
||||
%hook IGStoryViewerViewController
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciActiveStoryViewerVC = self;
|
||||
}
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
if (sciActiveStoryViewerVC == (UIViewController *)self) sciActiveStoryViewerVC = nil;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ Owner extraction ============
|
||||
|
||||
NSDictionary *sciOwnerInfoFromObject(id obj) {
|
||||
if (!obj) return nil;
|
||||
@try {
|
||||
id pk = nil, un = nil, fn = nil;
|
||||
if ([obj respondsToSelector:@selector(pk)])
|
||||
pk = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(pk));
|
||||
if ([obj respondsToSelector:@selector(username)])
|
||||
un = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(username));
|
||||
if ([obj respondsToSelector:@selector(fullName)])
|
||||
fn = ((id(*)(id, SEL))objc_msgSend)(obj, @selector(fullName));
|
||||
if (pk && un) {
|
||||
return @{ @"pk": [NSString stringWithFormat:@"%@", pk],
|
||||
@"username": [NSString stringWithFormat:@"%@", un],
|
||||
@"fullName": fn ? [NSString stringWithFormat:@"%@", fn] : @"" };
|
||||
}
|
||||
NSArray *nestedKeys = @[@"user", @"owner", @"author", @"reelUser", @"reelOwner"];
|
||||
for (NSString *k in nestedKeys) {
|
||||
@try {
|
||||
id sub = [obj valueForKey:k];
|
||||
if (sub && sub != obj) {
|
||||
NSDictionary *d = sciOwnerInfoFromObject(sub);
|
||||
if (d) return d;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSDictionary *sciOwnerInfoForStoryVC(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
@try {
|
||||
id vm = ((id(*)(id, SEL))objc_msgSend)(vc, @selector(currentViewModel));
|
||||
if (!vm) return nil;
|
||||
id owner = nil;
|
||||
@try { owner = [vm valueForKey:@"owner"]; } @catch (__unused id e) {}
|
||||
if (!owner) return nil;
|
||||
return sciOwnerInfoFromObject(owner);
|
||||
} @catch (__unused id e) { return nil; }
|
||||
}
|
||||
|
||||
NSDictionary *sciCurrentStoryOwnerInfo(void) {
|
||||
return sciOwnerInfoForStoryVC(sciActiveStoryViewerVC);
|
||||
}
|
||||
|
||||
// Find the section controller for a specific cell via ivar scan.
|
||||
static id sciFindSectionControllerForCell(UICollectionViewCell *cell) {
|
||||
Class sectionClass = NSClassFromString(@"IGStoryFullscreenSectionController");
|
||||
if (!sectionClass || !cell) return nil;
|
||||
unsigned int cCount = 0;
|
||||
Ivar *cIvars = class_copyIvarList([cell class], &cCount);
|
||||
for (unsigned int i = 0; i < cCount; i++) {
|
||||
const char *type = ivar_getTypeEncoding(cIvars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
id val = object_getIvar(cell, cIvars[i]);
|
||||
if (!val) continue;
|
||||
if ([val isKindOfClass:sectionClass]) { free(cIvars); return val; }
|
||||
unsigned int vCount = 0;
|
||||
Ivar *vIvars = class_copyIvarList([val class], &vCount);
|
||||
for (unsigned int j = 0; j < vCount; j++) {
|
||||
const char *type2 = ivar_getTypeEncoding(vIvars[j]);
|
||||
if (!type2 || type2[0] != '@') continue;
|
||||
id val2 = object_getIvar(val, vIvars[j]);
|
||||
if (val2 && [val2 isKindOfClass:sectionClass]) { free(vIvars); free(cIvars); return val2; }
|
||||
}
|
||||
if (vIvars) free(vIvars);
|
||||
}
|
||||
if (cIvars) free(cIvars);
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSDictionary *sciOwnerInfoFromSectionController(id sc) {
|
||||
if (!sc) return nil;
|
||||
NSArray *tryKeys = @[@"viewModel", @"item", @"model", @"object"];
|
||||
for (NSString *k in tryKeys) {
|
||||
@try {
|
||||
id obj = [sc valueForKey:k];
|
||||
if (obj) {
|
||||
NSDictionary *info = sciOwnerInfoFromObject(obj);
|
||||
if (info) return info;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
return sciOwnerInfoFromObject(sc);
|
||||
}
|
||||
|
||||
// Per-cell owner lookup: walks from the overlay to its IGStoryFullscreenCell,
|
||||
// finds the cell's section controller, and reads the owner. Gives the correct
|
||||
// owner even when multiple cells are alive (pre-loaded adjacent reels).
|
||||
NSDictionary *sciOwnerInfoForView(UIView *view) {
|
||||
if (!view) return nil;
|
||||
Class cellClass = NSClassFromString(@"IGStoryFullscreenCell");
|
||||
UIView *cur = view;
|
||||
UICollectionViewCell *cell = nil;
|
||||
while (cur) {
|
||||
if (cellClass && [cur isKindOfClass:cellClass]) { cell = (UICollectionViewCell *)cur; break; }
|
||||
cur = cur.superview;
|
||||
}
|
||||
if (cell) {
|
||||
id sc = sciFindSectionControllerForCell(cell);
|
||||
NSDictionary *info = sciOwnerInfoFromSectionController(sc);
|
||||
if (info) return info;
|
||||
}
|
||||
// Fallback: VC's currentViewModel
|
||||
UIViewController *vc = sciFindVC(view, @"IGStoryViewerViewController");
|
||||
return sciOwnerInfoForStoryVC(vc);
|
||||
}
|
||||
|
||||
BOOL sciIsCurrentStoryOwnerExcluded(void) {
|
||||
NSDictionary *info = sciCurrentStoryOwnerInfo();
|
||||
if (!info) return NO;
|
||||
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
|
||||
}
|
||||
|
||||
BOOL sciIsObjectStoryOwnerExcluded(id obj) {
|
||||
NSDictionary *info = sciOwnerInfoFromObject(obj);
|
||||
if (!info) return NO;
|
||||
return [SCIExcludedStoryUsers isUserPKExcluded:info[@"pk"]];
|
||||
}
|
||||
|
||||
// ============ Overlay utilities ============
|
||||
|
||||
void sciTriggerStoryMarkSeen(UIViewController *storyVC) {
|
||||
if (!storyVC) return;
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
|
||||
if (!overlayCls) return;
|
||||
SEL markSel = @selector(sciMarkSeenTapped:);
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
if ([v isKindOfClass:overlayCls] && [v respondsToSelector:markSel]) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(v, markSel, nil);
|
||||
return;
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
}
|
||||
|
||||
void sciRefreshAllVisibleOverlays(UIViewController *storyVC) {
|
||||
if (!storyVC) return;
|
||||
Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView");
|
||||
if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView");
|
||||
if (!overlayCls) return;
|
||||
SEL refreshSel = @selector(sciRefreshSeenButton);
|
||||
SEL audioSel = @selector(sciRefreshAudioButton);
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view];
|
||||
while (stack.count) {
|
||||
UIView *v = stack.lastObject; [stack removeLastObject];
|
||||
if ([v isKindOfClass:overlayCls]) {
|
||||
if ([v respondsToSelector:refreshSel])
|
||||
((void(*)(id, SEL))objc_msgSend)(v, refreshSel);
|
||||
if ([v respondsToSelector:audioSel])
|
||||
((void(*)(id, SEL))objc_msgSend)(v, audioSel);
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 3-dot menu injection ============
|
||||
// Hooks into the existing IGDSMenu hook in Tweak.x via sciMaybeAppendStoryExcludeMenuItem.
|
||||
// Always present regardless of master toggle (fallback when eye affordance is hidden).
|
||||
|
||||
NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) {
|
||||
if (!sciActiveStoryViewerVC) return items;
|
||||
BOOL looksLikeStoryHeader = NO;
|
||||
for (id it in items) {
|
||||
@try {
|
||||
id title = [it valueForKey:@"title"];
|
||||
NSString *t = [NSString stringWithFormat:@"%@", title ?: @""];
|
||||
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
|
||||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
|
||||
[t isEqualToString:@"Hide"]) {
|
||||
looksLikeStoryHeader = YES; break;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (!looksLikeStoryHeader) return items;
|
||||
|
||||
NSDictionary *ownerInfo = sciCurrentStoryOwnerInfo();
|
||||
if (!ownerInfo) return items;
|
||||
|
||||
NSString *pk = ownerInfo[@"pk"];
|
||||
NSString *username = ownerInfo[@"username"] ?: @"";
|
||||
NSString *fullName = ownerInfo[@"fullName"] ?: @"";
|
||||
// Bypass master toggle so the 3-dot fallback always shows
|
||||
BOOL inList = [SCIExcludedStoryUsers isInList:pk];
|
||||
BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode];
|
||||
|
||||
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
|
||||
if (!menuItemCls) return items;
|
||||
|
||||
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 *title = inList ? removeLabel : addLabel;
|
||||
|
||||
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
|
||||
void (^handler)(void) = ^{
|
||||
if (inList) {
|
||||
[SCIExcludedStoryUsers removePK:pk];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
|
||||
// Removing in block_selected = normal behavior → mark seen
|
||||
if (blockSelected) sciTriggerStoryMarkSeen(weakVC);
|
||||
} else {
|
||||
[SCIExcludedStoryUsers addOrUpdateEntry:@{
|
||||
@"pk": pk, @"username": username, @"fullName": fullName
|
||||
}];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
|
||||
// Adding in block_all = normal behavior → mark seen
|
||||
if (!blockSelected) sciTriggerStoryMarkSeen(weakVC);
|
||||
}
|
||||
sciRefreshAllVisibleOverlays(weakVC);
|
||||
};
|
||||
|
||||
id newItem = nil;
|
||||
@try {
|
||||
SEL initSel = @selector(initWithTitle:image:handler:);
|
||||
typedef id (*Init)(id, SEL, id, id, id);
|
||||
newItem = ((Init)objc_msgSend)([menuItemCls alloc], initSel, title, nil, handler);
|
||||
} @catch (__unused id e) { newItem = nil; }
|
||||
|
||||
if (!newItem) return items;
|
||||
|
||||
NSMutableArray *newItems = [items mutableCopy] ?: [NSMutableArray array];
|
||||
[newItems addObject:newItem];
|
||||
return [newItems copy];
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
// Full last active — replaces "Active Xm ago" with full date in DM chats.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static NSDateFormatter *sciDMDateFormatter(void) {
|
||||
static NSDateFormatter *df = nil;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
df = [NSDateFormatter new];
|
||||
df.dateFormat = @"MMM d 'at' h:mm a";
|
||||
});
|
||||
return df;
|
||||
}
|
||||
|
||||
// Replace "Active Xm/h ago" with full date using _lastActiveDate from the thread
|
||||
static void sciUpdateSubtitleLabel(UIView *titleView) {
|
||||
if (![SCIUtils getBoolPref:@"dm_full_last_active"]) return;
|
||||
|
||||
// Get _subtitleLabel
|
||||
Ivar subIvar = class_getInstanceVariable([titleView class], "_subtitleLabel");
|
||||
if (!subIvar) return;
|
||||
UILabel *label = object_getIvar(titleView, subIvar);
|
||||
if (![label isKindOfClass:[UILabel class]]) return;
|
||||
|
||||
NSString *text = label.text;
|
||||
if (!text.length) return;
|
||||
|
||||
// Only replace "Active X ago" patterns, not "Active now" or "Typing..."
|
||||
if (![text hasPrefix:@"Active "] || ![text hasSuffix:@"ago"]) return;
|
||||
|
||||
// Get the _titleViewModel to find lastActiveDate
|
||||
Ivar vmIvar = class_getInstanceVariable([titleView class], "_titleViewModel");
|
||||
if (!vmIvar) return;
|
||||
id vm = object_getIvar(titleView, vmIvar);
|
||||
if (!vm) return;
|
||||
|
||||
// Try to get lastActiveDate from the view model
|
||||
NSDate *activeDate = nil;
|
||||
|
||||
// Check vm for lastActiveDate / lastActive / activeDate
|
||||
for (NSString *sel in @[@"lastActiveDate", @"lastActive", @"activeDate"]) {
|
||||
if ([vm respondsToSelector:NSSelectorFromString(sel)]) {
|
||||
id val = [vm valueForKey:sel];
|
||||
if ([val isKindOfClass:[NSDate class]]) { activeDate = val; break; }
|
||||
if ([val isKindOfClass:[NSNumber class]]) {
|
||||
activeDate = [NSDate dateWithTimeIntervalSince1970:[(NSNumber *)val doubleValue]];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no date on VM, parse from the label text as fallback
|
||||
if (!activeDate) {
|
||||
// "Active 8m ago" → 8 minutes ago
|
||||
// "Active 2h ago" → 2 hours ago
|
||||
NSTimeInterval delta = 0;
|
||||
NSScanner *scanner = [NSScanner scannerWithString:text];
|
||||
[scanner scanString:@"Active " intoString:nil];
|
||||
double val = 0;
|
||||
if ([scanner scanDouble:&val]) {
|
||||
NSString *rest = [text substringFromIndex:scanner.scanLocation];
|
||||
if ([rest hasPrefix:@"m"]) delta = val * 60;
|
||||
else if ([rest hasPrefix:@"h"]) delta = val * 3600;
|
||||
else if ([rest hasPrefix:@"d"]) delta = val * 86400;
|
||||
}
|
||||
if (delta > 0) {
|
||||
activeDate = [NSDate dateWithTimeIntervalSinceNow:-delta];
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeDate) return;
|
||||
|
||||
NSString *formatted = [sciDMDateFormatter() stringFromDate:activeDate];
|
||||
if (formatted.length) {
|
||||
label.text = formatted;
|
||||
|
||||
// Also update _subtitleView and _transitionalSubtitleLabel if they exist
|
||||
Ivar svIvar = class_getInstanceVariable([titleView class], "_subtitleView");
|
||||
if (svIvar) {
|
||||
id sv = object_getIvar(titleView, svIvar);
|
||||
if ([sv isKindOfClass:[UILabel class]])
|
||||
[(UILabel *)sv setText:label.text];
|
||||
}
|
||||
Ivar tsIvar = class_getInstanceVariable([titleView class], "_transitionalSubtitleLabel");
|
||||
if (tsIvar) {
|
||||
id ts = object_getIvar(titleView, tsIvar);
|
||||
if ([ts isKindOfClass:[UILabel class]])
|
||||
[(UILabel *)ts setText:label.text];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGDirectLeftAlignedTitleView
|
||||
|
||||
- (void)setTitleViewModel:(id)vm {
|
||||
%orig;
|
||||
sciUpdateSubtitleLabel(self);
|
||||
}
|
||||
|
||||
- (void)animationCoordinatorDidUpdate:(id)coordinator {
|
||||
%orig;
|
||||
sciUpdateSubtitleLabel(self);
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,90 @@
|
||||
// Hide voice/video call buttons in DM thread header.
|
||||
|
||||
#import "../../Utils.h"
|
||||
|
||||
// IGDirectThreadCallButtonsCoordinator / IGDirectCallButton / IGNavigationBar
|
||||
// declared in InstagramHeaders.h
|
||||
|
||||
static BOOL sciShouldHide(UIView *b) {
|
||||
if (![b isKindOfClass:NSClassFromString(@"IGDirectCallButton")]) return NO;
|
||||
NSString *axId = b.accessibilityIdentifier;
|
||||
if ([axId isEqualToString:@"audio-call"]) return [SCIUtils getBoolPref:@"hide_voice_call_button"];
|
||||
if ([axId isEqualToString:@"video-chat"]) return [SCIUtils getBoolPref:@"hide_video_call_button"];
|
||||
return NO;
|
||||
}
|
||||
|
||||
static BOOL sciPlatterContainsHiddenButton(UIView *platter) {
|
||||
NSMutableArray *q = [NSMutableArray arrayWithObject:platter];
|
||||
while (q.count) {
|
||||
UIView *v = q.firstObject;
|
||||
[q removeObjectAtIndex:0];
|
||||
if (sciShouldHide(v)) return YES;
|
||||
[q addObjectsFromArray:v.subviews];
|
||||
}
|
||||
return NO;
|
||||
}
|
||||
|
||||
// Block taps in case a hidden button still receives hit-test events during transitions.
|
||||
%hook IGDirectThreadCallButtonsCoordinator
|
||||
- (void)_didTapAudioButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"hide_voice_call_button"]) return;
|
||||
%orig;
|
||||
}
|
||||
- (void)_didTapVideoButton:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"hide_video_call_button"]) return;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGDirectCallButton
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
if (!self.window) return;
|
||||
if (sciShouldHide((UIView *)self)) self.hidden = YES;
|
||||
}
|
||||
%end
|
||||
|
||||
// Re-pack platters on each layout: shift every non-back platter right by the
|
||||
// total width of the hidden call platters to eliminate the gap.
|
||||
static void sciRepackPlatters(UIView *container) {
|
||||
NSMutableArray *platters = [NSMutableArray array];
|
||||
for (UIView *sv in container.subviews)
|
||||
if ([NSStringFromClass([sv class]) isEqualToString:@"_UINavigationBarPlatterView"])
|
||||
[platters addObject:sv];
|
||||
|
||||
CGFloat hiddenWidth = 0;
|
||||
NSMutableArray *alive = [NSMutableArray array];
|
||||
for (UIView *p in platters) {
|
||||
if (sciPlatterContainsHiddenButton(p)) {
|
||||
hiddenWidth += p.frame.size.width;
|
||||
p.hidden = YES;
|
||||
} else {
|
||||
p.hidden = NO;
|
||||
[alive addObject:p];
|
||||
}
|
||||
}
|
||||
if (!alive.count || hiddenWidth == 0) {
|
||||
for (UIView *p in alive) p.transform = CGAffineTransformIdentity;
|
||||
return;
|
||||
}
|
||||
for (UIView *p in alive) {
|
||||
if (p.frame.origin.x < 60) { p.transform = CGAffineTransformIdentity; continue; }
|
||||
p.transform = CGAffineTransformMakeTranslation(hiddenWidth, 0);
|
||||
}
|
||||
}
|
||||
|
||||
%hook IGNavigationBar
|
||||
- (void)layoutSubviews {
|
||||
%orig;
|
||||
NSMutableArray *q = [NSMutableArray arrayWithObject:self];
|
||||
while (q.count) {
|
||||
UIView *v = q.firstObject;
|
||||
[q removeObjectAtIndex:0];
|
||||
if ([NSStringFromClass([v class]) containsString:@"NavigationBarPlatterContainer"]) {
|
||||
sciRepackPlatters(v);
|
||||
break;
|
||||
}
|
||||
[q addObjectsFromArray:v.subviews];
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,134 @@
|
||||
// Pull-to-refresh in the DMs tab silently clears preserved (locally retained)
|
||||
// unsent messages. This hook intercepts _pullToRefreshIfPossible to show a
|
||||
// confirmation dialog when both keep_deleted_message and
|
||||
// warn_refresh_clears_preserved are on.
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
extern NSMutableSet *sciGetPreservedIds(void);
|
||||
extern void sciClearPreservedIds(void);
|
||||
|
||||
static BOOL sciRefreshConfirmInFlight = NO;
|
||||
static BOOL sciRefreshAlertVisible = NO;
|
||||
|
||||
static UIRefreshControl *sciFindRefreshControl(UIViewController *vc) {
|
||||
Class igRC = NSClassFromString(@"IGRefreshControl");
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:vc.view];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ((igRC && [v isKindOfClass:igRC]) || [v isKindOfClass:[UIRefreshControl class]]) {
|
||||
return (UIRefreshControl *)v;
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// On cancel, the IGRefreshControl's state machine is already idle by the time
|
||||
// our handler runs — but the scroll view's contentInset stays expanded, leaving
|
||||
// the spinner area visually exposed. We grab the idle inset via the inbox VC's
|
||||
// idleTopContentInsetForRefreshControl: helper and animate the inset back.
|
||||
static void sciCancelRefresh(UIViewController *vc) {
|
||||
UIRefreshControl *rc = sciFindRefreshControl(vc);
|
||||
if (!rc) return;
|
||||
|
||||
Ivar stateIvar = class_getInstanceVariable([rc class], "_refreshState");
|
||||
if (stateIvar) {
|
||||
ptrdiff_t off = ivar_getOffset(stateIvar);
|
||||
*(NSInteger *)((char *)(__bridge void *)rc + off) = 0;
|
||||
}
|
||||
Ivar animIvar = class_getInstanceVariable([rc class], "_swiftAnimationInfo");
|
||||
if (animIvar) object_setIvar(rc, animIvar, nil);
|
||||
if ([rc respondsToSelector:@selector(endRefreshing)]) [rc endRefreshing];
|
||||
|
||||
SEL didEnd = NSSelectorFromString(@"refreshControlDidEndFinishLoadingAnimation:");
|
||||
if ([vc respondsToSelector:didEnd]) {
|
||||
((void(*)(id, SEL, id))objc_msgSend)(vc, didEnd, rc);
|
||||
}
|
||||
|
||||
UIScrollView *scroll = nil;
|
||||
UIView *cur = rc.superview;
|
||||
while (cur) {
|
||||
if ([cur isKindOfClass:[UIScrollView class]]) { scroll = (UIScrollView *)cur; break; }
|
||||
cur = cur.superview;
|
||||
}
|
||||
if (scroll) {
|
||||
SEL idleSel = NSSelectorFromString(@"idleTopContentInsetForRefreshControl:");
|
||||
CGFloat idleInset = scroll.contentInset.top;
|
||||
if ([vc respondsToSelector:idleSel]) {
|
||||
idleInset = ((CGFloat(*)(id, SEL, id))objc_msgSend)(vc, idleSel, rc);
|
||||
}
|
||||
UIEdgeInsets insets = scroll.contentInset;
|
||||
insets.top = idleInset;
|
||||
[UIView animateWithDuration:0.25 animations:^{
|
||||
scroll.contentInset = insets;
|
||||
CGPoint o = scroll.contentOffset;
|
||||
if (o.y < -idleInset) o.y = -idleInset;
|
||||
scroll.contentOffset = o;
|
||||
}];
|
||||
}
|
||||
}
|
||||
|
||||
static void (*orig_pullToRefresh)(id self, SEL _cmd);
|
||||
static void new_pullToRefresh(id self, SEL _cmd) {
|
||||
if (sciRefreshConfirmInFlight ||
|
||||
![SCIUtils getBoolPref:@"keep_deleted_message"] ||
|
||||
![SCIUtils getBoolPref:@"warn_refresh_clears_preserved"]) {
|
||||
orig_pullToRefresh(self, _cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
// IG fires _pullToRefreshIfPossible repeatedly while the user holds the
|
||||
// pull gesture — drop re-entrant calls until the alert is dismissed.
|
||||
if (sciRefreshAlertVisible) return;
|
||||
|
||||
NSUInteger count = sciGetPreservedIds().count;
|
||||
if (count == 0) {
|
||||
orig_pullToRefresh(self, _cmd);
|
||||
return;
|
||||
}
|
||||
|
||||
UIViewController *vc = (UIViewController *)self;
|
||||
NSString *msg = [NSString stringWithFormat:
|
||||
@"Refreshing the DMs tab will clear %lu preserved unsent message%@. This cannot be undone.",
|
||||
(unsigned long)count, count == 1 ? @"" : @"s"];
|
||||
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Clear preserved messages?")
|
||||
message:msg
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
|
||||
__weak UIViewController *weakSelf = vc;
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel
|
||||
handler:^(UIAlertAction *a) {
|
||||
sciCancelRefresh(weakSelf);
|
||||
sciRefreshAlertVisible = NO;
|
||||
}]];
|
||||
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Refresh") style:UIAlertActionStyleDestructive
|
||||
handler:^(UIAlertAction *a) {
|
||||
sciRefreshAlertVisible = NO;
|
||||
id strongSelf = weakSelf;
|
||||
if (!strongSelf) return;
|
||||
sciClearPreservedIds();
|
||||
sciRefreshConfirmInFlight = YES;
|
||||
((void(*)(id, SEL))objc_msgSend)(strongSelf, _cmd);
|
||||
sciRefreshConfirmInFlight = NO;
|
||||
}]];
|
||||
|
||||
sciRefreshAlertVisible = YES;
|
||||
UIViewController *top = [UIApplication sharedApplication].keyWindow.rootViewController;
|
||||
while (top.presentedViewController) top = top.presentedViewController;
|
||||
[top presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
%ctor {
|
||||
Class cls = NSClassFromString(@"IGDirectInboxViewController");
|
||||
if (!cls) return;
|
||||
SEL sel = NSSelectorFromString(@"_pullToRefreshIfPossible");
|
||||
if (class_getInstanceMethod(cls, sel))
|
||||
MSHookMessageEx(cls, sel, (IMP)new_pullToRefresh, (IMP *)&orig_pullToRefresh);
|
||||
}
|
||||
@@ -1,22 +1,742 @@
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "SCIExcludedThreads.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
%hook IGDirectRealtimeIrisThreadDelta
|
||||
+ (id)removeItemWithMessageId:(id)arg1 {
|
||||
if ([SCIUtils getBoolPref:@"keep_deleted_message"]) {
|
||||
arg1 = NULL;
|
||||
// Keep-deleted messages.
|
||||
//
|
||||
// Pipeline: each iris delta is per-thread, so its threadId is stashed in TLS
|
||||
// while the orig handler runs. The IGDirectMessageUpdate alloc hook stamps
|
||||
// new updates with that tid. At apply time we classify each update; remote
|
||||
// unsends get their _removeMessages_messageKeys cleared in place so IG's
|
||||
// applicator runs but removes nothing.
|
||||
//
|
||||
// _removeMessages_reason: 0 = unsend, 2 = delete-for-you.
|
||||
|
||||
// ============ STATE ============
|
||||
|
||||
#define SCI_SENDER_MAP_MAX 4000
|
||||
#define SCI_CONTENT_CLASSES_MAX 4000
|
||||
#define SCI_PENDING_MAX 500
|
||||
#define SCI_PRESERVED_MAX 200
|
||||
#define SCI_PRESERVED_IDS_KEY @"SCIPreservedMsgIds"
|
||||
#define SCI_PRESERVED_TAG 1399
|
||||
|
||||
static NSString * const kSCIDeltaTidTLSKey = @"SCI.currentDeltaTid";
|
||||
static const void *kSCIUpdateThreadIdKey = &kSCIUpdateThreadIdKey;
|
||||
|
||||
static BOOL sciLocalDeleteInProgress = NO;
|
||||
static NSMutableArray *sciPendingUpdates = nil;
|
||||
static NSMutableDictionary<NSString *, NSDate *> *sciDeleteForYouKeys = nil;
|
||||
static NSMutableSet *sciPreservedIds = nil;
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciMessageContentClasses = nil;
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciSenderPkBySid = nil;
|
||||
static NSMutableSet<NSString *> *sciPendingLocalSids = nil;
|
||||
|
||||
static void sciUpdateCellIndicator(id cell);
|
||||
|
||||
// ============ HELPERS ============
|
||||
|
||||
static NSString *sciGetCurrentDeltaTid(void) {
|
||||
return [NSThread currentThread].threadDictionary[kSCIDeltaTidTLSKey];
|
||||
}
|
||||
|
||||
static void sciSetCurrentDeltaTid(NSString *tid) {
|
||||
NSMutableDictionary *td = [NSThread currentThread].threadDictionary;
|
||||
if (tid) td[kSCIDeltaTidTLSKey] = tid;
|
||||
else [td removeObjectForKey:kSCIDeltaTidTLSKey];
|
||||
}
|
||||
|
||||
static BOOL sciKeepDeletedEnabled() {
|
||||
return [SCIUtils getBoolPref:@"keep_deleted_message"];
|
||||
}
|
||||
|
||||
static BOOL sciIndicateUnsentEnabled() {
|
||||
return [SCIUtils getBoolPref:@"indicate_unsent_messages"];
|
||||
}
|
||||
|
||||
NSMutableSet *sciGetPreservedIds() {
|
||||
if (!sciPreservedIds) {
|
||||
NSArray *saved = [[NSUserDefaults standardUserDefaults] arrayForKey:SCI_PRESERVED_IDS_KEY];
|
||||
sciPreservedIds = saved ? [NSMutableSet setWithArray:saved] : [NSMutableSet set];
|
||||
}
|
||||
return sciPreservedIds;
|
||||
}
|
||||
|
||||
static void sciSavePreservedIds() {
|
||||
NSMutableSet *ids = sciGetPreservedIds();
|
||||
while (ids.count > SCI_PRESERVED_MAX)
|
||||
[ids removeObject:[ids anyObject]];
|
||||
[[NSUserDefaults standardUserDefaults] setObject:[ids allObjects] forKey:SCI_PRESERVED_IDS_KEY];
|
||||
}
|
||||
|
||||
void sciClearPreservedIds() {
|
||||
[sciGetPreservedIds() removeAllObjects];
|
||||
[[NSUserDefaults standardUserDefaults] removeObjectForKey:SCI_PRESERVED_IDS_KEY];
|
||||
}
|
||||
|
||||
static NSMutableSet<NSString *> *sciGetPendingLocalSids() {
|
||||
if (!sciPendingLocalSids) sciPendingLocalSids = [NSMutableSet set];
|
||||
return sciPendingLocalSids;
|
||||
}
|
||||
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciGetSenderMap() {
|
||||
if (!sciSenderPkBySid) sciSenderPkBySid = [NSMutableDictionary dictionary];
|
||||
return sciSenderPkBySid;
|
||||
}
|
||||
|
||||
static void sciTrackSenderPk(NSString *sid, NSString *pk) {
|
||||
if (!sid.length || !pk.length) return;
|
||||
NSMutableDictionary *m = sciGetSenderMap();
|
||||
m[sid] = pk;
|
||||
if (m.count > SCI_SENDER_MAP_MAX) {
|
||||
NSArray *keys = [m allKeys];
|
||||
for (NSUInteger i = 0; i < keys.count / 10; i++) [m removeObjectForKey:keys[i]];
|
||||
}
|
||||
}
|
||||
|
||||
static NSMutableDictionary<NSString *, NSString *> *sciGetContentClasses() {
|
||||
if (!sciMessageContentClasses) sciMessageContentClasses = [NSMutableDictionary dictionary];
|
||||
return sciMessageContentClasses;
|
||||
}
|
||||
|
||||
static void sciTrackInsertedMessage(NSString *sid, NSString *className) {
|
||||
if (!sid.length || !className.length) return;
|
||||
NSMutableDictionary *map = sciGetContentClasses();
|
||||
map[sid] = className;
|
||||
if (map.count > SCI_CONTENT_CLASSES_MAX) {
|
||||
NSArray *keys = [map allKeys];
|
||||
for (NSUInteger i = 0; i < keys.count / 10; i++) [map removeObjectForKey:keys[i]];
|
||||
}
|
||||
}
|
||||
|
||||
static BOOL sciIsReactionRelatedMessage(NSString *sid) {
|
||||
if (!sid.length) return NO;
|
||||
NSString *className = sciGetContentClasses()[sid];
|
||||
if (!className.length) return NO;
|
||||
return [className containsString:@"Reaction"] ||
|
||||
[className containsString:@"ActionLog"] ||
|
||||
[className containsString:@"reaction"] ||
|
||||
[className containsString:@"actionLog"];
|
||||
}
|
||||
|
||||
// Walks IGWindow.userSession.user trying common pk field names. Cached.
|
||||
static NSString *sciCurrentUserPk() {
|
||||
static NSString *cached = nil;
|
||||
if (cached) return cached;
|
||||
@try {
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
id session = nil;
|
||||
@try { session = [w valueForKey:@"userSession"]; } @catch (__unused id e) {}
|
||||
if (!session) continue;
|
||||
id user = nil;
|
||||
@try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {}
|
||||
if (!user) continue;
|
||||
for (NSString *key in @[@"pk", @"instagramUserID", @"instagramUserId", @"userID", @"userId", @"identifier"]) {
|
||||
@try {
|
||||
id v = [user valueForKey:key];
|
||||
if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) {
|
||||
cached = [v copy];
|
||||
return cached;
|
||||
}
|
||||
if ([v isKindOfClass:[NSNumber class]]) {
|
||||
cached = [[(NSNumber *)v stringValue] copy];
|
||||
return cached;
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSString *sciExtractServerId(id key) {
|
||||
@try {
|
||||
Ivar sidIvar = class_getInstanceVariable([key class], "_messageServerId");
|
||||
if (sidIvar) {
|
||||
NSString *sid = object_getIvar(key, sidIvar);
|
||||
if ([sid isKindOfClass:[NSString class]] && sid.length > 0) return sid;
|
||||
}
|
||||
} @catch(id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// ============ IRIS DELTA STAMPING ============
|
||||
|
||||
static NSString *sciDeltaThreadId(id delta) {
|
||||
@try {
|
||||
id payload = [delta valueForKey:@"payload"];
|
||||
if (!payload) return nil;
|
||||
Ivar tdIvar = class_getInstanceVariable([payload class], "_threadDeltaPayload");
|
||||
id threadDelta = tdIvar ? object_getIvar(payload, tdIvar) : nil;
|
||||
if (!threadDelta) return nil;
|
||||
return [threadDelta valueForKey:@"threadId"];
|
||||
} @catch (__unused id e) { return nil; }
|
||||
}
|
||||
|
||||
static void (*orig_handleIrisDeltas)(id self, SEL _cmd, NSArray *deltas);
|
||||
static void new_handleIrisDeltas(id self, SEL _cmd, NSArray *deltas) {
|
||||
if (!deltas || deltas.count == 0) { orig_handleIrisDeltas(self, _cmd, deltas); return; }
|
||||
for (id delta in deltas) {
|
||||
sciSetCurrentDeltaTid(sciDeltaThreadId(delta));
|
||||
@try { orig_handleIrisDeltas(self, _cmd, @[delta]); } @catch (__unused id e) {}
|
||||
sciSetCurrentDeltaTid(nil);
|
||||
}
|
||||
}
|
||||
|
||||
// Some IG paths bypass the top-level handler and call the per-thread variant.
|
||||
static void (*orig_handleIrisDeltasGrouped)(id self, SEL _cmd, NSArray *deltas);
|
||||
static void new_handleIrisDeltasGrouped(id self, SEL _cmd, NSArray *deltas) {
|
||||
if (!deltas || deltas.count == 0) { orig_handleIrisDeltasGrouped(self, _cmd, deltas); return; }
|
||||
sciSetCurrentDeltaTid(sciDeltaThreadId(deltas.firstObject));
|
||||
@try { orig_handleIrisDeltasGrouped(self, _cmd, deltas); } @catch (__unused id e) {}
|
||||
sciSetCurrentDeltaTid(nil);
|
||||
}
|
||||
|
||||
// ============ ALLOC TRACKING ============
|
||||
|
||||
static id (*orig_msgUpdate_alloc)(id self, SEL _cmd);
|
||||
static id new_msgUpdate_alloc(id self, SEL _cmd) {
|
||||
id instance = orig_msgUpdate_alloc(self, _cmd);
|
||||
if (instance && sciKeepDeletedEnabled()) {
|
||||
NSString *tid = sciGetCurrentDeltaTid();
|
||||
if (tid) {
|
||||
objc_setAssociatedObject(instance, kSCIUpdateThreadIdKey, tid,
|
||||
OBJC_ASSOCIATION_COPY_NONATOMIC);
|
||||
}
|
||||
if (!sciPendingUpdates) sciPendingUpdates = [NSMutableArray array];
|
||||
@synchronized(sciPendingUpdates) {
|
||||
[sciPendingUpdates addObject:instance];
|
||||
while (sciPendingUpdates.count > SCI_PENDING_MAX)
|
||||
[sciPendingUpdates removeObjectAtIndex:0];
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
// ============ REMOTE UNSEND DETECTION ============
|
||||
|
||||
static void sciPruneStaleDeleteForYouKeys() {
|
||||
if (!sciDeleteForYouKeys) return;
|
||||
NSDate *cutoff = [NSDate dateWithTimeIntervalSinceNow:-10.0];
|
||||
for (NSString *k in [sciDeleteForYouKeys allKeys]) {
|
||||
if ([sciDeleteForYouKeys[k] compare:cutoff] == NSOrderedAscending)
|
||||
[sciDeleteForYouKeys removeObjectForKey:k];
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the keys ivar in place — IG's later apply iterates an empty list.
|
||||
static void sciNeuterRemoveUpdate(id update) {
|
||||
@try {
|
||||
Ivar ivar = class_getInstanceVariable([update class], "_removeMessages_messageKeys");
|
||||
if (ivar) object_setIvar(update, ivar, nil);
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
static void sciProcessOneUpdate(id update, NSMutableSet<NSString *> *preserved) {
|
||||
@try {
|
||||
Ivar removeIvar = class_getInstanceVariable([update class], "_removeMessages_messageKeys");
|
||||
if (!removeIvar) return;
|
||||
NSArray *keys = object_getIvar(update, removeIvar);
|
||||
if (!keys || keys.count == 0) return;
|
||||
|
||||
long long reason = -1;
|
||||
Ivar reasonIvar = class_getInstanceVariable([update class], "_removeMessages_reason");
|
||||
if (reasonIvar) {
|
||||
ptrdiff_t off = ivar_getOffset(reasonIvar);
|
||||
reason = *(long long *)((char *)(__bridge void *)update + off);
|
||||
}
|
||||
|
||||
// reason 2 = delete-for-you. Track keys so the reason=0 follow-up
|
||||
// (if any) can be recognised and let through.
|
||||
if (reason == 2) {
|
||||
NSDate *now = [NSDate date];
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid) sciDeleteForYouKeys[sid] = now;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (reason != 0) return;
|
||||
|
||||
// Per-sid intent: sids the user just locally removed via a hooked
|
||||
// mutation processor. Exact, raceless. Consumed on match.
|
||||
{
|
||||
NSMutableSet *pending = sciGetPendingLocalSids();
|
||||
BOOL anyIntent = NO;
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid && [pending containsObject:sid]) { anyIntent = YES; break; }
|
||||
}
|
||||
if (anyIntent) {
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid) [pending removeObject:sid];
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (sciLocalDeleteInProgress) return;
|
||||
|
||||
// Delete-for-you follow-up: any tracked key → let the whole batch through.
|
||||
BOOL anyMatched = NO;
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid && sciDeleteForYouKeys[sid]) { anyMatched = YES; break; }
|
||||
}
|
||||
if (anyMatched) {
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (sid) [sciDeleteForYouKeys removeObjectForKey:sid];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Real remote unsend — preserve, skipping reactions/action-logs and
|
||||
// any message recorded as sent by the current user.
|
||||
NSString *myPk = sciCurrentUserPk();
|
||||
for (id key in keys) {
|
||||
NSString *sid = sciExtractServerId(key);
|
||||
if (!sid) continue;
|
||||
if (sciIsReactionRelatedMessage(sid)) continue;
|
||||
NSString *senderPk = sciGetSenderMap()[sid];
|
||||
if (senderPk && myPk && [senderPk isEqualToString:myPk]) continue;
|
||||
[sciGetPreservedIds() addObject:sid];
|
||||
[preserved addObject:sid];
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
// Classify and neuter every pending update stamped with `tid`. Excluded
|
||||
// threads are passed through untouched.
|
||||
static NSSet<NSString *> *sciNeuterAndPreserveForThread(NSString *tid) {
|
||||
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
|
||||
if (!sciPendingUpdates || tid.length == 0) return preserved;
|
||||
if (!sciDeleteForYouKeys) sciDeleteForYouKeys = [NSMutableDictionary dictionary];
|
||||
sciPruneStaleDeleteForYouKeys();
|
||||
|
||||
BOOL excluded = [SCIExcludedThreads shouldKeepDeletedBeBlockedForThreadId:tid];
|
||||
|
||||
@synchronized(sciPendingUpdates) {
|
||||
NSMutableArray *remaining = [NSMutableArray array];
|
||||
for (id update in sciPendingUpdates) {
|
||||
NSString *stamp = objc_getAssociatedObject(update, kSCIUpdateThreadIdKey);
|
||||
if (![stamp isEqualToString:tid]) {
|
||||
[remaining addObject:update];
|
||||
continue;
|
||||
}
|
||||
if (excluded) continue;
|
||||
NSUInteger before = preserved.count;
|
||||
sciProcessOneUpdate(update, preserved);
|
||||
if (preserved.count > before) sciNeuterRemoveUpdate(update);
|
||||
}
|
||||
[sciPendingUpdates setArray:remaining];
|
||||
}
|
||||
if (preserved.count > 0) sciSavePreservedIds();
|
||||
return preserved;
|
||||
}
|
||||
|
||||
// ============ CACHE UPDATE HOOK ============
|
||||
|
||||
static void sciShowUnsentToast() {
|
||||
UIView *hostView = [UIApplication sharedApplication].keyWindow;
|
||||
if (!hostView) return;
|
||||
|
||||
UIView *pill = [[UIView alloc] init];
|
||||
pill.backgroundColor = [UIColor colorWithRed:0.85 green:0.15 blue:0.15 alpha:0.95];
|
||||
pill.layer.cornerRadius = 18;
|
||||
pill.layer.shadowColor = [UIColor blackColor].CGColor;
|
||||
pill.layer.shadowOpacity = 0.4;
|
||||
pill.layer.shadowOffset = CGSizeMake(0, 2);
|
||||
pill.layer.shadowRadius = 8;
|
||||
pill.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
pill.alpha = 0;
|
||||
|
||||
UILabel *label = [[UILabel alloc] init];
|
||||
label.text = SCILocalized(@"A message was unsent");
|
||||
label.textColor = [UIColor whiteColor];
|
||||
label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
|
||||
label.textAlignment = NSTextAlignmentCenter;
|
||||
label.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[pill addSubview:label];
|
||||
[hostView addSubview:pill];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[pill.topAnchor constraintEqualToAnchor:hostView.safeAreaLayoutGuide.topAnchor constant:8],
|
||||
[pill.centerXAnchor constraintEqualToAnchor:hostView.centerXAnchor],
|
||||
[pill.heightAnchor constraintEqualToConstant:36],
|
||||
[label.centerXAnchor constraintEqualToAnchor:pill.centerXAnchor],
|
||||
[label.centerYAnchor constraintEqualToAnchor:pill.centerYAnchor],
|
||||
[label.leadingAnchor constraintEqualToAnchor:pill.leadingAnchor constant:20],
|
||||
[label.trailingAnchor constraintEqualToAnchor:pill.trailingAnchor constant:-20],
|
||||
]];
|
||||
|
||||
[UIView animateWithDuration:0.3 animations:^{ pill.alpha = 1; }];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
[UIView animateWithDuration:0.3 animations:^{ pill.alpha = 0; } completion:^(BOOL f) {
|
||||
[pill removeFromSuperview];
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
static void sciRefreshVisibleCellIndicators() {
|
||||
Class cellClass = NSClassFromString(@"IGDirectMessageCell");
|
||||
if (!cellClass) return;
|
||||
UIWindow *window = [UIApplication sharedApplication].keyWindow;
|
||||
NSMutableArray *stack = [NSMutableArray arrayWithObject:window];
|
||||
while (stack.count > 0) {
|
||||
UIView *v = stack.lastObject;
|
||||
[stack removeLastObject];
|
||||
if ([v isKindOfClass:cellClass]) {
|
||||
sciUpdateCellIndicator(v);
|
||||
continue;
|
||||
}
|
||||
for (UIView *sub in v.subviews) [stack addObject:sub];
|
||||
}
|
||||
}
|
||||
|
||||
static void (*orig_applyUpdates)(id self, SEL _cmd, id updates, id completion, id userAccess);
|
||||
static void new_applyUpdates(id self, SEL _cmd, id updates, id completion, id userAccess) {
|
||||
if (!sciKeepDeletedEnabled()) {
|
||||
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
|
||||
return;
|
||||
}
|
||||
|
||||
return %orig(arg1);
|
||||
}
|
||||
%end
|
||||
|
||||
%hook IGDirectMessageUpdate
|
||||
+ (id)removeMessageWithMessageId:(id)arg1{
|
||||
if ([SCIUtils getBoolPref:@"keep_deleted_message"]) {
|
||||
arg1 = NULL;
|
||||
NSMutableSet<NSString *> *preserved = [NSMutableSet set];
|
||||
if ([updates isKindOfClass:[NSArray class]]) {
|
||||
for (id tu in (NSArray *)updates) {
|
||||
NSString *tid = nil;
|
||||
@try { tid = [tu valueForKey:@"threadId"]; } @catch (__unused id e) {}
|
||||
if (tid.length == 0) continue;
|
||||
NSSet *p = sciNeuterAndPreserveForThread(tid);
|
||||
if (p.count > 0) [preserved unionSet:p];
|
||||
}
|
||||
}
|
||||
|
||||
orig_applyUpdates(self, _cmd, updates, completion, userAccess);
|
||||
|
||||
if (preserved.count > 0) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
sciRefreshVisibleCellIndicators();
|
||||
if ([SCIUtils getBoolPref:@"unsent_message_toast"]) sciShowUnsentToast();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============ LOCAL DELETE TRACKING ============
|
||||
|
||||
// Hooked on the unsend mutation processor. Reads target sids straight off
|
||||
// _messageKeys for the per-sid intent path; the time-window flag stays as
|
||||
// a safety net for any sid the extraction may miss.
|
||||
static void (*orig_removeMutation_execute)(id self, SEL _cmd, id handler, id pkg);
|
||||
static void new_removeMutation_execute(id self, SEL _cmd, id handler, id pkg) {
|
||||
@try {
|
||||
Ivar mkIvar = class_getInstanceVariable([self class], "_messageKeys");
|
||||
id keys = mkIvar ? object_getIvar(self, mkIvar) : nil;
|
||||
if ([keys isKindOfClass:[NSArray class]]) {
|
||||
static const char *kSidNames[] = {"_serverId", "_messageServerId"};
|
||||
for (id k in (NSArray *)keys) {
|
||||
NSString *sid = nil;
|
||||
for (int ni = 0; ni < 2; ni++) {
|
||||
Ivar sidIvar = class_getInstanceVariable([k class], kSidNames[ni]);
|
||||
if (sidIvar) {
|
||||
id v = object_getIvar(k, sidIvar);
|
||||
if ([v isKindOfClass:[NSString class]] && [(NSString *)v length] > 0) {
|
||||
sid = v; break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sid) [sciGetPendingLocalSids() addObject:sid];
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
|
||||
sciLocalDeleteInProgress = YES;
|
||||
orig_removeMutation_execute(self, _cmd, handler, pkg);
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciLocalDeleteInProgress = NO;
|
||||
});
|
||||
}
|
||||
|
||||
// Sweeps every IGDirect*Outgoing*MutationProcessor and wraps its execute.
|
||||
// IGDirectGenericOutgoingMutationProcessor is the empirical DFY signal —
|
||||
// it fires for "Delete for you" but not for sends (sends use the *GraphQL*
|
||||
// or NonMedia variants). Other classes are wrapped only when their name
|
||||
// suggests removal, as a defensive net. Each class gets its own block so
|
||||
// origImp is captured per-class.
|
||||
static void sciHookAllRemovalMutationProcessors(void) {
|
||||
unsigned int count = 0;
|
||||
Class *all = objc_copyClassList(&count);
|
||||
if (!all) return;
|
||||
SEL execSel = NSSelectorFromString(@"executeWithResultHandler:accessoryPackage:");
|
||||
Class baseUnsend = NSClassFromString(@"IGDirectMessageOutgoingUpdateRemoveMessagesMutationProcessor");
|
||||
for (unsigned int i = 0; i < count; i++) {
|
||||
Class c = all[i];
|
||||
const char *cn = class_getName(c);
|
||||
if (!cn) continue;
|
||||
if (c == baseUnsend) continue;
|
||||
if (strstr(cn, "MutationProcessor") == NULL) continue;
|
||||
if (strstr(cn, "IGDirect") == NULL) continue;
|
||||
if (strstr(cn, "Outgoing") == NULL) continue;
|
||||
Method m = class_getInstanceMethod(c, execSel);
|
||||
if (!m) continue;
|
||||
|
||||
BOOL isDfySignal = (strcmp(cn, "IGDirectGenericOutgoingMutationProcessor") == 0);
|
||||
BOOL looksLikeRemoval = (strstr(cn, "Remove") != NULL ||
|
||||
strstr(cn, "Delete") != NULL ||
|
||||
strstr(cn, "Hide") != NULL ||
|
||||
strstr(cn, "Visibility") != NULL);
|
||||
if (!isDfySignal && !looksLikeRemoval) continue;
|
||||
|
||||
__block IMP origImp = method_getImplementation(m);
|
||||
IMP newImp = imp_implementationWithBlock(^(id self, id handler, id pkg) {
|
||||
sciLocalDeleteInProgress = YES;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciLocalDeleteInProgress = NO;
|
||||
});
|
||||
((void(*)(id, SEL, id, id))origImp)(self, execSel, handler, pkg);
|
||||
});
|
||||
IMP prev = class_replaceMethod(c, execSel, newImp, method_getTypeEncoding(m));
|
||||
if (prev) origImp = prev;
|
||||
}
|
||||
free(all);
|
||||
}
|
||||
|
||||
// ============ VISUAL INDICATOR ============
|
||||
|
||||
static NSString * _Nullable sciGetCellServerId(id cell) {
|
||||
@try {
|
||||
Ivar vmIvar = class_getInstanceVariable([cell class], "_viewModel");
|
||||
if (!vmIvar) return nil;
|
||||
id vm = object_getIvar(cell, vmIvar);
|
||||
if (!vm) return nil;
|
||||
|
||||
SEL metaSel = NSSelectorFromString(@"messageMetadata");
|
||||
if (![vm respondsToSelector:metaSel]) return nil;
|
||||
id meta = ((id(*)(id,SEL))objc_msgSend)(vm, metaSel);
|
||||
if (!meta) return nil;
|
||||
|
||||
Ivar keyIvar = class_getInstanceVariable([meta class], "_key");
|
||||
if (!keyIvar) return nil;
|
||||
id keyObj = object_getIvar(meta, keyIvar);
|
||||
if (!keyObj) return nil;
|
||||
|
||||
Ivar sidIvar = class_getInstanceVariable([keyObj class], "_serverId");
|
||||
if (!sidIvar) return nil;
|
||||
NSString *serverId = object_getIvar(keyObj, sidIvar);
|
||||
return [serverId isKindOfClass:[NSString class]] ? serverId : nil;
|
||||
} @catch(id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static BOOL sciCellIsPreserved(id cell) {
|
||||
NSString *sid = sciGetCellServerId(cell);
|
||||
return sid && [sciGetPreservedIds() containsObject:sid];
|
||||
}
|
||||
|
||||
// Closest squarish ancestor (32-60pt, ~equal w/h) — the visible button wrapper.
|
||||
static UIView *sciFindAccessoryWrapper(UIView *view) {
|
||||
UIView *cur = view;
|
||||
while (cur && cur.superview) {
|
||||
CGRect f = cur.frame;
|
||||
if (f.size.width >= 32 && f.size.width <= 60 &&
|
||||
fabs(f.size.width - f.size.height) < 4) {
|
||||
return cur;
|
||||
}
|
||||
cur = cur.superview;
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
// Hide trailing action buttons on preserved cells — they don't work and
|
||||
// overlap the "Unsent" label.
|
||||
static void sciSetTrailingButtonsHidden(UIView *cell, BOOL hidden) {
|
||||
if (!cell) return;
|
||||
Ivar accIvar = class_getInstanceVariable([cell class], "_tappableAccessoryViews");
|
||||
if (!accIvar) return;
|
||||
id accViews = object_getIvar(cell, accIvar);
|
||||
if (![accViews isKindOfClass:[NSArray class]]) return;
|
||||
for (UIView *v in (NSArray *)accViews) {
|
||||
if (![v isKindOfClass:[UIView class]]) continue;
|
||||
UIView *wrapper = sciFindAccessoryWrapper(v);
|
||||
wrapper.hidden = hidden;
|
||||
if (wrapper != v) v.hidden = hidden;
|
||||
}
|
||||
}
|
||||
|
||||
static void (*orig_addTappableAccessoryView)(id self, SEL _cmd, id view);
|
||||
static void new_addTappableAccessoryView(id self, SEL _cmd, id view) {
|
||||
orig_addTappableAccessoryView(self, _cmd, view);
|
||||
if (sciIndicateUnsentEnabled() && sciCellIsPreserved(self)) {
|
||||
if ([view isKindOfClass:[UIView class]]) {
|
||||
UIView *wrapper = sciFindAccessoryWrapper((UIView *)view);
|
||||
wrapper.hidden = YES;
|
||||
if (wrapper != view) ((UIView *)view).hidden = YES;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void sciUpdateCellIndicator(id cell) {
|
||||
UIView *view = (UIView *)cell;
|
||||
UIView *oldIndicator = [view viewWithTag:SCI_PRESERVED_TAG];
|
||||
Ivar bubbleIvar = class_getInstanceVariable([cell class], "_messageContentContainerView");
|
||||
UIView *bubble = bubbleIvar ? object_getIvar(cell, bubbleIvar) : nil;
|
||||
|
||||
if (!sciIndicateUnsentEnabled()) {
|
||||
if (oldIndicator) [oldIndicator removeFromSuperview];
|
||||
sciSetTrailingButtonsHidden(view, NO);
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *serverId = sciGetCellServerId(cell);
|
||||
BOOL isPreserved = serverId && [sciGetPreservedIds() containsObject:serverId];
|
||||
|
||||
if (!isPreserved) {
|
||||
if (oldIndicator) [oldIndicator removeFromSuperview];
|
||||
sciSetTrailingButtonsHidden(view, NO);
|
||||
return;
|
||||
}
|
||||
|
||||
sciSetTrailingButtonsHidden(view, YES);
|
||||
if (oldIndicator) return;
|
||||
|
||||
UIView *parent = bubble ?: view;
|
||||
UILabel *label = [[UILabel alloc] init];
|
||||
label.tag = SCI_PRESERVED_TAG;
|
||||
label.text = SCILocalized(@"Unsent");
|
||||
label.font = [UIFont italicSystemFontOfSize:10];
|
||||
label.textColor = [UIColor colorWithRed:1.0 green:0.3 blue:0.3 alpha:0.9];
|
||||
label.translatesAutoresizingMaskIntoConstraints = NO;
|
||||
[parent addSubview:label];
|
||||
|
||||
[NSLayoutConstraint activateConstraints:@[
|
||||
[label.leadingAnchor constraintEqualToAnchor:parent.trailingAnchor constant:4],
|
||||
[label.centerYAnchor constraintEqualToAnchor:parent.centerYAnchor],
|
||||
]];
|
||||
}
|
||||
|
||||
static void (*orig_configureCell)(id self, SEL _cmd, id vm, id ringSpec, id launcherSet);
|
||||
static void new_configureCell(id self, SEL _cmd, id vm, id ringSpec, id launcherSet) {
|
||||
orig_configureCell(self, _cmd, vm, ringSpec, launcherSet);
|
||||
// Capture serverId -> senderPk for every configured cell so the apply
|
||||
// hook can identify "from me" messages and skip preserving them.
|
||||
@try {
|
||||
Ivar vmIvar = class_getInstanceVariable([self class], "_viewModel");
|
||||
id vmObj = vmIvar ? object_getIvar(self, vmIvar) : nil;
|
||||
SEL metaSel = NSSelectorFromString(@"messageMetadata");
|
||||
id meta = (vmObj && [vmObj respondsToSelector:metaSel])
|
||||
? ((id(*)(id,SEL))objc_msgSend)(vmObj, metaSel) : nil;
|
||||
if (meta) {
|
||||
Ivar keyIvar = class_getInstanceVariable([meta class], "_key");
|
||||
id keyObj = keyIvar ? object_getIvar(meta, keyIvar) : nil;
|
||||
Ivar sidIvar = keyObj ? class_getInstanceVariable([keyObj class], "_serverId") : NULL;
|
||||
NSString *sid = sidIvar ? object_getIvar(keyObj, sidIvar) : nil;
|
||||
|
||||
Ivar pkIvar = class_getInstanceVariable([meta class], "_senderPk");
|
||||
id pk = pkIvar ? object_getIvar(meta, pkIvar) : nil;
|
||||
if ([sid isKindOfClass:[NSString class]] && [pk isKindOfClass:[NSString class]]) {
|
||||
sciTrackSenderPk(sid, pk);
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
sciUpdateCellIndicator(self);
|
||||
}
|
||||
|
||||
static void (*orig_cellLayoutSubviews)(id self, SEL _cmd);
|
||||
static void new_cellLayoutSubviews(id self, SEL _cmd) {
|
||||
orig_cellLayoutSubviews(self, _cmd);
|
||||
sciUpdateCellIndicator(self);
|
||||
}
|
||||
|
||||
// ============ ACTION LOG TRACKING ============
|
||||
|
||||
// IGDirectThreadActionLog is the local model for "X liked a message" rows.
|
||||
// Recording the message id lets the unsend path skip these as bookkeeping.
|
||||
static id (*orig_actionLogFullInit)(id, SEL, id, id, id, id, id, BOOL, BOOL, id);
|
||||
static id new_actionLogFullInit(id self, SEL _cmd,
|
||||
id message, id title, id textAttributes, id textParts,
|
||||
id actionLogType, BOOL collapsible, BOOL hidden, id genAIMetadata) {
|
||||
id result = orig_actionLogFullInit(self, _cmd, message, title, textAttributes, textParts,
|
||||
actionLogType, collapsible, hidden, genAIMetadata);
|
||||
@try {
|
||||
SEL midSel = @selector(messageId);
|
||||
if ([result respondsToSelector:midSel]) {
|
||||
id mid = ((id(*)(id, SEL))objc_msgSend)(result, midSel);
|
||||
if ([mid isKindOfClass:[NSString class]]) {
|
||||
sciTrackInsertedMessage(mid, @"IGDirectThreadActionLog");
|
||||
}
|
||||
}
|
||||
} @catch(id e) {}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ============ RUNTIME HOOKS ============
|
||||
|
||||
%ctor {
|
||||
Class actionLogCls = NSClassFromString(@"IGDirectThreadActionLog");
|
||||
if (actionLogCls) {
|
||||
SEL fullInit = NSSelectorFromString(@"initWithMessage:title:textAttributes:textParts:actionLogType:collapsible:hidden:genAIMetadata:");
|
||||
if (class_getInstanceMethod(actionLogCls, fullInit))
|
||||
MSHookMessageEx(actionLogCls, fullInit, (IMP)new_actionLogFullInit, (IMP *)&orig_actionLogFullInit);
|
||||
}
|
||||
|
||||
Class msgUpdateClass = NSClassFromString(@"IGDirectMessageUpdate");
|
||||
if (msgUpdateClass) {
|
||||
MSHookMessageEx(object_getClass(msgUpdateClass), @selector(alloc),
|
||||
(IMP)new_msgUpdate_alloc, (IMP *)&orig_msgUpdate_alloc);
|
||||
}
|
||||
|
||||
Class cacheClass = NSClassFromString(@"IGDirectCacheUpdatesApplicator");
|
||||
if (cacheClass) {
|
||||
SEL sel = NSSelectorFromString(@"_applyThreadUpdates:completion:userAccess:");
|
||||
if (class_getInstanceMethod(cacheClass, sel))
|
||||
MSHookMessageEx(cacheClass, sel, (IMP)new_applyUpdates, (IMP *)&orig_applyUpdates);
|
||||
}
|
||||
|
||||
Class irisClass = NSClassFromString(@"IGDirectRealtimeIrisDeltaHandler");
|
||||
if (irisClass) {
|
||||
SEL sel1 = NSSelectorFromString(@"handleIrisDeltas:");
|
||||
if (class_getInstanceMethod(irisClass, sel1))
|
||||
MSHookMessageEx(irisClass, sel1,
|
||||
(IMP)new_handleIrisDeltas, (IMP *)&orig_handleIrisDeltas);
|
||||
|
||||
SEL sel2 = NSSelectorFromString(@"_handleIrisDeltasGroupedByThread:");
|
||||
if (class_getInstanceMethod(irisClass, sel2))
|
||||
MSHookMessageEx(irisClass, sel2,
|
||||
(IMP)new_handleIrisDeltasGrouped, (IMP *)&orig_handleIrisDeltasGrouped);
|
||||
}
|
||||
|
||||
Class cellClass = NSClassFromString(@"IGDirectMessageCell");
|
||||
if (cellClass) {
|
||||
SEL configSel = NSSelectorFromString(@"configureWithViewModel:ringViewSpecFactory:launcherSet:");
|
||||
if (class_getInstanceMethod(cellClass, configSel))
|
||||
MSHookMessageEx(cellClass, configSel,
|
||||
(IMP)new_configureCell, (IMP *)&orig_configureCell);
|
||||
|
||||
SEL layoutSel = @selector(layoutSubviews);
|
||||
MSHookMessageEx(cellClass, layoutSel,
|
||||
(IMP)new_cellLayoutSubviews, (IMP *)&orig_cellLayoutSubviews);
|
||||
|
||||
SEL addAccSel = NSSelectorFromString(@"_addTappableAccessoryView:");
|
||||
if (class_getInstanceMethod(cellClass, addAccSel))
|
||||
MSHookMessageEx(cellClass, addAccSel,
|
||||
(IMP)new_addTappableAccessoryView, (IMP *)&orig_addTappableAccessoryView);
|
||||
}
|
||||
|
||||
Class removeMutationClass = NSClassFromString(@"IGDirectMessageOutgoingUpdateRemoveMessagesMutationProcessor");
|
||||
if (removeMutationClass) {
|
||||
SEL execSel = NSSelectorFromString(@"executeWithResultHandler:accessoryPackage:");
|
||||
if (class_getInstanceMethod(removeMutationClass, execSel))
|
||||
MSHookMessageEx(removeMutationClass, execSel,
|
||||
(IMP)new_removeMutation_execute, (IMP *)&orig_removeMutation_execute);
|
||||
}
|
||||
|
||||
sciHookAllRemovalMutationProcessors();
|
||||
|
||||
if (![SCIUtils getBoolPref:@"indicate_unsent_messages"]) {
|
||||
sciClearPreservedIds();
|
||||
}
|
||||
|
||||
return %orig(arg1);
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,292 @@
|
||||
// Notes actions — copy text, download GIF/audio from notes long-press menu.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import "../../Downloader/Download.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
@interface SCIDownloadDelegate (NotesExt)
|
||||
- (void)downloadDidFinishWithFileURL:(NSURL *)fileURL;
|
||||
@end
|
||||
|
||||
// Find the note model matching a username from visible tray cells
|
||||
static id sciFindNoteForUser(UIView *root, NSString *username) {
|
||||
NSMutableArray *q = [NSMutableArray arrayWithObject:root];
|
||||
int scanned = 0;
|
||||
while (q.count && scanned < 500) {
|
||||
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
|
||||
NSString *cls = NSStringFromClass([cur class]);
|
||||
if (![cls containsString:@"NotesTray"] && ![cls containsString:@"NotesUser"]) {
|
||||
for (UIView *s in cur.subviews) [q addObject:s];
|
||||
continue;
|
||||
}
|
||||
unsigned int cnt = 0;
|
||||
Ivar *ivars = class_copyIvarList([cur class], &cnt);
|
||||
for (unsigned int i = 0; i < cnt; i++) {
|
||||
const char *type = ivar_getTypeEncoding(ivars[i]);
|
||||
if (!type || type[0] != '@') continue;
|
||||
@try {
|
||||
id val = object_getIvar(cur, ivars[i]);
|
||||
if (!val || ![val respondsToSelector:NSSelectorFromString(@"note")]) continue;
|
||||
id note = [val valueForKey:@"note"];
|
||||
if (!note || ![note respondsToSelector:@selector(text)]) continue;
|
||||
NSString *noteUser = nil;
|
||||
@try {
|
||||
id uf = [note valueForKey:@"userFields"];
|
||||
if ([uf respondsToSelector:NSSelectorFromString(@"username")])
|
||||
noteUser = [uf valueForKey:@"username"];
|
||||
} @catch (__unused id e) {}
|
||||
if (!username || [noteUser isEqualToString:username])
|
||||
{ free(ivars); return note; }
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (ivars) free(ivars);
|
||||
for (UIView *s in cur.subviews) [q addObject:s];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Find the cell view model for a specific note, return the cell view
|
||||
static UIView *sciFindCellForNote(UIView *root, id targetNote) {
|
||||
NSMutableArray *q = [NSMutableArray arrayWithObject:root];
|
||||
int scanned = 0;
|
||||
while (q.count && scanned < 300) {
|
||||
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; scanned++;
|
||||
if (![NSStringFromClass([cur class]) containsString:@"Notes"]) {
|
||||
for (UIView *s in cur.subviews) [q addObject:s];
|
||||
continue;
|
||||
}
|
||||
Ivar vmIvar = class_getInstanceVariable([cur class], "viewModel");
|
||||
if (!vmIvar) vmIvar = class_getInstanceVariable([cur class], "_viewModel");
|
||||
if (!vmIvar) { for (UIView *s in cur.subviews) [q addObject:s]; continue; }
|
||||
id vm = object_getIvar(cur, vmIvar);
|
||||
if (!vm || ![vm respondsToSelector:NSSelectorFromString(@"note")]) {
|
||||
for (UIView *s in cur.subviews) [q addObject:s]; continue;
|
||||
}
|
||||
if ([vm valueForKey:@"note"] == targetNote) return cur;
|
||||
for (UIView *s in cur.subviews) [q addObject:s];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Get GIF image from a cell's IGGIFView only
|
||||
static UIImage *sciGIFImageFromCell(UIView *cell) {
|
||||
if (!cell) return nil;
|
||||
NSMutableArray *q = [NSMutableArray arrayWithObject:cell];
|
||||
int s = 0;
|
||||
while (q.count && s < 100) {
|
||||
UIView *cur = q.firstObject; [q removeObjectAtIndex:0]; s++;
|
||||
// Only match IGGIFView — not profile pics or other image views
|
||||
if ([NSStringFromClass([cur class]) containsString:@"GIFView"]) {
|
||||
if ([cur isKindOfClass:[UIImageView class]]) {
|
||||
UIImage *img = [(UIImageView *)cur image];
|
||||
if (img && img.size.width > 20) return img;
|
||||
}
|
||||
// Check subviews of GIFView for the actual image view
|
||||
for (UIView *sub in cur.subviews) {
|
||||
if ([sub isKindOfClass:[UIImageView class]]) {
|
||||
UIImage *img = [(UIImageView *)sub image];
|
||||
if (img && img.size.width > 20) return img;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (UIView *sub in cur.subviews) [q addObject:sub];
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
// Get audio URL from the cell's view model
|
||||
static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) {
|
||||
if (!cell) return nil;
|
||||
Ivar vmIvar = class_getInstanceVariable([cell class], "viewModel");
|
||||
if (!vmIvar) vmIvar = class_getInstanceVariable([cell class], "_viewModel");
|
||||
if (!vmIvar) return nil;
|
||||
id vm = object_getIvar(cell, vmIvar);
|
||||
if (!vm) return nil;
|
||||
|
||||
SEL audioSel = NSSelectorFromString(@"audioTrackWithUserMap:");
|
||||
if (![vm respondsToSelector:audioSel]) return nil;
|
||||
|
||||
@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) {}
|
||||
}
|
||||
}
|
||||
|
||||
} @catch (__unused id e) {}
|
||||
return nil;
|
||||
}
|
||||
|
||||
static SCIDownloadDelegate *sciNoteDl = nil;
|
||||
|
||||
static void (*orig_present)(UIViewController *, SEL, UIViewController *, BOOL, id);
|
||||
static void hook_present(UIViewController *self, SEL _cmd, UIViewController *vc, BOOL animated, id completion) {
|
||||
if (![NSStringFromClass([vc class]) isEqualToString:@"IGActionSheetController"]) {
|
||||
orig_present(self, _cmd, vc, animated, completion);
|
||||
return;
|
||||
}
|
||||
|
||||
Ivar actIvar = class_getInstanceVariable([vc class], "_actions");
|
||||
if (!actIvar) { orig_present(self, _cmd, vc, animated, completion); return; }
|
||||
|
||||
NSArray *actions = object_getIvar(vc, actIvar);
|
||||
BOOL isNotes = NO;
|
||||
for (id a in actions) {
|
||||
if (![a respondsToSelector:@selector(title)]) continue;
|
||||
NSString *t = [a valueForKey:@"title"];
|
||||
if ([t isKindOfClass:[NSString class]] && [t containsString:@"Mute notes"])
|
||||
{ isNotes = YES; break; }
|
||||
}
|
||||
|
||||
if (!isNotes) { orig_present(self, _cmd, vc, animated, completion); return; }
|
||||
|
||||
BOOL copyOnHold = [SCIUtils getBoolPref:@"note_copy_on_hold"];
|
||||
BOOL noteActions = [SCIUtils getBoolPref:@"note_actions"];
|
||||
|
||||
if (!copyOnHold && !noteActions) {
|
||||
orig_present(self, _cmd, vc, animated, completion);
|
||||
return;
|
||||
}
|
||||
|
||||
// Copy text immediately on long press, then let the menu open normally
|
||||
if (copyOnHold) {
|
||||
id note = sciFindNoteForUser(self.view, nil);
|
||||
NSString *text = nil;
|
||||
@try { text = [note valueForKey:@"text"]; } @catch (__unused id e) {}
|
||||
if (text.length) {
|
||||
[[UIPasteboard generalPasteboard] setString:text];
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note text copied")];
|
||||
}
|
||||
}
|
||||
|
||||
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
|
||||
SEL initSel = @selector(initWithTitle:subtitle:style:handler:accessibilityIdentifier:accessibilityLabel:);
|
||||
if (!actionCls || ![actionCls instancesRespondToSelector:initSel]) {
|
||||
orig_present(self, _cmd, vc, animated, completion);
|
||||
return;
|
||||
}
|
||||
|
||||
__weak UIViewController *weakSelf = self;
|
||||
__weak UIViewController *weakVC = vc;
|
||||
void (^handler)(void) = ^{
|
||||
UIViewController *sheet = weakVC;
|
||||
UIViewController *presenter = weakSelf;
|
||||
if (!presenter) return;
|
||||
|
||||
// Read username from the visible sheet
|
||||
NSString *user = nil;
|
||||
if (sheet && sheet.isViewLoaded) {
|
||||
NSMutableArray *lq = [NSMutableArray arrayWithObject:sheet.view];
|
||||
int ls = 0;
|
||||
while (lq.count && ls < 100) {
|
||||
UIView *cur = lq.firstObject; [lq removeObjectAtIndex:0]; ls++;
|
||||
if ([cur isKindOfClass:[UILabel class]]) {
|
||||
NSString *t = [(UILabel *)cur text];
|
||||
if (t.length > 0 && t.length < 30
|
||||
&& ![t isEqualToString:@"Cancel"]
|
||||
&& ![t isEqualToString:@"Report"]
|
||||
&& ![t isEqualToString:@"Mute notes"]
|
||||
&& ![t isEqualToString:@"View profile"]
|
||||
&& ![t isEqualToString:@"Note actions"]) {
|
||||
user = t; break;
|
||||
}
|
||||
}
|
||||
for (UIView *s in cur.subviews) [lq addObject:s];
|
||||
}
|
||||
}
|
||||
|
||||
id note = sciFindNoteForUser(presenter.view, user);
|
||||
if (!note) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Note not found")]; return; }
|
||||
|
||||
NSString *text = nil;
|
||||
@try { text = [note valueForKey:@"text"]; } @catch (__unused id e) {}
|
||||
UIView *cell = sciFindCellForNote(presenter.view, note);
|
||||
|
||||
// Build submenu
|
||||
UIAlertController *alert = [UIAlertController
|
||||
alertControllerWithTitle:nil message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
|
||||
if (text.length) {
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy text")
|
||||
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
[[UIPasteboard generalPasteboard] setString:text];
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Note text copied")];
|
||||
}]];
|
||||
}
|
||||
|
||||
// GIF: save via downloader (respects RyukGram album)
|
||||
UIImage *gifImage = sciGIFImageFromCell(cell);
|
||||
if (gifImage) {
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save GIF")
|
||||
style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
NSData *data = UIImagePNGRepresentation(gifImage);
|
||||
if (!data) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Failed to encode GIF")]; return; }
|
||||
NSString *path = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"note_gif_%@.png", [[NSUUID UUID] UUIDString]]];
|
||||
[data writeToFile:path atomically:YES];
|
||||
sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
|
||||
[sciNoteDl downloadDidFinishWithFileURL:[NSURL fileURLWithPath:path]];
|
||||
}]];
|
||||
}
|
||||
|
||||
// Audio (style=1): download from audioFileURL
|
||||
NSURL *audioURL = sciAudioURLFromCell(cell, note);
|
||||
if (audioURL) {
|
||||
[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];
|
||||
}]];
|
||||
}
|
||||
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel")
|
||||
style:UIAlertActionStyleCancel handler:nil]];
|
||||
|
||||
[sheet dismissViewControllerAnimated:YES completion:^{
|
||||
[presenter presentViewController:alert animated:YES completion:nil];
|
||||
}];
|
||||
};
|
||||
|
||||
typedef id (*InitFn)(id, SEL, id, id, NSInteger, id, id, id);
|
||||
id noteAction = ((InitFn)objc_msgSend)([actionCls alloc], initSel,
|
||||
@"Note actions", nil, (NSInteger)0, handler, nil, nil);
|
||||
|
||||
if (noteActions && noteAction) {
|
||||
NSMutableArray *newActions = [actions mutableCopy];
|
||||
[newActions insertObject:noteAction atIndex:0];
|
||||
object_setIvar(vc, actIvar, [newActions copy]);
|
||||
}
|
||||
|
||||
orig_present(self, _cmd, vc, animated, completion);
|
||||
}
|
||||
|
||||
%ctor {
|
||||
MSHookMessageEx([UIViewController class],
|
||||
@selector(presentViewController:animated:completion:),
|
||||
(IMP)hook_present, (IMP *)&orig_present);
|
||||
}
|
||||
@@ -0,0 +1,719 @@
|
||||
// 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,20 @@
|
||||
// Persistent per-user exclusion list for story read-receipts. Lookup is by
|
||||
// user pk (string). Excluded users get normal seen behavior — your view
|
||||
// shows up in their viewer list as if RyukGram weren't installed.
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@interface SCIExcludedStoryUsers : NSObject
|
||||
|
||||
+ (BOOL)isFeatureEnabled;
|
||||
+ (BOOL)isBlockSelectedMode;
|
||||
|
||||
+ (BOOL)isUserPKExcluded:(NSString *)pk;
|
||||
+ (BOOL)isInList:(NSString *)pk;
|
||||
+ (NSDictionary *)entryForPK:(NSString *)pk;
|
||||
+ (NSArray<NSDictionary *> *)allEntries;
|
||||
+ (NSUInteger)count;
|
||||
|
||||
+ (void)addOrUpdateEntry:(NSDictionary *)entry; // {pk, username, fullName}
|
||||
+ (void)removePK:(NSString *)pk;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,78 @@
|
||||
#import "SCIExcludedStoryUsers.h"
|
||||
#import "../../Utils.h"
|
||||
|
||||
#define SCI_STORY_EXCL_KEY @"excluded_story_users"
|
||||
#define SCI_STORY_INCL_KEY @"included_story_users"
|
||||
|
||||
@implementation SCIExcludedStoryUsers
|
||||
|
||||
+ (BOOL)isFeatureEnabled {
|
||||
return [SCIUtils getBoolPref:@"enable_story_user_exclusions"];
|
||||
}
|
||||
|
||||
+ (BOOL)isBlockSelectedMode {
|
||||
return [[SCIUtils getStringPref:@"story_blocking_mode"] isEqualToString:@"block_selected"];
|
||||
}
|
||||
|
||||
+ (NSString *)activeKey {
|
||||
return [self isBlockSelectedMode] ? SCI_STORY_INCL_KEY : SCI_STORY_EXCL_KEY;
|
||||
}
|
||||
|
||||
+ (NSArray<NSDictionary *> *)allEntries {
|
||||
return [[NSUserDefaults standardUserDefaults] arrayForKey:[self activeKey]] ?: @[];
|
||||
}
|
||||
|
||||
+ (NSUInteger)count { return [self allEntries].count; }
|
||||
|
||||
+ (void)saveAll:(NSArray *)entries {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:[self activeKey]];
|
||||
}
|
||||
|
||||
+ (NSDictionary *)entryForPK:(NSString *)pk {
|
||||
if (pk.length == 0) return nil;
|
||||
for (NSDictionary *e in [self allEntries]) {
|
||||
if ([e[@"pk"] isEqualToString:pk]) return e;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
+ (BOOL)isInList:(NSString *)pk {
|
||||
return [self entryForPK:pk] != nil;
|
||||
}
|
||||
|
||||
+ (BOOL)isUserPKExcluded:(NSString *)pk {
|
||||
if (![self isFeatureEnabled]) return NO;
|
||||
BOOL inList = [self isInList:pk];
|
||||
return [self isBlockSelectedMode] ? !inList : inList;
|
||||
}
|
||||
|
||||
+ (void)addOrUpdateEntry:(NSDictionary *)entry {
|
||||
NSString *pk = entry[@"pk"];
|
||||
if (pk.length == 0) return;
|
||||
NSMutableArray *all = [[self allEntries] mutableCopy];
|
||||
NSInteger existingIdx = -1;
|
||||
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
|
||||
if ([all[i][@"pk"] isEqualToString:pk]) { existingIdx = i; break; }
|
||||
}
|
||||
NSMutableDictionary *merged = [entry mutableCopy];
|
||||
if (existingIdx >= 0) {
|
||||
NSDictionary *old = all[existingIdx];
|
||||
if (old[@"addedAt"]) merged[@"addedAt"] = old[@"addedAt"];
|
||||
all[existingIdx] = merged;
|
||||
} else {
|
||||
if (!merged[@"addedAt"]) merged[@"addedAt"] = @([[NSDate date] timeIntervalSince1970]);
|
||||
[all addObject:merged];
|
||||
}
|
||||
[self saveAll:all];
|
||||
}
|
||||
|
||||
+ (void)removePK:(NSString *)pk {
|
||||
if (pk.length == 0) return;
|
||||
NSMutableArray *all = [[self allEntries] mutableCopy];
|
||||
[all filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
|
||||
return ![e[@"pk"] isEqualToString:pk];
|
||||
}]];
|
||||
[self saveAll:all];
|
||||
}
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,35 @@
|
||||
// Persistent per-chat exclusion list for read-receipt features. Lookup is by
|
||||
// canonical thread id (the MSYS string used by both inbox view models and
|
||||
// IGDirectThreadViewController). Each entry carries a per-thread keep-deleted
|
||||
// override that can force-include or force-exclude regardless of the global
|
||||
// default.
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
typedef NS_ENUM(NSInteger, SCIKeepDeletedOverride) {
|
||||
SCIKeepDeletedOverrideDefault = 0, // follow exclusions_default_keep_deleted
|
||||
SCIKeepDeletedOverrideExcluded = 1, // force keep-deleted OFF for this thread
|
||||
SCIKeepDeletedOverrideIncluded = 2, // force keep-deleted ON for this thread
|
||||
};
|
||||
|
||||
@interface SCIExcludedThreads : NSObject
|
||||
|
||||
+ (BOOL)isFeatureEnabled;
|
||||
+ (BOOL)isBlockSelectedMode; // YES = only listed chats get blocked
|
||||
|
||||
+ (BOOL)isThreadIdExcluded:(NSString *)threadId;
|
||||
+ (BOOL)isInList:(NSString *)threadId; // raw list check, ignores mode
|
||||
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId;
|
||||
+ (NSDictionary *)entryForThreadId:(NSString *)threadId;
|
||||
+ (NSArray<NSDictionary *> *)allEntries;
|
||||
+ (NSUInteger)count;
|
||||
|
||||
+ (void)addOrUpdateEntry:(NSDictionary *)entry;
|
||||
+ (void)removeThreadId:(NSString *)threadId;
|
||||
+ (void)setKeepDeletedOverride:(SCIKeepDeletedOverride)mode forThreadId:(NSString *)threadId;
|
||||
|
||||
// Currently-visible thread, set by IGDirectThreadViewController hooks.
|
||||
+ (void)setActiveThreadId:(NSString *)threadId;
|
||||
+ (NSString *)activeThreadId;
|
||||
+ (BOOL)isActiveThreadExcluded;
|
||||
|
||||
@end
|
||||
@@ -0,0 +1,123 @@
|
||||
#import "SCIExcludedThreads.h"
|
||||
#import "../../Utils.h"
|
||||
|
||||
#define SCI_EXCL_KEY @"excluded_threads"
|
||||
#define SCI_INCL_KEY @"included_threads"
|
||||
|
||||
@implementation SCIExcludedThreads
|
||||
|
||||
static NSString *sciActiveTid = nil;
|
||||
|
||||
+ (BOOL)isFeatureEnabled {
|
||||
return [SCIUtils getBoolPref:@"enable_chat_exclusions"];
|
||||
}
|
||||
|
||||
+ (BOOL)isBlockSelectedMode {
|
||||
return [[SCIUtils getStringPref:@"chat_blocking_mode"] isEqualToString:@"block_selected"];
|
||||
}
|
||||
|
||||
+ (NSString *)activeKey {
|
||||
return [self isBlockSelectedMode] ? SCI_INCL_KEY : SCI_EXCL_KEY;
|
||||
}
|
||||
|
||||
+ (NSArray<NSDictionary *> *)allEntries {
|
||||
return [[NSUserDefaults standardUserDefaults] arrayForKey:[self activeKey]] ?: @[];
|
||||
}
|
||||
|
||||
+ (NSUInteger)count { return [self allEntries].count; }
|
||||
|
||||
+ (void)saveAll:(NSArray *)entries {
|
||||
[[NSUserDefaults standardUserDefaults] setObject:entries forKey:[self activeKey]];
|
||||
}
|
||||
|
||||
+ (NSDictionary *)entryForThreadId:(NSString *)threadId {
|
||||
if (threadId.length == 0) return nil;
|
||||
for (NSDictionary *e in [self allEntries]) {
|
||||
if ([e[@"threadId"] isEqualToString:threadId]) return e;
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
|
||||
+ (BOOL)isInList:(NSString *)threadId {
|
||||
return [self entryForThreadId:threadId] != nil;
|
||||
}
|
||||
|
||||
+ (BOOL)isThreadIdExcluded:(NSString *)threadId {
|
||||
if (![self isFeatureEnabled]) return NO;
|
||||
BOOL inList = [self isInList:threadId];
|
||||
return [self isBlockSelectedMode] ? !inList : inList;
|
||||
}
|
||||
|
||||
+ (BOOL)shouldKeepDeletedBeBlockedForThreadId:(NSString *)threadId {
|
||||
if (![self isFeatureEnabled]) return NO;
|
||||
NSDictionary *e = [self entryForThreadId:threadId];
|
||||
|
||||
if ([self isBlockSelectedMode]) {
|
||||
// block_selected: listed chats are blocked
|
||||
// NOT in list → normal chat → block keep-deleted if default pref is on
|
||||
// IN list → blocked chat → keep-deleted should work (not blocked) unless overridden
|
||||
if (!e) return [SCIUtils getBoolPref:@"exclusions_default_keep_deleted"];
|
||||
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
|
||||
if (mode == SCIKeepDeletedOverrideExcluded) return YES;
|
||||
if (mode == SCIKeepDeletedOverrideIncluded) return NO;
|
||||
return NO; // default: keep-deleted works in blocked chats
|
||||
}
|
||||
|
||||
// block_all: listed chats are excluded (behave normally)
|
||||
if (!e) return NO;
|
||||
SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue];
|
||||
if (mode == SCIKeepDeletedOverrideExcluded) return YES;
|
||||
if (mode == SCIKeepDeletedOverrideIncluded) return NO;
|
||||
return [SCIUtils getBoolPref:@"exclusions_default_keep_deleted"];
|
||||
}
|
||||
|
||||
+ (void)addOrUpdateEntry:(NSDictionary *)entry {
|
||||
NSString *tid = entry[@"threadId"];
|
||||
if (tid.length == 0) return;
|
||||
NSMutableArray *all = [[self allEntries] mutableCopy];
|
||||
NSInteger existingIdx = -1;
|
||||
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
|
||||
if ([all[i][@"threadId"] isEqualToString:tid]) { existingIdx = i; break; }
|
||||
}
|
||||
NSMutableDictionary *merged = [entry mutableCopy];
|
||||
if (existingIdx >= 0) {
|
||||
NSDictionary *old = all[existingIdx];
|
||||
if (old[@"addedAt"]) merged[@"addedAt"] = old[@"addedAt"];
|
||||
if (old[@"keepDeletedOverride"]) merged[@"keepDeletedOverride"] = old[@"keepDeletedOverride"];
|
||||
all[existingIdx] = merged;
|
||||
} else {
|
||||
if (!merged[@"addedAt"]) merged[@"addedAt"] = @([[NSDate date] timeIntervalSince1970]);
|
||||
if (!merged[@"keepDeletedOverride"]) merged[@"keepDeletedOverride"] = @(SCIKeepDeletedOverrideDefault);
|
||||
[all addObject:merged];
|
||||
}
|
||||
[self saveAll:all];
|
||||
}
|
||||
|
||||
+ (void)removeThreadId:(NSString *)threadId {
|
||||
if (threadId.length == 0) return;
|
||||
NSMutableArray *all = [[self allEntries] mutableCopy];
|
||||
[all filterUsingPredicate:[NSPredicate predicateWithBlock:^BOOL(NSDictionary *e, id _) {
|
||||
return ![e[@"threadId"] isEqualToString:threadId];
|
||||
}]];
|
||||
[self saveAll:all];
|
||||
}
|
||||
|
||||
+ (void)setKeepDeletedOverride:(SCIKeepDeletedOverride)mode forThreadId:(NSString *)threadId {
|
||||
if (threadId.length == 0) return;
|
||||
NSMutableArray *all = [[self allEntries] mutableCopy];
|
||||
for (NSInteger i = 0; i < (NSInteger)all.count; i++) {
|
||||
if ([all[i][@"threadId"] isEqualToString:threadId]) {
|
||||
NSMutableDictionary *m = [all[i] mutableCopy];
|
||||
m[@"keepDeletedOverride"] = @(mode);
|
||||
all[i] = m;
|
||||
break;
|
||||
}
|
||||
}
|
||||
[self saveAll:all];
|
||||
}
|
||||
|
||||
+ (void)setActiveThreadId:(NSString *)threadId { sciActiveTid = [threadId copy]; }
|
||||
+ (NSString *)activeThreadId { return sciActiveTid; }
|
||||
+ (BOOL)isActiveThreadExcluded { return [self isThreadIdExcluded:sciActiveTid]; }
|
||||
|
||||
@end
|
||||
@@ -1,96 +1,433 @@
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Tweak.h"
|
||||
#import "../../Utils.h"
|
||||
#import "SCIExcludedThreads.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
// Returns the threadId for an IGDirectThreadViewController, or nil.
|
||||
static NSString *sciThreadIdForVC(id vc) {
|
||||
if (!vc) return nil;
|
||||
@try { return [vc valueForKey:@"threadId"]; } @catch (__unused id e) { return nil; }
|
||||
}
|
||||
|
||||
|
||||
// Seen buttons (in DMs)
|
||||
// - Enables no seen for messages
|
||||
// - Enables unlimited views of DM visual messages
|
||||
%hook IGTallNavigationBarView
|
||||
- (void)setRightBarButtonItems:(NSArray <UIBarButtonItem *> *)items {
|
||||
NSMutableArray *new_items = [[items filteredArrayUsingPredicate:
|
||||
[NSPredicate predicateWithBlock:^BOOL(UIView *value, NSDictionary *_) {
|
||||
if ([SCIUtils getBoolPref:@"hide_reels_blend"]) {
|
||||
return ![value.accessibilityIdentifier isEqualToString:@"blend-button"];
|
||||
}
|
||||
|
||||
return true;
|
||||
BOOL dmSeenToggleEnabled = NO;
|
||||
static NSInteger sciSeenAutoBypassCount = 0;
|
||||
__weak IGDirectThreadViewController *sciActiveThreadVC = nil;
|
||||
|
||||
static BOOL sciIsSeenToggleMode() {
|
||||
return [[SCIUtils getStringPref:@"seen_mode"] isEqualToString:@"toggle"];
|
||||
}
|
||||
|
||||
static BOOL sciAutoInteractEnabled() {
|
||||
if ([SCIExcludedThreads isActiveThreadExcluded]) return NO;
|
||||
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_interact"];
|
||||
}
|
||||
|
||||
BOOL sciAutoTypingEnabled() {
|
||||
if ([SCIExcludedThreads isActiveThreadExcluded]) return NO;
|
||||
return [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"seen_auto_on_typing"];
|
||||
}
|
||||
|
||||
void sciDoAutoSeen(IGDirectThreadViewController *threadVC) {
|
||||
sciSeenAutoBypassCount++;
|
||||
[threadVC markLastMessageAsSeen];
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciSeenAutoBypassCount--;
|
||||
});
|
||||
}
|
||||
|
||||
// ============ AUTO SEEN ON SEND ============
|
||||
|
||||
static void (*orig_setHasSent)(id self, SEL _cmd, BOOL sent);
|
||||
static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
|
||||
orig_setHasSent(self, _cmd, sent);
|
||||
if (!sent || !sciAutoInteractEnabled()) return;
|
||||
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
|
||||
sciDoAutoSeen((IGDirectThreadViewController *)self);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ AUTO SEEN ON TYPING ============
|
||||
// Tracks the visible thread VC so the typing-service hook (in
|
||||
// DisableTypingStatus.x) can mark its messages as seen.
|
||||
|
||||
%hook IGDirectThreadViewController
|
||||
- (void)viewDidAppear:(BOOL)animated {
|
||||
%orig;
|
||||
sciActiveThreadVC = self;
|
||||
}
|
||||
- (void)viewWillDisappear:(BOOL)animated {
|
||||
if (sciActiveThreadVC == self) sciActiveThreadVC = nil;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ NAV BAR BUTTONS ============
|
||||
|
||||
// Re-runs setRightBarButtonItems with the live items. The hook tags its own
|
||||
// buttons so they get stripped and rebuilt against the new exclusion state.
|
||||
void sciRefreshNavBarItems(UIView *anchor) {
|
||||
if (!anchor || ![anchor respondsToSelector:@selector(setRightBarButtonItems:)]) return;
|
||||
NSArray *cur = [(id)anchor performSelector:@selector(rightBarButtonItems)];
|
||||
[(id)anchor performSelector:@selector(setRightBarButtonItems:) withObject:cur];
|
||||
}
|
||||
|
||||
static NSDictionary *sciEntryFromThreadVC(UIViewController *vc);
|
||||
|
||||
// Long-press menu shared by the seen button and the un-exclude button.
|
||||
static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIWindow *window) {
|
||||
BOOL inList = threadId && [SCIExcludedThreads isInList:threadId];
|
||||
BOOL excluded = threadId && [SCIExcludedThreads isThreadIdExcluded:threadId];
|
||||
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
|
||||
BOOL seenFeatureOn = [SCIUtils getBoolPref:@"remove_lastseen"];
|
||||
|
||||
NSMutableArray<UIMenuElement *> *items = [NSMutableArray array];
|
||||
|
||||
if (seenFeatureOn && !excluded) {
|
||||
BOOL toggleMode = sciIsSeenToggleMode();
|
||||
|
||||
// Toggle mode: show toggle action + one-shot mark seen
|
||||
if (toggleMode) {
|
||||
NSString *toggleTitle = dmSeenToggleEnabled ? SCILocalized(@"Disable read receipts") : SCILocalized(@"Enable read receipts");
|
||||
UIImage *toggleImg2 = [UIImage systemImageNamed:@"arrow.triangle.2.circlepath"];
|
||||
UIAction *toggleAction = [UIAction actionWithTitle:toggleTitle image:toggleImg2 identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
dmSeenToggleEnabled = !dmSeenToggleEnabled;
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
|
||||
if (dmSeenToggleEnabled && [nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
[SCIUtils showToastForDuration:2.0 title:dmSeenToggleEnabled ? SCILocalized(@"Read receipts enabled") : SCILocalized(@"Read receipts disabled")];
|
||||
sciRefreshNavBarItems(anchor);
|
||||
}];
|
||||
toggleAction.state = dmSeenToggleEnabled ? UIMenuElementStateOn : UIMenuElementStateOff;
|
||||
[items addObject:toggleAction];
|
||||
|
||||
UIAction *markSeen = [UIAction actionWithTitle:SCILocalized(@"Mark messages as seen")
|
||||
image:[UIImage systemImageNamed:@"checkmark.circle"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked messages as seen")];
|
||||
}];
|
||||
[items addObject:markSeen];
|
||||
} else {
|
||||
// Button mode: just mark seen
|
||||
UIAction *seenAction = [UIAction actionWithTitle:SCILocalized(@"Mark messages as seen")
|
||||
image:[UIImage systemImageNamed:@"checkmark.circle"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked messages as seen")];
|
||||
}];
|
||||
[items addObject:seenAction];
|
||||
}
|
||||
}
|
||||
|
||||
NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude chat");
|
||||
NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude chat");
|
||||
NSString *toggleTitle = inList ? removeLabel : addLabel;
|
||||
UIImage *toggleImg = [UIImage systemImageNamed:inList ? @"eye.fill" : @"eye.slash"];
|
||||
__weak UIView *weakAnchor = anchor;
|
||||
UIAction *toggle = [UIAction actionWithTitle:toggleTitle image:toggleImg identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
if (!threadId) return;
|
||||
if (inList) {
|
||||
[SCIExcludedThreads removeThreadId:threadId];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
|
||||
// In block_selected, removing = normal behavior → mark seen
|
||||
if (blockSelected) {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
}
|
||||
} else {
|
||||
UIViewController *anchorVC = [SCIUtils nearestViewControllerForView:anchor];
|
||||
NSDictionary *entry = sciEntryFromThreadVC(anchorVC);
|
||||
if (!entry) entry = @{ @"threadId": threadId, @"threadName": @"", @"isGroup": @NO, @"users": @[] };
|
||||
[SCIExcludedThreads addOrUpdateEntry:entry];
|
||||
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
|
||||
// In block_all, excluding = normal behavior → mark seen
|
||||
if (!blockSelected) {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:weakAnchor];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
}
|
||||
}
|
||||
sciRefreshNavBarItems(weakAnchor);
|
||||
}];
|
||||
if (excluded) toggle.attributes = UIMenuElementAttributesDestructive;
|
||||
[items addObject:toggle];
|
||||
|
||||
// Unlimited replay toggle
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !excluded) {
|
||||
NSString *replayTitle = dmVisualMsgsViewedButtonEnabled
|
||||
? SCILocalized(@"Visual messages: expiring")
|
||||
: SCILocalized(@"Visual messages: unlimited replay");
|
||||
UIImage *replayImg = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled
|
||||
? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
|
||||
UIAction *replayAction = [UIAction actionWithTitle:replayTitle image:replayImg identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
|
||||
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
|
||||
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
|
||||
sciRefreshNavBarItems(anchor);
|
||||
}];
|
||||
replayAction.state = dmVisualMsgsViewedButtonEnabled ? UIMenuElementStateOff : UIMenuElementStateOn;
|
||||
[items addObject:replayAction];
|
||||
}
|
||||
|
||||
UIAction *openSettings = [UIAction actionWithTitle:SCILocalized(@"Messages settings")
|
||||
image:[UIImage systemImageNamed:@"gear"]
|
||||
identifier:nil
|
||||
handler:^(__kindof UIAction *_) {
|
||||
UIWindow *win = window;
|
||||
if (!win) {
|
||||
for (UIWindow *w in [UIApplication sharedApplication].windows) {
|
||||
if (w.isKeyWindow) { win = w; break; }
|
||||
}
|
||||
}
|
||||
[SCIUtils showSettingsVC:win atTopLevelEntry:@"Messages"];
|
||||
}];
|
||||
[items addObject:openSettings];
|
||||
|
||||
return [UIMenu menuWithTitle:@"" children:items];
|
||||
}
|
||||
|
||||
// Extract thread info from an IGDirectThreadViewController
|
||||
static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
|
||||
if (!vc) return nil;
|
||||
NSString *tid = sciThreadIdForVC(vc);
|
||||
if (!tid) return nil;
|
||||
NSString *name = @"";
|
||||
NSMutableArray *users = [NSMutableArray array];
|
||||
@try {
|
||||
// Try to get thread title from navigation item
|
||||
name = vc.navigationItem.title ?: @"";
|
||||
// Try to get the thread object for user info
|
||||
id thread = [vc valueForKey:@"thread"];
|
||||
if (thread) {
|
||||
id threadUsers = [thread valueForKey:@"users"];
|
||||
if ([threadUsers isKindOfClass:[NSArray class]]) {
|
||||
for (id u in (NSArray *)threadUsers) {
|
||||
NSMutableDictionary *d = [NSMutableDictionary dictionary];
|
||||
@try {
|
||||
id pk = [u valueForKey:@"pk"];
|
||||
id un = [u valueForKey:@"username"];
|
||||
id fn = [u valueForKey:@"fullName"];
|
||||
if (pk) d[@"pk"] = [NSString stringWithFormat:@"%@", pk];
|
||||
if (un) d[@"username"] = [NSString stringWithFormat:@"%@", un];
|
||||
if (fn) d[@"fullName"] = [NSString stringWithFormat:@"%@", fn];
|
||||
} @catch (__unused id e) {}
|
||||
if (d.count) [users addObject:d];
|
||||
}
|
||||
}
|
||||
}
|
||||
} @catch (__unused id e) {}
|
||||
return @{ @"threadId": tid, @"threadName": name, @"isGroup": @NO, @"users": users };
|
||||
}
|
||||
|
||||
%hook IGTallNavigationBarView
|
||||
|
||||
%new - (void)sciAddToListHandler:(UIBarButtonItem *)sender {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
NSDictionary *entry = sciEntryFromThreadVC(nearestVC);
|
||||
if (!entry) return;
|
||||
UIAlertController *alert = [UIAlertController
|
||||
alertControllerWithTitle:SCILocalized(@"Add to block list?")
|
||||
message:SCILocalized(@"Read receipts will be blocked for this chat.")
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
|
||||
[SCIExcludedThreads addOrUpdateEntry:entry];
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
|
||||
sciRefreshNavBarItems(weakSelf);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[nearestVC presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
%new - (void)sciUnexcludeButtonHandler:(UIBarButtonItem *)sender {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
NSString *tid = sciThreadIdForVC(nearestVC);
|
||||
if (!tid) return;
|
||||
|
||||
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
|
||||
NSString *alertTitle = bs ? SCILocalized(@"Remove from block list?") : SCILocalized(@"Un-exclude chat?");
|
||||
NSString *alertMsg = bs ? SCILocalized(@"Read receipts will no longer be blocked for this chat.")
|
||||
: SCILocalized(@"This chat will resume normal read-receipt behavior.");
|
||||
UIAlertController *alert = [UIAlertController
|
||||
alertControllerWithTitle:alertTitle message:alertMsg preferredStyle:UIAlertControllerStyleAlert];
|
||||
__weak typeof(self) weakSelf = self;
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Remove") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
|
||||
[SCIExcludedThreads removeThreadId:tid];
|
||||
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Removed")];
|
||||
sciRefreshNavBarItems(weakSelf);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[nearestVC presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
- (void)setRightBarButtonItems:(NSArray <UIBarButtonItem *> *)items {
|
||||
// Strip our own injected buttons (so re-runs don't dupe) and drop
|
||||
// IGDirectCallButton-backed items when their hide pref is on — some
|
||||
// account variants bundle them into the same platter as our eye btn.
|
||||
BOOL hideVoice = [SCIUtils getBoolPref:@"hide_voice_call_button"];
|
||||
BOOL hideVideo = [SCIUtils getBoolPref:@"hide_video_call_button"];
|
||||
BOOL hideBlend = [SCIUtils getBoolPref:@"hide_reels_blend"];
|
||||
NSMutableArray *new_items = [[items filteredArrayUsingPredicate:
|
||||
[NSPredicate predicateWithBlock:^BOOL(UIBarButtonItem *value, NSDictionary *_) {
|
||||
NSString *aid = value.accessibilityIdentifier;
|
||||
if ([aid isEqualToString:@"sci-seen-btn"] ||
|
||||
[aid isEqualToString:@"sci-unex-btn"] ||
|
||||
[aid isEqualToString:@"sci-visual-btn"]) return NO;
|
||||
if (hideBlend && [aid isEqualToString:@"blend-button"]) return NO;
|
||||
UIView *cv = value.customView;
|
||||
if (cv && [cv isKindOfClass:NSClassFromString(@"IGDirectCallButton")]) {
|
||||
NSString *cvAx = cv.accessibilityIdentifier;
|
||||
if (hideVoice && [cvAx isEqualToString:@"audio-call"]) return NO;
|
||||
if (hideVideo && [cvAx isEqualToString:@"video-chat"]) return NO;
|
||||
}
|
||||
return YES;
|
||||
}]
|
||||
] mutableCopy];
|
||||
|
||||
// Messages seen
|
||||
if ([SCIUtils getBoolPref:@"remove_lastseen"]) {
|
||||
UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.message"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)];
|
||||
// setRightBarButtonItems: runs before viewDidAppear: fires, so the global
|
||||
// active thread id isn't reliable here — read it directly from the VC.
|
||||
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
NSString *navThreadId = sciThreadIdForVC(navNearestVC);
|
||||
BOOL navExcluded = navThreadId && [SCIExcludedThreads isThreadIdExcluded:navThreadId];
|
||||
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:)];
|
||||
seenButton.accessibilityIdentifier = @"sci-seen-btn";
|
||||
if (sciIsSeenToggleMode())
|
||||
[seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
|
||||
seenButton.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
[new_items addObject:seenButton];
|
||||
}
|
||||
|
||||
// DM visual messages viewed
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"]) {
|
||||
UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)];
|
||||
[new_items addObject:dmVisualMsgsViewedButton];
|
||||
// In block_all: show remove button for listed (excluded) chats
|
||||
// In block_selected: show remove button for listed chats, or add button for non-listed chats
|
||||
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
|
||||
BOOL showListButton = [SCIUtils getBoolPref:@"remove_lastseen"] && [SCIUtils getBoolPref:@"chat_quick_list_button"];
|
||||
// block_all + in list: show remove button (no seen button shown for excluded chats)
|
||||
// block_selected + NOT in list: show add-to-list button
|
||||
// block_selected + in list: DON'T show (seen button already visible with long-press menu)
|
||||
BOOL showRemoveBtn = !blockSelected && navInList && navExcluded;
|
||||
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];
|
||||
listBtn.accessibilityIdentifier = @"sci-unex-btn";
|
||||
listBtn.tintColor = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor;
|
||||
listBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window);
|
||||
[new_items addObject:listBtn];
|
||||
}
|
||||
|
||||
if (dmVisualMsgsViewedButtonEnabled) {
|
||||
[dmVisualMsgsViewedButton setTintColor:SCIUtils.SCIColor_Primary];
|
||||
} else {
|
||||
[dmVisualMsgsViewedButton setTintColor:UIColor.labelColor];
|
||||
}
|
||||
// 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:)];
|
||||
replayBtn.accessibilityIdentifier = @"sci-visual-btn";
|
||||
replayBtn.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
|
||||
[new_items addObject:replayBtn];
|
||||
}
|
||||
|
||||
%orig([new_items copy]);
|
||||
}
|
||||
|
||||
// Messages seen button
|
||||
// ============ MESSAGES SEEN BUTTON ============
|
||||
|
||||
%new - (void)seenButtonHandler:(UIBarButtonItem *)sender {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) {
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
|
||||
[SCIUtils showToastForDuration:2.5 title:@"Marked messages as seen"];
|
||||
if (sciIsSeenToggleMode()) {
|
||||
dmSeenToggleEnabled = !dmSeenToggleEnabled;
|
||||
[sender setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor];
|
||||
if (dmSeenToggleEnabled) {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Read receipts enabled")];
|
||||
} else {
|
||||
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Read receipts disabled")];
|
||||
}
|
||||
} else {
|
||||
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) {
|
||||
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
|
||||
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Marked messages as seen")];
|
||||
}
|
||||
}
|
||||
// Rebuild menu so toggle text updates
|
||||
UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self];
|
||||
NSString *tid = sciThreadIdForVC(navNearestVC);
|
||||
sender.menu = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window);
|
||||
}
|
||||
// DM visual messages viewed button
|
||||
%new - (void)dmVisualMsgsViewedButtonHandler:(UIBarButtonItem *)sender {
|
||||
if (dmVisualMsgsViewedButtonEnabled) {
|
||||
dmVisualMsgsViewedButtonEnabled = false;
|
||||
[sender setTintColor:UIColor.labelColor];
|
||||
|
||||
[SCIUtils showToastForDuration:4.5 title:@"Visual messages can be replayed without expiring"];
|
||||
}
|
||||
else {
|
||||
dmVisualMsgsViewedButtonEnabled = true;
|
||||
[sender setTintColor:SCIUtils.SCIColor_Primary];
|
||||
|
||||
[SCIUtils showToastForDuration:4.5 title:@"Visual messages will now expire after viewing"];
|
||||
}
|
||||
%new - (void)sciReplayToggleHandler:(UIBarButtonItem *)sender {
|
||||
dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled;
|
||||
sender.image = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"];
|
||||
sender.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary;
|
||||
[SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled
|
||||
? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")];
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// Messages seen logic
|
||||
// ============ SEEN BLOCKING LOGIC ============
|
||||
|
||||
%hook IGDirectThreadViewListAdapterDataSource
|
||||
- (BOOL)shouldUpdateLastSeenMessage {
|
||||
if ([SCIUtils getBoolPref:@"remove_lastseen"]) {
|
||||
if ([SCIExcludedThreads isActiveThreadExcluded]) return %orig; // excluded → behave normally
|
||||
if (sciIsSeenToggleMode() && dmSeenToggleEnabled) return %orig;
|
||||
if (sciSeenAutoBypassCount > 0) return %orig;
|
||||
return false;
|
||||
}
|
||||
|
||||
return %orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// DM stories viewed logic
|
||||
// ============ DM VISUAL MESSAGES VIEWED LOGIC ============
|
||||
|
||||
%hook IGDirectVisualMessageViewerEventHandler
|
||||
- (void)visualMessageViewerController:(id)arg1 didBeginPlaybackForVisualMessage:(id)arg2 atIndex:(NSInteger)arg3 {
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"]) {
|
||||
// Check if dm stories should be marked as viewed
|
||||
if (dmVisualMsgsViewedButtonEnabled) {
|
||||
%orig;
|
||||
}
|
||||
}
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled
|
||||
&& ![SCIExcludedThreads isActiveThreadExcluded]) return;
|
||||
%orig;
|
||||
}
|
||||
- (void)visualMessageViewerController:(id)arg1 didEndPlaybackForVisualMessage:(id)arg2 atIndex:(NSInteger)arg3 mediaCurrentTime:(CGFloat)arg4 forNavType:(NSInteger)arg5 {
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"]) {
|
||||
// Check if dm stories should be marked as viewed
|
||||
if (dmVisualMsgsViewedButtonEnabled) {
|
||||
%orig;
|
||||
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !dmVisualMsgsViewedButtonEnabled
|
||||
&& ![SCIExcludedThreads isActiveThreadExcluded]) return;
|
||||
%orig;
|
||||
}
|
||||
%end
|
||||
|
||||
// ============ RUNTIME HOOKS ============
|
||||
|
||||
%ctor {
|
||||
Class threadVCClass = NSClassFromString(@"IGDirectThreadViewController");
|
||||
if (threadVCClass) {
|
||||
SEL sentSel = NSSelectorFromString(@"setHasSentAMessageOrUpdate:");
|
||||
if (class_getInstanceMethod(threadVCClass, sentSel)) {
|
||||
MSHookMessageEx(threadVCClass, sentSel,
|
||||
(IMP)new_setHasSent, (IMP *)&orig_setHasSent);
|
||||
}
|
||||
}
|
||||
}
|
||||
%end
|
||||
@@ -0,0 +1,148 @@
|
||||
// Mark seen + advance when replying or reacting to a story.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "StoryHelpers.h"
|
||||
#import <objc/message.h>
|
||||
#import <objc/runtime.h>
|
||||
#import <substrate.h>
|
||||
|
||||
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();
|
||||
}
|
||||
- (void)_send {
|
||||
%orig;
|
||||
if (sciActiveStoryVC) sciOnStoryReply();
|
||||
}
|
||||
%end
|
||||
|
||||
// Composer emoji reaction buttons (forwarded to the Swift footer delegate)
|
||||
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();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
// Swipe-up quick reactions tray
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
Class footerCls = NSClassFromString(@"IGStoryDefaultFooter.IGStoryFullscreenDefaultFooterView");
|
||||
Class qrCtrl = NSClassFromString(@"IGStoryQuickReactions.IGStoryQuickReactionsController");
|
||||
Class qrDelegate = NSClassFromString(@"IGStoryQuickReactionsDelegate.IGStoryQuickReactionsDelegateImpl");
|
||||
if (!footerCls || !qrCtrl || !qrDelegate) return;
|
||||
installed = YES;
|
||||
|
||||
SEL quick = NSSelectorFromString(@"inputView:didTapEmojiQuickReactionButton:");
|
||||
if (class_getInstanceMethod(footerCls, quick))
|
||||
MSHookMessageEx(footerCls, quick, (IMP)new_footerEmojiQuick, (IMP *)&orig_footerEmojiQuick);
|
||||
|
||||
SEL reaction = NSSelectorFromString(@"inputView:didTapEmojiReactionButton:");
|
||||
if (class_getInstanceMethod(footerCls, reaction))
|
||||
MSHookMessageEx(footerCls, reaction, (IMP)new_footerEmojiReaction, (IMP *)&orig_footerEmojiReaction);
|
||||
|
||||
SEL qrSel = NSSelectorFromString(@"quickReactionsView:sourceEmojiButton:didTapEmoji:");
|
||||
if (class_getInstanceMethod(qrCtrl, qrSel))
|
||||
MSHookMessageEx(qrCtrl, qrSel, (IMP)new_qrCtrlDidTapEmoji, (IMP *)&orig_qrCtrlDidTapEmoji);
|
||||
|
||||
SEL qrdSel = NSSelectorFromString(@"storyQuickReactionsController:sourceEmojiButton:didTapEmoji:");
|
||||
if (class_getInstanceMethod(qrDelegate, qrdSel))
|
||||
MSHookMessageEx(qrDelegate, qrdSel, (IMP)new_qrDelegateDidTapEmoji, (IMP *)&orig_qrDelegateDidTapEmoji);
|
||||
}
|
||||
|
||||
%hook IGStoryFullscreenOverlayView
|
||||
- (void)didMoveToWindow {
|
||||
%orig;
|
||||
sciInstallReplyHooks();
|
||||
}
|
||||
%end
|
||||
|
||||
%ctor {
|
||||
sciInstallReplyHooks();
|
||||
}
|
||||
@@ -0,0 +1,772 @@
|
||||
// Send audio/video files as voice messages in DMs.
|
||||
// Injects an Upload Audio item into the DM plus menu, runs the file through a
|
||||
// trim UI, transcodes to AAC m4a (or passes formats IG accepts as-is), then
|
||||
// hands the URL to IG's native voice pipeline.
|
||||
|
||||
#import "../../Utils.h"
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../SCIFFmpeg.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
|
||||
typedef id (*SCIMsgSend)(id, SEL);
|
||||
static inline id sciAF(id obj, SEL sel) {
|
||||
if (!obj || ![obj respondsToSelector:sel]) return nil;
|
||||
return ((SCIMsgSend)objc_msgSend)(obj, sel);
|
||||
}
|
||||
|
||||
static __weak UIViewController *sciAudioThreadVC = nil;
|
||||
static BOOL sciDMMenuPending = NO;
|
||||
|
||||
#pragma mark - Send audio through IG pipeline
|
||||
|
||||
static NSSet<NSString *> *sciPassthroughAudioExts(void);
|
||||
|
||||
static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
|
||||
AVAsset *asset = [AVAsset assetWithURL:audioURL];
|
||||
double duration = CMTimeGetSeconds(asset.duration);
|
||||
// AVFoundation returns 0/NaN for containers it can't parse (e.g. Ogg).
|
||||
if (duration <= 0 || isnan(duration)) duration = 1.0;
|
||||
|
||||
id voiceController = sciAF(threadVC, @selector(voiceController));
|
||||
id voiceRecordVC = nil;
|
||||
if (voiceController) {
|
||||
Ivar vrIvar = class_getInstanceVariable([voiceController class], "_voiceRecordViewController");
|
||||
voiceRecordVC = vrIvar ? object_getIvar(voiceController, vrIvar) : nil;
|
||||
}
|
||||
|
||||
id waveform = nil;
|
||||
Class wfClass = NSClassFromString(@"IGDirectAudioWaveform");
|
||||
NSMutableArray *fallbackArr = [NSMutableArray array];
|
||||
for (int i = 0; i < MAX(10, MIN((int)(duration * 10), 300)); i++)
|
||||
[fallbackArr addObject:@(0.1 + arc4random_uniform(80) / 100.0)];
|
||||
|
||||
if (wfClass) {
|
||||
NSArray *rawData = nil;
|
||||
SEL genSel = @selector(generateWaveformDataFromAudioFile:maxLength:);
|
||||
if ([wfClass respondsToSelector:genSel]) {
|
||||
typedef id (*GenFn)(id, SEL, id, NSInteger);
|
||||
rawData = ((GenFn)objc_msgSend)(wfClass, genSel, audioURL, (NSInteger)(duration * 10));
|
||||
}
|
||||
if (!rawData) rawData = fallbackArr;
|
||||
|
||||
SEL scaleSel = @selector(scaledArrayOfNumbers:);
|
||||
if ([wfClass respondsToSelector:scaleSel]) {
|
||||
typedef id (*ScaleFn)(id, SEL, id);
|
||||
NSArray *scaled = ((ScaleFn)objc_msgSend)(wfClass, scaleSel, rawData);
|
||||
if (scaled) rawData = scaled;
|
||||
}
|
||||
|
||||
SEL initWF = @selector(initWithVolumeRecordingInterval:averageVolume:);
|
||||
if ([wfClass instancesRespondToSelector:initWF]) {
|
||||
typedef id (*InitFn)(id, SEL, double, id);
|
||||
waveform = ((InitFn)objc_msgSend)([wfClass alloc], initWF, 0.1, rawData);
|
||||
}
|
||||
if (!waveform) {
|
||||
waveform = [[wfClass alloc] init];
|
||||
for (NSString *n in @[@"_averageVolume", @"_waveformData", @"_data", @"_volumes"]) {
|
||||
Ivar iv = class_getInstanceVariable(wfClass, [n UTF8String]);
|
||||
if (iv) { object_setIvar(waveform, iv, rawData); break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!waveform) waveform = fallbackArr;
|
||||
|
||||
@try {
|
||||
SEL vmSel = @selector(visualMessageViewerPresentationManagerDidRecordAudioClipWithURL:waveform:duration:entryPoint:toReplyToMessageWithID:);
|
||||
if ([threadVC respondsToSelector:vmSel]) {
|
||||
typedef void (*Fn)(id, SEL, id, id, double, NSInteger, id);
|
||||
((Fn)objc_msgSend)(threadVC, vmSel, audioURL, waveform, duration, (NSInteger)2, nil);
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
|
||||
return;
|
||||
}
|
||||
SEL s7 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:aiVoiceEffectApplied:sendButtonTypeTapped:);
|
||||
if ([threadVC respondsToSelector:s7]) {
|
||||
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger, id, id);
|
||||
((Fn)objc_msgSend)(threadVC, s7, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2, nil, nil);
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
|
||||
return;
|
||||
}
|
||||
SEL s5 = @selector(voiceRecordViewController:didRecordAudioClipWithURL:waveform:duration:entryPoint:);
|
||||
if ([threadVC respondsToSelector:s5]) {
|
||||
typedef void (*Fn)(id, SEL, id, id, id, CGFloat, NSInteger);
|
||||
((Fn)objc_msgSend)(threadVC, s5, voiceRecordVC, audioURL, waveform, (CGFloat)duration, (NSInteger)2);
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
|
||||
return;
|
||||
}
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No voice send method found")];
|
||||
} @catch (NSException *e) {
|
||||
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Send failed: %@"), e.reason]];
|
||||
}
|
||||
}
|
||||
|
||||
#pragma mark - Audio conversion with optional trim
|
||||
|
||||
// Unified failure alert: explains why, lets the user try sending raw, and links
|
||||
// to the GitHub issues page for format requests.
|
||||
static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewController *threadVC) {
|
||||
NSString *fileExt = [[url pathExtension] lowercaseString];
|
||||
NSString *displayExt = (fileExt.length > 0) ? [NSString stringWithFormat:@".%@", fileExt] : @"This file";
|
||||
NSString *title = [NSString stringWithFormat:@"%@ can't be converted", displayExt];
|
||||
NSString *msg = [NSString stringWithFormat:
|
||||
@"iOS audio APIs couldn't process this file%@%@\n\n"
|
||||
"You can try sending it to Instagram as-is — IG's server may accept it "
|
||||
"(e.g. Opus/Ogg from web users), or it may silently fail.\n\n"
|
||||
"If you'd like RyukGram to support this format natively, open an issue:\n"
|
||||
"https://github.com/faroukbmiled/RyukGram/issues",
|
||||
reason.length > 0 ? @":\n" : @".",
|
||||
reason.length > 0 ? reason : @""];
|
||||
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:title
|
||||
message:msg
|
||||
preferredStyle:UIAlertControllerStyleAlert];
|
||||
__weak UIViewController *weakVC = threadVC;
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Send anyway") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
|
||||
sciSendAudioFile(url, weakVC);
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Open GitHub") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
|
||||
[[UIApplication sharedApplication]
|
||||
openURL:[NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram/issues"]
|
||||
options:@{} completionHandler:nil];
|
||||
}]];
|
||||
[alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]];
|
||||
|
||||
UIViewController *presenter = threadVC ?: [UIApplication sharedApplication].keyWindow.rootViewController;
|
||||
[presenter presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// FFmpeg path: any format → AAC M4A, with optional trim
|
||||
static void sciFFmpegConvertAndSend(NSURL *url, UIViewController *threadVC, CMTimeRange trimRange) {
|
||||
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
|
||||
CMTimeGetSeconds(trimRange.duration) > 0;
|
||||
|
||||
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"rg_ffaudio_%u.m4a", arc4random()]];
|
||||
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
|
||||
|
||||
NSMutableString *cmd = [NSMutableString stringWithFormat:@"-y -i \"%@\"", url.path];
|
||||
if (hasTrim) {
|
||||
double ss = CMTimeGetSeconds(trimRange.start);
|
||||
double dur = CMTimeGetSeconds(trimRange.duration);
|
||||
[cmd appendFormat:@" -ss %.3f -t %.3f", ss, dur];
|
||||
}
|
||||
[cmd appendFormat:@" -vn -c:a aac -b:a 128k -ar 44100 -ac 1 \"%@\"", out];
|
||||
|
||||
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
|
||||
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
|
||||
} else {
|
||||
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
|
||||
}
|
||||
});
|
||||
}];
|
||||
}
|
||||
|
||||
// AVFoundation fallback for iOS-native formats
|
||||
static void sciAVFoundationConvertAndSend(NSURL *url, UIViewController *threadVC, CMTimeRange trimRange) {
|
||||
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
|
||||
CMTimeGetSeconds(trimRange.duration) > 0;
|
||||
|
||||
dispatch_async(dispatch_get_global_queue(0, 0), ^{
|
||||
AVAsset *asset = [AVAsset assetWithURL:url];
|
||||
AVAssetTrack *audioTrack = [[asset tracksWithMediaType:AVMediaTypeAudio] firstObject];
|
||||
if (!audioTrack) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
sciShowUnsupportedAlert(url, @"no audio track could be read", threadVC);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
AVMutableComposition *comp = [AVMutableComposition composition];
|
||||
AVMutableCompositionTrack *ct = [comp addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
|
||||
|
||||
CMTimeRange sourceRange = hasTrim ? trimRange : CMTimeRangeMake(kCMTimeZero, asset.duration);
|
||||
NSError *insertErr = nil;
|
||||
[ct insertTimeRange:sourceRange ofTrack:audioTrack atTime:kCMTimeZero error:&insertErr];
|
||||
if (insertErr) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
sciShowUnsupportedAlert(url, insertErr.localizedDescription, threadVC);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"rg_exp_%u.m4a", arc4random()]];
|
||||
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
|
||||
|
||||
AVAssetExportSession *exp = [AVAssetExportSession exportSessionWithAsset:comp presetName:AVAssetExportPresetAppleM4A];
|
||||
exp.outputURL = [NSURL fileURLWithPath:out];
|
||||
exp.outputFileType = AVFileTypeAppleM4A;
|
||||
|
||||
[exp exportAsynchronouslyWithCompletionHandler:^{
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (exp.status == AVAssetExportSessionStatusCompleted) {
|
||||
sciSendAudioFile([NSURL fileURLWithPath:out], threadVC);
|
||||
} else {
|
||||
sciShowUnsupportedAlert(url, exp.error.localizedDescription, threadVC);
|
||||
}
|
||||
});
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
|
||||
BOOL hasTrim = CMTIMERANGE_IS_VALID(trimRange) && !CMTIMERANGE_IS_EMPTY(trimRange) &&
|
||||
CMTimeGetSeconds(trimRange.duration) > 0;
|
||||
|
||||
// Passthrough formats IG accepts directly (no conversion needed, trim ignored)
|
||||
NSString *ext = [[url pathExtension] lowercaseString];
|
||||
if (!isVideo && !hasTrim && [sciPassthroughAudioExts() containsObject:ext]) {
|
||||
sciSendAudioFile(url, threadVC);
|
||||
return;
|
||||
}
|
||||
|
||||
[SCIUtils showToastForDuration:1.5 title:isVideo ? SCILocalized(@"Extracting audio...") : SCILocalized(@"Converting...")];
|
||||
|
||||
// FFmpeg handles any format + video→audio extraction
|
||||
if ([SCIFFmpeg isAvailable]) {
|
||||
sciFFmpegConvertAndSend(url, threadVC, trimRange);
|
||||
return;
|
||||
}
|
||||
|
||||
// Passthrough without trim when no FFmpeg
|
||||
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
|
||||
sciSendAudioFile(url, threadVC);
|
||||
return;
|
||||
}
|
||||
|
||||
// AVFoundation fallback
|
||||
sciAVFoundationConvertAndSend(url, threadVC, trimRange);
|
||||
}
|
||||
|
||||
// Formats IG accepts as-is (no conversion needed)
|
||||
static NSSet<NSString *> *sciPassthroughAudioExts(void) {
|
||||
static NSSet *set;
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
set = [NSSet setWithArray:@[@"m4a", @"aac", @"ogg", @"opus"]];
|
||||
});
|
||||
return set;
|
||||
}
|
||||
|
||||
static void sciConvertAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo) {
|
||||
NSString *ext = [[url pathExtension] lowercaseString];
|
||||
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
|
||||
sciSendAudioFile(url, threadVC);
|
||||
return;
|
||||
}
|
||||
sciExportAndSend(url, threadVC, isVideo, kCMTimeRangeInvalid);
|
||||
}
|
||||
|
||||
#pragma mark - Audio/Video trim VC
|
||||
|
||||
@interface SCITrimViewController : UIViewController
|
||||
@property (nonatomic, strong) NSURL *mediaURL;
|
||||
@property (nonatomic, assign) BOOL isVideo;
|
||||
@property (nonatomic, strong) AVPlayer *player;
|
||||
@property (nonatomic, strong) UILabel *durationLabel;
|
||||
@property (nonatomic, strong) UILabel *rangeLabel;
|
||||
@property (nonatomic, strong) UIView *trackView;
|
||||
@property (nonatomic, strong) UIView *selectedRange;
|
||||
@property (nonatomic, strong) UIView *leftHandle;
|
||||
@property (nonatomic, strong) UIView *rightHandle;
|
||||
@property (nonatomic, strong) UIView *playhead;
|
||||
@property (nonatomic, strong) UIButton *playBtn;
|
||||
@property (nonatomic, assign) double totalDuration;
|
||||
@property (nonatomic, assign) double startTime;
|
||||
@property (nonatomic, assign) double endTime;
|
||||
@property (nonatomic, assign) BOOL isPlaying;
|
||||
@property (nonatomic, strong) id timeObserver;
|
||||
@property (nonatomic, weak) UIViewController *threadVC;
|
||||
@end
|
||||
|
||||
static const CGFloat kTrackH = 56.0;
|
||||
static const CGFloat kHandleW = 16.0;
|
||||
static const CGFloat kHandleHitW = 48.0; // wide touch target
|
||||
static const CGFloat kTrackMargin = 24.0;
|
||||
|
||||
@implementation SCITrimViewController
|
||||
|
||||
- (void)viewDidLoad {
|
||||
[super viewDidLoad];
|
||||
self.view.backgroundColor = [UIColor colorWithRed:0.06 green:0.06 blue:0.08 alpha:1.0];
|
||||
self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark;
|
||||
|
||||
AVAsset *asset = [AVAsset assetWithURL:self.mediaURL];
|
||||
self.totalDuration = CMTimeGetSeconds(asset.duration);
|
||||
self.startTime = 0;
|
||||
self.endTime = self.totalDuration;
|
||||
|
||||
CGFloat w = self.view.bounds.size.width;
|
||||
CGFloat safeBottom = 34; // approximate safe area
|
||||
CGFloat bottomY = self.view.bounds.size.height - safeBottom;
|
||||
|
||||
// ── send button (bottom, full width, thumb-reachable) ──
|
||||
UIButton *sendBtn = [UIButton buttonWithType:UIButtonTypeSystem];
|
||||
sendBtn.frame = CGRectMake(kTrackMargin, bottomY - 56, w - kTrackMargin * 2, 50);
|
||||
sendBtn.backgroundColor = [UIColor systemBlueColor];
|
||||
sendBtn.layer.cornerRadius = 14;
|
||||
[sendBtn setTitle:SCILocalized(@"Send Audio") forState:UIControlStateNormal];
|
||||
[sendBtn setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal];
|
||||
sendBtn.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
|
||||
[sendBtn addTarget:self action:@selector(sendTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:sendBtn];
|
||||
|
||||
// ── play/pause button ──
|
||||
CGFloat playY = sendBtn.frame.origin.y - 64;
|
||||
self.playBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
self.playBtn.frame = CGRectMake(w / 2 - 28, playY, 56, 56);
|
||||
self.playBtn.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.1];
|
||||
self.playBtn.layer.cornerRadius = 28;
|
||||
UIImageSymbolConfiguration *playCfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
|
||||
[self.playBtn setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:playCfg] forState:UIControlStateNormal];
|
||||
self.playBtn.tintColor = [UIColor whiteColor];
|
||||
[self.playBtn addTarget:self action:@selector(playPauseTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:self.playBtn];
|
||||
|
||||
// ── range label (above play button) ──
|
||||
self.rangeLabel = [[UILabel alloc] initWithFrame:CGRectMake(kTrackMargin, playY - 36, w - kTrackMargin * 2, 24)];
|
||||
self.rangeLabel.textColor = [UIColor whiteColor];
|
||||
self.rangeLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightMedium];
|
||||
self.rangeLabel.textAlignment = NSTextAlignmentCenter;
|
||||
[self.view addSubview:self.rangeLabel];
|
||||
|
||||
// ── track (range selector) ──
|
||||
CGFloat trackY = self.rangeLabel.frame.origin.y - kTrackH - 20;
|
||||
|
||||
// track background
|
||||
self.trackView = [[UIView alloc] initWithFrame:CGRectMake(kTrackMargin, trackY, w - kTrackMargin * 2, kTrackH)];
|
||||
self.trackView.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.08];
|
||||
self.trackView.layer.cornerRadius = 10;
|
||||
self.trackView.clipsToBounds = YES;
|
||||
[self.view addSubview:self.trackView];
|
||||
|
||||
// generate waveform bars
|
||||
[self generateWaveformBars];
|
||||
|
||||
// selected range overlay
|
||||
self.selectedRange = [[UIView alloc] initWithFrame:CGRectMake(0, 0, self.trackView.bounds.size.width, kTrackH)];
|
||||
self.selectedRange.backgroundColor = [UIColor colorWithRed:0.35 green:0.5 blue:1.0 alpha:0.25];
|
||||
self.selectedRange.userInteractionEnabled = NO;
|
||||
self.selectedRange.layer.cornerRadius = 10;
|
||||
[self.trackView addSubview:self.selectedRange];
|
||||
|
||||
// left handle — wide invisible hit area with narrow visual handle inside
|
||||
self.leftHandle = [[UIView alloc] initWithFrame:CGRectMake(-kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20)];
|
||||
self.leftHandle.backgroundColor = [UIColor clearColor];
|
||||
self.leftHandle.userInteractionEnabled = YES;
|
||||
UIView *leftVisual = [self createHandleVisual];
|
||||
leftVisual.frame = CGRectMake((kHandleHitW - kHandleW) / 2, 10, kHandleW, kTrackH);
|
||||
leftVisual.layer.maskedCorners = kCALayerMinXMinYCorner | kCALayerMinXMaxYCorner;
|
||||
leftVisual.tag = 7001;
|
||||
[self.leftHandle addSubview:leftVisual];
|
||||
[self.trackView addSubview:self.leftHandle];
|
||||
|
||||
UIPanGestureRecognizer *leftPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(leftHandlePan:)];
|
||||
[self.leftHandle addGestureRecognizer:leftPan];
|
||||
|
||||
// right handle
|
||||
CGFloat trackW = self.trackView.bounds.size.width;
|
||||
self.rightHandle = [[UIView alloc] initWithFrame:CGRectMake(trackW - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20)];
|
||||
self.rightHandle.backgroundColor = [UIColor clearColor];
|
||||
self.rightHandle.userInteractionEnabled = YES;
|
||||
UIView *rightVisual = [self createHandleVisual];
|
||||
rightVisual.frame = CGRectMake((kHandleHitW - kHandleW) / 2, 10, kHandleW, kTrackH);
|
||||
rightVisual.layer.maskedCorners = kCALayerMaxXMinYCorner | kCALayerMaxXMaxYCorner;
|
||||
rightVisual.tag = 7001;
|
||||
[self.rightHandle addSubview:rightVisual];
|
||||
[self.trackView addSubview:self.rightHandle];
|
||||
|
||||
UIPanGestureRecognizer *rightPan = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(rightHandlePan:)];
|
||||
[self.rightHandle addGestureRecognizer:rightPan];
|
||||
|
||||
// playhead
|
||||
self.playhead = [[UIView alloc] initWithFrame:CGRectMake(0, 2, 2.5, kTrackH - 4)];
|
||||
self.playhead.backgroundColor = [UIColor whiteColor];
|
||||
self.playhead.layer.cornerRadius = 1.25;
|
||||
self.playhead.hidden = YES;
|
||||
[self.trackView addSubview:self.playhead];
|
||||
|
||||
// ── top area: icon + file info ──
|
||||
CGFloat topAreaY = 70;
|
||||
UIImageSymbolConfiguration *iconCfg = [UIImageSymbolConfiguration configurationWithPointSize:36 weight:UIImageSymbolWeightLight];
|
||||
UIImageView *icon = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:self.isVideo ? @"video.fill" : @"waveform"
|
||||
withConfiguration:iconCfg]];
|
||||
icon.tintColor = [UIColor colorWithWhite:1.0 alpha:0.5];
|
||||
icon.contentMode = UIViewContentModeScaleAspectFit;
|
||||
icon.frame = CGRectMake(w / 2 - 24, topAreaY, 48, 48);
|
||||
[self.view addSubview:icon];
|
||||
|
||||
UILabel *nameLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, topAreaY + 56, w - 40, 20)];
|
||||
nameLabel.text = [self.mediaURL lastPathComponent];
|
||||
nameLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.4];
|
||||
nameLabel.font = [UIFont systemFontOfSize:13];
|
||||
nameLabel.textAlignment = NSTextAlignmentCenter;
|
||||
nameLabel.lineBreakMode = NSLineBreakByTruncatingMiddle;
|
||||
[self.view addSubview:nameLabel];
|
||||
|
||||
self.durationLabel = [[UILabel alloc] initWithFrame:CGRectMake(20, topAreaY + 78, w - 40, 20)];
|
||||
self.durationLabel.textColor = [UIColor colorWithWhite:1.0 alpha:0.3];
|
||||
self.durationLabel.font = [UIFont systemFontOfSize:12];
|
||||
self.durationLabel.textAlignment = NSTextAlignmentCenter;
|
||||
self.durationLabel.text = [NSString stringWithFormat:SCILocalized(@"Total: %@"), [self formatTime:self.totalDuration]];
|
||||
[self.view addSubview:self.durationLabel];
|
||||
|
||||
// ── cancel X button (top-left) ──
|
||||
UIButton *cancelBtn = [UIButton buttonWithType:UIButtonTypeCustom];
|
||||
cancelBtn.frame = CGRectMake(12, 50, 36, 36);
|
||||
UIImageSymbolConfiguration *xCfg = [UIImageSymbolConfiguration configurationWithPointSize:16 weight:UIImageSymbolWeightMedium];
|
||||
[cancelBtn setImage:[UIImage systemImageNamed:@"xmark" withConfiguration:xCfg] forState:UIControlStateNormal];
|
||||
cancelBtn.tintColor = [UIColor colorWithWhite:1.0 alpha:0.6];
|
||||
cancelBtn.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.08];
|
||||
cancelBtn.layer.cornerRadius = 18;
|
||||
[cancelBtn addTarget:self action:@selector(cancelTapped) forControlEvents:UIControlEventTouchUpInside];
|
||||
[self.view addSubview:cancelBtn];
|
||||
|
||||
[self updateRangeUI];
|
||||
}
|
||||
|
||||
- (void)generateWaveformBars {
|
||||
CGFloat trackW = self.trackView.bounds.size.width;
|
||||
int barCount = (int)(trackW / 4);
|
||||
CGFloat barW = 2.0;
|
||||
CGFloat gap = (trackW - barCount * barW) / (barCount - 1);
|
||||
|
||||
for (int i = 0; i < barCount; i++) {
|
||||
CGFloat h = 8 + arc4random_uniform((unsigned int)(kTrackH - 16));
|
||||
CGFloat x = i * (barW + gap);
|
||||
UIView *bar = [[UIView alloc] initWithFrame:CGRectMake(x, (kTrackH - h) / 2, barW, h)];
|
||||
bar.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.15];
|
||||
bar.layer.cornerRadius = 1;
|
||||
bar.tag = 8000 + i;
|
||||
[self.trackView insertSubview:bar atIndex:0];
|
||||
}
|
||||
}
|
||||
|
||||
- (UIView *)createHandleVisual {
|
||||
UIView *handle = [[UIView alloc] init];
|
||||
handle.backgroundColor = [UIColor systemBlueColor];
|
||||
handle.layer.cornerRadius = 4;
|
||||
handle.userInteractionEnabled = NO;
|
||||
|
||||
UIView *grip = [[UIView alloc] initWithFrame:CGRectMake(5, kTrackH / 2 - 8, 6, 16)];
|
||||
grip.userInteractionEnabled = NO;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
UIView *line = [[UIView alloc] initWithFrame:CGRectMake(i * 4, 0, 1.5, 16)];
|
||||
line.backgroundColor = [UIColor colorWithWhite:1.0 alpha:0.7];
|
||||
line.layer.cornerRadius = 0.75;
|
||||
[grip addSubview:line];
|
||||
}
|
||||
[handle addSubview:grip];
|
||||
return handle;
|
||||
}
|
||||
|
||||
- (CGFloat)timeToX:(double)time {
|
||||
CGFloat trackW = self.trackView.bounds.size.width;
|
||||
return (time / self.totalDuration) * trackW;
|
||||
}
|
||||
|
||||
- (double)xToTime:(CGFloat)x {
|
||||
CGFloat trackW = self.trackView.bounds.size.width;
|
||||
double t = (x / trackW) * self.totalDuration;
|
||||
return MAX(0, MIN(t, self.totalDuration));
|
||||
}
|
||||
|
||||
- (void)leftHandlePan:(UIPanGestureRecognizer *)pan {
|
||||
CGPoint translation = [pan translationInView:self.trackView];
|
||||
[pan setTranslation:CGPointZero inView:self.trackView];
|
||||
|
||||
CGFloat centerX = CGRectGetMidX(self.leftHandle.frame) + translation.x;
|
||||
double newTime = [self xToTime:centerX];
|
||||
newTime = MAX(0, MIN(newTime, self.endTime - 0.5));
|
||||
self.startTime = newTime;
|
||||
[self updateRangeUI];
|
||||
}
|
||||
|
||||
- (void)rightHandlePan:(UIPanGestureRecognizer *)pan {
|
||||
CGPoint translation = [pan translationInView:self.trackView];
|
||||
[pan setTranslation:CGPointZero inView:self.trackView];
|
||||
|
||||
CGFloat centerX = CGRectGetMidX(self.rightHandle.frame) + translation.x;
|
||||
double newTime = [self xToTime:centerX];
|
||||
newTime = MIN(self.totalDuration, MAX(newTime, self.startTime + 0.5));
|
||||
self.endTime = newTime;
|
||||
[self updateRangeUI];
|
||||
}
|
||||
|
||||
- (void)updateRangeUI {
|
||||
CGFloat leftX = [self timeToX:self.startTime];
|
||||
CGFloat rightX = [self timeToX:self.endTime];
|
||||
|
||||
self.leftHandle.frame = CGRectMake(leftX - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20);
|
||||
self.rightHandle.frame = CGRectMake(rightX - kHandleHitW / 2, -10, kHandleHitW, kTrackH + 20);
|
||||
self.selectedRange.frame = CGRectMake(leftX, 0, rightX - leftX, kTrackH);
|
||||
|
||||
double sel = self.endTime - self.startTime;
|
||||
self.rangeLabel.text = [NSString stringWithFormat:@"%@ — %@ (%@)",
|
||||
[self formatTime:self.startTime], [self formatTime:self.endTime], [self formatDuration:sel]];
|
||||
}
|
||||
|
||||
- (NSString *)formatTime:(double)secs {
|
||||
int m = (int)secs / 60;
|
||||
int s = (int)secs % 60;
|
||||
return [NSString stringWithFormat:@"%d:%02d", m, s];
|
||||
}
|
||||
|
||||
- (NSString *)formatDuration:(double)secs {
|
||||
if (secs < 60) return [NSString stringWithFormat:@"%.1fs", secs];
|
||||
int m = (int)secs / 60;
|
||||
double s = secs - m * 60;
|
||||
return [NSString stringWithFormat:@"%dm %.0fs", m, s];
|
||||
}
|
||||
|
||||
- (void)playPauseTapped {
|
||||
if (self.isPlaying) {
|
||||
[self stopPlayback];
|
||||
} else {
|
||||
[self startPlayback];
|
||||
}
|
||||
}
|
||||
|
||||
- (void)startPlayback {
|
||||
[self stopPlayback];
|
||||
self.player = [AVPlayer playerWithURL:self.mediaURL];
|
||||
[self.player seekToTime:CMTimeMakeWithSeconds(self.startTime, 600) toleranceBefore:kCMTimeZero toleranceAfter:kCMTimeZero];
|
||||
self.playhead.hidden = NO;
|
||||
self.isPlaying = YES;
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
|
||||
[self.playBtn setImage:[UIImage systemImageNamed:@"pause.fill" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
|
||||
__weak SCITrimViewController *weakSelf = self;
|
||||
self.timeObserver = [self.player addPeriodicTimeObserverForInterval:CMTimeMakeWithSeconds(0.05, 600) queue:dispatch_get_main_queue() usingBlock:^(CMTime time) {
|
||||
SCITrimViewController *s = weakSelf;
|
||||
if (!s) return;
|
||||
double current = CMTimeGetSeconds(time);
|
||||
if (current >= s.endTime) {
|
||||
[s stopPlayback];
|
||||
return;
|
||||
}
|
||||
CGFloat x = [s timeToX:current];
|
||||
s.playhead.frame = CGRectMake(x - 1.25, 2, 2.5, kTrackH - 4);
|
||||
}];
|
||||
|
||||
[self.player play];
|
||||
}
|
||||
|
||||
- (void)stopPlayback {
|
||||
if (self.timeObserver && self.player) {
|
||||
[self.player removeTimeObserver:self.timeObserver];
|
||||
}
|
||||
self.timeObserver = nil;
|
||||
[self.player pause];
|
||||
self.player = nil;
|
||||
self.isPlaying = NO;
|
||||
self.playhead.hidden = YES;
|
||||
|
||||
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightMedium];
|
||||
[self.playBtn setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:cfg] forState:UIControlStateNormal];
|
||||
}
|
||||
|
||||
- (void)cancelTapped {
|
||||
[self stopPlayback];
|
||||
[self dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
- (void)sendTapped {
|
||||
[self stopPlayback];
|
||||
double dur = self.endTime - self.startTime;
|
||||
if (dur < 0.5) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Selection too short (min 0.5s)")];
|
||||
return;
|
||||
}
|
||||
|
||||
UIViewController *tvc = self.threadVC;
|
||||
NSURL *url = self.mediaURL;
|
||||
BOOL video = self.isVideo;
|
||||
CMTimeRange trimRange = CMTimeRangeMake(CMTimeMakeWithSeconds(self.startTime, 600), CMTimeMakeWithSeconds(dur, 600));
|
||||
|
||||
[self dismissViewControllerAnimated:YES completion:^{
|
||||
if (tvc) sciExportAndSend(url, tvc, video, trimRange);
|
||||
}];
|
||||
}
|
||||
|
||||
- (UIStatusBarStyle)preferredStatusBarStyle { return UIStatusBarStyleLightContent; }
|
||||
|
||||
@end
|
||||
|
||||
static void sciShowTrimVC(NSURL *url, BOOL isVideo, UIViewController *threadVC) {
|
||||
SCITrimViewController *trimVC = [[SCITrimViewController alloc] init];
|
||||
trimVC.mediaURL = url;
|
||||
trimVC.isVideo = isVideo;
|
||||
trimVC.threadVC = threadVC;
|
||||
trimVC.modalPresentationStyle = UIModalPresentationFullScreen;
|
||||
[threadVC presentViewController:trimVC animated:YES completion:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Show picker options
|
||||
|
||||
static void sciShowUploadAudioOptions(UIViewController *threadVC) {
|
||||
sciAudioThreadVC = threadVC;
|
||||
|
||||
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Upload Audio")
|
||||
message:nil
|
||||
preferredStyle:UIAlertControllerStyleActionSheet];
|
||||
|
||||
__weak UIViewController *weakVC = threadVC;
|
||||
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Audio/Video from Files") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
|
||||
UIViewController *vc = weakVC;
|
||||
if (!vc) return;
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
NSArray *types = [SCIFFmpeg isAvailable]
|
||||
? @[@"public.audio", @"public.audiovisual-content"]
|
||||
: @[@"public.audio", @"public.mpeg-4-audio", @"public.mp3", @"com.microsoft.waveform-audio",
|
||||
@"public.aiff-audio", @"com.apple.m4a-audio",
|
||||
@"public.movie", @"public.mpeg-4", @"com.apple.quicktime-movie"];
|
||||
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
|
||||
initWithDocumentTypes:types inMode:UIDocumentPickerModeImport];
|
||||
#pragma clang diagnostic pop
|
||||
picker.delegate = (id<UIDocumentPickerDelegate>)vc;
|
||||
[vc presentViewController:picker animated:YES completion:nil];
|
||||
}]];
|
||||
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Video from Library") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
|
||||
UIViewController *vc = weakVC;
|
||||
if (!vc) return;
|
||||
UIImagePickerController *imgPicker = [[UIImagePickerController alloc] init];
|
||||
imgPicker.sourceType = UIImagePickerControllerSourceTypePhotoLibrary;
|
||||
imgPicker.mediaTypes = @[@"public.movie"];
|
||||
imgPicker.delegate = (id<UINavigationControllerDelegate, UIImagePickerControllerDelegate>)vc;
|
||||
imgPicker.videoExportPreset = AVAssetExportPresetPassthrough;
|
||||
imgPicker.allowsEditing = YES; // enables built-in video trimming
|
||||
[vc presentViewController:imgPicker animated:YES completion:nil];
|
||||
}]];
|
||||
|
||||
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
|
||||
[threadVC presentViewController:alert animated:YES completion:nil];
|
||||
}
|
||||
|
||||
#pragma mark - Hook IGDSMenu to inject native menu item
|
||||
|
||||
%hook IGDSMenu
|
||||
|
||||
- (id)initWithMenuItems:(NSArray *)items edr:(BOOL)edr headerLabelText:(id)header {
|
||||
if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return %orig;
|
||||
|
||||
// Only inject into DM plus menus — sciDMMenuPending is set right before
|
||||
// this menu is created by the composer overflow button callback
|
||||
if (!sciDMMenuPending) return %orig;
|
||||
sciDMMenuPending = NO;
|
||||
|
||||
for (id item in items) {
|
||||
id title = sciAF(item, @selector(title));
|
||||
if ([title isKindOfClass:[NSString class]] && [title isEqualToString:@"Upload Audio"]) return %orig;
|
||||
}
|
||||
|
||||
Class itemClass = NSClassFromString(@"IGDSMenuItem");
|
||||
if (!itemClass) return %orig;
|
||||
|
||||
UIImage *img = [[UIImage systemImageNamed:@"waveform"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
|
||||
|
||||
void (^handler)(void) = ^{
|
||||
UIViewController *threadVC = sciAudioThreadVC;
|
||||
if (threadVC) sciShowUploadAudioOptions(threadVC);
|
||||
};
|
||||
|
||||
SEL initSel = @selector(initWithTitle:image:handler:);
|
||||
if (![itemClass instancesRespondToSelector:initSel]) return %orig;
|
||||
|
||||
typedef id (*InitFn)(id, SEL, id, id, id);
|
||||
id audioItem = ((InitFn)objc_msgSend)([itemClass alloc], initSel, @"Upload Audio", img, handler);
|
||||
if (!audioItem) return %orig;
|
||||
|
||||
NSMutableArray *newItems = [NSMutableArray arrayWithObject:audioItem];
|
||||
[newItems addObjectsFromArray:items];
|
||||
|
||||
return %orig(newItems, edr, header);
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
#pragma mark - Hook IGDirectThreadViewController
|
||||
|
||||
%hook IGDirectThreadViewController
|
||||
|
||||
- (void)composerOverflowButtonMenuWillPrepareExpandWithPlusButton:(id)plusButton {
|
||||
%orig;
|
||||
if (![SCIUtils getBoolPref:@"send_audio_as_file"]) return;
|
||||
sciAudioThreadVC = self;
|
||||
sciDMMenuPending = YES;
|
||||
}
|
||||
|
||||
// Convert unsupported formats to M4A before showing trim UI
|
||||
static void sciPrepareAndShowTrim(NSURL *url, UIViewController *threadVC) {
|
||||
AVAsset *asset = [AVAsset assetWithURL:url];
|
||||
double dur = CMTimeGetSeconds(asset.duration);
|
||||
BOOL avCanRead = dur > 0 && !isnan(dur);
|
||||
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
|
||||
|
||||
if (avCanRead) {
|
||||
sciShowTrimVC(url, isVideo, threadVC);
|
||||
return;
|
||||
}
|
||||
|
||||
// AVFoundation can't read it — pre-convert with FFmpeg
|
||||
if ([SCIFFmpeg isAvailable]) {
|
||||
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Converting...")];
|
||||
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
|
||||
[NSString stringWithFormat:@"rg_pre_%u.m4a", arc4random()]];
|
||||
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
|
||||
|
||||
NSString *cmd = [NSString stringWithFormat:@"-y -i \"%@\" -vn -c:a aac -b:a 128k -ar 44100 \"%@\"", url.path, out];
|
||||
[SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (success && [[NSFileManager defaultManager] fileExistsAtPath:out]) {
|
||||
sciShowTrimVC([NSURL fileURLWithPath:out], NO, threadVC);
|
||||
} else {
|
||||
sciShowUnsupportedAlert(url, @"FFmpeg conversion failed", threadVC);
|
||||
}
|
||||
});
|
||||
}];
|
||||
return;
|
||||
}
|
||||
|
||||
// No FFmpeg, can't read — unsupported
|
||||
sciShowUnsupportedAlert(url, @"Format not supported without FFmpegKit", threadVC);
|
||||
}
|
||||
|
||||
// File picker delegate
|
||||
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
|
||||
NSURL *url = urls.firstObject;
|
||||
if (!url) return;
|
||||
sciPrepareAndShowTrim(url, self);
|
||||
}
|
||||
|
||||
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url {
|
||||
if (!url) return;
|
||||
sciPrepareAndShowTrim(url, self);
|
||||
}
|
||||
|
||||
%new - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {}
|
||||
|
||||
// video picker delegate — UIImagePickerController with allowsEditing handles trimming
|
||||
%new - (void)imagePickerController:(UIImagePickerController *)picker didFinishPickingMediaWithInfo:(NSDictionary *)info {
|
||||
[picker dismissViewControllerAnimated:YES completion:nil];
|
||||
NSURL *videoURL = info[UIImagePickerControllerMediaURL];
|
||||
if (!videoURL) {
|
||||
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not get video URL")];
|
||||
return;
|
||||
}
|
||||
// UIImagePickerController with allowsEditing already trimmed the video for us
|
||||
sciConvertAndSend(videoURL, self, YES);
|
||||
}
|
||||
|
||||
%new - (void)imagePickerControllerDidCancel:(UIImagePickerController *)picker {
|
||||
[picker dismissViewControllerAnimated:YES completion:nil];
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,103 @@
|
||||
// Send files in DMs — adds a "Send File" option to the plus menu.
|
||||
|
||||
#import "../../InstagramHeaders.h"
|
||||
#import "../../Utils.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
|
||||
static BOOL sciFileMenuPending = NO;
|
||||
static __weak UIViewController *sciFileThreadVC = nil;
|
||||
|
||||
@interface _SCIFilePickerDelegate : NSObject <UIDocumentPickerDelegate>
|
||||
@property (nonatomic, weak) UIViewController *threadVC;
|
||||
@end
|
||||
|
||||
static _SCIFilePickerDelegate *sciFilePickerDelegate = nil;
|
||||
|
||||
@implementation _SCIFilePickerDelegate
|
||||
|
||||
- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray<NSURL *> *)urls {
|
||||
NSURL *url = urls.firstObject;
|
||||
if (!url || !self.threadVC) return;
|
||||
|
||||
id msgSenderFC = nil;
|
||||
@try { msgSenderFC = [self.threadVC valueForKey:@"messageSenderFeatureController"]; } @catch (__unused id e) {}
|
||||
if (!msgSenderFC) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Message sender not found")]; return; }
|
||||
|
||||
id sender = nil;
|
||||
@try { sender = [msgSenderFC valueForKey:@"messageSender"]; } @catch (__unused id e) {}
|
||||
if (!sender) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Send service not found")]; return; }
|
||||
|
||||
SEL sendSel = NSSelectorFromString(@"sendFileWithURL:threadKey:attribution:replyMessagePk:quotedPublishedMessage:messageSentSpeedLogger:messageSentSpeedMarker:localSendSpeedLogger:localSendSpeedMarker:");
|
||||
if (![sender respondsToSelector:sendSel]) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"File sending not supported")]; return; }
|
||||
|
||||
id threadKey = nil;
|
||||
@try { threadKey = [self.threadVC valueForKey:@"threadKey"]; } @catch (__unused id e) {}
|
||||
if (!threadKey) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No thread key")]; return; }
|
||||
|
||||
typedef void (*SendFn)(id, SEL, id, id, id, id, id, id, id, id, id);
|
||||
((SendFn)objc_msgSend)(sender, sendSel, url, threadKey, nil, nil, nil, nil, nil, nil, nil);
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
static void sciShowFilePicker(UIViewController *threadVC) {
|
||||
sciFilePickerDelegate = [_SCIFilePickerDelegate new];
|
||||
sciFilePickerDelegate.threadVC = threadVC;
|
||||
|
||||
UIDocumentPickerViewController *picker = [[UIDocumentPickerViewController alloc]
|
||||
initWithDocumentTypes:@[@"public.data"] inMode:UIDocumentPickerModeImport];
|
||||
picker.delegate = sciFilePickerDelegate;
|
||||
picker.allowsMultipleSelection = NO;
|
||||
[threadVC presentViewController:picker animated:YES completion:nil];
|
||||
}
|
||||
|
||||
// MARK: - Plus menu injection
|
||||
|
||||
%hook IGDSMenu
|
||||
|
||||
- (id)initWithMenuItems:(NSArray *)items edr:(BOOL)edr headerLabelText:(id)header {
|
||||
if (![SCIUtils getBoolPref:@"send_file"] || !sciFileMenuPending) return %orig;
|
||||
sciFileMenuPending = NO;
|
||||
|
||||
for (id item in items) {
|
||||
if ([item respondsToSelector:@selector(title)]) {
|
||||
id title = [item valueForKey:@"title"];
|
||||
if ([title isKindOfClass:[NSString class]] && [title isEqualToString:@"Send File"]) return %orig;
|
||||
}
|
||||
}
|
||||
|
||||
Class itemClass = NSClassFromString(@"IGDSMenuItem");
|
||||
if (!itemClass) return %orig;
|
||||
|
||||
UIImage *img = [[UIImage systemImageNamed:@"doc"] imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate];
|
||||
void (^handler)(void) = ^{
|
||||
if (sciFileThreadVC) sciShowFilePicker(sciFileThreadVC);
|
||||
};
|
||||
|
||||
SEL initSel = @selector(initWithTitle:image:handler:);
|
||||
if (![itemClass instancesRespondToSelector:initSel]) return %orig;
|
||||
|
||||
typedef id (*InitFn)(id, SEL, id, id, id);
|
||||
id fileItem = ((InitFn)objc_msgSend)([itemClass alloc], initSel, @"Send File", img, handler);
|
||||
if (!fileItem) return %orig;
|
||||
|
||||
NSMutableArray *newItems = [NSMutableArray arrayWithObject:fileItem];
|
||||
[newItems addObjectsFromArray:items];
|
||||
return %orig(newItems, edr, header);
|
||||
}
|
||||
|
||||
%end
|
||||
|
||||
// MARK: - Thread VC hook
|
||||
|
||||
%hook IGDirectThreadViewController
|
||||
|
||||
- (void)composerOverflowButtonMenuWillPrepareExpandWithPlusButton:(id)plusButton {
|
||||
%orig;
|
||||
if (![SCIUtils getBoolPref:@"send_file"]) return;
|
||||
sciFileThreadVC = self;
|
||||
sciFileMenuPending = YES;
|
||||
}
|
||||
|
||||
%end
|
||||
@@ -0,0 +1,161 @@
|
||||
// Story audio mute/unmute toggle. Posts mute-switch-state-changed to toggle
|
||||
// IG's audio. Reads _audioEnabled on IGAudioStatusAnnouncer for icon state.
|
||||
|
||||
#import <AVFoundation/AVFoundation.h>
|
||||
#import "StoryHelpers.h"
|
||||
#import <objc/runtime.h>
|
||||
#import <objc/message.h>
|
||||
#import <substrate.h>
|
||||
|
||||
extern "C" __weak UIViewController *sciActiveStoryViewerVC;
|
||||
extern "C" void sciRefreshAllVisibleOverlays(UIViewController *);
|
||||
|
||||
static id sciAudioAnnouncer = nil;
|
||||
|
||||
static BOOL sciIGAudioEnabled(void) {
|
||||
if (!sciAudioAnnouncer) return NO;
|
||||
Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled");
|
||||
if (!ivar) return NO;
|
||||
ptrdiff_t offset = ivar_getOffset(ivar);
|
||||
return *(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset);
|
||||
}
|
||||
|
||||
// ============ Volume KVO ============
|
||||
|
||||
@interface _SciVolumeObserver : NSObject
|
||||
@end
|
||||
@implementation _SciVolumeObserver
|
||||
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object
|
||||
change:(NSDictionary *)change context:(void *)context {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
});
|
||||
}
|
||||
@end
|
||||
static _SciVolumeObserver *sciVolumeObserver = nil;
|
||||
|
||||
// ============ Public API ============
|
||||
|
||||
extern "C" {
|
||||
|
||||
BOOL sciStoryAudioBypass = NO;
|
||||
|
||||
void sciToggleStoryAudio(void) {
|
||||
BOOL on = sciIGAudioEnabled();
|
||||
sciStoryAudioBypass = YES;
|
||||
[[NSNotificationCenter defaultCenter]
|
||||
postNotificationName:@"mute-switch-state-changed"
|
||||
object:nil
|
||||
userInfo:@{@"mute-state": @(on ? 0 : 1)}];
|
||||
sciStoryAudioBypass = NO;
|
||||
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
}
|
||||
|
||||
BOOL sciIsStoryAudioEnabled(void) {
|
||||
return sciIGAudioEnabled();
|
||||
}
|
||||
|
||||
static BOOL sciKVORegistered = NO;
|
||||
|
||||
void sciInitStoryAudioState(void) {
|
||||
if (sciKVORegistered) return;
|
||||
if (!sciVolumeObserver) sciVolumeObserver = [_SciVolumeObserver new];
|
||||
@try {
|
||||
[[AVAudioSession sharedInstance] addObserver:sciVolumeObserver
|
||||
forKeyPath:@"outputVolume"
|
||||
options:NSKeyValueObservingOptionNew
|
||||
context:NULL];
|
||||
sciKVORegistered = YES;
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
void sciResetStoryAudioState(void) {
|
||||
if (!sciKVORegistered) return;
|
||||
@try {
|
||||
[[AVAudioSession sharedInstance] removeObserver:sciVolumeObserver forKeyPath:@"outputVolume"];
|
||||
sciKVORegistered = NO;
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
||||
// ============ Announcer hooks ============
|
||||
|
||||
static id (*orig_announcerInit)(id, SEL);
|
||||
static id new_announcerInit(id self, SEL _cmd) {
|
||||
id r = orig_announcerInit(self, _cmd);
|
||||
sciAudioAnnouncer = self;
|
||||
return r;
|
||||
}
|
||||
|
||||
static void (*orig_announce)(id, SEL, BOOL, NSInteger);
|
||||
static void new_announce(id self, SEL _cmd, BOOL enabled, NSInteger reason) {
|
||||
orig_announce(self, _cmd, enabled, reason);
|
||||
if (sciActiveStoryViewerVC) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 3-dot menu item ============
|
||||
|
||||
extern "C" NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *items) {
|
||||
if (!sciActiveStoryViewerVC) return items;
|
||||
|
||||
BOOL looksLikeStoryHeader = NO;
|
||||
for (id it in items) {
|
||||
@try {
|
||||
NSString *t = [NSString stringWithFormat:@"%@", [it valueForKey:@"title"] ?: @""];
|
||||
if ([t isEqualToString:@"Report"] || [t isEqualToString:@"Mute"] ||
|
||||
[t isEqualToString:@"Unfollow"] || [t isEqualToString:@"Follow"] ||
|
||||
[t isEqualToString:@"Hide"]) { looksLikeStoryHeader = YES; break; }
|
||||
} @catch (__unused id e) {}
|
||||
}
|
||||
if (!looksLikeStoryHeader) return items;
|
||||
|
||||
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
|
||||
if (!menuItemCls) return items;
|
||||
|
||||
BOOL on = sciIGAudioEnabled();
|
||||
NSString *title = on ? SCILocalized(@"Mute story audio") : SCILocalized(@"Unmute story audio");
|
||||
void (^handler)(void) = ^{ sciToggleStoryAudio(); };
|
||||
|
||||
id newItem = nil;
|
||||
@try {
|
||||
typedef id (*Init)(id, SEL, id, id, id);
|
||||
newItem = ((Init)objc_msgSend)([menuItemCls alloc],
|
||||
@selector(initWithTitle:image:handler:), title, nil, handler);
|
||||
} @catch (__unused id e) {}
|
||||
|
||||
if (!newItem) return items;
|
||||
NSMutableArray *newItems = [items mutableCopy];
|
||||
[newItems addObject:newItem];
|
||||
return [newItems copy];
|
||||
}
|
||||
|
||||
// ============ Ringer listener ============
|
||||
|
||||
static void sciRingerChanged(CFNotificationCenterRef center, void *observer,
|
||||
CFNotificationName name, const void *object,
|
||||
CFDictionaryRef userInfo) {
|
||||
dispatch_async(dispatch_get_main_queue(), ^{
|
||||
if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
|
||||
});
|
||||
}
|
||||
|
||||
// ============ Init ============
|
||||
|
||||
__attribute__((constructor)) static void _storyAudioInit(void) {
|
||||
CFNotificationCenterAddObserver(
|
||||
CFNotificationCenterGetDarwinNotifyCenter(), NULL,
|
||||
sciRingerChanged, CFSTR("com.apple.springboard.ringerstate"),
|
||||
NULL, CFNotificationSuspensionBehaviorDeliverImmediately);
|
||||
|
||||
Class cls = NSClassFromString(@"IGAudioStatusAnnouncer");
|
||||
if (!cls) return;
|
||||
MSHookMessageEx(cls, @selector(init), (IMP)new_announcerInit, (IMP *)&orig_announcerInit);
|
||||
SEL s = NSSelectorFromString(@"_announceForDeviceStateChangesIfNeededForAudioEnabled:reason:");
|
||||
if (class_getInstanceMethod(cls, s))
|
||||
MSHookMessageEx(cls, s, (IMP)new_announce, (IMP *)&orig_announce);
|
||||
}
|
||||