[release] RyukGram v1.2.0

### Features
- **Open Instagram links in app (Safari extension)** — bundled Safari web extension (sideload IPA only). Enable in Safari → Extensions; instagram.com links open in the app.
- **Localization** — every user-facing string flows through a central translation layer. Globe button in Settings; missing keys fall back to English. Ships English only — see the "Translating RyukGram" section in the README to add more.
- **Action buttons** — context-aware menus on feed, reels, and stories (expand, repost, download, copy caption, etc.) with per-context default tap action and carousel/multi-story bulk download
- **Enhanced HD downloads** — up to 1080p via DASH + FFmpegKit with quality picker, preview playback, encoding-speed options, and 720p fallback
- **Repost**, **media viewer**, **media zoom** (long-press), **download pill** (frosted glass, stacks concurrent downloads)
- **Fake location** — overrides CoreLocation app-wide, map picker + saved presets, optional quick-toggle button on the Friends Map
- **Messages-only mode** — strips every tab except DM inbox + profile
- **Launch tab** — pick which tab the app opens to
- Full last active date in DMs — show full date instead of "Active 2h ago"
- Custom date format — 12 formats with per-surface toggles (feed, notes/comments/stories, DMs)
- Send files in DMs (experimental)
- View story mentions
- Hide suggested stories
- Story tray long-press actions — view HD profile picture from the tray menu
- Advance on story reply — auto-skip to next story after sending a reply or reaction
- Mark story as seen on reply or emoji reaction
- Hide metrics (likes, comments, shares counts)
- Hide messages tab
- Hide voice/video call buttons in DM thread header (independent toggles)
- Disable app haptics
- Disable reels tab refresh
- Disable disappearing messages mode in DMs
- Follow indicator — shows whether the profile user follows you
- Copy note text on long press
- Zoom profile photo — long press opens full-screen viewer
- Notes actions — copy text, download GIF/audio from notes long-press menu
- Confirm unfollow
- Feed refresh controls — disable background refresh, home button refresh, and home button scroll

### Improvements
- Default tap action: added copy URL, repost, and view mentions options; dynamic menu generation per context
- Settings pages reordered: General → Feed → Stories → Reels → Messages → Profile → Navigation → Saving → Confirmations
- Fake location picker: native Apple Maps-style UI (search, long-press to drop pin, current location)
- Liquid glass floating tab bar + dynamic sizing
- Upload audio: FFmpegKit re-encode + trim for any audio/video input
- Settings reorganized with per-context action button config; new Profile page
- Highlight cover: full-screen viewer replaces direct download
- Switched HD encoder to `h264_videotoolbox` (hardware) — no GPL FFmpegKit required
- Legacy long-press download deprecated (off by default), replaced by action buttons

### Fixes
- Hide suggested stories no longer removes followed users' stories on scroll
- Settings search bar transparency with liquid glass off; auto-deactivates on push
- HD download cancel: tapping pill aborts in-flight downloads + FFmpeg sessions cleanly
- Download pill stuck state on background/foreground, progress reset per download
- Disappearing messages mode confirmation not firing on swipe
- Detailed color picker not working on story draw `†`
- DM seen toggle menu not updating after tap
- Reel refresh confirmation appearing on first app launch `†`
- Reels action button displacing profile pictures on photo reels
- Disappearing DM media download (expand, share, save to Photos with progress pill)
- Carousel "Download all" not showing item count in feed
- Encoding speed setting being ignored for HD downloads
- Various upstream SCInsta merges (Meta AI hiding, suggested chats hiding, notes tray) — marked `†`

> `†` Merged from upstream [SCInsta](https://github.com/SoCuul/SCInsta) by SoCuul

### Credits
- Thanks to [@erupts0](https://github.com/erupts0) (John) for testing and feature suggestions
- Thanks to [@euoradan](https://t.me/euoradan) (Radan) for experimental Instagram feature flag research
- Safari extension forked/cleaned from [BillyCurtis/OpenInstagramSafariExtension](https://github.com/BillyCurtis/OpenInstagramSafariExtension)

### Known Issues
- Preserved unsent messages can't be removed via "Delete for you"; pull-to-refresh clears them (warning available in settings)
- "Delete for you" detection uses a ~2s window after the local action — a real unsend landing in that window may be missed (rare)
This commit is contained in:
faroukbmiled
2026-04-16 03:03:30 +01:00
parent 9b2c7dc202
commit 86eaa95019
124 changed files with 11523 additions and 1393 deletions
+8 -10
View File
@@ -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 }}
+29 -16
View File
@@ -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
+30 -25
View File
@@ -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,19 +112,19 @@ jobs:
- name: Generate release notes
if: steps.check_release.outputs.should_release == 'true'
id: notes
run: |
PENDING_FILE="PENDING_CHANGES.md"
PREV_TAG=$(git tag --sort=-creatordate | grep -v "v${VERSION}$" | head -n1 || true)
{
echo "## Changelog"
echo ""
if [ -f "$PENDING_FILE" ]; then
# Drop the copy-as-commit header lines (HTML comment + [release] token)
# so they don't appear in the published release body.
sed -e '/^<!--/,/-->$/d' -e '/^\[release\]/d' "$PENDING_FILE"
fi
git log -1 --pretty=%B | tail -n +2 | sed -e '/^<!--/,/-->$/d' -e '/^\[release\]/d'
echo ""
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})"
@@ -128,16 +135,14 @@ jobs:
- 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 }}
+8
View File
@@ -42,3 +42,11 @@ deploy.sh
PENDING_CHANGES.md
PENDING_CHANGES.md.bk
wrapper/
scripts/*.py
scripts/__pycache__/
# FFmpegKit frameworks
modules/ffmpegkit/
# External reference tweaks
exp_flags/
+2 -2
View File
@@ -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 AVFoundation UniformTypeIdentifiers
$(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
+78 -20
View File
@@ -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 424.0.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,6 +21,8 @@ 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 **\***
@@ -32,6 +34,7 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- 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
@@ -46,12 +49,18 @@ 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
@@ -72,21 +81,30 @@ 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 **\***
- Download highlight cover from profile long-press menu **\***
- Save action — choose between share sheet or save directly to Photos **\***
- Save to RyukGram album — optional toggle that routes downloads (and share-sheet "Save to Photos" picks) into a dedicated "RyukGram" album in 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 (preserves unsent messages with visual indicator and notification pill) **\***
@@ -97,13 +115,21 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- 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) **\***
@@ -111,10 +137,10 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- 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 the story overlay and 3-dot menu to toggle audio **\***
- 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) **\***
- 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
@@ -127,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
@@ -141,6 +171,12 @@ 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) **\***
@@ -151,9 +187,28 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com
- 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.
@@ -191,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
+177 -17
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
Package: com.faroukbmiled.ryukgram
Name: RyukGram
Version: 1.1.5.1
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();
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

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>
+53
View File
@@ -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/"
+36
View File
@@ -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."
+37
View File
@@ -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
+165
View File
@@ -0,0 +1,165 @@
#import "SCIActionButton.h"
#import "SCIActionMenu.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 {
// Fallback: user can long-press for menu.
}
}
// 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
+48
View File
@@ -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
+132
View File
@@ -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
+98
View File
@@ -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
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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
+437
View File
@@ -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
+10
View File
@@ -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
+109
View File
@@ -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
+20 -2
View File
@@ -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;
+334 -103
View File
@@ -2,70 +2,145 @@
#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 {
@@ -74,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 {
@@ -100,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
@@ -127,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];
@@ -164,46 +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 = error.localizedDescription;
self.pill.progressRing.hidden = YES;
[self.pill dismissAfterDelay:3.0];
} else if (!error) {
// nil error without fileURL callback — dismiss stale pill
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
if (self.pill.superview) [self.pill dismissAfterDelay: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:
@@ -218,7 +456,7 @@
[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;
}
@@ -227,17 +465,10 @@
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:useAlbum ? @"Saved to RyukGram" : @"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")];
}
});
};
@@ -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
+12 -7
View File
@@ -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
+22 -27
View File
@@ -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
+185
View File
@@ -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);
}
+2 -2
View File
@@ -59,7 +59,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
NSMutableArray *extra = [NSMutableArray array];
if (hasText && [SCIUtils getBoolPref:@"copy_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Copy"
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Copy")
image:[UIImage systemImageNamed:@"doc.on.doc"]
identifier:nil
handler:^(__kindof UIAction *_) {
@@ -68,7 +68,7 @@ static id new_commentCtxMenu(id self, SEL _cmd, id cv, id indexPath, CGPoint poi
}
if (hasGif && [SCIUtils getBoolPref:@"download_gif_comment"]) {
[extra addObject:[UIAction actionWithTitle:@"Download GIF"
[extra addObject:[UIAction actionWithTitle:SCILocalized(@"Download GIF")
image:[UIImage systemImageNamed:@"arrow.down.circle"]
identifier:nil
handler:^(__kindof UIAction *_) {
+1 -1
View File
@@ -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];
+1 -1
View File
@@ -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
+33
View File
@@ -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
+49
View File
@@ -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();
}];
}
+115
View File
@@ -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)
}
+55 -10
View File
@@ -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];
}
+25
View File
@@ -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,90 @@
// Hide suggested stories from the tray. Drops items the user doesn't follow
// (friendship_status.following=0 or empty fieldCache); highlights pass through.
#import "../../InstagramHeaders.h"
#import "../../Utils.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <substrate.h>
// IGListAdapter declared in InstagramHeaders.h
static __weak id sciTrayAdapter = nil;
// ── Suggested item detection ──
// Returns YES if the item should be kept. Highlights / non-tray rows pass
// through; followed reels keep; empty fieldCache (freshly-streamed suggested
// users) drops; otherwise check friendship_status.following.
static BOOL sciIsFollowedTrayItem(id obj) {
if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return YES;
@try {
if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) return YES;
id owner = [obj valueForKey:@"reelOwner"];
if (!owner) return YES;
Ivar userIvar = class_getInstanceVariable([owner class], "_userReelOwner_user");
if (!userIvar) return YES;
id igUser = object_getIvar(owner, userIvar);
if (!igUser) return YES;
Ivar fcIvar = NULL;
for (Class c = [igUser class]; c && !fcIvar; c = class_getSuperclass(c))
fcIvar = class_getInstanceVariable(c, "_fieldCache");
if (!fcIvar) return YES;
const char *fcType = ivar_getTypeEncoding(fcIvar);
if (!fcType || fcType[0] != '@') return YES;
id fc = object_getIvar(igUser, fcIvar);
if (![fc isKindOfClass:[NSDictionary class]]) return YES;
if ([(NSDictionary *)fc count] == 0) return NO;
id fs = [(NSDictionary *)fc objectForKey:@"friendship_status"];
if (!fs) return YES;
return [[fs valueForKey:@"following"] boolValue];
} @catch (__unused NSException *e) {
return YES;
}
}
// ── Data source filter ──
static NSArray *(*orig_objectsForListAdapter)(id, SEL, id);
static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) {
NSArray *objects = orig_objectsForListAdapter(self, _cmd, adapter);
sciTrayAdapter = adapter;
if (![SCIUtils getBoolPref:@"hide_suggested_stories"]) return objects;
NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count];
for (id obj in objects) {
if (sciIsFollowedTrayItem(obj)) [filtered addObject:obj];
}
return [filtered copy];
}
// ── Reload tray on pref change ──
static void sciReloadTray(void) {
dispatch_async(dispatch_get_main_queue(), ^{
IGListAdapter *adapter = sciTrayAdapter;
if (adapter) [adapter performUpdatesAnimated:YES completion:nil];
});
}
%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);
[[NSNotificationCenter defaultCenter] addObserverForName:@"SCISuggestedStoriesReload"
object:nil queue:nil
usingBlock:^(NSNotification *n) { sciReloadTray(); }];
}
+14 -27
View File
@@ -1,15 +1,12 @@
// Download highlight cover image from the profile long-press menu.
// Captures the long-pressed IGStoryTrayCell, finds the IGImageView inside it,
// and saves the cover using the user's download settings.
// 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>
static SCIDownloadDelegate *sciHighlightDl = nil;
// Find the IGStoryTrayCell with an active long-press gesture
static UIView *sciFindLongPressedCell(UIView *root) {
Class cellCls = NSClassFromString(@"IGStoryTrayCell");
@@ -46,29 +43,20 @@ static UIImage *sciCoverImageFromCell(UIView *cell) {
return nil;
}
static void sciSaveCoverImage(UIImage *image, UIViewController *presenter) {
static void sciViewCoverImage(UIImage *image) {
if (!image) {
[SCIUtils showErrorHUDWithDescription:@"Could not find cover image"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find cover image")];
return;
}
NSString *method = [SCIUtils getStringPref:@"dw_save_action"];
if ([method isEqualToString:@"photos"]) {
// Save to Photos (respects RyukGram album pref)
NSData *data = UIImageJPEGRepresentation(image, 1.0);
if (!data) return;
NSString *tmpPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"%@.jpg", [[NSUUID UUID] UUIDString]]];
[data writeToFile:tmpPath atomically:YES];
NSURL *tmpURL = [NSURL fileURLWithPath:tmpPath];
sciHighlightDl = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:NO];
[sciHighlightDl downloadDidFinishWithFileURL:tmpURL];
} else {
// Share sheet
UIActivityViewController *activityVC = [[UIActivityViewController alloc]
initWithActivityItems:@[image] applicationActivities:nil];
if (presenter) [presenter presentViewController:activityVC animated:YES completion:nil];
}
// 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)
@@ -90,16 +78,15 @@ static void new_present(id self, SEL _cmd, id vc, BOOL animated, id completion)
if (actions && actions.count >= 2 && actions.count <= 6) {
Class actionCls = NSClassFromString(@"IGActionSheetControllerAction");
if (actionCls) {
__weak UIViewController *weakSelf = (UIViewController *)self;
void (^handler)(void) = ^{
UIImage *cover = sciCoverImageFromCell(sciLongPressedHighlightCell);
sciSaveCoverImage(cover, weakSelf);
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,
@"Download cover", nil, 0, handler, nil, nil);
@"View cover", nil, 0, handler, nil, nil);
if (newAction) {
NSMutableArray *newActions = [actions mutableCopy];
+33
View File
@@ -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
+198
View File
@@ -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
+65
View File
@@ -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
+20
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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)");
+4 -4
View File
@@ -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]];
+7 -8
View File
@@ -8,8 +8,7 @@
// a copy button alongside IG's own buttons, then opens a menu to copy
// username/name/bio.
@interface IGProfileViewController : UIViewController
@end
// IGProfileViewController declared in InstagramHeaders.h
static id sci_safeValueForKey(id obj, NSString *key) {
@try { return [obj valueForKey:key]; }
@@ -107,7 +106,7 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu",
username, fullName, (unsigned long)biography.length);
UIAlertController *menu = [UIAlertController alertControllerWithTitle:@"Copy from profile"
UIAlertController *menu = [UIAlertController alertControllerWithTitle:SCILocalized(@"Copy from profile")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
@@ -117,12 +116,12 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
handler:^(UIAlertAction *_) { sci_copyAndToast(username, @"username"); }]];
}
if (fullName.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy name"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy name")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(fullName, @"name"); }]];
}
if (biography.length) {
[menu addAction:[UIAlertAction actionWithTitle:@"Copy bio"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy bio")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(biography, @"bio"); }]];
}
@@ -134,16 +133,16 @@ static void sci_copyAndToast(NSString *value, NSString *label) {
if (parts.count >= 2) {
NSString *combined = [parts componentsJoinedByString:@"\n\n"];
[menu addAction:[UIAlertAction actionWithTitle:@"Copy all"
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy all")
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *_) { sci_copyAndToast(combined, @"all"); }]];
}
if (menu.actions.count == 0) {
[menu addAction:[UIAlertAction actionWithTitle:@"Nothing to copy" style:UIAlertActionStyleDefault handler:nil]];
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Nothing to copy") style:UIAlertActionStyleDefault handler:nil]];
}
[menu addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[menu addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
if (sender) {
menu.popoverPresentationController.sourceView = sender;
@@ -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")
+6 -4
View File
@@ -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;
+210 -511
View File
@@ -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]
+125
View File
@@ -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
+40
View File
@@ -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
+5 -5
View File
@@ -136,13 +136,13 @@ static UIView * _Nullable sciFindSubmitButton(UIView *root) {
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
return;
}
UITextField *textField = sciFindTextField(self);
if (!textField) {
[SCIUtils showErrorHUDWithDescription:@"No text field found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No text field found")];
return;
}
@@ -172,16 +172,16 @@ static UIView * _Nullable sciFindSubmitButton(UIView *root) {
NSString *password = sciGetPassword(self);
if (!password) {
[SCIUtils showErrorHUDWithDescription:@"No password found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No password found")];
return;
}
[[UIPasteboard generalPasteboard] setString:password];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Password"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Password")
message:password
preferredStyle:UIAlertControllerStyleAlert];
[alert addAction:[UIAlertAction actionWithTitle:@"Copied!" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copied!") style:UIAlertActionStyleCancel handler:nil]];
UIViewController *topVC = topMostController();
if (topVC) [topVC presentViewController:alert animated:YES completion:nil];
}
+3 -3
View File
@@ -54,12 +54,12 @@ static BOOL sciReelRefreshBypassing = NO;
((void(*)(id,SEL))objc_msgSend)(rc, @selector(endRefreshing));
[self refreshControlDidEndFinishLoadingAnimation:rc];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Refresh Reels?"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Refresh Reels?")
message:nil
preferredStyle:UIAlertControllerStyleAlert];
__weak id weakSelf = self;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[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);
@@ -123,7 +123,7 @@ static BOOL sciShouldBlockSeenVisualForObj(id obj) {
// 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.
static __weak UIViewController *sciActiveStoryVC = nil;
__weak UIViewController *sciActiveStoryVC = nil;
%hook IGStoryViewerViewController
- (void)viewDidAppear:(BOOL)animated {
@@ -72,27 +72,27 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
id directAudio = nil;
@try { directAudio = [capturedVM valueForKey:@"audio"]; } @catch (NSException *e) {}
if (!directAudio) {
[SCIUtils showErrorHUDWithDescription:@"Could not get audio data. Try again after refreshing the chat."];
[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:@"Audio not loaded yet. Play the message first and try again."];
[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:@"No audio URL found. Try again after refreshing the chat."];
[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:@"Downloading audio..."];
[pill setText:SCILocalized(@"Downloading audio...")];
[pill showInView:topView];
NSURLSessionDownloadTask *task = [[NSURLSession sharedSession]
@@ -119,7 +119,7 @@ static id new_prismMenuView_init3(id self, SEL _cmd, NSArray *elements, id heade
void (^present)(NSURL *) = ^(NSURL *url) {
dispatch_async(dispatch_get_main_queue(), ^{
[pill setText:@"Done!"];
[pill setText:SCILocalized(@"Done!")];
[pill dismissAfterDelay:0.5];
[SCIUtils showShareVC:url];
});
@@ -74,8 +74,8 @@ static id new_ctxMenuCfg(id self, SEL _cmd, id indexPath) {
UIMenu *base = origProvider ? origProvider(suggested) : [UIMenu menuWithChildren:suggested];
BOOL inList = [SCIExcludedThreads isInList:tid];
BOOL blockSelected = [SCIExcludedThreads isBlockSelectedMode];
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
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
@@ -221,22 +221,22 @@ NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *items) {
Class menuItemCls = NSClassFromString(@"IGDSMenuItem");
if (!menuItemCls) return items;
NSString *addLabel = blockSelected ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
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 ? @"Unblocked" : @"Un-excluded"];
[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 ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
// Adding in block_all = normal behavior → mark seen
if (!blockSelected) sciTriggerStoryMarkSeen(weakVC);
}
@@ -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
@@ -97,18 +97,18 @@ static void new_pullToRefresh(id self, SEL _cmd) {
@"Refreshing the DMs tab will clear %lu preserved unsent message%@. This cannot be undone.",
(unsigned long)count, count == 1 ? @"" : @"s"];
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Clear preserved messages?"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Clear preserved messages?")
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakSelf = vc;
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel
handler:^(UIAlertAction *a) {
sciCancelRefresh(weakSelf);
sciRefreshAlertVisible = NO;
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Refresh" style:UIAlertActionStyleDestructive
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Refresh") style:UIAlertActionStyleDestructive
handler:^(UIAlertAction *a) {
sciRefreshAlertVisible = NO;
id strongSelf = weakSelf;
@@ -356,7 +356,7 @@ static void sciShowUnsentToast() {
pill.alpha = 0;
UILabel *label = [[UILabel alloc] init];
label.text = @"A message was unsent";
label.text = SCILocalized(@"A message was unsent");
label.textColor = [UIColor whiteColor];
label.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold];
label.textAlignment = NSTextAlignmentCenter;
@@ -606,7 +606,7 @@ static void sciUpdateCellIndicator(id cell) {
UIView *parent = bubble ?: view;
UILabel *label = [[UILabel alloc] init];
label.tag = SCI_PRESERVED_TAG;
label.text = @"Unsent";
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;
@@ -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);
}
+248 -134
View File
@@ -1,7 +1,14 @@
// Download + mark seen buttons on story/DM visual message overlay
// 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;
@@ -18,92 +25,110 @@ 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 *);
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"];
}
static void sciDownloadWithConfirm(void(^block)(void)) {
if ([SCIUtils getBoolPref:@"dw_confirm"]) {
[SCIUtils showConfirmation:block title:@"Download?"];
} else {
block();
}
}
static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// ── 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;
if (!ds) return;
Ivar msgIvar = class_getInstanceVariable([ds class], "_currentMessage");
Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil;
id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil;
if (!msg) return;
id rawVideo = sciCall(msg, @selector(rawVideo));
if (rawVideo) {
NSURL *url = [SCIUtils getVideoUrl:rawVideo];
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryVideoDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
id rawPhoto = sciCall(msg, @selector(rawPhoto));
if (rawPhoto) {
NSURL *url = [SCIUtils getPhotoUrl:rawPhoto];
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
id imgSpec = sciCall(msg, NSSelectorFromString(@"imageSpecifier"));
if (imgSpec) {
NSURL *url = sciCall(imgSpec, @selector(url));
if (url) {
sciInitStoryDownloaders();
sciDownloadWithConfirm(^{ [sciStoryImageDl downloadFileWithURL:url fileExtension:[[url lastPathComponent] pathExtension] hudLabel:nil]; });
return;
}
}
if (!msg) return nil;
Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo");
id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil;
if (vmi) {
Ivar mediaIvar = class_getInstanceVariable([vmi class], "_media");
id mediaObj = mediaIvar ? object_getIvar(vmi, mediaIvar) : nil;
if (mediaObj) {
IGMedia *media = sciExtractMediaFromItem(mediaObj);
if (!media && [mediaObj isKindOfClass:NSClassFromString(@"IGMedia")]) media = (IGMedia *)mediaObj;
if (media) { sciDownloadWithConfirm(^{ sciDownloadMedia(media); }); return; }
}
}
Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil;
id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil;
if (!visMedia) return nil;
[SCIUtils showErrorHUDWithDescription:@"Could not find media"];
// 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
@@ -114,18 +139,17 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
%orig;
if (!self.superview) return;
// Download button
if ([SCIUtils getBoolPref:@"dw_story"] && ![self viewWithTag:1340]) {
// 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:@"arrow.down" withConfiguration:cfg] forState:UIControlStateNormal];
[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;
[btn addTarget:self action:@selector(sciDownloadTapped:) forControlEvents:UIControlEventTouchUpInside];
[self addSubview:btn];
[NSLayoutConstraint activateConstraints:@[
[btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100],
@@ -133,9 +157,108 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
[btn.widthAnchor constraintEqualToConstant:36],
[btn.heightAnchor constraintEqualToConstant:36]
]];
[SCIActionButton configureButton:btn
context:SCIActionContextStories
prefKey:@"stories_action_default"
mediaProvider:^id (UIView *sourceView) {
// DM disappearing message — handle directly
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);
}];
// For DM visual messages: override menu with download/share/expand
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 {
// Story — use normal action menu
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);
}
}]
]];
btn.showsMenuAsPrimaryAction = YES;
// 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 (left side, small)
// Audio toggle button
sciInitStoryAudioState();
if ([SCIUtils getBoolPref:@"story_audio_toggle"] && ![self viewWithTag:1341]) {
UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom];
@@ -168,6 +291,17 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// ============ 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];
@@ -304,33 +438,6 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
[sender setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal];
}
// ============ Download handler ============
%new - (void)sciDownloadTapped:(UIButton *)sender {
UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium];
[haptic impactOccurred];
[UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); }
completion:^(BOOL f) { [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformIdentity; }]; }];
@try {
id item = sciGetCurrentStoryItem(self);
IGMedia *media = sciExtractMediaFromItem(item);
if (media) {
sciDownloadWithConfirm(^{ sciDownloadMedia(media); });
return;
}
UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController");
if (dmVC) {
sciDownloadDMVisualMessage(dmVC);
return;
}
[SCIUtils showErrorHUDWithDescription:@"Could not find media"];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
}
}
// ============ Seen button tap ============
%new - (void)sciSeenButtonTapped:(UIButton *)sender {
@@ -343,19 +450,19 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
if (bs && !inList && ownerPK) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:[NSString stringWithFormat:@"Story seen receipts will be blocked for @%@.", ownerInfo[@"username"] ?: @""]
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:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[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:@"Added to block list"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
@@ -369,18 +476,18 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
// Block all + in list: tap to remove from exclude list
if (inList) {
UIViewController *host = [SCIUtils nearestViewControllerForView:self];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude story seen?";
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 ? @"Unblock" : @"Un-exclude" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:bs ? SCILocalized(@"Unblock") : SCILocalized(@"Un-exclude") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedStoryUsers removePK:ownerPK];
[SCIUtils showToastForDuration:2.0 title:bs ? @"Unblocked" : @"Un-excluded"];
[SCIUtils showToastForDuration:2.0 title:bs ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")];
if (bs) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[host presentViewController:alert animated:YES completion:nil];
return;
}
@@ -391,7 +498,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold];
[sender setImage:[UIImage systemImageNamed:(sciStorySeenToggleEnabled ? @"eye.fill" : @"eye") withConfiguration:cfg] forState:UIControlStateNormal];
sender.tintColor = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor];
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? @"Story read receipts enabled" : @"Story read receipts disabled"];
[SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")];
return;
}
@@ -406,6 +513,9 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
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; } }
@@ -417,31 +527,35 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
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:@"Mark seen" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[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 ? @"Add to block list" : @"Exclude story seen";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude story seen";
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 ? @"Unblocked" : @"Un-excluded"];
[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 ? @"Blocked" : @"Excluded"];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")];
if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC);
}
sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC);
resume();
}]];
}
[sheet addAction:[UIAlertAction actionWithTitle:@"Stories settings" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIUtils showSettingsVC:capturedWin atTopLevelEntry:@"Stories"];
[sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:^(UIAlertAction *_) {
resume();
}]];
[sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
sheet.popoverPresentationController.sourceView = btn;
sheet.popoverPresentationController.sourceRect = btn.bounds;
[host presentViewController:sheet animated:YES completion:nil];
@@ -466,7 +580,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
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; }
if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; }
sciAllowSeenForPK(media);
sciSeenBypassActive = YES;
@@ -496,7 +610,7 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
}
}
sciSeenBypassActive = NO;
[SCIUtils showToastForDuration:2.0 title:@"Marked as seen" subtitle:@"Will sync when leaving stories"];
[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) {
@@ -561,13 +675,13 @@ static void sciDownloadDMVisualMessage(UIViewController *dmVC) {
dmVisualMsgsViewedButtonEnabled = wasEnabled;
});
[SCIUtils showToastForDuration:1.5 title:@"Marked as viewed"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")];
return;
}
[SCIUtils showErrorHUDWithDescription:@"VC not found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Error: %@", e.reason]];
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]];
}
}
+108 -64
View File
@@ -73,7 +73,7 @@ static void new_setHasSent(id self, SEL _cmd, BOOL sent) {
// Re-runs setRightBarButtonItems with the live items. The hook tags its own
// buttons so they get stripped and rebuilt against the new exclusion state.
static void sciRefreshNavBarItems(UIView *anchor) {
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];
@@ -92,37 +92,50 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (seenFeatureOn && !excluded) {
BOOL toggleMode = sciIsSeenToggleMode();
NSString *title;
UIImage *img;
// Toggle mode: show toggle action + one-shot mark seen
if (toggleMode) {
title = dmSeenToggleEnabled ? @"Disable read receipts" : @"Enable read receipts";
img = [UIImage systemImageNamed:dmSeenToggleEnabled ? @"eye.slash" : @"eye"];
} else {
title = @"Mark messages as seen";
img = [UIImage systemImageNamed:@"eye"];
}
UIAction *seenAction = [UIAction actionWithTitle:title image:img identifier:nil
handler:^(__kindof UIAction *_) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if (![nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) return;
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;
if (dmSeenToggleEnabled) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:anchor];
if (dmSeenToggleEnabled && [nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:@"Read receipts enabled"];
} else {
[SCIUtils showToastForDuration:2.0 title:@"Read receipts disabled"];
}
} else {
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.0 title:@"Marked messages as seen"];
}
}];
[items addObject:seenAction];
[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 ? @"Add to block list" : @"Exclude chat";
NSString *removeLabel = blockSelected ? @"Remove from block list" : @"Un-exclude chat";
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;
@@ -131,7 +144,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (!threadId) return;
if (inList) {
[SCIExcludedThreads removeThreadId:threadId];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Unblocked" : @"Un-excluded"];
[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];
@@ -143,7 +156,7 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
NSDictionary *entry = sciEntryFromThreadVC(anchorVC);
if (!entry) entry = @{ @"threadId": threadId, @"threadName": @"", @"isGroup": @NO, @"users": @[] };
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:blockSelected ? @"Blocked" : @"Excluded"];
[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];
@@ -156,7 +169,25 @@ static UIMenu *sciBuildThreadActionsMenu(UIView *anchor, NSString *threadId, UIW
if (excluded) toggle.attributes = UIMenuElementAttributesDestructive;
[items addObject:toggle];
UIAction *openSettings = [UIAction actionWithTitle:@"Messages settings"
// 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 *_) {
@@ -213,16 +244,16 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
NSDictionary *entry = sciEntryFromThreadVC(nearestVC);
if (!entry) return;
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Add to block list?"
message:@"Read receipts will be blocked for this chat."
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:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) {
[SCIExcludedThreads addOrUpdateEntry:entry];
[SCIUtils showToastForDuration:2.0 title:@"Added to block list"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Added to block list")];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[nearestVC presentViewController:alert animated:YES completion:nil];
}
@@ -232,30 +263,40 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
if (!tid) return;
BOOL bs = [SCIExcludedThreads isBlockSelectedMode];
NSString *alertTitle = bs ? @"Remove from block list?" : @"Un-exclude chat?";
NSString *alertMsg = bs ? @"Read receipts will no longer be blocked for this chat."
: @"This chat will resume normal read-receipt behavior.";
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:@"Remove" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Remove") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) {
[SCIExcludedThreads removeThreadId:tid];
[SCIUtils showToastForDuration:2.0 title:@"Removed"];
[SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Removed")];
sciRefreshNavBarItems(weakSelf);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[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-running this hook doesn't dupe them.
// 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 ([SCIUtils getBoolPref:@"hide_reels_blend"])
return ![aid isEqualToString:@"blend-button"];
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];
@@ -298,11 +339,15 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
[new_items addObject:listBtn];
}
if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded) {
UIBarButtonItem *dmVisualMsgsViewedButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"photo.badge.checkmark"] style:UIBarButtonItemStylePlain target:self action:@selector(dmVisualMsgsViewedButtonHandler:)];
dmVisualMsgsViewedButton.accessibilityIdentifier = @"sci-visual-btn";
[new_items addObject:dmVisualMsgsViewedButton];
[dmVisualMsgsViewedButton setTintColor:dmVisualMsgsViewedButtonEnabled ? SCIUtils.SCIColor_Primary : 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]);
@@ -318,32 +363,31 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) {
UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self];
if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)])
[(IGDirectThreadViewController *)nearestVC markLastMessageAsSeen];
[SCIUtils showToastForDuration:2.5 title:@"Read receipts enabled"];
[SCIUtils showToastForDuration:2.5 title:SCILocalized(@"Read receipts enabled")];
} else {
[SCIUtils showToastForDuration:2.5 title:@"Read receipts disabled"];
[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:@"Marked messages as seen"];
[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
// ============ SEEN BLOCKING LOGIC ============
@@ -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();
}
@@ -5,6 +5,7 @@
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../SCIFFmpeg.h"
#import <objc/runtime.h>
#import <objc/message.h>
#import <AVFoundation/AVFoundation.h>
@@ -77,26 +78,26 @@ static void sciSendAudioFile(NSURL *audioURL, UIViewController *threadVC) {
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:@"Audio sent"];
[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:@"Audio sent"];
[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:@"Audio sent"];
[SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Audio sent")];
return;
}
[SCIUtils showErrorHUDWithDescription:@"No voice send method found"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"No voice send method found")];
} @catch (NSException *e) {
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:@"Send failed: %@", e.reason]];
[SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Send failed: %@"), e.reason]];
}
}
@@ -121,10 +122,10 @@ static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewControll
message:msg
preferredStyle:UIAlertControllerStyleAlert];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Send anyway" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Send anyway") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
sciSendAudioFile(url, weakVC);
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Open GitHub" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[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];
@@ -135,19 +136,38 @@ static void sciShowUnsupportedAlert(NSURL *url, NSString *reason, UIViewControll
[presenter presentViewController:alert animated:YES completion:nil];
}
static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVideo, CMTimeRange trimRange) {
// 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;
// Allowlisted formats skip AVFoundation entirely; trim is ignored since
// AVFoundation can't read their timelines anyway.
NSString *ext = [[url pathExtension] lowercaseString];
if (!isVideo && [sciPassthroughAudioExts() containsObject:ext]) {
sciSendAudioFile(url, threadVC);
return;
}
NSString *out = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"rg_ffaudio_%u.m4a", arc4random()]];
[[NSFileManager defaultManager] removeItemAtPath:out error:nil];
[SCIUtils showToastForDuration:1.5 title:isVideo ? @"Extracting audio..." : @"Converting..."];
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];
@@ -192,9 +212,36 @@ static void sciExportAndSend(NSURL *url, UIViewController *threadVC, BOOL isVide
});
}
// Extensions IG accepts as voice messages without conversion. Append after testing.
// m4a/aac — native iOS recording format
// ogg/opus — what web/desktop IG sends
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;
@@ -261,7 +308,7 @@ static const CGFloat kTrackMargin = 24.0;
sendBtn.frame = CGRectMake(kTrackMargin, bottomY - 56, w - kTrackMargin * 2, 50);
sendBtn.backgroundColor = [UIColor systemBlueColor];
sendBtn.layer.cornerRadius = 14;
[sendBtn setTitle:@"Send Audio" forState:UIControlStateNormal];
[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];
@@ -364,7 +411,7 @@ static const CGFloat kTrackMargin = 24.0;
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:@"Total: %@", [self formatTime:self.totalDuration]];
self.durationLabel.text = [NSString stringWithFormat:SCILocalized(@"Total: %@"), [self formatTime:self.totalDuration]];
[self.view addSubview:self.durationLabel];
// ── cancel X button (top-left) ──
@@ -532,7 +579,7 @@ static const CGFloat kTrackMargin = 24.0;
[self stopPlayback];
double dur = self.endTime - self.startTime;
if (dur < 0.5) {
[SCIUtils showErrorHUDWithDescription:@"Selection too short (min 0.5s)"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Selection too short (min 0.5s)")];
return;
}
@@ -564,28 +611,30 @@ static void sciShowTrimVC(NSURL *url, BOOL isVideo, UIViewController *threadVC)
static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciAudioThreadVC = threadVC;
UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Upload Audio"
UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Upload Audio")
message:nil
preferredStyle:UIAlertControllerStyleActionSheet];
__weak UIViewController *weakVC = threadVC;
[alert addAction:[UIAlertAction actionWithTitle:@"Audio/Video from Files" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[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:@[@"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"]
inMode:UIDocumentPickerModeImport];
initWithDocumentTypes:types inMode:UIDocumentPickerModeImport];
#pragma clang diagnostic pop
picker.delegate = (id<UIDocumentPickerDelegate>)vc;
[vc presentViewController:picker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Video from Library" style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Video from Library") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) {
UIViewController *vc = weakVC;
if (!vc) return;
UIImagePickerController *imgPicker = [[UIImagePickerController alloc] init];
@@ -597,7 +646,7 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
[vc presentViewController:imgPicker animated:YES completion:nil];
}]];
[alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]];
[alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]];
[threadVC presentViewController:alert animated:YES completion:nil];
}
@@ -654,23 +703,52 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
sciDMMenuPending = YES;
}
// file picker delegate — show trim UI
// 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;
// detect if it's a video file
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentAtURL:(NSURL *)url {
if (!url) return;
AVAsset *asset = [AVAsset assetWithURL:url];
BOOL isVideo = [[asset tracksWithMediaType:AVMediaTypeVideo] count] > 0;
sciShowTrimVC(url, isVideo, self);
sciPrepareAndShowTrim(url, self);
}
%new - (void)documentPickerWasCancelled:(UIDocumentPickerViewController *)controller {}
@@ -680,7 +758,7 @@ static void sciShowUploadAudioOptions(UIViewController *threadVC) {
[picker dismissViewControllerAnimated:YES completion:nil];
NSURL *videoURL = info[UIImagePickerControllerMediaURL];
if (!videoURL) {
[SCIUtils showErrorHUDWithDescription:@"Could not get video URL"];
[SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not get video URL")];
return;
}
// UIImagePickerController with allowsEditing already trimmed the video for us
+103
View File
@@ -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
@@ -118,7 +118,7 @@ extern "C" NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *items) {
if (!menuItemCls) return items;
BOOL on = sciIGAudioEnabled();
NSString *title = on ? @"Mute story audio" : @"Unmute story audio";
NSString *title = on ? SCILocalized(@"Mute story audio") : SCILocalized(@"Unmute story audio");
void (^handler)(void) = ^{ sciToggleStoryAudio(); };
id newItem = nil;
@@ -0,0 +1,523 @@
// View story mentions — list mentioned users for the current story item.
// Reachable via eye long-press menu and the 3-dot story menu.
#import "../../Utils.h"
#import "../../InstagramHeaders.h"
#import "../../Networking/SCIInstagramAPI.h"
#import "StoryHelpers.h"
#import <objc/runtime.h>
#import <objc/message.h>
extern __weak UIViewController *sciActiveStoryViewerVC;
// Forward decl — defined below.
static id sciFieldCacheValue(id obj, NSString *key);
static NSString *sciUserPK(id userObj) {
if (!userObj) return nil;
id pk = sciFieldCacheValue(userObj, @"strong_id__");
if (!pk) pk = sciFieldCacheValue(userObj, @"pk");
if (!pk) {
@try {
Ivar pkIvar = class_getInstanceVariable([userObj class], "_pk");
if (pkIvar) pk = object_getIvar(userObj, pkIvar);
} @catch (__unused id e) {}
}
return pk ? [NSString stringWithFormat:@"%@", pk] : nil;
}
static void sciStyleFollowBtn(UIButton *btn, BOOL following) {
[btn setTitle:following ? SCILocalized(@"Following") : SCILocalized(@"Follow") forState:UIControlStateNormal];
btn.backgroundColor = following ? [UIColor tertiarySystemFillColor] : [UIColor systemBlueColor];
[btn setTitleColor:following ? [UIColor labelColor] : [UIColor whiteColor] forState:UIControlStateNormal];
}
// ============ Mention extraction ============
static NSArray *sciCurrentStoryMentions(UIView *anchor) {
UIViewController *storyVC = nil;
if (anchor) storyVC = sciFindVC(anchor, @"IGStoryViewerViewController");
if (!storyVC) storyVC = sciActiveStoryViewerVC;
if (!storyVC) return nil;
UIResponder *start = anchor ?: (UIResponder *)storyVC.view;
id item = sciGetCurrentStoryItem(start);
IGMedia *media = nil;
if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) {
media = (IGMedia *)item;
} else {
media = sciExtractMediaFromItem(item);
}
if (!media) {
@try {
id sc = sciFindSectionController(storyVC);
if (sc) {
SEL csi = NSSelectorFromString(@"currentStoryItem");
if ([sc respondsToSelector:csi])
media = ((id(*)(id,SEL))objc_msgSend)(sc, csi);
}
} @catch (__unused id e) {}
}
if (!media) {
@try {
id vm = sciCall(storyVC, @selector(currentViewModel));
id storyItem = sciCall1(storyVC, @selector(currentStoryItemForViewModel:), vm);
if ([storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) {
media = (IGMedia *)storyItem;
} else {
media = sciExtractMediaFromItem(storyItem);
}
} @catch (__unused id e) {}
}
if (!media) return nil;
SEL sel = NSSelectorFromString(@"reelMentions");
if (![media respondsToSelector:sel]) return nil;
return ((id(*)(id,SEL))objc_msgSend)(media, sel);
}
// IGUser stores fields in a Pando-backed dictionary. KVC goes through a
// resolver that returns NSNull for many keys, so we read the dict directly.
static id sciFieldCacheValue(id obj, NSString *key) {
if (!obj || !key) return nil;
static Ivar fcIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGAPIStorableObject");
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
});
if (!fcIvar) return nil;
NSDictionary *fc = object_getIvar(obj, fcIvar);
if (!fc) return nil;
id val = fc[key];
if (!val || [val isKindOfClass:[NSNull class]]) return nil;
return val;
}
static NSDictionary *sciMentionUserInfo(id mention) {
if (!mention) return nil;
NSMutableDictionary *info = [NSMutableDictionary dictionary];
@try {
id user = [mention valueForKey:@"user"];
if (!user) return nil;
info[@"userObj"] = user;
NSString *username = sciFieldCacheValue(user, @"username");
if (username.length) info[@"username"] = username;
NSString *fullName = sciFieldCacheValue(user, @"full_name");
if (fullName.length) info[@"fullName"] = fullName;
NSString *picStr = sciFieldCacheValue(user, @"profile_pic_url");
if (picStr.length) {
NSURL *picURL = [NSURL URLWithString:picStr];
if (picURL) info[@"picURL"] = picURL;
}
} @catch (__unused id e) {}
return info.count > 1 ? [info copy] : nil;
}
// ============ Bottom sheet VC ============
#define kAvatarSize 52.0
#define kRowHeight 72.0
@interface SCIStoryMentionsVC : UIViewController <UITableViewDataSource, UITableViewDelegate>
@property (nonatomic, strong) NSArray<NSDictionary *> *userInfos;
@property (nonatomic, strong) UITableView *tableView;
@property (nonatomic, strong) NSString *currentUsername;
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSDictionary *> *friendshipStatuses;
@end
@implementation SCIStoryMentionsVC
- (void)viewDidLoad {
[super viewDidLoad];
@try {
id window = [[UIApplication sharedApplication] keyWindow];
if ([window respondsToSelector:@selector(userSession)])
self.currentUsername = ((IGUserSession *)[window valueForKey:@"userSession"]).user.username;
} @catch (__unused id e) {}
UIColor *bg = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) {
return tc.userInterfaceStyle == UIUserInterfaceStyleDark
? [UIColor colorWithRed:0.09 green:0.09 blue:0.09 alpha:1]
: [UIColor colorWithRed:0.98 green:0.98 blue:0.98 alpha:1];
}];
self.view.backgroundColor = bg;
UILabel *titleLabel = [[UILabel alloc] init];
titleLabel.text = SCILocalized(@"Mentions");
titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold];
titleLabel.textColor = [UIColor labelColor];
titleLabel.textAlignment = NSTextAlignmentCenter;
titleLabel.translatesAutoresizingMaskIntoConstraints = NO;
UIButton *closeBtn = [UIButton buttonWithType:UIButtonTypeSystem];
UIImage *closeImg = [UIImage systemImageNamed:@"xmark"
withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:15
weight:UIImageSymbolWeightSemibold]];
[closeBtn setImage:closeImg forState:UIControlStateNormal];
closeBtn.tintColor = [UIColor secondaryLabelColor];
closeBtn.translatesAutoresizingMaskIntoConstraints = NO;
[closeBtn addTarget:self action:@selector(closeTapped) forControlEvents:UIControlEventTouchUpInside];
UIView *sep = [[UIView alloc] init];
sep.backgroundColor = [UIColor separatorColor];
sep.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain];
self.tableView.dataSource = self;
self.tableView.delegate = self;
self.tableView.translatesAutoresizingMaskIntoConstraints = NO;
self.tableView.backgroundColor = bg;
self.tableView.separatorStyle = UITableViewCellSeparatorStyleSingleLine;
self.tableView.separatorColor = [UIColor separatorColor];
self.tableView.separatorInset = UIEdgeInsetsMake(0, 16 + kAvatarSize + 14, 0, 0);
self.tableView.rowHeight = kRowHeight;
[self.view addSubview:titleLabel];
[self.view addSubview:closeBtn];
[self.view addSubview:sep];
[self.view addSubview:self.tableView];
[NSLayoutConstraint activateConstraints:@[
[titleLabel.topAnchor constraintEqualToAnchor:self.view.topAnchor constant:22],
[titleLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor],
[closeBtn.centerYAnchor constraintEqualToAnchor:titleLabel.centerYAnchor],
[closeBtn.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor constant:-16],
[closeBtn.widthAnchor constraintEqualToConstant:30],
[closeBtn.heightAnchor constraintEqualToConstant:30],
[sep.topAnchor constraintEqualToAnchor:titleLabel.bottomAnchor constant:14],
[sep.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[sep.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[sep.heightAnchor constraintEqualToConstant:1.0 / [UIScreen mainScreen].scale],
[self.tableView.topAnchor constraintEqualToAnchor:sep.bottomAnchor],
[self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor],
[self.tableView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
]];
// Bulk-fetch friendship statuses for all mentions in one round trip.
self.friendshipStatuses = [NSMutableDictionary dictionary];
NSMutableArray *pks = [NSMutableArray array];
for (NSDictionary *info in self.userInfos) {
NSString *pk = sciUserPK(info[@"userObj"]);
if (pk.length) [pks addObject:pk];
}
if (pks.count) {
__weak typeof(self) weakSelf = self;
[SCIInstagramAPI fetchFriendshipStatusesForPKs:pks completion:^(NSDictionary *statuses, NSError *error) {
if (!statuses.count) return;
[weakSelf.friendshipStatuses addEntriesFromDictionary:statuses];
[weakSelf.tableView reloadData];
}];
}
if (self.userInfos.count == 0) {
UIImageView *emptyIcon = [[UIImageView alloc] initWithImage:
[UIImage systemImageNamed:@"at"
withConfiguration:[UIImageSymbolConfiguration configurationWithPointSize:36
weight:UIImageSymbolWeightLight]]];
emptyIcon.tintColor = [UIColor tertiaryLabelColor];
emptyIcon.translatesAutoresizingMaskIntoConstraints = NO;
UILabel *emptyLabel = [[UILabel alloc] init];
emptyLabel.text = SCILocalized(@"No mentions in this story");
emptyLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightMedium];
emptyLabel.textColor = [UIColor secondaryLabelColor];
emptyLabel.textAlignment = NSTextAlignmentCenter;
emptyLabel.translatesAutoresizingMaskIntoConstraints = NO;
UIStackView *empty = [[UIStackView alloc] initWithArrangedSubviews:@[emptyIcon, emptyLabel]];
empty.axis = UILayoutConstraintAxisVertical;
empty.spacing = 12;
empty.alignment = UIStackViewAlignmentCenter;
empty.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:empty];
[NSLayoutConstraint activateConstraints:@[
[empty.centerXAnchor constraintEqualToAnchor:self.tableView.centerXAnchor],
[empty.centerYAnchor constraintEqualToAnchor:self.tableView.centerYAnchor],
]];
}
}
- (void)closeTapped {
[self dismissViewControllerAnimated:YES completion:nil];
}
- (void)viewDidDisappear:(BOOL)animated {
[super viewDidDisappear:animated];
// Resume story playback when mentions sheet dismisses
if (sciActiveStoryViewerVC) {
SEL sel = NSSelectorFromString(@"tryResumePlayback");
if ([sciActiveStoryViewerVC respondsToSelector:sel]) {
((void(*)(id,SEL))objc_msgSend)(sciActiveStoryViewerVC, sel);
}
}
}
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
return self.userInfos.count;
}
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
static NSString *rid = @"mention";
UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:rid];
UIImageView *avatar;
UILabel *nameLabel, *subLabel;
UIButton *followBtn;
UIActivityIndicatorView *spinner;
static const NSInteger kAvTag = 101, kNmTag = 102, kSbTag = 103, kFlTag = 104, kSpTag = 105;
if (!cell) {
cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid];
cell.backgroundColor = [UIColor clearColor];
cell.selectionStyle = UITableViewCellSelectionStyleNone;
avatar = [[UIImageView alloc] init];
avatar.tag = kAvTag;
avatar.layer.cornerRadius = kAvatarSize / 2.0;
avatar.clipsToBounds = YES;
avatar.contentMode = UIViewContentModeScaleAspectFill;
avatar.backgroundColor = [UIColor secondarySystemBackgroundColor];
avatar.translatesAutoresizingMaskIntoConstraints = NO;
nameLabel = [[UILabel alloc] init];
nameLabel.tag = kNmTag;
nameLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold];
nameLabel.textColor = [UIColor labelColor];
nameLabel.translatesAutoresizingMaskIntoConstraints = NO;
subLabel = [[UILabel alloc] init];
subLabel.tag = kSbTag;
subLabel.font = [UIFont systemFontOfSize:14];
subLabel.textColor = [UIColor secondaryLabelColor];
subLabel.translatesAutoresizingMaskIntoConstraints = NO;
followBtn = [UIButton buttonWithType:UIButtonTypeSystem];
followBtn.tag = kFlTag;
followBtn.titleLabel.font = [UIFont systemFontOfSize:13 weight:UIFontWeightSemibold];
followBtn.layer.cornerRadius = 8;
followBtn.clipsToBounds = YES;
followBtn.translatesAutoresizingMaskIntoConstraints = NO;
spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium];
spinner.tag = kSpTag;
spinner.hidesWhenStopped = YES;
spinner.translatesAutoresizingMaskIntoConstraints = NO;
UIStackView *text = [[UIStackView alloc] initWithArrangedSubviews:@[nameLabel, subLabel]];
text.axis = UILayoutConstraintAxisVertical;
text.spacing = 2;
text.translatesAutoresizingMaskIntoConstraints = NO;
[cell.contentView addSubview:avatar];
[cell.contentView addSubview:text];
[cell.contentView addSubview:followBtn];
[followBtn addSubview:spinner];
[NSLayoutConstraint activateConstraints:@[
[avatar.leadingAnchor constraintEqualToAnchor:cell.contentView.leadingAnchor constant:16],
[avatar.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[avatar.widthAnchor constraintEqualToConstant:kAvatarSize],
[avatar.heightAnchor constraintEqualToConstant:kAvatarSize],
[text.leadingAnchor constraintEqualToAnchor:avatar.trailingAnchor constant:14],
[text.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[text.trailingAnchor constraintLessThanOrEqualToAnchor:followBtn.leadingAnchor constant:-10],
[followBtn.trailingAnchor constraintEqualToAnchor:cell.contentView.trailingAnchor constant:-16],
[followBtn.centerYAnchor constraintEqualToAnchor:cell.contentView.centerYAnchor],
[followBtn.widthAnchor constraintGreaterThanOrEqualToConstant:90],
[followBtn.heightAnchor constraintEqualToConstant:32],
[spinner.centerXAnchor constraintEqualToAnchor:followBtn.centerXAnchor],
[spinner.centerYAnchor constraintEqualToAnchor:followBtn.centerYAnchor],
]];
} else {
avatar = [cell.contentView viewWithTag:kAvTag];
nameLabel = [cell.contentView viewWithTag:kNmTag];
subLabel = [cell.contentView viewWithTag:kSbTag];
followBtn = [cell.contentView viewWithTag:kFlTag];
spinner = [followBtn viewWithTag:kSpTag];
}
NSDictionary *info = self.userInfos[indexPath.row];
NSString *username = info[@"username"] ?: @"Unknown";
NSString *fullName = info[@"fullName"];
NSURL *picURL = info[@"picURL"];
nameLabel.text = username;
subLabel.text = fullName ?: @"";
subLabel.hidden = !fullName.length;
avatar.image = [UIImage systemImageNamed:@"person.circle.fill"];
avatar.tintColor = [UIColor tertiaryLabelColor];
if (picURL) {
NSURL *url = [picURL copy];
NSInteger row = indexPath.row;
dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:url];
if (!data) return;
UIImage *img = [UIImage imageWithData:data];
if (!img) return;
dispatch_async(dispatch_get_main_queue(), ^{
UITableViewCell *c = [tableView cellForRowAtIndexPath:
[NSIndexPath indexPathForRow:row inSection:0]];
if (!c) return;
UIImageView *av = [c.contentView viewWithTag:kAvTag];
if (av) { av.image = img; av.tintColor = nil; }
});
});
}
[followBtn removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside];
[spinner stopAnimating];
spinner.color = [UIColor whiteColor];
BOOL isMe = self.currentUsername && [username isEqualToString:self.currentUsername];
if (isMe) {
followBtn.hidden = YES;
} else {
followBtn.hidden = NO;
id userObj = info[@"userObj"];
BOOL following = NO;
NSString *pk = sciUserPK(userObj);
NSDictionary *status = pk ? self.friendshipStatuses[pk] : nil;
if ([status isKindOfClass:[NSDictionary class]]) {
following = [status[@"following"] boolValue];
}
sciStyleFollowBtn(followBtn, following);
objc_setAssociatedObject(followBtn, "userObj", userObj, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
[followBtn addTarget:self action:@selector(followTapped:) forControlEvents:UIControlEventTouchUpInside];
}
return cell;
}
- (void)followTapped:(UIButton *)sender {
id userObj = objc_getAssociatedObject(sender, "userObj");
if (!userObj) return;
NSString *pk = sciUserPK(userObj);
if (!pk.length) return;
BOOL currentlyFollowing = [[sender titleForState:UIControlStateNormal] isEqualToString:@"Following"];
void (^doIt)(void) = ^{
UIActivityIndicatorView *spinner = [sender viewWithTag:105];
NSString *savedTitle = [sender titleForState:UIControlStateNormal];
[sender setTitle:@"" forState:UIControlStateNormal];
sender.userInteractionEnabled = NO;
[spinner startAnimating];
__weak typeof(self) weakSelf = self;
SCIAPICompletion done = ^(NSDictionary *response, NSError *error) {
[spinner stopAnimating];
sender.userInteractionEnabled = YES;
BOOL ok = (response && [response[@"status"] isEqualToString:@"ok"]);
if (ok) {
sciStyleFollowBtn(sender, !currentlyFollowing);
NSMutableDictionary *s = [weakSelf.friendshipStatuses[pk] mutableCopy] ?: [NSMutableDictionary dictionary];
s[@"following"] = @(!currentlyFollowing);
weakSelf.friendshipStatuses[pk] = [s copy];
} else {
[sender setTitle:savedTitle forState:UIControlStateNormal];
}
};
if (currentlyFollowing) [SCIInstagramAPI unfollowUserPK:pk completion:done];
else [SCIInstagramAPI followUserPK:pk completion:done];
};
if (!currentlyFollowing && [SCIUtils getBoolPref:@"follow_confirm"]) {
[SCIUtils showConfirmation:doIt];
} else if (currentlyFollowing && [SCIUtils getBoolPref:@"unfollow_confirm"]) {
[SCIUtils showConfirmation:doIt];
} else {
doIt();
}
}
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
NSDictionary *info = self.userInfos[indexPath.row];
NSString *username = info[@"username"];
if (!username) return;
[self dismissViewControllerAnimated:YES completion:^{
NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", username]];
if ([[UIApplication sharedApplication] canOpenURL:url])
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}];
}
@end
// ============ Entry points ============
void sciShowStoryMentions(UIViewController *presenter, UIView *anchor) {
if (![SCIUtils getBoolPref:@"view_story_mentions"]) return;
NSArray *mentions = sciCurrentStoryMentions(anchor);
NSMutableArray *infos = [NSMutableArray array];
for (id mention in mentions) {
NSDictionary *info = sciMentionUserInfo(mention);
if (info) [infos addObject:info];
}
SCIStoryMentionsVC *vc = [[SCIStoryMentionsVC alloc] init];
vc.userInfos = [infos copy];
vc.modalPresentationStyle = UIModalPresentationPageSheet;
if (@available(iOS 15.0, *)) {
UISheetPresentationController *sheet = vc.sheetPresentationController;
sheet.detents = @[UISheetPresentationControllerDetent.mediumDetent,
UISheetPresentationControllerDetent.largeDetent];
@try { [sheet setValue:@YES forKey:@"prefersGrabberIndicator"]; } @catch (__unused id e) {}
sheet.prefersScrollingExpandsWhenScrolledToEdge = YES;
}
[presenter presentViewController:vc animated:YES completion:nil];
}
NSArray *sciMaybeAppendStoryMentionsMenuItem(NSArray *items) {
if (!sciActiveStoryViewerVC) return items;
if (![SCIUtils getBoolPref:@"view_story_mentions"]) 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;
__weak UIViewController *weakVC = sciActiveStoryViewerVC;
void (^handler)(void) = ^{
UIViewController *vc = weakVC;
if (!vc) return;
sciShowStoryMentions(vc, vc.view);
};
id newItem = nil;
@try {
typedef id (*Init)(id, SEL, id, id, id);
newItem = ((Init)objc_msgSend)([menuItemCls alloc],
@selector(initWithTitle:image:handler:), @"View mentions", nil, handler);
} @catch (__unused id e) {}
if (!newItem) return items;
NSMutableArray *newItems = [items mutableCopy];
[newItems addObject:newItem];
return [newItems copy];
}
+34
View File
@@ -293,6 +293,9 @@
- (void)handleLongPress:(UILongPressGestureRecognizer *)gr; // new
@end
@interface IGHomeFeedHeaderView : UIView
@end
@interface IGHomeFeedHeaderViewController
- (void)headerDidLongPressLogo:(id)arg1;
@end
@@ -434,6 +437,9 @@
@interface IGUFIInteractionCountsView : UIView
@end
@interface IGUFIButtonBarView : UIView
@end
@interface IGFeedItemUFICell : UIView
- (void)UFIButtonBarDidTapOnRepost:(id)arg1;
@end
@@ -482,6 +488,9 @@
@property (readonly, nonatomic) long long destination;
@end
@interface IGCommentThreadConfiguration : NSObject
@end
@interface IGDSMenuItem : NSObject
@end
@@ -520,6 +529,31 @@
@property (readonly, nonatomic) IGCreationActionBarButton *button;
@end
// Call buttons in DM thread header. Coordinator owns _audioCallButton / _videoCallButton
// (both IGDirectCallButton) and forwards taps to _didTapAudioButton: / _didTapVideoButton:.
// Discovered by dumping the thread VC view hierarchy for IGDirectCallButton.
@interface IGDirectThreadCallButtonsCoordinator : NSObject @end
@interface IGDirectCallButton : UIView @end
// IG's UINavigationBar subclass — hosts the iOS 26 liquid-glass platter layout.
@interface IGNavigationBar : UINavigationBar @end
// Story tray list adapter — drives data source updates for the home feed tray.
@interface IGListAdapter : NSObject
- (void)performUpdatesAnimated:(BOOL)animated completion:(void (^)(BOOL))completion;
@end
// Reels/feed video cell — used for long-press zoom gesture attachment.
@interface IGFeedItemPageVideoCell : UICollectionViewCell @end
// Profile page view controller — `user` is the IGUser being displayed.
@interface IGProfileViewController : UIViewController
@property (nonatomic, strong) id user;
@end
// Notes thought-bubble view on profiles — the note's touch target.
@interface IGDirectNotesThoughtBubbleView : UIView @end
/////////////////////////////////////////////////////////////////////////////
@@ -0,0 +1,905 @@
/*
* RyukGram — Localizable.strings (English source of truth)
* -------------------------------------------------------------------------
*
* Every user-facing string in RyukGram goes through the macro
* SCILocalized(@"English text here")
* in the Objective-C source. The argument is BOTH the lookup key and the
* English fallback, so if a translation is missing the user still sees
* clean English — nothing ever breaks.
*
*
* HOW TO ADD A NEW LANGUAGE
* -------------------------------------------------------------------------
*
* 1. Copy this file into a new folder named after the language code:
* src/Localization/Resources/<code>.lproj/Localizable.strings
* e.g. ar.lproj (Arabic)
* es.lproj (Spanish)
* fr.lproj (French)
* 2. Translate the RIGHT-hand side of every `"key" = "value";` line.
* Do NOT touch the left-hand side — that is the lookup key and must
* stay identical to the English version, otherwise the app will never
* find your translation.
* 3. Keep every format specifier (%@, %lu, %d, %lld, %1$@, …) exactly
* as-is, in the same order. If you need to reorder them, switch to
* positional specifiers (%1$@ %2$lu).
* 4. Keep embedded quotes escaped with a backslash: \" — and newlines
* as \n.
* 5. Open a pull request at https://github.com/faroukbmiled/RyukGram/pulls
* so we can ship the language in the next release.
*
*
* HOW TO ADD A NEW STRING IN CODE
* -------------------------------------------------------------------------
*
* Just wrap the English text with SCILocalized(...) in the .m / .x / .xm
* file — the helper resolves to the English text automatically when no
* translation exists. Then add the same English text as BOTH the key and
* the value inside the matching section below, e.g.
*
* "Download all items" = "Download all items";
*
* Translators copy that line into their own .lproj and translate only the
* right-hand side.
*
*
* FILE FORMAT NOTES
* -------------------------------------------------------------------------
*
* - UTF-8, LF line endings.
* - Slash-star block comments and double-slash line comments both work.
* - DO NOT nest one slash-star block comment inside another — the
* parser will close the outer block at the first inner close marker
* and every lookup in the file will silently fail.
* - Keys and values are both quoted; every line ends with a semicolon.
*/
//////////////////////////////////////////////////////////////////////////////
// CHROME — TOP BAR, LANGUAGE PICKER, FIRST-RUN //
// Shown on the root Settings screen: title, search bar, the globe language //
// menu, and the one-time welcome alert. These use dotted keys (settings.*) //
// and are hand-authored rather than extracted from English source. //
//////////////////////////////////////////////////////////////////////////////
"settings.firstrun.message" = "In the future: Hold down on the three lines at the top right of your profile page, to re-open RyukGram settings.";
"settings.firstrun.ok" = "I understand!";
"settings.firstrun.title" = "RyukGram Settings Info";
"settings.language.system" = "System default";
"settings.language.title" = "Language";
"settings.language.english_only" = "RyukGram currently ships with English only. Other languages are wired up and waiting for translations — help translate into your language by following the short guide in the README.";
"settings.language.ok" = "OK";
"settings.language.help_translate" = "Help translate";
"settings.results.many" = "%lu results";
"settings.results.none" = "No results";
"settings.results.one" = "%lu result";
"settings.search.placeholder" = "Search settings";
"settings.title" = "RyukGram Settings";
//////////////////////////////////////////////////////////////////////////////
// GENERAL //
// Settings → General tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a copy option to the comment long-press menu" = "Adds a copy option to the comment long-press menu";
"Adds a download option for GIF comments" = "Adds a download option for GIF comments";
"Browser" = "Browser";
"Comments" = "Comments";
"Copy comment text" = "Copy comment text";
"Copy description" = "Copy description";
"Copy description text fields by long-pressing on them" = "Copy description text fields by long-pressing on them";
"Date format" = "Date format";
"Disable app haptics" = "Disable app haptics";
"Disables haptics/vibrations within the app" = "Disables haptics/vibrations within the app";
"Do not save recent searches" = "Do not save recent searches";
"Download GIF comments" = "Download GIF comments";
"Embed domain" = "Embed domain";
"Embed domain: %@" = "Embed domain: %@";
"Enable liquid glass buttons" = "Enable liquid glass buttons";
"Enable liquid glass surfaces" = "Enable liquid glass surfaces";
"Enable teen app icons" = "Enable teen app icons";
"Enables experimental liquid glass buttons" = "Enables experimental liquid glass buttons";
"Enables liquid glass tab bar, floating navigation, and other UI elements" = "Enables liquid glass tab bar, floating navigation, and other UI elements";
"Experimental features" = "Experimental features";
"Focus/distractions" = "Focus/distractions";
"General" = "General";
"Hide Meta AI" = "Hide Meta AI";
"Hide ads" = "Hide ads";
"Hide explore posts grid" = "Hide explore posts grid";
"Hide friends map" = "Hide friends map";
"Hide metrics" = "Hide metrics";
"Hide notes tray" = "Hide notes tray";
"Hide trending searches" = "Hide trending searches";
"Hides all suggested users for you to follow, outside your feed" = "Hides all suggested users for you to follow, outside your feed";
"Hides like/comment/share counts on posts and reels" = "Hides like/comment/share counts on posts and reels";
"Hides the friends map icon in the notes tray" = "Hides the friends map icon in the notes tray";
"Hides the grid of suggested posts on the explore/search tab" = "Hides the grid of suggested posts on the explore/search tab";
"Hides the meta ai buttons/functionality within the app" = "Hides the meta ai buttons/functionality within the app";
"Hides the notes tray in the DM inbox" = "Hides the notes tray in the DM inbox";
"Hides the suggested broadcast channels in direct messages" = "Hides the suggested broadcast channels in direct messages";
"Hides the trending searches under the explore search bar" = "Hides the trending searches under the explore search bar";
"Hold down on the Instagram logo to change the app icon" = "Hold down on the Instagram logo to change the app icon";
"Long press on the eyedropper tool in stories to customize the text color more precisely" = "Long press on the eyedropper tool in stories to customize the text color more precisely";
"No suggested chats" = "No suggested chats";
"No suggested users" = "No suggested users";
"Notes" = "Notes";
"Open links in external browser" = "Open links in external browser";
"Opens links in Safari instead of Instagram's in-app browser" = "Opens links in Safari instead of Instagram's in-app browser";
"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs";
"Removes all ads from the Instagram app" = "Removes all ads from the Instagram app";
"Removes igsh, utm_source, and other tracking parameters from shared links" = "Removes igsh, utm_source, and other tracking parameters from shared links";
"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker.";
"Replace domain in shared links" = "Replace domain in shared links";
"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc.";
"Search bars will no longer save your recent searches" = "Search bars will no longer save your recent searches";
"Sharing" = "Sharing";
"Strip tracking from links" = "Strip tracking from links";
"Strip tracking params" = "Strip tracking params";
"These features rely on hidden Instagram flags and may not work on all accounts or versions.\nExperimental flags research by @euoradan (Radan)." = "These features rely on hidden Instagram flags and may not work on all accounts or versions.\nExperimental flags research by @euoradan (Radan).";
"Use detailed color picker" = "Use detailed color picker";
//////////////////////////////////////////////////////////////////////////////
// DATE FORMAT //
// Settings → Date format tab //
//////////////////////////////////////////////////////////////////////////////
"Alternate" = "Alternate";
"Always ask" = "Always ask";
"Balanced" = "Balanced";
"Block all" = "Block all";
"Block selected" = "Block selected";
"Button" = "Button";
"Classic" = "Classic";
"Date format — %@" = "Date format — %@";
"Default" = "Default";
"Disabled" = "Disabled";
"Download and share" = "Download and share";
"Download to Photos" = "Download to Photos";
"Enabled" = "Enabled";
"Expand" = "Expand";
"Explore" = "Explore";
"Fast" = "Fast";
"Feed" = "Feed";
"High" = "High";
"Inbox" = "Inbox";
"Low" = "Low";
"Max" = "Max";
"Medium" = "Medium";
"Mute/Unmute" = "Mute/Unmute";
"Open menu" = "Open menu";
"Pause/Play" = "Pause/Play";
"Profile" = "Profile";
"Quality" = "Quality";
"Reels" = "Reels";
"Requires restart" = "Requires restart";
"Save to Photos" = "Save to Photos";
"Share sheet" = "Share sheet";
"Standard" = "Standard";
"Toggle" = "Toggle";
//////////////////////////////////////////////////////////////////////////////
// FEED //
// Settings → Feed tab //
//////////////////////////////////////////////////////////////////////////////
"Action button" = "Action button";
"Adds 'View profile picture' and 'View cover' to story tray long-press menus" = "Adds 'View profile picture' and 'View cover' to story tray long-press menus";
"Adds a RyukGram action button under each feed post with download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below." = "Adds a RyukGram action button under each feed post with download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below.";
"Controls when and how the feed refreshes. Background refresh occurs when returning to the app after ~10 minutes. Home button refresh occurs when tapping the Home tab while already on it." = "Controls when and how the feed refreshes. Background refresh occurs when returning to the app after ~10 minutes. Home button refresh occurs when tapping the Home tab while already on it.";
"Default tap action" = "Default tap action";
"Disable background refresh" = "Disable background refresh";
"Disable home button refresh" = "Disable home button refresh";
"Disable home button scroll" = "Disable home button scroll";
"Disable video autoplay" = "Disable video autoplay";
"Hide" = "Hide";
"Hide entire feed" = "Hide entire feed";
"Hide repost button" = "Hide repost button";
"Hide stories tray" = "Hide stories tray";
"Hide suggested stories" = "Hide suggested stories";
"Hides suggested accounts" = "Hides suggested accounts";
"Hides suggested reels" = "Hides suggested reels";
"Hides suggested threads posts" = "Hides suggested threads posts";
"Hides the repost button on feed posts" = "Hides the repost button on feed posts";
"Hides the story tray at the top" = "Hides the story tray at the top";
"Inserts a button row below like/comment/share on each post" = "Inserts a button row below like/comment/share on each post";
"Long press on media to expand in full-screen viewer" = "Long press on media to expand in full-screen viewer";
"Media" = "Media";
"Media zoom" = "Media zoom";
"No suggested for you" = "No suggested for you";
"No suggested posts" = "No suggested posts";
"No suggested reels" = "No suggested reels";
"No suggested threads" = "No suggested threads";
"Prevents feed from reloading when returning from background" = "Prevents feed from reloading when returning from background";
"Prevents videos from playing automatically" = "Prevents videos from playing automatically";
"Refresh" = "Refresh";
"Removes all content from your home feed" = "Removes all content from your home feed";
"Removes suggested accounts from the stories tray" = "Removes suggested accounts from the stories tray";
"Removes suggested posts" = "Removes suggested posts";
"Scroll to top without refreshing when tapping Home" = "Scroll to top without refreshing when tapping Home";
"Show action button" = "Show action button";
"Stories tray" = "Stories tray";
"Tapping Home does nothing when already on feed" = "Tapping Home does nothing when already on feed";
"Tray long-press actions" = "Tray long-press actions";
"What happens on a single tap. Long-press always opens the full menu" = "What happens on a single tap. Long-press always opens the full menu";
//////////////////////////////////////////////////////////////////////////////
// REELS //
// Settings → Reels tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a RyukGram action button above the reel sidebar with view-cover/download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below." = "Adds a RyukGram action button above the reel sidebar with view-cover/download/share/copy/expand/repost entries. Tap opens the menu by default; change the tap behavior below.";
"Always show progress scrubber" = "Always show progress scrubber";
"Change what happens when you tap on a reel" = "Change what happens when you tap on a reel";
"Confirm reel refresh" = "Confirm reel refresh";
"Disable auto-unmuting reels" = "Disable auto-unmuting reels";
"Disable scrolling reels" = "Disable scrolling reels";
"Disable tab button refresh" = "Disable tab button refresh";
"Doom scrolling limit" = "Doom scrolling limit";
"Forces the progress bar to appear on every reel" = "Forces the progress bar to appear on every reel";
"Hide reels header" = "Hide reels header";
"Hides the repost button on the reels sidebar" = "Hides the repost button on the reels sidebar";
"Hides the top navigation bar when watching reels" = "Hides the top navigation bar when watching reels";
"Hiding" = "Hiding";
"Limits" = "Limits";
"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "Limits the amount of reels available to scroll at any given time, and prevents refreshing";
"Only loads %@ %@" = "Only loads %@ %@";
"Places a button above the like/comment/share column on each reel" = "Places a button above the like/comment/share column on each reel";
"Prevent doom scrolling" = "Prevent doom scrolling";
"Prevents reels from being scrolled to the next video" = "Prevents reels from being scrolled to the next video";
"Prevents reels from unmuting when the volume/silent button is pressed" = "Prevents reels from unmuting when the volume/silent button is pressed";
"Shows an alert when you trigger a reels refresh" = "Shows an alert when you trigger a reels refresh";
"Shows buttons to reveal and auto-fill the password on locked reels" = "Shows buttons to reveal and auto-fill the password on locked reels";
"Tap Controls" = "Tap Controls";
"Tapping the Reels tab while on reels does nothing" = "Tapping the Reels tab while on reels does nothing";
"Unlock password-locked reels" = "Unlock password-locked reels";
//////////////////////////////////////////////////////////////////////////////
// PROFILE //
// Settings → Profile tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a button next to the burger menu on profiles to copy username, name or bio" = "Adds a button next to the burger menu on profiles to copy username, name or bio";
"Adds a view option to the highlight long-press menu to open the cover in full-screen" = "Adds a view option to the highlight long-press menu to open the cover in full-screen";
"Copy note on long press" = "Copy note on long press";
"Follow indicator" = "Follow indicator";
"Long press a profile picture to open it in full-screen with zoom, share, and save" = "Long press a profile picture to open it in full-screen with zoom, share, and save";
"Long press the note bubble on a profile to copy the text" = "Long press the note bubble on a profile to copy the text";
"Long press to download directly (ignored when zoom is on)" = "Long press to download directly (ignored when zoom is on)";
"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "Long-press gestures on profile elements — kept separate from the per-feature action buttons.";
"Profile copy button" = "Profile copy button";
"Save profile picture" = "Save profile picture";
"Shows whether the profile user follows you" = "Shows whether the profile user follows you";
"View highlight cover" = "View highlight cover";
"Zoom profile photo" = "Zoom profile photo";
//////////////////////////////////////////////////////////////////////////////
// SAVING & DOWNLOADS //
// Settings → Saving tab //
//////////////////////////////////////////////////////////////////////////////
"Confirm before download" = "Confirm before download";
"Deprecated. The RyukGram action button (configured per feature in Feed/Reels/Stories) is the new way to download media. Enable this master toggle only if you prefer the old multi-finger long-press directly on the media." = "Deprecated. The RyukGram action button (configured per feature in Feed/Reels/Stories) is the new way to download media. Enable this master toggle only if you prefer the old multi-finger long-press directly on the media.";
"Downloads" = "Downloads";
"Downloads with %@ %@" = "Downloads with %@ %@";
"Enable long-press gesture" = "Enable long-press gesture";
"Finger count for long-press" = "Finger count for long-press";
"Legacy long-press gesture" = "Legacy long-press gesture";
"Long-press hold time" = "Long-press hold time";
"Master toggle for the deprecated gesture workflow (off by default)" = "Master toggle for the deprecated gesture workflow (off by default)";
"Press finger(s) for %@ %@" = "Press finger(s) for %@ %@";
"Route saves into a dedicated album in Photos instead of the camera roll root" = "Route saves into a dedicated album in Photos instead of the camera roll root";
"Save action" = "Save action";
"Save to RyukGram album" = "Save to RyukGram album";
"Saving" = "Saving";
"Show a confirmation dialog before starting a download" = "Show a confirmation dialog before starting a download";
"What happens after the gesture downloads" = "What happens after the gesture downloads";
"When \"Save to RyukGram album\" is on, downloads and share-sheet \"Save to Photos\" picks are routed into a dedicated \"RyukGram\" album in your Photos library." = "When \"Save to RyukGram album\" is on, downloads and share-sheet \"Save to Photos\" picks are routed into a dedicated \"RyukGram\" album in your Photos library.";
//////////////////////////////////////////////////////////////////////////////
// STORIES //
// Settings → Stories tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a RyukGram action button next to the eye button on stories with download/share/copy/expand/repost/view-mentions entries. Tap opens the menu by default; change the tap behavior below." = "Adds a RyukGram action button next to the eye button on stories with download/share/copy/expand/repost/view-mentions entries. Tap opens the menu by default; change the tap behavior below.";
"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu" = "Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu";
"Advance on story like" = "Advance on story like";
"Advance on story reply" = "Advance on story reply";
"Advance when marking as seen" = "Advance when marking as seen";
"Audio" = "Audio";
"Block all: all stories blocked — listed users are exceptions.\nBlock selected: only listed users are blocked — everything else is normal.\nBoth lists are saved independently." = "Block all: all stories blocked — listed users are exceptions.\nBlock selected: only listed users are blocked — everything else is normal.\nBoth lists are saved independently.";
"Blocking mode" = "Blocking mode";
"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" = "Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)";
"Disable instants creation" = "Disable instants creation";
"Disable story seen receipt" = "Disable story seen receipt";
"Enable story user list" = "Enable story user list";
"Hides the functionality to create/send instants" = "Hides the functionality to create/send instants";
"Hides the notification for others when you view their story" = "Hides the notification for others when you view their story";
"Inserts a button next to the seen/eye button on story overlays" = "Inserts a button next to the seen/eye button on story overlays";
"Keep stories visually unseen" = "Keep stories visually unseen";
"Liking a story automatically advances to the next one after a short delay" = "Liking a story automatically advances to the next one after a short delay";
"Manage list" = "Manage list";
"Manage list (%lu)" = "Manage list (%lu)";
"Manual seen button mode" = "Manual seen button mode";
"Mark seen on story like" = "Mark seen on story like";
"Mark seen on story reply" = "Mark seen on story reply";
"Marks a story as seen the moment you tap the heart, even with seen blocking on" = "Marks a story as seen the moment you tap the heart, even with seen blocking on";
"Marks a story as seen when you send a reply or emoji reaction, even with seen blocking on" = "Marks a story as seen when you send a reply or emoji reaction, even with seen blocking on";
"Master toggle. When off, the list is ignored" = "Master toggle. When off, the list is ignored";
"Other" = "Other";
"Playback" = "Playback";
"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" = "Prevents stories from visually marking as seen in the tray (keeps colorful ring)";
"Quick list button in stories" = "Quick list button in stories";
"Search, sort, swipe to remove" = "Search, sort, swipe to remove";
"Seen receipts" = "Seen receipts";
"Sending a reply or emoji reaction automatically advances to the next story" = "Sending a reply or emoji reaction automatically advances to the next story";
"Show mentioned users in eye button and story menu" = "Show mentioned users in eye button and story menu";
"Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only" = "Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only";
"Stop story auto-advance" = "Stop story auto-advance";
"Stories" = "Stories";
"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "Stories won't auto-skip to the next one when the timer ends. Tap to advance manually";
"Story audio toggle" = "Story audio toggle";
"Story user list" = "Story user list";
"Tapping the eye button to mark a story as seen advances to the next story automatically" = "Tapping the eye button to mark a story as seen advances to the next story automatically";
"View story mentions" = "View story mentions";
"Which stories get seen-receipt blocking" = "Which stories get seen-receipt blocking";
//////////////////////////////////////////////////////////////////////////////
// MESSAGES — READ RECEIPTS //
// Settings → Read receipts tab //
//////////////////////////////////////////////////////////////////////////////
"Adds a button to DM threads to mark messages as seen" = "Adds a button to DM threads to mark messages as seen";
"Auto mark seen on interact" = "Auto mark seen on interact";
"Auto mark seen on typing" = "Auto mark seen on typing";
"Control when messages are marked as seen" = "Control when messages are marked as seen";
"How the seen button behaves" = "How the seen button behaves";
"Manually mark messages as seen" = "Manually mark messages as seen";
"Marks messages as seen when you send any message" = "Marks messages as seen when you send any message";
"Marks messages as seen when you start typing" = "Marks messages as seen when you start typing";
"Read receipt mode" = "Read receipt mode";
"Read receipts" = "Read receipts";
//////////////////////////////////////////////////////////////////////////////
// MESSAGES — KEEP DELETED //
// Settings → Keep deleted messages tab //
//////////////////////////////////////////////////////////////////////////////
"Activity" = "Activity";
"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" = "Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio";
"Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram" = "Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram";
"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages" = "Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages";
"Adds copy text, download GIF/audio to the note long-press menu" = "Adds copy text, download GIF/audio to the note long-press menu";
"Block all: all chats blocked — listed chats are exceptions.\nBlock selected: only listed chats are blocked — everything else is normal.\nBoth lists are saved independently. Long-press a chat in the inbox to add or remove." = "Block all: all chats blocked — listed chats are exceptions.\nBlock selected: only listed chats are blocked — everything else is normal.\nBoth lists are saved independently. Long-press a chat in the inbox to add or remove.";
"Block keep-deleted for excluded chats" = "Block keep-deleted for excluded chats";
"Block keep-deleted for unlisted chats" = "Block keep-deleted for unlisted chats";
"Chat list" = "Chat list";
"Confirmation dialog before clearing preserved messages" = "Confirmation dialog before clearing preserved messages";
"Copies note text directly on long press without opening the menu" = "Copies note text directly on long press without opening the menu";
"Copy text on hold" = "Copy text on hold";
"Custom emojis and background/text colors" = "Custom emojis and background/text colors";
"Custom note themes" = "Custom note themes";
"Disable disappearing mode swipe" = "Disable disappearing mode swipe";
"Disable screenshot detection" = "Disable screenshot detection";
"Disable typing status" = "Disable typing status";
"Disable view-once limitations" = "Disable view-once limitations";
"Download voice messages" = "Download voice messages";
"Enable chat list" = "Enable chat list";
"Enable note theming" = "Enable note theming";
"Enables the notes theme picker" = "Enables the notes theme picker";
"Files" = "Files";
"Full last active date" = "Full last active date";
"Hide reels blend button" = "Hide reels blend button";
"Hide video call button" = "Hide video call button";
"Hide voice call button" = "Hide voice call button";
"Hides the blend button in DMs" = "Hides the blend button in DMs";
"Hides typing indicator from others" = "Hides typing indicator from others";
"Indicate unsent messages" = "Indicate unsent messages";
"Keep deleted messages" = "Keep deleted messages";
"Makes view-once messages behave like normal visual messages (loopable/pauseable)" = "Makes view-once messages behave like normal visual messages (loopable/pauseable)";
"Note actions" = "Note actions";
"Preserve messages that others unsend" = "Preserve messages that others unsend";
"Preserves messages that others unsend" = "Preserves messages that others unsend";
"Prevents accidental swipe-up activation of disappearing mode" = "Prevents accidental swipe-up activation of disappearing mode";
"Quick list button in chats" = "Quick list button in chats";
"Removes the audio call button from DM thread header" = "Removes the audio call button from DM thread header";
"Removes the screenshot-prevention features for visual messages in DMs" = "Removes the screenshot-prevention features for visual messages in DMs";
"Removes the video call button from DM thread header" = "Removes the video call button from DM thread header";
"Replay visual messages without expiring. Toggle in the eye button menu, or as a standalone button when the eye button is disabled" = "Replay visual messages without expiring. Toggle in the eye button menu, or as a standalone button when the eye button is disabled";
"Search, sort, swipe to remove or toggle keep-deleted" = "Search, sort, swipe to remove or toggle keep-deleted";
"Send audio as file" = "Send audio as file";
"Send files (experimental)" = "Send files (experimental)";
"Show full date instead of \"Active 2h ago\"" = "Show full date instead of \"Active 2h ago\"";
"Shows a button in DM threads to add/remove chats from the list. Long-press for more options" = "Shows a button in DM threads to add/remove chats from the list. Long-press for more options";
"Shows a notification pill when a message is unsent" = "Shows a notification pill when a message is unsent";
"Shows an \"Unsent\" label on preserved messages" = "Shows an \"Unsent\" label on preserved messages";
"Unlimited replay of visual messages" = "Unlimited replay of visual messages";
"Unsent message notification" = "Unsent message notification";
"Visual messages" = "Visual messages";
"Voice messages" = "Voice messages";
"Warn before clearing on refresh" = "Warn before clearing on refresh";
"Which chats get read-receipt blocking" = "Which chats get read-receipt blocking";
"⚠️ Pull-to-refresh in the DMs tab clears all preserved messages. Enable the warning below to get a confirmation dialog." = "⚠️ Pull-to-refresh in the DMs tab clears all preserved messages. Enable the warning below to get a confirmation dialog.";
//////////////////////////////////////////////////////////////////////////////
// MESSAGES //
// Settings → Messages tab //
//////////////////////////////////////////////////////////////////////////////
"Messages" = "Messages";
"Threads" = "Threads";
//////////////////////////////////////////////////////////////////////////////
// NAVIGATION //
// Settings → Navigation tab //
//////////////////////////////////////////////////////////////////////////////
"Hide create tab" = "Hide create tab";
"Hide explore tab" = "Hide explore tab";
"Hide feed tab" = "Hide feed tab";
"Hide messages tab" = "Hide messages tab";
"Hide reels tab" = "Hide reels tab";
"Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab." = "Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab.";
"Hides the create tab on the bottom navigation bar" = "Hides the create tab on the bottom navigation bar";
"Hides the direct messages tab on the bottom navigation bar" = "Hides the direct messages tab on the bottom navigation bar";
"Hides the explore/search tab on the bottom navigation bar" = "Hides the explore/search tab on the bottom navigation bar";
"Hides the feed/home tab on the bottom navigation bar" = "Hides the feed/home tab on the bottom navigation bar";
"Hides the reels tab on the bottom navigation bar" = "Hides the reels tab on the bottom navigation bar";
"Hiding tabs" = "Hiding tabs";
"Icon order" = "Icon order";
"Launch tab" = "Launch tab";
"Lets you swipe to switch between navigation bar tabs" = "Lets you swipe to switch between navigation bar tabs";
"Messages only" = "Messages only";
"Messages-only mode" = "Messages-only mode";
"Navigation" = "Navigation";
"Swipe between tabs" = "Swipe between tabs";
"Tab the app opens to. Ignored when Messages-only is on" = "Tab the app opens to. Ignored when Messages-only is on";
"The order of the icons on the bottom navigation bar" = "The order of the icons on the bottom navigation bar";
"Turn IG into a DM-only client" = "Turn IG into a DM-only client";
//////////////////////////////////////////////////////////////////////////////
// CONFIRM ACTIONS //
// Settings → Confirm actions tab //
//////////////////////////////////////////////////////////////////////////////
"Confirm actions" = "Confirm actions";
"Confirm call" = "Confirm call";
"Confirm changing theme" = "Confirm changing theme";
"Confirm follow" = "Confirm follow";
"Confirm follow requests" = "Confirm follow requests";
"Confirm like: Posts/Stories" = "Confirm like: Posts/Stories";
"Confirm like: Reels" = "Confirm like: Reels";
"Confirm posting comment" = "Confirm posting comment";
"Confirm repost" = "Confirm repost";
"Confirm shh mode" = "Confirm shh mode";
"Confirm sticker interaction" = "Confirm sticker interaction";
"Confirm unfollow" = "Confirm unfollow";
"Confirm voice messages" = "Confirm voice messages";
"Shows an alert to confirm before sending a voice message" = "Shows an alert to confirm before sending a voice message";
"Shows an alert to confirm before toggling disappearing messages" = "Shows an alert to confirm before toggling disappearing messages";
"Shows an alert when you accept/decline a follow request" = "Shows an alert when you accept/decline a follow request";
"Shows an alert when you change a chat theme to confirm" = "Shows an alert when you change a chat theme to confirm";
"Shows an alert when you click a sticker on someone's story to confirm the action" = "Shows an alert when you click a sticker on someone's story to confirm the action";
"Shows an alert when you click the audio/video call button to confirm before calling" = "Shows an alert when you click the audio/video call button to confirm before calling";
"Shows an alert when you click the follow button to confirm the follow" = "Shows an alert when you click the follow button to confirm the follow";
"Shows an alert when you click the like button on posts or stories to confirm the like" = "Shows an alert when you click the like button on posts or stories to confirm the like";
"Shows an alert when you click the like button on reels to confirm the like" = "Shows an alert when you click the like button on reels to confirm the like";
"Shows an alert when you click the post comment button to confirm" = "Shows an alert when you click the post comment button to confirm";
"Shows an alert when you click the repost button to confirm before resposting" = "Shows an alert when you click the repost button to confirm before resposting";
"Shows an alert when you click the unfollow button to confirm" = "Shows an alert when you click the unfollow button to confirm";
//////////////////////////////////////////////////////////////////////////////
// BACKUP & RESTORE //
// Settings → Backup & Restore tab //
//////////////////////////////////////////////////////////////////////////////
"Backup & Restore" = "Backup & Restore";
"Export settings" = "Export settings";
"Export your RyukGram settings to a JSON file and import them later. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes." = "Export your RyukGram settings to a JSON file and import them later. Importing resets all settings to defaults before applying the imported values, and shows a preview before anything changes.";
"Import settings" = "Import settings";
"Load settings from a JSON file" = "Load settings from a JSON file";
"Reset to defaults" = "Reset to defaults";
"Revert every RyukGram preference" = "Revert every RyukGram preference";
"Save settings as a JSON file" = "Save settings as a JSON file";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL //
// Settings → Experimental tab //
//////////////////////////////////////////////////////////////////////////////
"Experimental" = "Experimental";
"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!";
"Warning" = "Warning";
//////////////////////////////////////////////////////////////////////////////
// ADVANCED //
// Settings → Advanced tab //
//////////////////////////////////////////////////////////////////////////////
"Advanced" = "Advanced";
"Automatically opens settings when the app launches" = "Automatically opens settings when the app launches";
"Disable safe mode" = "Disable safe mode";
"Enable tweak settings quick-access" = "Enable tweak settings quick-access";
"Hold on the home tab to open RyukGram settings" = "Hold on the home tab to open RyukGram settings";
"Instagram" = "Instagram";
"Pause playback when opening settings" = "Pause playback when opening settings";
"Pauses any playing video/audio when settings opens" = "Pauses any playing video/audio when settings opens";
"Prevents Instagram from resetting settings after crashes (at your own risk)" = "Prevents Instagram from resetting settings after crashes (at your own risk)";
"Reset onboarding state" = "Reset onboarding state";
"Settings" = "Settings";
"Show tweak settings on app launch" = "Show tweak settings on app launch";
//////////////////////////////////////////////////////////////////////////////
// DEBUG //
// Settings → Debug tab //
//////////////////////////////////////////////////////////////////////////////
"Button Cell" = "Button Cell";
"Change the value on the right" = "Change the value on the right";
"Debug" = "Debug";
"Enable FLEX gesture" = "Enable FLEX gesture";
"Hold 5 fingers on the screen to open FLEX" = "Hold 5 fingers on the screen to open FLEX";
"I have %@%@" = "I have %@%@";
"Link Cell" = "Link Cell";
"Menu Cell" = "Menu Cell";
"Open FLEX on app focus" = "Open FLEX on app focus";
"Open FLEX on app launch" = "Open FLEX on app launch";
"Opens FLEX when the app is focused" = "Opens FLEX when the app is focused";
"Opens FLEX when the app launches" = "Opens FLEX when the app launches";
"Static Cell" = "Static Cell";
"Stepper cell" = "Stepper cell";
"Switch Cell" = "Switch Cell";
"Switch Cell (Restart)" = "Switch Cell (Restart)";
"Tap the switch" = "Tap the switch";
"Using icon" = "Using icon";
"Using image" = "Using image";
"_ Example" = "_ Example";
//////////////////////////////////////////////////////////////////////////////
// DOWNLOADS & MEDIA ACTIONS //
// Action button menus, download/share/copy toasts, quality picker pills. //
//////////////////////////////////////////////////////////////////////////////
"%@ settings" = "%@ settings";
"Cancelled" = "Cancelled";
"Copied %lu URLs" = "Copied %lu URLs";
"Copied caption" = "Copied caption";
"Copied download URL" = "Copied download URL";
"Copy all URLs" = "Copy all URLs";
"Copy caption" = "Copy caption";
"Copy download URL" = "Copy download URL";
"Could not extract any URLs" = "Could not extract any URLs";
"Could not extract media URL" = "Could not extract media URL";
"Could not extract photo URL" = "Could not extract photo URL";
"Could not extract video URL" = "Could not extract video URL";
"Done" = "Done";
"Download all (%lu)" = "Download all (%lu)";
"Download all stories and share?" = "Download all stories and share?";
"Download all to Photos" = "Download all to Photos";
"Download and share all" = "Download and share all";
"Download and share?" = "Download and share?";
"Download failed" = "Download failed";
"Downloaded %lu items" = "Downloaded %lu items";
"Downloading %@..." = "Downloading %@...";
"Downloading..." = "Downloading...";
"Failed to save" = "Failed to save";
"HD download complete" = "HD download complete";
"Mute audio" = "Mute audio";
"No URLs" = "No URLs";
"No URLs found" = "No URLs found";
"No caption on this post" = "No caption on this post";
"No carousel children" = "No carousel children";
"No cover image" = "No cover image";
"No files downloaded" = "No files downloaded";
"No media" = "No media";
"No media URL" = "No media URL";
"No media to expand" = "No media to expand";
"No media to show" = "No media to show";
"No video URL" = "No video URL";
"Not a carousel" = "Not a carousel";
"Nothing to save" = "Nothing to save";
"Nothing to share" = "Nothing to share";
"Opening creator..." = "Opening creator...";
"Photo library access denied" = "Photo library access denied";
"Photos access denied" = "Photos access denied";
"Preparing repost..." = "Preparing repost...";
"Repost" = "Repost";
"Repost unavailable" = "Repost unavailable";
"Save all stories to Photos?" = "Save all stories to Photos?";
"Save failed" = "Save failed";
"Save to Photos?" = "Save to Photos?";
"Saved %lu items" = "Saved %lu items";
"Saved to Photos" = "Saved to Photos";
"Saved to RyukGram" = "Saved to RyukGram";
"Tap to cancel" = "Tap to cancel";
"Unmute audio" = "Unmute audio";
"View cover" = "View cover";
"View mentions" = "View mentions";
//////////////////////////////////////////////////////////////////////////////
// STORIES & MESSAGES (FEATURES) //
// Buttons, menu entries, toasts and alerts shown while watching stories or //
// inside DM threads. //
//////////////////////////////////////////////////////////////////////////////
"A message was unsent" = "A message was unsent";
"Add" = "Add";
"Add to block list" = "Add to block list";
"Add to block list?" = "Add to block list?";
"Added to block list" = "Added to block list";
"Audio not loaded yet. Play the message first and try again." = "Audio not loaded yet. Play the message first and try again.";
"Audio sent" = "Audio sent";
"Audio/Video from Files" = "Audio/Video from Files";
"Blocked" = "Blocked";
"Cancel" = "Cancel";
"Clear preserved messages?" = "Clear preserved messages?";
"Converting..." = "Converting...";
"Copy text" = "Copy text";
"Could not find media" = "Could not find media";
"Could not find story media" = "Could not find story media";
"Could not get audio data. Try again after refreshing the chat." = "Could not get audio data. Try again after refreshing the chat.";
"Could not get video URL" = "Could not get video URL";
"Disable read receipts" = "Disable read receipts";
"Done!" = "Done!";
"Download audio" = "Download audio";
"Downloading audio..." = "Downloading audio...";
"Enable read receipts" = "Enable read receipts";
"Error: %@" = "Error: %@";
"Exclude chat" = "Exclude chat";
"Exclude story seen" = "Exclude story seen";
"Excluded" = "Excluded";
"Extracting audio..." = "Extracting audio...";
"Failed to encode GIF" = "Failed to encode GIF";
"File sending not supported" = "File sending not supported";
"Follow" = "Follow";
"Following" = "Following";
"Mark messages as seen" = "Mark messages as seen";
"Mark seen" = "Mark seen";
"Marked as seen" = "Marked as seen";
"Marked as viewed" = "Marked as viewed";
"Marked messages as seen" = "Marked messages as seen";
"Mentions" = "Mentions";
"Message sender not found" = "Message sender not found";
"Messages settings" = "Messages settings";
"Mute story audio" = "Mute story audio";
"No audio URL found. Try again after refreshing the chat." = "No audio URL found. Try again after refreshing the chat.";
"No mentions in this story" = "No mentions in this story";
"No thread key" = "No thread key";
"No voice send method found" = "No voice send method found";
"Note not found" = "Note not found";
"Note text copied" = "Note text copied";
"Open GitHub" = "Open GitHub";
"Read receipts disabled" = "Read receipts disabled";
"Read receipts enabled" = "Read receipts enabled";
"Read receipts will be blocked for this chat." = "Read receipts will be blocked for this chat.";
"Read receipts will no longer be blocked for this chat." = "Read receipts will no longer be blocked for this chat.";
"Remove" = "Remove";
"Remove from block list" = "Remove from block list";
"Remove from block list?" = "Remove from block list?";
"Removed" = "Removed";
"Save GIF" = "Save GIF";
"Selection too short (min 0.5s)" = "Selection too short (min 0.5s)";
"Send Audio" = "Send Audio";
"Send anyway" = "Send anyway";
"Send failed: %@" = "Send failed: %@";
"Send service not found" = "Send service not found";
"Share" = "Share";
"Story read receipts disabled" = "Story read receipts disabled";
"Story read receipts enabled" = "Story read receipts enabled";
"Story seen receipts will be blocked for @%@." = "Story seen receipts will be blocked for @%@.";
"This chat will resume normal read-receipt behavior." = "This chat will resume normal read-receipt behavior.";
"Total: %@" = "Total: %@";
"Un-exclude" = "Un-exclude";
"Un-exclude chat" = "Un-exclude chat";
"Un-exclude chat?" = "Un-exclude chat?";
"Un-exclude story seen" = "Un-exclude story seen";
"Un-exclude story seen?" = "Un-exclude story seen?";
"Un-excluded" = "Un-excluded";
"Unblock" = "Unblock";
"Unblocked" = "Unblocked";
"Unlimited replay enabled" = "Unlimited replay enabled";
"Unmute story audio" = "Unmute story audio";
"Unsent" = "Unsent";
"Upload Audio" = "Upload Audio";
"VC not found" = "VC not found";
"Video from Library" = "Video from Library";
"Visual messages will expire" = "Visual messages will expire";
"Visual messages: expiring" = "Visual messages: expiring";
"Visual messages: unlimited replay" = "Visual messages: unlimited replay";
"Will sync when leaving stories" = "Will sync when leaving stories";
//////////////////////////////////////////////////////////////////////////////
// GENERAL FEATURES //
// Strings inside per-feature overlays: fake location, color picker, notes //
// customization, profile copy, etc. //
//////////////////////////////////////////////////////////////////////////////
"Add location" = "Add location";
"Add preset" = "Add preset";
"Change location" = "Change location";
"Click the Apply button after this to see the emoji" = "Click the Apply button after this to see the emoji";
"Copied text to clipboard" = "Copied text to clipboard";
"Copy" = "Copy";
"Copy all" = "Copy all";
"Copy bio" = "Copy bio";
"Copy from profile" = "Copy from profile";
"Copy name" = "Copy name";
"Could not find cover image" = "Could not find cover image";
"Current: %@" = "Current: %@";
"Disable" = "Disable";
"Download GIF" = "Download GIF";
"Enable" = "Enable";
"Enter Emoji Text" = "Enter Emoji Text";
"Fake location" = "Fake location";
"Name" = "Name";
"Nothing to copy" = "Nothing to copy";
"Save" = "Save";
"Save preset" = "Save preset";
"Saved locations" = "Saved locations";
"Select color" = "Select color";
"Set location" = "Set location";
"Settings…" = "Settings…";
"Type emoji..." = "Type emoji...";
"direct-inbox-tab" = "direct-inbox-tab";
"mainfeed-tab" = "mainfeed-tab";
//////////////////////////////////////////////////////////////////////////////
// SETTINGS VIEWS & DIALOGS //
// Excluded-lists managers, backup/restore flows, in-picker labels. //
//////////////////////////////////////////////////////////////////////////////
"Add custom domain" = "Add custom domain";
"Add preset…" = "Add preset…";
"All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect." = "All RyukGram settings will be reset to defaults and the imported values applied. The app will need to restart for some changes to take effect.";
"Apply" = "Apply";
"Apply imported settings?" = "Apply imported settings?";
"Apply to" = "Apply to";
"Chats" = "Chats";
"Could not read file." = "Could not read file.";
"Could not write temporary file." = "Could not write temporary file.";
"Current location" = "Current location";
"Custom" = "Custom";
"Date Format" = "Date Format";
"Delete" = "Delete";
"Done editing" = "Done editing";
"Edit values" = "Edit values";
"Enable fake location" = "Enable fake location";
"Every RyukGram preference will revert to its built-in default. This can't be undone." = "Every RyukGram preference will revert to its built-in default. This can't be undone.";
"Excluded chats" = "Excluded chats";
"Excluded users" = "Excluded users";
"File is not a valid RyukGram settings export." = "File is not a valid RyukGram settings export.";
"Follow default" = "Follow default";
"Force OFF (allow unsends)" = "Force OFF (allow unsends)";
"Force ON (preserve unsends)" = "Force ON (preserve unsends)";
"Form view" = "Form view";
"Format" = "Format";
"Import failed" = "Import failed";
"Import preview" = "Import preview";
"Included chats" = "Included chats";
"Included users" = "Included users";
"KD: ON" = "KD: ON";
"KD: default" = "KD: default";
"Keep-deleted" = "Keep-deleted";
"Keep-deleted override" = "Keep-deleted override";
"Off" = "Off";
"On" = "On";
"Presets" = "Presets";
"Raw JSON view" = "Raw JSON view";
"Remove Selected" = "Remove Selected";
"Remove from list" = "Remove from list";
"Reset" = "Reset";
"Reset all settings?" = "Reset all settings?";
"Saved presets are reusable. Tap a preset to make it the active location." = "Saved presets are reusable. Tap a preset to make it the active location.";
"Search address or place" = "Search address or place";
"Search by name or username" = "Search by name or username";
"Search by username or name" = "Search by username or name";
"Search settings" = "Search settings";
"Select" = "Select";
"Select location on map" = "Select location on map";
"Set current location" = "Set current location";
"Set keep-deleted override" = "Set keep-deleted override";
"Settings exported" = "Settings exported";
"Settings imported" = "Settings imported";
"Show seconds" = "Show seconds";
"Sort by" = "Sort by";
"Story users" = "Story users";
"Toggle each NSDate formatter IG uses. Different surfaces (feed, comments, stories, DMs) go through different methods — enable the ones you want the custom format applied to." = "Toggle each NSDate formatter IG uses. Different surfaces (feed, comments, stories, DMs) go through different methods — enable the ones you want the custom format applied to.";
"Use this location" = "Use this location";
"When on, all CoreLocation requests inside Instagram return the location below." = "When on, all CoreLocation requests inside Instagram return the location below.";
"When on, all CoreLocation requests inside Instagram return the location below. Toggle the map button to show or hide the quick toggle on the Friends Map view." = "When on, all CoreLocation requests inside Instagram return the location below. Toggle the map button to show or hide the quick toggle on the Friends Map view.";
"Show map button" = "Show map button";
//////////////////////////////////////////////////////////////////////////////
// REELS (FEATURES) //
// Strings from Reels. //
//////////////////////////////////////////////////////////////////////////////
"Copied!" = "Copied!";
"No password found" = "No password found";
"No text field found" = "No text field found";
"Password" = "Password";
"Refresh Reels?" = "Refresh Reels?";
//////////////////////////////////////////////////////////////////////////////
// PROFILE (FEATURES) //
// Strings from Profile. //
//////////////////////////////////////////////////////////////////////////////
"Doesn't follow you" = "Doesn't follow you";
"Follows you" = "Follows you";
"Note copied" = "Note copied";
//////////////////////////////////////////////////////////////////////////////
// CONFIRM DIALOGS (IN-FEATURE) //
// Strings from Confirm dialogs. //
//////////////////////////////////////////////////////////////////////////////
"Unfollow?" = "Unfollow?";
//////////////////////////////////////////////////////////////////////////////
// MISC //
// Anything that didn't fit a named section. Usually short labels. //
//////////////////////////////////////////////////////////////////////////////
"720p • progressive • fastest" = "720p • progressive • fastest";
"Are you sure?" = "Are you sure?";
"Copy audio URL" = "Copy audio URL";
"Copy quality info" = "Copy quality info";
"Copy video URL" = "Copy video URL";
"Could not access reel media" = "Could not access reel media";
"Could not access reel photo" = "Could not access reel photo";
"Could not extract photo url from post" = "Could not extract photo url from post";
"Could not extract photo url from reel" = "Could not extract photo url from reel";
"Could not extract photo url from story" = "Could not extract photo url from story";
"Could not extract video url from post" = "Could not extract video url from post";
"Could not extract video url from reel" = "Could not extract video url from reel";
"Could not extract video url from story" = "Could not extract video url from story";
"Download Quality" = "Download Quality";
"FFmpegKit Debug" = "FFmpegKit Debug";
"Later" = "Later";
"No!" = "No!";
"Restart" = "Restart";
"Restart required" = "Restart required";
"Yes" = "Yes";
"You must restart the app to apply this change" = "You must restart the app to apply this change";
//////////////////////////////////////////////////////////////////////////////
// ABOUT / CREDITS //
// Strings from the About / Credits footer of Settings. //
//////////////////////////////////////////////////////////////////////////////
"%@ — view source, report issues, see releases" = "%@ — view source, report issues, see releases";
"Credits" = "Credits";
"Developer" = "Developer";
"Donate to SoCuul" = "Donate to SoCuul";
"Original SCInsta developer" = "Original SCInsta developer";
"Ryuk" = "Ryuk";
"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul";
"RyukGram on GitHub" = "RyukGram on GitHub";
"SoCuul" = "SoCuul";
"Support the original developer" = "Support the original developer";
"View Repo" = "View Repo";
"View the source code on GitHub" = "View the source code on GitHub";
//////////////////////////////////////////////////////////////////////////////
// HD DOWNLOADS //
// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). //
//////////////////////////////////////////////////////////////////////////////
"Download video at the highest available quality" = "Download video at the highest available quality";
"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit.";
"Encoding speed" = "Encoding speed";
"Enhanced downloads" = "Enhanced downloads";
"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable.";
"Faster = lower quality" = "Faster = lower quality";
"Photo quality" = "Photo quality";
"Use highest resolution available" = "Use highest resolution available";
"Video quality" = "Video quality";
"Which quality to download" = "Which quality to download";
//////////////////////////////////////////////////////////////////////////////
// EXPERIMENTAL / DEBUG //
// Placeholder rows only shown in the experimental settings sandbox. //
//////////////////////////////////////////////////////////////////////////////
"Navigation Cell" = "Navigation Cell";
+38
View File
@@ -0,0 +1,38 @@
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
#ifdef __cplusplus
extern "C" {
#endif
// Localization pref key — value is BCP-47 code ("en", "ar", "es") or "system".
extern NSString *const SCILanguagePrefKey;
// Resource bundle (RyukGram.bundle) shipped next to the dylib.
// Returns nil only on broken installs; callers fall back to the key itself.
NSBundle * _Nullable SCILocalizationBundle(void);
// Fresh lookup each call — cheap enough (NSBundle caches strings files internally).
// `fallback` is returned when the key is missing. Pass the English source text.
NSString *SCILocalizedString(NSString *key, NSString * _Nullable fallback);
// Languages we actually ship. `system` means "follow iOS locale".
// Ordered for the picker UI; first entry is always "system".
NSArray<NSDictionary<NSString *, NSString *> *> *SCIAvailableLanguages(void);
// Currently-active language code ("en", "ar", …) after resolving "system".
NSString *SCIResolvedLanguageCode(void);
// Invalidate cached bundles/strings after a language switch.
void SCILocalizationReset(void);
#ifdef __cplusplus
}
#endif
NS_ASSUME_NONNULL_END
// Convenience macro — key doubles as English fallback so missing translations
// degrade gracefully to the source text.
#define SCILocalized(key) SCILocalizedString((key), (key))
+99
View File
@@ -0,0 +1,99 @@
#import "SCILocalization.h"
#import <dlfcn.h>
NSString *const SCILanguagePrefKey = @"sci_language";
static NSBundle *gResourceBundle = nil;
static NSBundle *gLanguageBundle = nil;
static NSString *gLanguageBundleCode = nil;
static dispatch_once_t gResourceOnce;
static NSBundle *resolveResourceBundle(void) {
// 1) Sideload: cyan copies RyukGram.bundle into the app's resource root.
NSString *path = [[NSBundle mainBundle] pathForResource:@"RyukGram" ofType:@"bundle"];
// 2) Jailbreak: .deb drops the bundle into Library/Application Support.
if (!path) {
NSArray *fallbacks = @[
@"/var/jb/Library/Application Support/RyukGram.bundle",
@"/Library/Application Support/RyukGram.bundle",
];
NSFileManager *fm = [NSFileManager defaultManager];
for (NSString *p in fallbacks) {
if ([fm fileExistsAtPath:p]) { path = p; break; }
}
}
// 3) Last resort: sibling of the loaded dylib (dev / Feather with loose files).
if (!path) {
Dl_info info;
if (dladdr((const void *)&resolveResourceBundle, &info) && info.dli_fname) {
NSString *dylibPath = [NSString stringWithUTF8String:info.dli_fname];
NSString *candidate = [[dylibPath stringByDeletingLastPathComponent]
stringByAppendingPathComponent:@"RyukGram.bundle"];
if ([[NSFileManager defaultManager] fileExistsAtPath:candidate]) path = candidate;
}
}
return path ? [NSBundle bundleWithPath:path] : nil;
}
NSBundle *SCILocalizationBundle(void) {
dispatch_once(&gResourceOnce, ^{ gResourceBundle = resolveResourceBundle(); });
return gResourceBundle;
}
static NSString *preferredLanguageCode(NSBundle *resource) {
NSString *pref = [[NSUserDefaults standardUserDefaults] stringForKey:SCILanguagePrefKey];
if (pref.length && ![pref isEqualToString:@"system"]) return pref;
// Match iOS locale against the languages actually shipped in the bundle.
NSArray<NSString *> *shipped = [resource localizations];
NSArray<NSString *> *matches = [NSBundle preferredLocalizationsFromArray:shipped
forPreferences:[NSLocale preferredLanguages]];
return matches.firstObject ?: @"en";
}
NSString *SCIResolvedLanguageCode(void) {
NSBundle *b = SCILocalizationBundle();
return b ? preferredLanguageCode(b) : @"en";
}
static NSBundle *activeLanguageBundle(void) {
NSBundle *resource = SCILocalizationBundle();
if (!resource) return nil;
NSString *code = preferredLanguageCode(resource);
if (gLanguageBundle && [code isEqualToString:gLanguageBundleCode]) return gLanguageBundle;
NSString *lprojPath = [resource pathForResource:code ofType:@"lproj"];
if (!lprojPath) lprojPath = [resource pathForResource:@"en" ofType:@"lproj"];
gLanguageBundle = lprojPath ? [NSBundle bundleWithPath:lprojPath] : resource;
gLanguageBundleCode = [code copy];
return gLanguageBundle;
}
NSString *SCILocalizedString(NSString *key, NSString *fallback) {
if (key.length == 0) return fallback ?: @"";
NSBundle *lang = activeLanguageBundle();
if (!lang) return fallback ?: key;
// NSBundle returns the key itself when missing (when `value` is nil) —
// that's our signal to fall back to the English source text.
NSString *value = [lang localizedStringForKey:key value:@"\x01SCI_MISSING\x01" table:nil];
if ([value isEqualToString:@"\x01SCI_MISSING\x01"]) return fallback ?: key;
return value;
}
NSArray<NSDictionary<NSString *, NSString *> *> *SCIAvailableLanguages(void) {
// `code` is what we persist; `native` is shown in the picker (endonyms read best).
return @[
@{ @"code": @"system", @"native": @"System", @"english": @"System default" },
@{ @"code": @"en", @"native": @"English", @"english": @"English" },
];
}
void SCILocalizationReset(void) {
gLanguageBundle = nil;
gLanguageBundleCode = nil;
}
+35
View File
@@ -0,0 +1,35 @@
// Reusable wrapper for Instagram private API calls. Reads the Bearer token
// for the active account from IG's keychain group and uses it to talk to
// the legacy /api/v1/ endpoints. Account switches are picked up automatically.
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
typedef void(^SCIAPICompletion)(NSDictionary * _Nullable response, NSError * _Nullable error);
typedef void(^SCIAPIStatusesCompletion)(NSDictionary * _Nullable statuses, NSError * _Nullable error);
@interface SCIInstagramAPI : NSObject
// ============ Generic ============
// `path` is the part after /api/v1/, e.g. "friendships/create/123/".
// `body` is form-encoded if non-nil. `completion` runs on the main queue.
+ (void)sendRequestWithMethod:(NSString *)method
path:(NSString *)path
body:(nullable NSDictionary *)body
completion:(nullable SCIAPICompletion)completion;
// ============ Friendships ============
+ (void)followUserPK:(NSString *)pk completion:(nullable SCIAPICompletion)completion;
+ (void)unfollowUserPK:(NSString *)pk completion:(nullable SCIAPICompletion)completion;
// Bulk-fetch friendship statuses for a set of user PKs in one round trip.
// Statuses dict maps pk → {following, outgoing_request, is_private, ...}.
+ (void)fetchFriendshipStatusesForPKs:(NSArray<NSString *> *)pks
completion:(nullable SCIAPIStatusesCompletion)completion;
@end
NS_ASSUME_NONNULL_END
+190
View File
@@ -0,0 +1,190 @@
// Reusable IG private API helper. See SCIInstagramAPI.h.
#import "SCIInstagramAPI.h"
#import <UIKit/UIKit.h>
#import <objc/runtime.h>
#import <objc/message.h>
#import <sys/sysctl.h>
#define SCI_API_BASE @"https://i.instagram.com/api/v1/"
#define SCI_APP_ID @"124024574287414" // public IG iOS app id constant
// User-Agent in IG's exact format, generated from the device + IG bundle.
static NSString *sciUserAgent(void) {
static NSString *ua = nil;
static dispatch_once_t once;
dispatch_once(&once, ^{
NSString *version = [NSBundle mainBundle].infoDictionary[@"CFBundleShortVersionString"] ?: @"424.0.0";
char machine[64] = {0};
size_t size = sizeof(machine);
sysctlbyname("hw.machine", machine, &size, NULL, 0);
NSString *device = machine[0] ? [NSString stringWithUTF8String:machine] : @"iPhone15,2";
NSString *iosVersion = [[UIDevice currentDevice].systemVersion stringByReplacingOccurrencesOfString:@"." withString:@"_"];
NSString *locale = [NSLocale currentLocale].localeIdentifier ?: @"en_US";
NSString *lang = [[NSLocale preferredLanguages] firstObject] ?: @"en";
UIScreen *screen = [UIScreen mainScreen];
ua = [NSString stringWithFormat:@"Instagram %@ (%@; iOS %@; %@; %@; scale=%.2f; %.0fx%.0f; 0)",
version, device, iosVersion, locale, lang,
screen.scale, screen.nativeBounds.size.width, screen.nativeBounds.size.height];
});
return ua;
}
// ============ IG runtime accessors ============
// Active IGUserSession. Walks every window across all connected scenes
// since key window can be nil in some states.
static id sciCurrentUserSession(void) {
@try {
UIApplication *app = [UIApplication sharedApplication];
NSMutableArray *windows = [NSMutableArray array];
if (app.keyWindow) [windows addObject:app.keyWindow];
for (UIWindow *w in app.windows) if (w) [windows addObject:w];
for (UIScene *scene in app.connectedScenes) {
if ([scene isKindOfClass:[UIWindowScene class]]) {
for (UIWindow *w in ((UIWindowScene *)scene).windows) if (w) [windows addObject:w];
}
}
for (id w in windows) {
if ([w respondsToSelector:@selector(userSession)]) {
id s = [w valueForKey:@"userSession"];
if (s) return s;
}
}
} @catch (__unused id e) {}
return nil;
}
// PK of the currently active account. Changes on quick-switch.
static NSString *sciCurrentUserPK(void) {
@try {
id session = sciCurrentUserSession();
id user = session ? [session valueForKey:@"user"] : nil;
if (!user) return nil;
Ivar pkIvar = class_getInstanceVariable([user class], "_pk");
if (pkIvar) {
id pk = object_getIvar(user, pkIvar);
if (pk) return [NSString stringWithFormat:@"%@", pk];
}
} @catch (__unused id e) {}
return nil;
}
// Bearer token for the active account, read fresh from
// -[IGUserSession authHeaderManager] -> -[IGUserAuthHeaderManager authHeader].
static NSString *sciAuthHeader(void) {
@try {
id session = sciCurrentUserSession();
if (!session || ![session respondsToSelector:@selector(authHeaderManager)]) return nil;
id manager = ((id(*)(id, SEL))objc_msgSend)(session, @selector(authHeaderManager));
if (!manager || ![manager respondsToSelector:@selector(authHeader)]) return nil;
id header = ((id(*)(id, SEL))objc_msgSend)(manager, @selector(authHeader));
if ([header isKindOfClass:[NSString class]] && [(NSString *)header length]) return header;
} @catch (__unused id e) {}
return nil;
}
// ============ Request building ============
static NSString *sciFormEncode(NSDictionary *params) {
if (!params.count) return @"";
NSMutableArray *parts = [NSMutableArray array];
NSCharacterSet *allowed = [NSCharacterSet URLQueryAllowedCharacterSet];
for (NSString *key in params) {
NSString *val = [NSString stringWithFormat:@"%@", params[key]];
NSString *ek = [key stringByAddingPercentEncodingWithAllowedCharacters:allowed];
NSString *ev = [val stringByAddingPercentEncodingWithAllowedCharacters:allowed];
[parts addObject:[NSString stringWithFormat:@"%@=%@", ek, ev]];
}
return [parts componentsJoinedByString:@"&"];
}
static NSMutableURLRequest *sciBuildRequest(NSString *method, NSURL *url, NSDictionary *body) {
NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:url];
req.HTTPMethod = method ?: @"GET";
[req setValue:sciUserAgent() forHTTPHeaderField:@"User-Agent"];
[req setValue:SCI_APP_ID forHTTPHeaderField:@"X-IG-App-ID"];
[req setValue:@"WIFI" forHTTPHeaderField:@"X-IG-Connection-Type"];
[req setValue:@"en-US" forHTTPHeaderField:@"Accept-Language"];
NSString *auth = sciAuthHeader();
if (auth) [req setValue:auth forHTTPHeaderField:@"Authorization"];
for (NSHTTPCookie *c in [[NSHTTPCookieStorage sharedHTTPCookieStorage] cookiesForURL:url]) {
if ([c.name isEqualToString:@"csrftoken"]) {
[req setValue:c.value forHTTPHeaderField:@"X-CSRFToken"];
break;
}
}
if (body) {
req.HTTPBody = [sciFormEncode(body) dataUsingEncoding:NSUTF8StringEncoding];
[req setValue:@"application/x-www-form-urlencoded; charset=UTF-8"
forHTTPHeaderField:@"Content-Type"];
}
return req;
}
static void sciPerformRequest(NSMutableURLRequest *req, SCIAPICompletion completion) {
NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithRequest:req
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
NSDictionary *resp = nil;
if (data.length) {
@try {
id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if ([parsed isKindOfClass:[NSDictionary class]]) resp = parsed;
} @catch (__unused id e) {}
}
if (completion) {
dispatch_async(dispatch_get_main_queue(), ^{ completion(resp, error); });
}
}];
[task resume];
}
@implementation SCIInstagramAPI
// ============ Generic ============
+ (void)sendRequestWithMethod:(NSString *)method
path:(NSString *)path
body:(NSDictionary *)body
completion:(SCIAPICompletion)completion {
NSString *clean = [path hasPrefix:@"/"] ? [path substringFromIndex:1] : path;
NSURL *url = [NSURL URLWithString:[SCI_API_BASE stringByAppendingString:clean]];
sciPerformRequest(sciBuildRequest(method, url, body), completion);
}
// ============ Friendships ============
+ (void)followUserPK:(NSString *)pk completion:(SCIAPICompletion)completion {
if (!pk.length) { if (completion) completion(nil, nil); return; }
[self sendRequestWithMethod:@"POST"
path:[NSString stringWithFormat:@"friendships/create/%@/", pk]
body:@{@"user_id": pk, @"radio_type": @"wifi-none"}
completion:completion];
}
+ (void)unfollowUserPK:(NSString *)pk completion:(SCIAPICompletion)completion {
if (!pk.length) { if (completion) completion(nil, nil); return; }
[self sendRequestWithMethod:@"POST"
path:[NSString stringWithFormat:@"friendships/destroy/%@/", pk]
body:@{@"user_id": pk, @"radio_type": @"wifi-none"}
completion:completion];
}
+ (void)fetchFriendshipStatusesForPKs:(NSArray<NSString *> *)pks
completion:(SCIAPIStatusesCompletion)completion {
if (!pks.count) { if (completion) completion(nil, nil); return; }
[self sendRequestWithMethod:@"POST"
path:@"friendships/show_many/"
body:@{@"user_ids": [pks componentsJoinedByString:@","]}
completion:^(NSDictionary *response, NSError *error) {
NSDictionary *statuses = nil;
id s = response[@"friendship_statuses"];
if ([s isKindOfClass:[NSDictionary class]]) statuses = s;
if (completion) completion(statuses, error);
}];
}
@end
+33
View File
@@ -0,0 +1,33 @@
// SCIDashParser — parses DASH MPD manifests from IGMedia for HD streams.
#import <Foundation/Foundation.h>
@interface SCIDashRepresentation : NSObject
@property (nonatomic, strong) NSURL *url;
@property (nonatomic, assign) NSInteger bandwidth;
@property (nonatomic, assign) NSInteger width;
@property (nonatomic, assign) NSInteger height;
@property (nonatomic, copy) NSString *contentType; // "video" or "audio"
@property (nonatomic, copy) NSString *qualityLabel; // "1080p", "720p", etc.
@property (nonatomic, assign) float frameRate; // 0 if unknown
@property (nonatomic, copy) NSString *codecs; // e.g. "avc1.4d401f" or "mp4a.40.2"
@end
typedef NS_ENUM(NSInteger, SCIVideoQuality) {
SCIVideoQualityLowest,
SCIVideoQualityMedium,
SCIVideoQualityHighest,
SCIVideoQualityAsk
};
@interface SCIDashParser : NSObject
+ (NSArray<SCIDashRepresentation *> *)parseManifest:(NSString *)xmlString;
+ (SCIDashRepresentation *)bestVideoFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (SCIDashRepresentation *)bestAudioFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (NSArray<SCIDashRepresentation *> *)videoRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (SCIDashRepresentation *)representationForQuality:(SCIVideoQuality)quality
fromRepresentations:(NSArray<SCIDashRepresentation *> *)reps;
+ (NSString *)dashManifestForMedia:(id)media;
@end
+217
View File
@@ -0,0 +1,217 @@
#import "SCIDashParser.h"
#import <objc/runtime.h>
#import <objc/message.h>
@implementation SCIDashRepresentation
@end
static id sciDashFieldCache(id obj, NSString *key) {
if (!obj || !key) return nil;
static Ivar fcIvar = NULL;
static dispatch_once_t once;
dispatch_once(&once, ^{
Class c = NSClassFromString(@"IGAPIStorableObject");
if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache");
});
if (!fcIvar) return nil;
id fc = nil;
@try { fc = object_getIvar(obj, fcIvar); } @catch (__unused id e) { return nil; }
if (![fc isKindOfClass:[NSDictionary class]]) return nil;
id val = ((NSDictionary *)fc)[key];
if (!val || [val isKindOfClass:[NSNull class]]) return nil;
return val;
}
@implementation SCIDashParser
+ (NSString *)dashManifestForMedia:(id)media {
if (!media) return nil;
NSArray *keys = @[@"video_dash_manifest", @"dash_manifest",
@"video_dash_manifest_url", @"dash_manifest_url"];
for (NSString *key in keys) {
id val = sciDashFieldCache(media, key);
if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10)
return val;
}
id video = nil;
SEL videoSel = @selector(video);
if ([media respondsToSelector:videoSel]) {
video = ((id(*)(id, SEL))objc_msgSend)(media, videoSel);
if (video && ![(id)video isKindOfClass:[NSObject class]]) video = nil;
}
if (video) {
for (NSString *key in keys) {
id val = sciDashFieldCache(video, key);
if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10)
return val;
}
}
return nil;
}
+ (NSArray<SCIDashRepresentation *> *)parseManifest:(NSString *)xmlString {
if (!xmlString.length) return @[];
NSMutableArray<SCIDashRepresentation *> *results = [NSMutableArray array];
NSError *err = nil;
// AdaptationSet blocks (handles both contentType= and mimeType= patterns)
NSRegularExpression *adaptRE = [NSRegularExpression
regularExpressionWithPattern:@"(<AdaptationSet[^>]*>)(.*?)</AdaptationSet>"
options:NSRegularExpressionDotMatchesLineSeparators error:&err];
if (err) return @[];
NSRegularExpression *ctRE = [NSRegularExpression
regularExpressionWithPattern:@"contentType=\"(video|audio)\"" options:NSRegularExpressionCaseInsensitive error:nil];
NSRegularExpression *mtRE = [NSRegularExpression
regularExpressionWithPattern:@"mimeType=\"(video|audio)/[^\"]*\"" options:NSRegularExpressionCaseInsensitive error:nil];
NSRegularExpression *repRE = [NSRegularExpression
regularExpressionWithPattern:@"<Representation[^>]*>"
options:0 error:nil];
NSRegularExpression *baseURLRE = [NSRegularExpression
regularExpressionWithPattern:@"<BaseURL>(.*?)</BaseURL>"
options:0 error:nil];
NSRegularExpression *bwRE = [NSRegularExpression
regularExpressionWithPattern:@"bandwidth=\"(\\d+)\"" options:0 error:nil];
NSRegularExpression *widthRE = [NSRegularExpression
regularExpressionWithPattern:@"(?:^|\\s)width=\"(\\d+)\"" options:0 error:nil];
NSRegularExpression *heightRE = [NSRegularExpression
regularExpressionWithPattern:@"(?:^|\\s)height=\"(\\d+)\"" options:0 error:nil];
NSRegularExpression *labelRE = [NSRegularExpression
regularExpressionWithPattern:@"FBQualityLabel=\"([^\"]+)\"" options:0 error:nil];
NSRegularExpression *fpsRE = [NSRegularExpression
regularExpressionWithPattern:@"frameRate=\"([0-9./]+)\"" options:0 error:nil];
NSRegularExpression *codecsRE = [NSRegularExpression
regularExpressionWithPattern:@"codecs=\"([^\"]+)\"" options:0 error:nil];
[adaptRE enumerateMatchesInString:xmlString options:0
range:NSMakeRange(0, xmlString.length)
usingBlock:^(NSTextCheckingResult *adaptMatch, __unused NSMatchingFlags flags, __unused BOOL *stop) {
NSString *adaptTag = [xmlString substringWithRange:[adaptMatch rangeAtIndex:1]];
NSString *adaptBody = [xmlString substringWithRange:[adaptMatch rangeAtIndex:2]];
NSString *contentType = nil;
NSTextCheckingResult *ctMatch = [ctRE firstMatchInString:adaptTag options:0
range:NSMakeRange(0, adaptTag.length)];
if (ctMatch) {
contentType = [[adaptTag substringWithRange:[ctMatch rangeAtIndex:1]] lowercaseString];
} else {
NSTextCheckingResult *mtMatch = [mtRE firstMatchInString:adaptTag options:0
range:NSMakeRange(0, adaptTag.length)];
if (mtMatch) {
contentType = [[adaptTag substringWithRange:[mtMatch rangeAtIndex:1]] lowercaseString];
}
}
if (!contentType) return;
NSArray<NSTextCheckingResult *> *repMatches =
[repRE matchesInString:adaptBody options:0 range:NSMakeRange(0, adaptBody.length)];
NSArray<NSTextCheckingResult *> *urlMatches =
[baseURLRE matchesInString:adaptBody options:0 range:NSMakeRange(0, adaptBody.length)];
for (NSUInteger i = 0; i < repMatches.count && i < urlMatches.count; i++) {
NSString *repTag = [adaptBody substringWithRange:repMatches[i].range];
NSString *baseURL = [adaptBody substringWithRange:[urlMatches[i] rangeAtIndex:1]];
if (!baseURL.length) continue;
baseURL = [baseURL stringByReplacingOccurrencesOfString:@"&amp;" withString:@"&"];
SCIDashRepresentation *rep = [SCIDashRepresentation new];
rep.url = [NSURL URLWithString:baseURL];
rep.contentType = contentType;
NSTextCheckingResult *bwMatch = [bwRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (bwMatch) rep.bandwidth = [[repTag substringWithRange:[bwMatch rangeAtIndex:1]] integerValue];
NSTextCheckingResult *wMatch = [widthRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (wMatch) rep.width = [[repTag substringWithRange:[wMatch rangeAtIndex:1]] integerValue];
NSTextCheckingResult *hMatch = [heightRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (hMatch) rep.height = [[repTag substringWithRange:[hMatch rangeAtIndex:1]] integerValue];
NSTextCheckingResult *fpsMatch = [fpsRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (fpsMatch) {
NSString *raw = [repTag substringWithRange:[fpsMatch rangeAtIndex:1]];
NSArray *parts = [raw componentsSeparatedByString:@"/"];
if (parts.count == 2) {
float num = [parts[0] floatValue], den = [parts[1] floatValue];
if (den > 0) rep.frameRate = num / den;
} else {
rep.frameRate = [raw floatValue];
}
}
NSTextCheckingResult *codecsMatch = [codecsRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (codecsMatch) rep.codecs = [repTag substringWithRange:[codecsMatch rangeAtIndex:1]];
// Quality label from shorter dimension (1080x1920 → "1080p")
if (rep.width > 0 && rep.height > 0) {
NSInteger shortSide = MIN(rep.width, rep.height);
rep.qualityLabel = [NSString stringWithFormat:@"%ldp", (long)shortSide];
} else if (rep.height > 0) {
rep.qualityLabel = [NSString stringWithFormat:@"%ldp", (long)rep.height];
} else {
NSTextCheckingResult *lMatch = [labelRE firstMatchInString:repTag options:0
range:NSMakeRange(0, repTag.length)];
if (lMatch) rep.qualityLabel = [repTag substringWithRange:[lMatch rangeAtIndex:1]];
}
if (rep.url) [results addObject:rep];
}
}];
return [results copy];
}
+ (SCIDashRepresentation *)bestVideoFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
return [[self videoRepresentations:reps] firstObject];
}
+ (SCIDashRepresentation *)bestAudioFromRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
SCIDashRepresentation *best = nil;
for (SCIDashRepresentation *r in reps) {
if (![r.contentType isEqualToString:@"audio"]) continue;
if (!best || r.bandwidth > best.bandwidth) best = r;
}
return best;
}
+ (NSArray<SCIDashRepresentation *> *)videoRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
NSMutableArray *videos = [NSMutableArray array];
for (SCIDashRepresentation *r in reps) {
if ([r.contentType isEqualToString:@"video"]) [videos addObject:r];
}
return [videos sortedArrayUsingComparator:^NSComparisonResult(SCIDashRepresentation *a, SCIDashRepresentation *b) {
return [@(b.bandwidth) compare:@(a.bandwidth)]; // descending
}];
}
+ (SCIDashRepresentation *)representationForQuality:(SCIVideoQuality)quality
fromRepresentations:(NSArray<SCIDashRepresentation *> *)reps {
NSArray *sorted = [self videoRepresentations:reps];
if (!sorted.count) return nil;
switch (quality) {
case SCIVideoQualityHighest: return sorted.firstObject;
case SCIVideoQualityLowest: return sorted.lastObject;
case SCIVideoQualityMedium: return sorted[sorted.count / 2];
case SCIVideoQualityAsk: return sorted.firstObject; // caller handles the picker
}
return sorted.firstObject;
}
@end

Some files were not shown because too many files have changed in this diff Show More