From 86eaa9501996b4aca5f68adfafaac3e8da2b88c4 Mon Sep 17 00:00:00 2001 From: faroukbmiled Date: Thu, 16 Apr 2026 03:03:30 +0100 Subject: [PATCH] [release] RyukGram v1.2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### 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) --- .github/workflows/buildapp.yml | 18 +- .github/workflows/buildtweak.yml | 45 +- .github/workflows/release.yml | 55 +- .gitignore | 8 + Makefile | 4 +- README.md | 98 +- build.sh | 194 ++- control | 2 +- .../Info.plist | Bin 0 -> 1001 bytes .../OpenInstagramSafariExtension | Bin 0 -> 90160 bytes .../_locales/en/messages.json | 10 + .../background.js | 0 .../content.js | 41 + .../images/icon-128.png | Bin 0 -> 15002 bytes .../images/icon-256.png | Bin 0 -> 43408 bytes .../images/icon-48.png | Bin 0 -> 3436 bytes .../images/icon-512.png | Bin 0 -> 131545 bytes .../images/icon-64.png | Bin 0 -> 5151 bytes .../images/icon-96.png | Bin 0 -> 9749 bytes .../images/toolbar-icon-16.png | Bin 0 -> 1588 bytes .../images/toolbar-icon-19.png | Bin 0 -> 1297 bytes .../images/toolbar-icon-32.png | Bin 0 -> 2419 bytes .../images/toolbar-icon-38.png | Bin 0 -> 2971 bytes .../images/toolbar-icon-48.png | Bin 0 -> 3809 bytes .../images/toolbar-icon-72.png | Bin 0 -> 6051 bytes .../manifest.json | 39 + .../popup.css | 22 + .../popup.html | 12 + .../popup.js | 0 scripts/fetch-ffmpegkit.sh | 53 + scripts/setup-ffmpegkit.sh | 36 + src/ActionButton/SCIActionButton.h | 37 + src/ActionButton/SCIActionButton.m | 165 +++ src/ActionButton/SCIActionMenu.h | 48 + src/ActionButton/SCIActionMenu.m | 132 ++ src/ActionButton/SCIMediaActions.h | 98 ++ src/ActionButton/SCIMediaActions.m | 1198 +++++++++++++++++ src/ActionButton/SCIMediaViewer.h | 24 + src/ActionButton/SCIMediaViewer.m | 437 ++++++ src/ActionButton/SCIRepostSheet.h | 10 + src/ActionButton/SCIRepostSheet.m | 109 ++ src/Downloader/Download.h | 22 +- src/Downloader/Download.m | 437 ++++-- src/Features/ActionButton/FeedActionButton.xm | 259 ++++ .../ActionButton/ReelsActionButton.xm | 171 +++ src/Features/Confirm/FollowConfirm.x | 19 +- src/Features/Confirm/ShhConfirm.x | 49 +- src/Features/Feed/StoryTrayActions.x | 185 +++ src/Features/General/CommentActions.xm | 4 +- src/Features/General/CopyDescription.x | 2 +- src/Features/General/DetailedColorPicker.xm | 2 +- .../General/DisableBackgroundRefresh.x | 210 +++ src/Features/General/DisableHaptics.x | 33 + src/Features/General/FakeLocation.xm | 49 + src/Features/General/FakeLocationMapButton.x | 260 ++++ src/Features/General/FeedDateFormat.x | 115 ++ src/Features/General/HideMetaAI.xm | 65 +- src/Features/General/HideMetrics.x | 25 + src/Features/General/HideNotesTray.x | 0 src/Features/General/HideSuggestedStories.x | 90 ++ .../General/HighlightCoverDownload.xm | 41 +- src/Features/General/LaunchTab.x | 33 + src/Features/General/MediaZoom.x | 198 +++ src/Features/General/MessagesOnly.x | 65 + src/Features/General/Navigation.xm | 20 + src/Features/General/NoRecentSearches.x | 4 +- src/Features/General/NoSuggestedUsers.x | 2 +- src/Features/General/NotesCustomization.x | 8 +- src/Features/General/ProfileCopyButton.x | 15 +- src/Features/General/SCIDateFormatEntries.h | 26 + src/Features/General/SCSettingsMenuEntry.x | 10 +- src/Features/Media/MediaDownload.xm | 721 +++------- src/Features/Profile/FollowIndicator.x | 125 ++ src/Features/Profile/ProfileNoteCopy.x | 40 + src/Features/Reels/PasswordedReels.xm | 10 +- src/Features/Reels/ReelsPlayback.xm | 6 +- .../StoriesAndMessages/DisableStorySeen.x | 2 +- .../DownloadAudioMessage.xm | 10 +- .../StoriesAndMessages/ExcludeFromSeen.x | 4 +- .../StoriesAndMessages/ExcludeFromStorySeen.x | 8 +- .../StoriesAndMessages/FullLastActive.x | 108 ++ .../StoriesAndMessages/HideCallButtons.x | 90 ++ .../StoriesAndMessages/InboxRefreshWarning.x | 6 +- .../StoriesAndMessages/KeepDeletedMessages.x | 4 +- .../StoriesAndMessages/NotesActions.x | 292 ++++ .../StoriesAndMessages/OverlayButtons.xm | 382 ++++-- src/Features/StoriesAndMessages/SeenButtons.x | 172 ++- .../StoriesAndMessages/SeenOnStoryReply.x | 148 ++ .../StoriesAndMessages/SendAudioAsFile.xm | 160 ++- src/Features/StoriesAndMessages/SendFile.x | 103 ++ .../StoriesAndMessages/StoryAudioToggle.xm | 2 +- .../StoriesAndMessages/StoryMentions.x | 523 +++++++ src/InstagramHeaders.h | 34 + .../Resources/en.lproj/Localizable.strings | 905 +++++++++++++ src/Localization/SCILocalization.h | 38 + src/Localization/SCILocalization.m | 99 ++ src/Networking/SCIInstagramAPI.h | 35 + src/Networking/SCIInstagramAPI.x | 190 +++ src/SCIDashParser.h | 33 + src/SCIDashParser.m | 217 +++ src/SCIFFmpeg.h | 40 + src/SCIFFmpeg.m | 597 ++++++++ src/SCIPrefix.h | 5 + src/SCIQualityPicker.h | 15 + src/SCIQualityPicker.m | 475 +++++++ src/Settings/SCIDateFormatPickerVC.h | 8 + src/Settings/SCIDateFormatPickerVC.m | 171 +++ src/Settings/SCIEmbedDomainViewController.m | 12 +- src/Settings/SCIExcludedChatsViewController.m | 38 +- .../SCIExcludedStoryUsersViewController.m | 18 +- src/Settings/SCIFakeLocationPickerVC.h | 12 + src/Settings/SCIFakeLocationPickerVC.m | 388 ++++++ src/Settings/SCIFakeLocationSettingsVC.h | 4 + src/Settings/SCIFakeLocationSettingsVC.m | 234 ++++ src/Settings/SCISearchBarStyler.h | 10 + src/Settings/SCISearchBarStyler.m | 45 + src/Settings/SCISetting.h | 1 + src/Settings/SCISettingsBackup.h | 1 + src/Settings/SCISettingsBackup.m | 86 +- src/Settings/SCISettingsViewController.m | 128 +- src/Settings/TweakSettings.m | 687 ++++++---- src/Tweak.x | 129 +- src/Utils.h | 6 + src/Utils.m | 25 +- 124 files changed, 11523 insertions(+), 1393 deletions(-) create mode 100644 extensions/OpenInstagramSafariExtension.appex/Info.plist create mode 100644 extensions/OpenInstagramSafariExtension.appex/OpenInstagramSafariExtension create mode 100644 extensions/OpenInstagramSafariExtension.appex/_locales/en/messages.json create mode 100644 extensions/OpenInstagramSafariExtension.appex/background.js create mode 100644 extensions/OpenInstagramSafariExtension.appex/content.js create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/icon-128.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/icon-256.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/icon-48.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/icon-512.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/icon-64.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/icon-96.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-16.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-19.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-32.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-38.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-48.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-72.png create mode 100644 extensions/OpenInstagramSafariExtension.appex/manifest.json create mode 100644 extensions/OpenInstagramSafariExtension.appex/popup.css create mode 100644 extensions/OpenInstagramSafariExtension.appex/popup.html create mode 100644 extensions/OpenInstagramSafariExtension.appex/popup.js create mode 100755 scripts/fetch-ffmpegkit.sh create mode 100755 scripts/setup-ffmpegkit.sh create mode 100644 src/ActionButton/SCIActionButton.h create mode 100644 src/ActionButton/SCIActionButton.m create mode 100644 src/ActionButton/SCIActionMenu.h create mode 100644 src/ActionButton/SCIActionMenu.m create mode 100644 src/ActionButton/SCIMediaActions.h create mode 100644 src/ActionButton/SCIMediaActions.m create mode 100644 src/ActionButton/SCIMediaViewer.h create mode 100644 src/ActionButton/SCIMediaViewer.m create mode 100644 src/ActionButton/SCIRepostSheet.h create mode 100644 src/ActionButton/SCIRepostSheet.m create mode 100644 src/Features/ActionButton/FeedActionButton.xm create mode 100644 src/Features/ActionButton/ReelsActionButton.xm create mode 100644 src/Features/Feed/StoryTrayActions.x create mode 100644 src/Features/General/DisableBackgroundRefresh.x create mode 100644 src/Features/General/DisableHaptics.x create mode 100644 src/Features/General/FakeLocation.xm create mode 100644 src/Features/General/FakeLocationMapButton.x create mode 100644 src/Features/General/FeedDateFormat.x create mode 100644 src/Features/General/HideMetrics.x create mode 100644 src/Features/General/HideNotesTray.x create mode 100644 src/Features/General/HideSuggestedStories.x create mode 100644 src/Features/General/LaunchTab.x create mode 100644 src/Features/General/MediaZoom.x create mode 100644 src/Features/General/MessagesOnly.x create mode 100644 src/Features/General/SCIDateFormatEntries.h create mode 100644 src/Features/Profile/FollowIndicator.x create mode 100644 src/Features/Profile/ProfileNoteCopy.x create mode 100644 src/Features/StoriesAndMessages/FullLastActive.x create mode 100644 src/Features/StoriesAndMessages/HideCallButtons.x create mode 100644 src/Features/StoriesAndMessages/NotesActions.x create mode 100644 src/Features/StoriesAndMessages/SeenOnStoryReply.x create mode 100644 src/Features/StoriesAndMessages/SendFile.x create mode 100644 src/Features/StoriesAndMessages/StoryMentions.x create mode 100644 src/Localization/Resources/en.lproj/Localizable.strings create mode 100644 src/Localization/SCILocalization.h create mode 100644 src/Localization/SCILocalization.m create mode 100644 src/Networking/SCIInstagramAPI.h create mode 100644 src/Networking/SCIInstagramAPI.x create mode 100644 src/SCIDashParser.h create mode 100644 src/SCIDashParser.m create mode 100644 src/SCIFFmpeg.h create mode 100644 src/SCIFFmpeg.m create mode 100644 src/SCIPrefix.h create mode 100644 src/SCIQualityPicker.h create mode 100644 src/SCIQualityPicker.m create mode 100644 src/Settings/SCIDateFormatPickerVC.h create mode 100644 src/Settings/SCIDateFormatPickerVC.m create mode 100644 src/Settings/SCIFakeLocationPickerVC.h create mode 100644 src/Settings/SCIFakeLocationPickerVC.m create mode 100644 src/Settings/SCIFakeLocationSettingsVC.h create mode 100644 src/Settings/SCIFakeLocationSettingsVC.m create mode 100644 src/Settings/SCISearchBarStyler.h create mode 100644 src/Settings/SCISearchBarStyler.m diff --git a/.github/workflows/buildapp.yml b/.github/workflows/buildapp.yml index d2b8df4..6bc3565 100644 --- a/.github/workflows/buildapp.yml +++ b/.github/workflows/buildapp.yml @@ -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 }} diff --git a/.github/workflows/buildtweak.yml b/.github/workflows/buildtweak.yml index 87f5f7e..d300cba 100644 --- a/.github/workflows/buildtweak.yml +++ b/.github/workflows/buildtweak.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 58d664d..879bc5e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index fcc380e..1724892 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/Makefile b/Makefile index 8367d44..31c8c79 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 2a93038..98bc438 100644 --- a/README.md +++ b/README.md @@ -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 . 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 - [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 diff --git a/build.sh b/build.sh index 380f5b0..94e5314 100755 --- a/build.sh +++ b/build.sh @@ -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 diff --git a/control b/control index b57eab3..152e10b 100644 --- a/control +++ b/control @@ -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 diff --git a/extensions/OpenInstagramSafariExtension.appex/Info.plist b/extensions/OpenInstagramSafariExtension.appex/Info.plist new file mode 100644 index 0000000000000000000000000000000000000000..05f44c66691ef2c1a0d0dbdcfee98fa364523997 GIT binary patch literal 1001 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 literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/icon-256.png b/extensions/OpenInstagramSafariExtension.appex/images/icon-256.png new file mode 100644 index 0000000000000000000000000000000000000000..6bd3d206a0ecafd601566b61b8b21f2b92f69042 GIT binary patch literal 43408 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 literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/icon-48.png b/extensions/OpenInstagramSafariExtension.appex/images/icon-48.png new file mode 100644 index 0000000000000000000000000000000000000000..353e8fbd504c0cec25c2e7603123358d517d45de GIT binary patch literal 3436 zcmV-y4U_VTP)DODjvxcr z3W^5PP*Q8Hq_sbqn6znPC~4xK{-vQmY+C!vq_J(VF>Rx^Hpbv1*i@*BC{RXTf(*hu zm~n=gVdg$(?(^K+wf3EL*mLHNpklg`eb+jBz1QAn?|sg>HZ%qsbxX4o%}uTuf0a-p zi6)nLNqHZ$;yPBv75cS=*wMN~KP6 zY!rONngnO#pcY~Q8ontS#@fxca=ENz88>QdM`UzbWc5Cs7QT)Pg~HTq5(Icr+wXp}*(|-3oj8 ze10Grjq?3)jqhkDFO^=ecbNpKXCQronWxjbY;I~0+<2BCX$mKiY1pi7mkk0IeR|?1-^tY6iFZl(Q8n`Q!^o`tCp_^mW9S?;u$gx(?P6X5Z4sQcSaNj+rh` zs{~@PSce2@Rk&`tPKg1=L+1}5*Y^^ZgeKq#*=dJyqvTnMXwjk`z5R7UCl|l+O z8VyJ8JYt?H1ZpQ>E1t%5_82OO?TEDBs{&&W)&g;##KtrOU&rHd9!7`S6lm^;bs?Qr z8rl!@xa#K){SJ$2N3b~Ig5hpM+J8UXO?M&Kv>E|T-b!8@I)UN7LkQ;&qbb}2Z}}oJ zg)0cH_@Ox(H}cOmX&QwI*;E2VxMfPna4}|JW75ahrF;_S-`a~+;XVXHQDg$>z@W zhz6ROttzO91%49kn-Fi?i0tG}^!7Z9R_7^1YcC^regf{!$KduwO`mMS>R&VKA=;)6 zG!&qDu#xN;>ohBJ<?)HX8W%(=6eJq zV_5`pZ^Ib+I~Aa!bQ}5CjY`aO0vDx)@R_%MgH;r~H?kD@)?Em;KcJFaNM8DTKX?{n zz5C}CgacZ&`EiumAII=m4&L-3%=Eu}Q#K_Tl>k>ooC(cXZ-XDbfo9_f!qH|-$G;C> z)19V{=r+QAAHIZ8`gKH|Qw`2?J2yiD0 z2`B}cI%6L_kCvDZh3F3W7T1#9+hhdOU4wOMK)1t#PgGxY*F>aLcr4uh=MS_;WKqqQV+msQ#3c3TKXEI|LKK>L| zw3OkX#jawHDTl%reH|~|^iy0JFT#=SLE-AVrXPumWrb#JCcuOk5}L6=geQIMHjC{%HEA~aT>nj$I7k* zDmr=+EMVRPN}7JG3fZXvn!jVPxnrmX*UlRu){`W}M(=yS$I9?Ug#5HC+a0Jm<8TJr z=hzlmD{fw~4cAg-*lE)%P$n zL+9T>boyUNP|?*>C#}#G*CJZ6DUk;9dLPp*EAGR{6cw^Kj#~DDvMIK$m=oZQOaU_+ zL2jxao)$X{?^4BPC;G-FG~>)fFN&A`jLudYoQ~Ozuxh|rP2*bcizt;1l?0E9zhxr= ziPfe}@ol%ePTUR!XZ4+n@yO5`kt(Mxghd-=eE!9RAtUs3t-MFyQ7xC$*a0i=1yAY_TnBdgyXY zvpuJW?l-ED)5xJrKDke3i1?F)&3i!R7c-bPlTw-U%2DrQC!#F$rDM;c(|-;TzG-I* zW$J74HJY(`=L*ck9)ZIhH0|j2>VfhIlTUU)Ai?Xj^qEsgH=@&^C9qb_QStIrmHHCM zhD4^2n?Qc}00K)LC>azzo&UMwPJ4~k|Lae3HCivqj%K7o55p7eF!jZbV6Jebkc5ri zJ=E+06}&zIx^Jdc0#b3EX&U7Dh*3Bw31fCHGLMZi@aZws`2&o)tF-D+z%+Ws%qUuI zbnNg^*wn`G#byV3MQnB_+V6bSOo)XBPyYi(i6=lk$*SFt3_UM6Zrh=3=m7{V*r*iXE)T=* z2w(=``Z}+)>OUeUQ^-6@aEHpCG8W9ji&t*L=rwv4oW6)+>Jyc{&KH`owCu!XxEwr6 zG=J$W<&0-NN{NSvU$3U>5u0r!9wT^K6Hl(rzcymWk%zdgj5`Y!614ns`=Dq`lvl+<%&HK=Dvy!aL zI*lMbdIn9lbMz)7h@9j0h6T0i-*5!jS=-u2aCtHYBRdE`CBc1CF)N-)4bGW`f21(?3g1U#;h4~fc)wszUon2oyx

hxlu_ef)w$NiIO&xk?pxaTx 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�k~PKPIhsOjv{Hq#GY3$Iia%~k)-_|E?t&d81K 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

FOI2||_xI5HC4;zYiFLC+6t$2$1~J%c z30pE>_giFV4+{g7BhuP;PkOai-9!1sK`JNQS&u9G6h0bKDo0u1&_)G6iueW zFs!%V^7ORdm5H_wEBP=Jz~v!K7s!nq67g&vu|-S;$N%i)|7y=8B>4E-fvJSO*QjaB zRGh6~j;2fMBM-CLga!8c#}qiSg3F_Gu;$DXtwE)4v7A^PkLz|Ts2 zd6yED`voO~+N82#fK5-h@7KtFXrGpoWog5GG%A}btp(0()?b=Q2$V+MWs3dm{-LSM^@i5edSYwNz0}pOYD_aTRLHJpm ze`UU<*!Zx-T-DgA5dqKS=)ies!@T9t)Vusg)JvgXA?|H!iaq&zN@HH>WGc>+CfA7f zS3({2`lDN+vVrDBeE!j{U+snsJg{GSt1FY1P2V|Zr!-K-=*Xuz*3k}kGtMD7_;(w! zsOwg`OAEM6=Li3A$lbD1R3hF){pr-nLz72UP=w8OF-AH)%^pdwK${~w6Obv)Kq2ok z`#@|O3}bhd37rTkT@Nm?BXzi1@7|-x6c?{u!p3=e9YXIWltA3d`@FHEgm<6f@iFZ7poiyr&S>2dGpzYw z%joI+GBIEK4&T~^sbc|b4MHb(#*peOFoy%78Caw`l~7}k#1jM7*kHfib?HUd+uN1F zkMRvSCUhFRxCtH_KVBS^mIau%Y4lNeo$fiufBs>%7KaWsBCs!b=t)HWrQ13oCqo`C z^;L6D-mUYSIr2#K#s23G{y6ghKsSm(P0#Gwdpm+4N`u~xB1!;QF`gq-+b*T8)=yj+ zUFzIIAx_-Z@;(mfF(AqH+g^Np-hZa<9W2f(%baEHCPa_t_n{jzC3H``pBb^Ehp8J} z?ejnbkcS~;EOspznX)us={Wwv_r6gPfhVx58+)mD&-o_-2~g-2SIey6QeX0a7S{$0QY1zsO*lEbX=`y1WRvIv7`3W; zy)ZAQyu<&komPQ72vkV3&%W5!VC}TIaWeux_u3v$^jJC8vgkuH5UY z{8^-$(7*U>u|*1E##(Yg@7bHW8I*o9jsZUuOWh=U}e4BRgOBT#X@4Kz=!D zPXHB6-jBzxJ_qcD7_44w2w|)Q3a3RqX2C0^kr6m^XuU zeUM|tou&ejuJzMT$pYo|dR6fd&_Sgeq_6$c&|6Ha+DAS{4VtDa74M|Y+S|mz8*-Wa z9?gH*AZH}Bl3p;*l1$WWFT`t2W61*Fs8@I5($I`QC1b9&=M_c+gn)&&)siDMR}Q$v z!JkhjW=`&fn@yp zl1DQI=YA}Qz}E5(GS=DL>gz$hS0{F!MVIz$mw8!KV|$k8CjZ~2#|?o(1~N9o;U!yK zA>RNCV{?R^erlgQnf|#XGnic#m~#+XsWFf?DARbLO0TTrS45x?zHxWbsjiNi)7a!* zWm_(6R$zzue&|MlV)kM0GSv8SxutDIr_x1*+alT~-pI;v25#L{v(Va}^aXCbR_hqTN5Sqn-2U0oW-ov_q3qIq6!dR4Vq%`(`fjN5#Nx?VVzw*2^-xO4cf+F|&( zl|N;>3IOukuqIy`H|m`^d|3fCD-%E7;!67D_N4lRjNMn`1ubycPbIgkvNd&yH)K?& z0AgX8?TRg7)8uft!wjJLt&D zanL-%Azi$e*41yFp=^p8(iogrWs-J;8^arSDRX5=ft(abvCSXzZ<~kaczeztA76c| zCP8BL(ve%ADl5mI+f?VOrw%!AA2Mwx(l>{-vqWBc)&SoOAuG`od2jbxLMGQzgYzk(M?hz^;Ow$T>rIF$OpaR+4I3G#x z*1k74kWBm9VgsP=_CdarT@=`$L`vi2Z(_#>jmhQ3MUX5$ zeRcFD+d({wWe9};!<)w7rV&R^*1fb}ly@kE zQzxJ2vmZqg%@MtJ5T*cUF=Wz})F)!iSHIhvru~-RUtr^kI^ycS*FyJdzuE_^ewy87 z;;`5E8loc^Fg1mNrM?HGc$)yn0{+2w$ZOjFF9x6Hj$fF<*_1XpQ!gFgVIOxsoc`0Y zW6du;fB`gb$m2j{*DEYYFTpxlB&pR-&;PJOvyK# z_OaQvY>)EG74+MK-*sZ^MSA;D{jus-ZAfi^Xw80D3vHNg0ai{|q^?dJ zR-vzh^g8_m59>7@pnlOh=Dm3pVEMY8fN?HT0-=C~E+Bi1kkqC1in~>Fchp(?Aw2y8 zB?ot?kQ*}ss2BatgWs|_8(*mDc?jsRPG;DMCK3?a{JDQl>rhT?e!k%#1!Cka(4~)o zEl1Taf?Jtu@)D3K9m~g2N4~@^- zAO9c3$GA-&eF$7{-onH?=3DFPa!B%J2%S%@1xasV)59Ok4*zU-@flmYGTQh5!31-t++y<_0MDaEWY6w&vdZ?^24#fk5GzN8y@R zGwHcvKuQOMzL;jf5*zXOmKpx>=__B0g4kR3lhf)$56amvD)dV^@2d;i_tEMPGQd9V zJ9Hl{kKq6dLn~Fl?X#uUw&u0cl~t=*71u3yOtm$> z;oa`-DIa=iA^>3Qy@U5n^#QwKo`I881|K)gIP7jJVC2N*` zl=`tTEwA$*17=HX@S=a!T?c?g^N$4GbzsDXn(g3#Bn*}DWrJB_%xCy*Iht#}_XsJg z_Fv?&dcC*DytM~?83Oyn$`+b?-OI4t46?XlJo2Xmsye0tIev@qQ8m&x)3M2o1?U0P z!S4;8!nhE0_T@e9zM?4EulumMwNmHRXNC&@D%#>ZPwBOxXY7xEt6QTxXZv|?4A81OLmrEwjq zrxY;%@8Q9dmt>rP4n+NHd={`y!ftPCa-4=CP}^GP>5ev%qp0Eaw?Za%5Fyb7Ne^}MC0@kaGwN)O~7`xD1SCXh;WKh z#0Rkgd5m-GoYHIP*7G~&sRMq%>C_+HoB*K*0z-&$8xH%5vOBzA$fD;y;IFYupZJ)cV5C z=mayb*UvW3Vfd}+q6%VGh;h%_B}L%crWe+{GY!{$bHJXSc6;74C1IAmN(ZQ_E_&0f zDAKZwk8DlHDgokiN--OJeJd3U6>Z;ux2^)^HDlD19=$8;$Pe**!vfj$^)u<8u z$Hia-?+a#+SfCM|KaRh1X?ZUIBKpMWz+3!prz40RflG(GDN^eqHjmj{FOzJMf3+I} z2=rI-As1E_v?1Le-LASxJ%mcCwC-}4Z52I1^EZjcZOaC;1RnDm?`6L@);v01btS9} zvnTB8)II&dT;QKJ=UA!4&kV&IQvU|{PlGnjdBpzg2W75=K0!pXdWBH?pMVDmUVQ$* z>SWX`bx14ZwX>|wDx1?&^TLnvN}rGZaK&NT9@?(c@+-b+sp?q%Jp(8 zzMnZVuQEQEhU)FgU0hU)JBv=ocwMf(N^_$nNXWHU>1K$HeouPb5YIR${6c2B|G?3k z-V%L#Qnt_g1;0xmsWIad;hq6$YeekRMUf;vD94DJ-{E(iCXc(azesSzDJg$_FO-Id z(I>)m{~4(Z@|z*A`;y+u*IZ2Z0oT%-qQ)>RKe;OC83mP+U7KMD4_zRAC%)v)m7cz` zbV9}LavSJJE3?leAN+96hUjJxlTF^OD8tXiwdr!hrPlBS1E&PU-y1cYh&~m;dLYLKIbV0D??b=X{DI}Pu1#+qB ztboGBp^5Z*2lMy*ww00v7lhK}Zj;X5(lUEo-v6T^C8FQXA;BTvNcj~iFs58fxwx`P z5|vK3*8L2d>!|r+gjU4sFa1;}atph4C6#BtMkPeWkESsZ>@5#rwcFs;`xuWlp-Jd8 zjOp(Dge#547T&?gYpE+9a;Gln3`m;yUc6>LGh8*bB%~^PO)egg1@%2*6@pG~)>%j4Z zzYn1;lghJ_WV-<?%3qs8hu&r*IQH z3ol?E3^!WiW~a%>p_Ty^Y@XiK^BZD@C+)m#xS_eND$=q07X{qP9{=Z}z;c!teNTMS z(*B7!Orh3$aDMNI=T5M!hH(EPowy*PWa#%{uceB;?ZSWlo$58`@NF0r)g zPW$m#-3;Hvk4KsU_&KTh4}%L^BBmW9%bMew9~%7BYZ@M>4`G))nuu4j`Om(Q_$sn+ zFdsI3#CUeuan^Pg_HE;pLFYa-^wYtvFQ5CY-S;J}Z?Qh&;lxANMnHm;QoH(78T_B> zENwz{r68G=ElM-6;?YOQQ$kLebs?feT>;x29<#U{-<+p?Y9!dIPn%`>0brZD?u4JoAph$>x6 zA-l~ep}Cjb-&9^0ASSPX_P0Rg9^YSmJr@vu=igIElJ~og)Ve z(i0FW$>sYs@J)lP$sb%ppvcx1Ni>PWk-WrFi$@ZCeb_6~@{eKTEge(8Z+%LuqbHWW z5lmLdXMtY4t^E847wlHyz&y}L@UEb#RjiYdbgvP@vnABJGezG%dDM`1}2pU_P%0_LgTZ5(DIz+vim`wqguSqLMM&B)EU( z(fO_0U$}D3b!t=#DDZ*4SK5Otmt;4EY%^5UTPrcZVJv&6bSr3P8OT&+apc2)R_#SF zZJpbL$OAd+C#slLuO4y~2~zAKvNt7ITIyQK);Ed(eC(j#_mCzwAjr2#ozr^uC-3%SP)7v7TQsh zMogej_F$x`d-|rq7ypa|Na_r z>DpSF#G3zlGXs}!NbgHSg=R(Y?n-Sx)^+YjETQ)Lj*2`9BG7c^DA6~hg4i@b3i_is zO6h^CFc~>eYYG8XJ#HI3x6oL#Q#w$y6irg;3DfqZIRz|jgqfUuwv8UVz7Br|OlU%f z4qaNR`)E+&l$yY(AY3m7iOkA)inAV~0I7R@)rHSyk)`Fg;II3PJWFqqc^aJd5XIAC z31PTH#Mt*1?w2O+&7va)lw$e0Ujov1z?R3Oz4 z-uEy;)zM0?vR^Tm5{ESvOWiTds#*HnV9rOIszA$EMP*>=fq&Q0yYlG0X2xge`p{Yl zbAan!m(O|T<3CL|vhL9{I6EygHGSUG)ncOX zPF2Q6vI=B1KmYF*Cu1w;RT0B@4r;vjT%h+Gnp+ogTYCFlCho?K?^5=U=07%pmi~WY z|ERsA;xU05Wlg_e92F5$vMl*w75C?1*Kpyo5Tt?EC)AOa8*jeJC9rmu*&bhSZS?fx z_dp^OMZ>Fsewg!snelCWGrSAtJUo%5v3j**E7s4ffDTDu`MMNwd-x-Y=Ne)#!O^;L zimHMVgKYT0nD|xZmJjMHoS-G&$Y@GI^>%D;p*lEncM0?$OH$oIeY{O~p8oQgkS9wi z73S(rqg{U3H@|nCy~*f=r1>JMO zI9hfabdB{szNmB9E9_}&LZ#Ci`~Y*Y5@FhL{2Xq@qIOjE$H_@ErpU<=flWZ_J5QmE z6-SPGZ#n?;=M#a+(HB+!iH!+-FTs=@zl8k36`u%n60q#M(TKo>nD+^y}BFvmG?V)2pY7{OE@QHEo+6R7c*y-LZ|`YVj6t4?Aff0Gs4Pfzd~_b z(R<(b1UGlN2^5u#f#3-23fwz;aBxGeT=R;3HXFUSnS8@|-^9BL_+j(ubY4AiT(Vui z>GlH36a2284Z+o!J8TdvIc?;E82fGaPhnbDk#`D3jFrDG$!JCd!=2e66pTpjO+n#= zX9up?5YHaj{Or4VuwAvK@0`VeqUKkS(ea(V%7bc}}9LB51Z z5hT%&^Z&aKy?*cS1%W8jDB%HkM!9*xqY`YZqpJicTg9OVylv<-{9g;HcYSN8a>XgP zyuoBUNHGNSxFcX)!hapPozMvUD~0<}TmO)6N`$dRZpm(Oa~=lUA9u}7bIUG?*`u5i ze%DS|?K^?Tm;)BQxA|Q%@|=?Yr1a+mVMu2{^Gd^yhFTyNYXfjvHtmjRL+ClgdG)#A-zn)IH?KD2m^)ebIic z(y~K?wUYJ0vE{y7yMN7@iVI0`eL{GV8Q?0OxNnONLW>pDT}J-KkH}VfBEHbgP@c$2)PyKE{vJZs8-!e|sSlr9B%thtgXIBKA;oI78IFarI15_ZNY( ztzMudGP&@7XX`-!o+@Y7711* zV973mXz7za+x*4sN8eUG3G+R8qx?a#Sd5WS zI;wF!WH7eM;9|q(sDBYTLRQN1lvDpJwqubBIhTyamoo-+fg#Wl=H4%y2kH;*FC!>m ztrgXiJ(|j2XRPLw$o!Q&e1%NNwNL1WM8cJ(B`nED5Ra_R%U;c8yn6NW&YKhrkNw+! z4P5FM@p^$=Em6S^bNo6QaH>4Y?Z+xshc)Nc$gkJX!4FK+Hs?DNtj)OdjSM4r`{zE= zP?Tq2Z7a%+8MP{OI>ZYv3;L~Wlcqr0w(p+AA9xGO_z3F)*aryn9)U$XV~7?&Pj7ot^NM%Lu|%9zeS!^5TtjS;)k0rEb;u zv%iX8aDBt3dkKn*g0b}m*^?%}CB>xfTiwk9I2`)mdH7xmKRBHx1=VR2S-Rl)_zH&* zn+|CAGz*sA?&;UK>q(iA#GJR9U@C?jRAb+D9uy3F!nMM3&KPl>&kRoIUIl3GG^1QHa6F>0(6Wh@~NUdILByYF7>xVVuSw% z1Y43N*&sBtNY{$>XTo@`IH!ZZQf3C+AT4odbPq)|7RWZ-DWh{81=zKP#x5xRG0tF1 zGWBJD{Qd}u1(|>AMy~+T{_pl{=x+$Wd8XYF(SMrH!UzWX{#sRf|DBh>r3;{_YOB|) zuQ75y+p^3OqOcxSQ8uc?5Rpa15!W*gK>>oCit_MeO8v+Yd+l2;RX4 z*CO3jlnI`XG*FG3qh5W>lE5=`m+n45n97$OA2dg@a67F%uzLGaJN0o}zU)3F!IJ`A zk0d2{f_%ss>(_jP2_2&){mpKsEUeS149OL3p1U&SJrmJnlAi$w&p*r8VFC}-2A0!3 zeVq=3jx(VYS%YUOZ$V6^kMl}lIFH*Hh@G;V5kInTpHsOn=5apD^r0oMsDKv21vehg z?hjE3iOnuLCnCQ*gmV$!VclyVu?g{ZcpRWdjpWGZ^*10Gg&1s~#ihsmSq3mM>g6E0v(q-M93|LP90 zg|5~svx{tY2iz$y$86B&)I+;4pIvSA)9UUC`i$AE@AlvuyO~ z#5Jtcx?S?xoljePkOM1jux_yQ!8Q+u zp8Em=HR^IW1--rpFsyeBy}h)?o|SSrG*Q$g3fS?e7*hT@Ef^9X*j1ku0I$JCqA)tt zI#HD6fr_8u>(@Wex!lXS&JogmdqcH3VOfK@d2xWEgr*M^;t#b93S$BcpDpcyt4lMZ z>6ujk9;urcg*Ih8cCb3{T2%C^7%$+yFUU2v`7OA4p>o6KuLj#+ygkH@(_vUH&jqDE z>iO|pmd1DgYUxf~zlA%`gy&h=u0Y58Caq2{n58)8J&kiCBHl9l+n?a(k&jx5KjipJ z(q8SouKd>n%6b#@46UE?dt#p?$#llvb?k`@V7fylt56?Ad8(_c7wuFsi(C|TUJc-Z zKaZ0OsR0e$ZUm%zshDDbwIZV?by^fmwAmDGJtqb3re3n!en*d1x(C|E@?T8*_UHce zD~b_l1I$5B$~GP*8#%W?kD7`ph8_bm3f|HOoXJZCC67ifh4S|DsMo=DFl5gkn4*7C zyQM@Xuoc*>n+9FRy4l_&%o`Bvzc%LNj6WB;8rWS84A1s|Q9`lGlYk_%45{@Z*dlt> z*@D8SsxsWNr|>1E5Rtf1w%bSt)R*hYP~575rBp4Nm%VdEr?!05402I?qZdpv`P;l> zAl!>0ABe4|#ez+*`9L1eUgccP`H&MV-93af>r#D+(=DHf{@PfVFKW+k?b@D?4EXh1 z(u2lW-Ir4C&np5D%_w12!CiO2mb`~k_}zS-nPNBusG?K101=dL4~9O@D&8P>&AY+D z_688G>m8@kcV?LM0L-s(1eLp1>knGuNk|(93<^m`vSa$oe_?N@#dFsEj3~<=dRkRy z%QWj)#0LJwc~Xng8f%c-1aIE$N{KDS(FDHlgG2zB++b4Q)2b5gl>X43L#9x;M;&F3)+FoU!YSA_ zo;jJ##CY*a$&qDa*}WrvoeNwkq}R>z2;&#d3lav9G;>H-H^Iv_BmP|%TgYESexvO7 zA5Qc>f8@b%yynz;W#yy$0v1hl=A_?VcT@7(ailhtZ~kastlKapD;oSV5b4AjcktmM zw*-hW(o93)3evriE2P@ls%AWF@l{NH{}ouhTNed5`qp9gun26<0uxCg{KjK}P!)SP zBn1U~elT|D;qPmM2S~k_CJ&hY`+mL z(%b;?{U??!jq#5zo)DK5-IMR|TgNFr`pm_`qsAE;vr(DJ&*nXWaSVXE%p3k=b#z4s z4<(k`)()3uQ(Wp$>#phoe=7R+-miU|*cDVuU~{vDC=}arPOQGZb9N~?{@175uwn;A zhyj}lq~aZRY1A6~d%SVSDxhP6tA6t?Es83`29lTWK%P2EO+FHP;x8U;e;`_pblDz81*$K? zhI2W!2bk8)pa;LKK$!=F*Veyeypp6o6aiFMBJ(`X^11Hw%)qF_3V@Tb+-KufW`cO! zqhzQ_JNq_T-^}O0msSr585I48>oVwR9WOARKPa!%ieK3Am)iC7be2~~y2+Rpm&m0I zGx##-D&CuT6rNTWGIOPtyq+(Wi$_acZM^8t^r2|#ro4Mlo3L`BS~5EmIh2qI7&gws z5kxKT+5Na*xggc9i^U5i8i%7QoBFgztQz3~16~Yf@fY%K#{S7bsYFsFe`IaB@ z;Tn?rJmbS{hTQpt^daCrnbt3Owa(XRVx-0m_=Xrq-y4Zp)zta3;ty>awH>W}vlEA+ zZ=+mktDF`JzkKeQA0l=bkWQ#0rxL=EB-#fU@RmRv#W63{dCv?OjLcnk7nwnIuN2Yn z3UgJHGq)KFOR(UubO9I7*NXJf(_hVbn-%W*ENjr;?EWIoTyR9(|Jm+nCQrZPge9-z z(~!hJygcFyOQ_9<4D(v9-g7do1w#u(G?}X9k4KwZ`&n8C7uoN8ie=x5;d^D`6$|pI ztr)G8YcKjDk`~Rio-abo1@x|V2t9Xu&i^b59t6g3Bi8h-wPx_5MwPW6oVsd#$Ew!Z z0_~I9P6&jx) z(3s5zQAFzzK;U2d4VG0Q%VzjRf#hH30-w#}E`_}oDdt4haL5C54+i~Yi$mPNUg(GD z+M=)0wm(!eYLWBnV8Ddn{xq8Sw@D0(g7JFFdk<`^n>s~;{ z-!S=iA=}D_iFQD=WiNC;}QahV(L{YiQ?_J}y@D97~$==7m zAxnJ`NW!&+;jwg{yJK@2in|~A*XG)RJ-`b32F#Qqr_T8N9C`aZMSNUq3$Xvsfo{ZoUNQH2V*0KS^ zttY1(((UY@`s&R^)@b>{z?3`9<*a*<9O#Z1qX$PnqKyp1%{g#a0B^6afcP!{h9Uw} z)cto!7UCzYUz#oob}wM20}?5pmSdBWhKhTw0oW%x^vC=m1ljTA2RfopV1Skobr{s=#G_)W?8 zqgQJGsl6WkVd&)9!$%JuX6HI|@-yEh9;eH|XhC;iU|^i^m#MfyTko+B`K!B&G%^Xu zGufppo0yt84Lv$V7uw9eR&q_2mzM;dR6jr1kX}#{YllLh&V-Sy8y|+9>Un;M) zKX}QNxr;Ja>O^$b=g(He;=sr0-{gQ%+OuPZV7yOr3M)@Y!>NoM!$Ijoi zJ+K=sY!KNe-+9G7{uJhLJ6nbA{BU=|mELK34uq)C8aOoRUMh1|WkiEwR!Z zESo1k7YJ$BHOA{``RfMS=5;o?BKr59uyxx(um@WXL$GUI&RuxeM=@6CB{ozStJ7HJ z{`C>QNh0Lwm)EOqiE0oeL!Q2k)ehn7s-LlrYKr5=J$_xF0r^cHQZv_rH<@sA+4aeK}K&~~&U)xF}fQ5QspKq{1pckXd~*zA+7tmoOZ#8VX;tIeo* zIHWm~n&kJRe#YnR{!P$om6Mu@2Z>J>oV*1a&R{COq+)v+D-sYVrk`Rb!pIgD*;-fUlp(*RSy6TLg&yn&M}EtiwK zU#4${Z|E3~lw4`&|K?*h0#>@^vz5Kuex-U40TD6nSa`k@+V_6gcEoq%{!HM9CE}%j zs7;W(d`KJvP78<2Tc`Ej(m5JJalk}|Ig5(IcqXV*iGDx!kbyl(9HQ?$F-KL9VAPlh z^JDL!an0^ll_xr}*pH8eAJTx^r#;e}zA`Mn9kW@Oq(^SqO-nhlJ5j>)P`cS{Q%Whk zRi3Kj;O?2JsJPV4w9nk@_=y@{MY*R8N2qbFA?eKrPt+gPUiQ(UmP_1dN@-%bg#JEi zT=*9eaI=3~Bh9-#*s%P@?IFn-18JSEAA5IxuFOzV`5=^|4WjA_VfMQro|abH$FhjE z5})>N-|n8>U|sCkt=(kz6(7EspKW6KI{o@q$sy5T7O&mGdn_}9XtgisU4@i1%m#CI zR%5gHKC74~#>&}NguWNq*I~+)InE0&Iz=4!R9#so#kT=1$5cWqa_<`y%w9>g4?xCX zHg+S+9Q;&fBjm!;uzOXGj~99_3Y2(29O&o_Y{AhdTErvngg$ctqUMxoE_2CFHg1thyLY&O;ttBac*Evp1;<7roA>`J^aw=uXM!i~=#sjxQrm z%j0?vL?vES(fw3Ocp%XE1A~;VSX9bsnCR$h@ntPJkgdI4^preieujuz*@nIEHN|Q! z(>&qG(p5Lx`-NS>%<)xOf4IugbhCE@uihKI=gWM5buAOGsH``?`$~}}j%_@F+DI_5 zzj)jNq3OcZo}>taEhKMsQ=6*8e}o!woTgI2p&v2l*MMWApPg$fW-3FYYqAo z#kmLTK^a&!a{R#aF%p1G^Pm9w64N!|*Y)WEZg z<;=(0En|BYPVGi2L%_7~JMSaF4*M#Ms;nnJ;Tyr}t~9U0tI&xZge_>!3~UV-7$`Ph8aN4)aWXj)JV)QLtg&z@w%qzsBJ~(- z8H(3*=PO+hDSi!4?%mvHRFbsgKT^cQ}sPQcqAMC^>r`2OF98!!R%ofrKIiyh>KL z>^i+LNc+#8oVgHN=SywI>8R+%!G}aMRU<@Y{gfyA<$_QTn6WCaCNahr zV*>|n-IK5yAokU5{;D39L%0WZRq9>Y45{o6zu2YT{cy6NM(5j>+D*nt7U51Rd{120 zqC7=S*FiQYE|T!{#B#3f%yEw7<{+2Yw^kjM$6tRkij-A|jWfJ>BIHNG8wx0aOm91<1ug3_P#m7++b&wgs7G9w~;N60mb9!xzIYA z+~_aWTf?2BfisA~$T4O;U7!6Hv*C|m%jw&ZJe*o%fjD>>%9CRK=^bM_7A1t7g{%R$QsbrRJgUBd((hpdKdC6J$KWRFR(blQS5E|k`;na!;M@+{P!On zwdw8e6w?;4dy3O!;6qZpOkQ#Q>PJU0@YrnGCgW^+ar0E7L&9MXraUkEhJ{4qF8#e3 zT5oYMpoUz<#X48U!_l9z} zSC|~^U{UcHG0S$;X5=V!b?>QSO7+}DTf&tlb!f^xMVuH#1Pf71m%L@YA^ikvEz;59i@3Ls3E-NyK}kMAyU1dzjU1sEc`#w)`@ zV0(gko(R65o^$ULS}Q$psCnn=zTHPUgdNRt&gU0cfa^NR%=KcS+s4VNy%Qh=L_wwLLFo__0gv=1oq!-MKxUGh@kgVm!p;l(ynCQ?yiqq1!?F?P<@Ac3n{aBuPn1R+*=LfVwd^nqID?PYH&kMDwrrre1FA<%F)y`A2_sBh}$B z4>&igTH;Q1erju}6f`%J_PS_b!Gbd21~C-DK7Ei zR=Hc|%E+BIAT=(DC!G<(5kareOa@F=J7f8xYrkK|+p5_)50b5=~D$ zzta&3mP#mGOlf3zszCHt;yIO##7(jC zF_ByD*3!~N&VU$<~?X) zkNU1g3ca#1SEpj_=6-jV$)kXHe3*996P(|zKn2yf=M01YDal*bWh)8P4jU2i9nKOJ z*MfnO$Kl7zp@c17$#KrP2fIgc;PqIkVpnnW0a*RjWulRTi;dhh7m4IyCrPk6M86ZP z1#L6;N%lbcYIzT(cle~CecDDXF`vb?nePQRG;;;4d&L(+hDGJ|BbsQW|M79f)~ zjs)j_%mowuGj)ZP4JOWe9GiTWJ~Lx2tGrTn0_p7?O1**Ddx9!$1oFXldzETD$6)$c zXc40=^dtf}eIQef+7VlJQUt7V2_0bjOLBS%ii!=}M3xz{Bt*u)WHJ#q70VaA7F zE9cehySwD^Kykl`L{3yN9;L6$HVhalkUSvL&mPLQUIuphV*XC>5ArnbUB)L=foeR$ zIlMz4tD{O*MLtEHB%VCn4;&I4UEJ7PDc@gFY#3>foShwYVyAAe8n1G4B2hQm={1ZxVj36C161*f7-FCsZ z{JS14V-~l3Rc^+|?UU5y4KtDt3$xg|645^e*Ev!zwY)yQe)wTbrIOYw=4_L!+D?d< zNF^f{1qQ3>X7X+4nRFWZsX_lbN?G^xD`7pMFPjej{gdJuehOFYU}b;X8NPhhCnZp+ z)?a`Aggodz$Evvf9$W|H&KJh~h5O`Z9A0Z2U-vWN0=tr{hLRIt2CuhT25;rg7^gaN zP7xNVxG^FMNYFp0ryyu&r=T!Q!zXC0exfG?phhwAsAc+cD#6f;-mr{x+}+VG>AC?t zWu@n)om+|+%eBY~%mwqMG_nj|Lj({m80W%OplhI6)|_+tEyVs{hVb_E6U<|qP}aT! z5rAl>?BEUcIE)iJe6KrbL+Axsp*y8HDy?ECPWKk`UX^Baar(jV{gYHGq3&^*V>EYx_bYsE;sQof*6D6xCfgy_RsCFOb`!<}t=s7}% ze=QPn1Sxp&h7=3azR)F9nN{zgdo4n%YaUV~-D}kuW-)~gKKnbi6^c?$ugih2h$3gC zzhNBhj1BB&t~%$<`iif`4viOk<02EekCuGAzvzZJ3{p`Ts@L;tdUoJ7qER)K+i$#t z@ga|{(oH>N-I9M5L5%f^(IwQZF0kE*)W=}Ncd|1c(;q9C0%$@T>m(#%I8DXvQ4W;?^QUO|EkZUtdI(IAf78sC){ zir53S`$?9tT?`cswr!P{&wKAmSezNDBvjXoJO6ac8(r(_h3vC8DdiR6?3f#})Ro~u zpO(1&!R9k|<^^%a!T^U=oc!9{4)xQ!R(LGg9H%!V5=&W8m z*;VxH(XN1SRp6IWSG)Il5djkkMDp%nMWsHdpodJ`~Q@5Xy!B}j~t)%Z}-8Qa8 z{Mq6z64NgK_r)_^uMHdMAHwo#yGvxzi={_2)x~{f04F^cJQ3Dy+ zno44`EHe26S=UC>#?@d64hCrmg6~Bza$?C+`aAmKu0N+bAdwy zz0(RPG$&;8ZUal)KDo!WFHvQ#1u;|;X331E zRqvP=BnKr_Hf!=j*D`$;=p`ns_svMC@AwynTr;DNGL(Y4ls!RQbHJmZxK+WhRYGn2 zD9hH|v*ntMf6nYr;@3bap@;;@VdVbO2f8R-9QyvgTg}P!vum;k7`q{{N zJbyX-!*N|JDns*fZn0N!d1H2_j{Fznz#0B@ z7lUA>4PGv)2j7=?UvGNXpvZr~QX;qh!r#$cM@HM)icl%LW<1EBHP4nUEn;WJm&(wi zzKnh^8Ej4XsiOvAO0!E-`)NFkZxo;$hg;?{Q$QqosD|8Mb6yMhG^B^Ulu_DFt!viO zT|-}L4BjxtDVLy<4{6UT(NmUkUP?xAj%WDUs@A6sQLE9!lHz&9^w>IkqzxwGxa6z+ z^yO>$0&b@;o7?}Z*E*vx`sVUb?e<*C@nc5F+BRvcZL2Qxdg22D1zUxz9R~j(0rV%u zWjCtdPPQ0PLr$yl(H}l?SfYzCnliwIP)-Ky5gw|+LmLw|H6Pw~+r1yI`p`Fd@#y$) zNB-<8HBy8n(FfH$=i*GUf6bD{3ju-8xI0%LD1^Sq) zsdos-{GtIVk-pcL%M?dN)?#su@nY#I2Z@@`+~2_O6J{+F&XbYBf>@{pGsn*?UwKv- zjSy2;DQ)YA9r|UnvM`@bSvK;W3Egf5k)gHh(S}Y^wh~>O)#iJuenidG&u*qRcap3& z@A?ya+D6Jo$Ru>iG3Vbv6N29N7?wDOndNxPE9JQ)Fy{$Z*>$=tvRB?{R>L~Q@nn(6sm8`wGGZ4qbywq#BRc`72|27&rlu@TSn z-khVL0)fr-YoG3?H#Kh`vU8xcW_H+9;bNn)>eu~ZWm_*3UFi!)GcLJFHz6#;Aza$!+mgb9JL|?a9E`MuuCqQwMW@=sDxQxj)Wqy}2d8k}5 zllLV31C#Wx<8$EgWyxY>(arRgT-UZR7{Gw_v8kB-;YG@t@!HdF&lyx&Ut_=tTseW9 z|Mu8XQ;aCDL`3X0TMvxtwGi%V9UM!MRm+-q@{(N8MCOUOo$lCl_;KgU2HS~goQ#oi z_{O}U?q!~_d?L=%`HhQo_OXCG(Y?HKN=q}u^(eXn*C^p-LA`OgwzSYN(F$872)0mP zo4PUd*AkTt}6adg_>wJm6*>~!|H8M9ZVOUDXE@eF|vSjPj|S3-6GMCo4) z9!?kXnQJal)A5xZ)_U$TBL@#N^#7ftl+yg!uPwm~9;ZZR{0rS?)JH{z~0 zzyDydy?y5Jz7`(FrOvMUQ}Y1U$Z47R=qMN zI?pDk7bDIeo5qjk8`$V4@HP3IgURVrQ@Ja9<8b%%#ih7P2y_=!uJxh#T`hql-onQd znV?#l`TTNmgu=BXiGXJ{Zeb6vraOXcBo=1uW-sZU+&Zm{9hY?uXR~F6ap8!M%QZ^u z;U1|JyN><)TlN0TC8+Z>xywfsJ6*qHKQS|SvZc3oCwE#P7j6PVroOP}ZxArsJ&qc2 zQx_X9ij!ckGhhhz{-;l;lD6!Yf&Z67MdnEGZs$N&|w#rJ*{QH2km$C7$6AkdGa3oU6`oQ~*ln zpsL)(Y~@Hnd89b?*(*BNzQCZdEkku+`fa4%uFC70yK=3&mXcT2g2E4PW)t2q5_h*c zeZfwR=n!ad#E-<;022K*&-4zUmx*s9O0OvclS>eqiR1;DoSp|zd5yhXoGZ7=X*9xV znjs|igMKba!4}OBPxe_GrCgl0155SJN4Gm?gQYfUZKw;=zO^q6N_k#HcTGyf{3`P) z>c0RU9hPQhjBvCiUbm0Y^5U60s*(SdF)rkN!P|zf-YT0xBU}>`wz(Z<1;nV@vxu~0 z%x7A!GI0qtUN*{`_KM^1_i|aBrTma*BkOr%K=~KunRy@cLHpQgIZ;8E4TvVFh|rgyluu#Gi>Aq*v6?EurwmjZSH<8r_iR~X6a$4fFpx9POH%+KzR|}vrA^w|C5Cfj z-qaQn(5HCkYXasGXOID8qVH&~%~k{caQdj5j4l84$}8e2OfMZdAk=};UUyy`pBX$4 zmp>?2m&P_zdhNhylUaavN8iS{1OwNJr6?koK9CVa!%k|4pXrbOUw&_#6VwA%sc)D2 zJ1|taE9VnKnWM{1lvVFrBjM+(HLKMj$~Nk$WAP}d%|lNld8_J~Ip3dW?g2ne0YNW- zz`aO&p=dzRdql=6Dd`$!Q=ziq+1rI$pj3m;w_JSBJBFQ<#4CIBD{G+nZ5kHmy^rEA z+7%Y2suFc%1x99wFEiJFS-|;^bX?Ml`kr-DLbME`cWRVI4g`tCdMkFg>(7FckI=r) z;C0FP_OG~VjV0u1vkcU8jdkhY5a^W3-57^nYT8E7(+7 zo3vi|2y+~LH*^jORkh@X^ zmOMTgh#)S?pKgW*j++7`MnN~QLZNTNPG=*Yhe^*A(Xu>rIi|s3U{oA(a4jrzldpYc zi{vsIu^OT2r0T>H-^(SOHDE4qJj}`trWx$K#yq*`W8N8;$anT8u!75XIT3#UVaOGG z|L@D%{c=w0Ko1LR$*&-Yb#04qbX$lgFV1;=s+HDB3y0=l9!v4nJ42+~a0lo4p=k=+ zJrEdA=Cv!EvS{GYH0G1saN51_7{~&YQ(4#Waeg;Vh5q>ug{e;xl+gt!e>-vv?z14M z;vX=f^k<;YNZJ(#^XlQ|N9(%W&n1*S1Cv!t8Ni<})ks@C)n%lZCJHx;m0NWC+4MdNB_;fP z#hqEgcSg(+KUTMv({#76Ec?&JdE+K@m63v`t#ec3QEthY_9iC=W6vb@06nl3EW$S1 zPmD*&EVefho|zcw{9`U3=L~f1w!sG!HJexNDoQdt?GH(pZ~ioV>4Oi|^7Ic1oHc}7 z6UZbS%f1 zzjp7XrMZT4K@RlO6ez;)L?xAHyGoZ^Oc|O^X%HTq4SH z$gsOl*eyiA=1J%4GdaqkAdOA+=`zt;j~9U*?(39ftMUSfY+;azX;DB|{huAWN^e;E z+L0aSlOY9J04fyugJj;w<(~&cBE;CtdlfL`9tM!I7<1@SHDm%O?f|txN?bXhJCANa zBx~!5ux3g@D+rfII9Ns$FG)xyIp%0<7~Hf0ZskuAplT{{>cUTC%ZQ>NL z+wWHmUcaBD>^fccNnHq|3#LYE!T~1M1bx2QgRUF0{7HlOhnV$ZvLIK(&ieb^hiAoJ z2RUVMI*mN)a@lfym~-rusTe0=_<9^KN7w6DyX_$X9q=r{DLxTF7m*x@*qa|%W%efI z-JV(9;%AoeK>BfowTw39&Bk=yHuD1`;qlI3IY> z#hn?q3sK9*HN03j^g$n`w}2&Iyo=o^?W>U*D_lca8(ri9h0Fk&4Cl_o8Vfpg-Yl_s z(zwy}4t};tj&~-qP=5oy^MtAa~n1IX8N?5NX`ppbZniQ z@2|0w!Bt4W{w_2m7;H8EHVfTemNUE;JCp^E&WX52N)t#W7rq@f~@zC2t&pO*9vWD9?Itikbvp> zvKYj6(5bI@X3?y_uBbniqg9sP=;c25^xrm|(m<<5*_P-RFJj%YQf3VM{~1Bpi9DD- zWh!`iYM|m8tXy!!KU!92UhrZh*_;7j)}vWuw$P4UNwFEVRGT#sbCC!UV~^zjzG+`6 z=MjU{erm}arDqI0@sN2ZKgNFD>6=3kv0uj6?5~Y6$)L=T9pg7CskndT{bSNlDUZ+$ z=Evm=j7%Z@f~;q7Q*Cyac2@uZQO&s*Zs^}7rs)!?PhIlWJvEH!=C2)fqmfmb{k3-Y zVTrn$8((&e+{W|ax`ED`K7>Q;uVqwakqgnRNMMx&w=XvDQCcfPUa|B4uC(IMugE7N zc2et{a?=a15?3NL2iaj9b(P1@@r*L?BsBn)0yuKlaQma{`ifApKl4% zgCJWmD~QeDY(ieOrGfo5ymvaZGx0B@>kpjA4U&m*Vn24MD@nt0_qM8{sb9SUdiR$c zQi*ZP!1I}}m}!8{{E>&*UMRpOVW>ga@=u0Mo69=tyN2SkyKsDlrW#A}(6pg6njrf8 zTi}`7m>W1cS!JUm9W~=_foJa8T`6WlFa~h((SCN310=ic^56Hs3thq~M#Dt~&p(~-}UJCfncW9ZDs9B{O- z?Z_(m^e061MEZ&ZFQxsV=N=^a3|stI1YpT}Zcx3eLP2*e$rACZbe1fkb!l5-#;4rE z!0CxCbXyk^A)puVNgMn|kb0vkQ!I8c=y$iGa1MRkk8JwiJ@pvff?$4n`%BMtT zyf0}z&^{ps7M8mAjSrT!=GA8cSlDGZ> z;43+W4+5y|jIl;pAyIEzNYk)k6u@j55_OMxi=_5y_#jR5yK7x#rzklbynBP% z659(haA`P`M!33$6$>fZ>G25ZSiKG#rBTHxP?Ky2!6mr<%@g|J-HCbip2O(mobTjP zNf4c9FKvzv*JeI6$1F21>U3syraC^I=n_dePrFVrrteuil_VAGcUVI-`GEpdDhHxp zYTU6=lH#euPT7^)nH{uibi_i3Y@6(>w>HfAeU8riAi>{K%foyn$lXFXQ)dP)Ec|-u zn4TT6MeJ}=Rv1m|)YACvuGzYLLp9e7K0CqzOHnzTet5|%InSC|6%9vSP5dC7lPuu) z+unW{(iu&8-Quy~Ff}=wq&VD&a=XiOAHqLb1XC;Wkk69aVzj`oNo_LnYZ~8_7Sjnb zDcQ_w-}z}}GWYF%bKA3~0qvZ66EA}H%Po7zys)prJf1M`18d6zn0Me=C-0`;5F)$X zi(OsuBgm&}7WGAYBN6Y72M34lm8ucd1Efy`_*^7DKK^ZOfpqwj7UaeXS$l4rt+&e~ zYAcyE1t)y89r%~9S+SP}-GhvpJ`t8%bW?BLeaw#iIOmY4vnTO|uo`Z$E+F1z+~lh_ zT`{K@?!QWIYeCIesqKuW#tUQ|kKp;q4M6Bpc&#{ZN7vhLF^!UcpDGA*HID#;AgQg# z9}49RuAgfJA>3D#{=KBfpC!l>3+Br@ne|uJlVFbVucyU6`6tZGzNYX(m~d={JZeu? zzS*simD2MzCcJF=pdWI@tzh$Q5L@0u&Taea^&}^IZ0DAA0fZjuOWA-0NOz!rVt!Ci zyBTe}@7d~bnm=N}0i_xT>*&CB~Gcl1PlQ^QnDW zbcnA;Z5r#+iH^5IgKMXS5)d|+@!f}P(+2;vK|*8wUtI$2uNM>u@KSGcw@xWZ`e$Eq zZw$7=DVhBCp%7bTD$#l+eW4Ri`gqsrFw8`wFA-vy514Vd@MX<>)8Ffp5WhJ60~ z;A3qC((0E9~ym`;Vn085|P2h~;)$vdewuEv z+xH&NoXUy1=WSVmxa-|yHPq39dDf3UC@wV7@?f1@FDuM%x0LQea62(%pOU`6>jxs< zXV2`7>`GA^>OH2YWp?dPn%|tUJ8cc>Q4Sz85u7#j6MYy)O_>W0BrPDG6ilsMG=@}$ zp-%uAPDTR1l!Mn3yyOaNkl0L7_~G-qe&z5DxA0w+#?LySKsfku5g2mSNmO9R6WSp> zulP4nnpj+LzG7^PsW;KY@SUio6=|KI6evB?s6^)IfO`DflWvP~m1Ulz?}i?czSs=y z@cFtcI*fOe3b>geHaW`J$L2OzYF%52x(roX2@|^@P1Jws)lSExp(eM(@T1?^e#Wc} zAbw(h2t4${tFlEjDl^s8Sfks-&%>9e>*6p1Fk?+@i2Kfl8uOPOZ05rdP`A)#uzNTt zW%m%}{lym0Vx(S4F2qmOpD$9@{d+Y8$9~9qQ}9Wg_9chr19l3-kXyYwAlUQ{jA6ZW zlh|#ChGO5-{NJ0 z7jGi>0iQSE#RR6<(>j_ap)%{6-+G?qQGbi%m#ysCtj`b>_0k-kJHb|)`LY9W5r8i` zoZ60I&~V)}zb>4KGh+=f!Ul#ysimPF!Og+_!AFR7#0gHYwZv@GrfJM};2|{<;z5g5 zkNCZ}Y>ld8lQ?^NNWKpspI*pQd7P&GslSCRR+KgxNrvhIfSC|>jSk3L87Kf^x4lzG zyACK7{KB2mO)u@!hsJljj=C{t@`uJF#O%M`?S65f?_iGHiH)vi9PZ$t#WjG9%J4XZ z7_E5?t2j%M3WFUi%lLN^_L3GU!!Rj4AwzN4ASO@atx+X1>u3aN8N!~p^BocM^JG_j z__>?XLL4s>`v0{FP~hu7IO1>KtFPtMx<7$qJ;{1X^F^#6QQ=PoKg~jGGa3bD*hyBM zL9(cxb~}gfic2BNDzfhj<(ph3&~~wY=+b1EPx2?yUTn>ujx}Zpw;_fy&gymu_yNwz z#zkY=ZaTZE3GlEIm=amMQS6~!|Wu!qVc2h}|t*HOPnOq{}q zvd4D{T9N#egVd+{*Z(yvK0FopxC3QQ$a2A^g!qhqEi$s--)>+%Skofi_p;CsQSY^T z_VYI(8CIUGdbbYKkc>jJ(kge=GNqba=N+aMVjML)ELnmuKIk^6<=*F4%J!*kEr!RZ zaolifYO*d?t$4ATBpmPM_ye#mo}kCoP%Ogr_lp~K>E;w)`lo<+%h1PO|E&c;4|qJ+ zq0F}0%E^_2iIr?NTcDna|0BYs_cc;ao$s7s-;;v3E0l8}#Ql(4Wez89iW|f!3^h}J zm!@F7njcFtXE6^=#*F+QhYs!ch+baam#xAxi zS}`X#QjsE=A)O6qkJ)+W1=3#3zkR-8NSimI3 z{*%afqsE+3BwtG{-R0i19!R^~PJOuMOFyf=p%VTfxC3os1945B>2U*$ol&uJ0uwN{x!d2S)U%Dh&s=9IG!o;&8?WgH6G=M@%lRv9IT7sV{&(ot)BFu=RAEk} zM@0xI^}uhySe?41##97p_^|gog)a>rDwH57UZTIjHH?f6l&{{XsT47!bP;b*d1o9j zep^NI_0ulA`&~t)xQOwS#GNM6QULj~{$b2eM#1L;ktJxV(@7WIXnj}VQ9_u&T};?& zxi6!6>k3c}=%#8vU;Nvf8>gXsE#6x`N8XnpcscG%=v)!us$Fxkor?s)uQrCwKRAej zA7U5FVGQA(x7xFsL1J2Qj3;N|xLuSo+q9HL2zyp=h+OyTk=dgMSHd&1&ey=A;Z2H= z{rTak(T!4r@`-I>97O1$mBhLti%jy`+u3%&V|YMeM(KoT-K)Q*^kiyKYh8S8{T{8% zsJ0tVagbrK;NX5)DxVUh?QK_fMg|Tub=`Qr7wiw9UKu+{M2VUH(DTMcyV+qamjYGs z1w184t4$3lg8^2F$=c>ntfKp3wQKL;H!8-rA>sHe6t32fJRf(S@;$PHgj%WacxV(zOP(Lqbk%tKq!*a&7+}*FS@!wKF$}|ty-Zm-WD$K2b780N0^3XhU^>7o zIWSWE^T9Djo!2@~^u*O%=@WCYh&j-0JG&e{<~R{O0OY$|!z^+a|4%=tYJl6$LzMc1 zVUj&8DoA!a(9W{UX#PYHXx>E!W~)#Cdmq;xx~{bg&^AwA^DM7=m%w_$lyswZlTi90Q0 znqN}*fqi0xQEt_CSC1jL^Gd$<2M0BaiQJDm=GXybi{T(khJ zG91Scdd~1UbSuD|-36+smlN5+>wC){Xsl(Scr}CABxxS0CuFOXGLhqFKoopFS;C^fvglBYwh*;dd zA7r+l?4a>C4#=~@Ll{YUnFq^@lwZqQ#G>X`svuRXBYHuL9N)4*31}x{z4(l3#ewXI zl2iwf-rJ;P1##|N^iMc)6_3p*)p;^Yj$zxIYx@XKq^|ik3*7xkPQIIga*-Gt+FYKk z5Saf!W^QMG>Sz>*`K?)6f*R6vPtHUTi(=;A>ryFMx1BYk^(mS7+#YtvYoHJ8LPu)6 z^5XNFiLLQhSWNh!5FQhB*y&e>PD42}G)X|tWuW~D@5XwjB5O*C)wy-0Q}Uqu5m_RD zWi1q~zpMDz9sOoeM6kPIkb*(mJJ<*1+<)x?*@~n!o_Ra-FanH{@khu!UxJFkQD(~# z=vsL#ej`3&O`n{-)~D@b-X;+t*SwV8eSVq#s7COu`Eu)b*i3h=!#bF2psu{R`?8c1 zv~w)k9#1`LXkqk%SEV6`B&{iLORX+VmS?WQht#=r{hr?<0-SaD^CP8-9Y6qs=@@}3 z1^8wwhl?DMhqAjVv{J^tqrsu2(ThrmN5cXfLT!7J!Jvmz zgJBXba=+u?GThk#Jv0@jl3|BXY`7>o_gIjm2-h`HT@7XJ(l+VW<#7xT2Dnj~3b$xzD$-#j-Mbp8EVs;& zN_M^b{|^j@=mN-Hz6-ofR%FVVOZht{9tLV{BF z5B_P=s%dn{$Thdnb9#msoivV}{0VR5YIi%8(|>HPxj&*~M$CrucOxwiezjDG{hE@n z&o*^r?r>&hs_h5d)}MGRSOQ7u{xm`zY$%}*Y4;CQj~yu%XVe(m_W-S#d$9=NY04vFjk7j?Gr3D^U~PHd*PEo*_8AV zhlEfdXlD6zf(6OSe>x{lo=J9htI}AvI9u$t)0Rqo z5(IgF{fNeU^pDhpxk=Mz$|vKi`md~g1RUqyadC3Q^m*x7iTri-c%4;;b)-R7^CGI6 zim61_xg$4(t42^Zyr0}%$11GHlGRoC#@`DN9_EgZI3H0P@!lbknq;pF9MO=9mI6lRC zU~Z$M`f+rYc8fyjoXa6uX2A=Q#lN zvz%ZKd8Ip-_h3xYCQNYhJfy1SkJrm%%#!J`A|Px?pg`)q4r=J)Do88_a9~@S>)0f# zgpM;N<&&}6%eVS%?|_7xy*C{^(AOiJ)Ghm81 zbDuD6;#kGEOKJT|l^E2#m$~xysBT zXWU4I{ngg`@H>P(;m^YpV}0M9RF z*bI22sE*TRrMeG}i!Y?OwgM1L4T**h{}cYdes2sJJ`zK7wD4a3Rc1(#!c~2R0L=c~ zq|!<_GtYO)4+c*Tl5@cwi#brhI-eM*V-da4tCRwy1H*OenpA*l5hw6^*H8(1)C7&gRu*ItHk%B{I?~5Vx}<`YKn$PU~wsTS%sa zsBkM3rXWmj^KC&nPDMp%HI%x(sEqt;tMxBVPS&qx5P4{vBOFWNYSC!~&Cv(bY-U4p zp?Su~34hE~xrE(i>|}RnD0WMM0^e<-lp@4z?ka+ZlG)GKt1a2Z+08-C0us6cv(g6y z4OBjq`Y*`Xj6t+ z>-{BQG92fs4w%K|A$T6p}ukkPEIc!1aG=sTK z&V-Xb;XdI|G>nW8YArFPp_wd|Ajf`gfd8^+`D7GhF~D1=2C5VvyTF))%0u@ubq44aN2BG<&UYMB!oaFdA*_NiO}R7@7C%Bm{t zE)nYrB-#JjfuUhaTW*oF2yDHhgdgL5vj?NUzh8BpV>~e0g8OKYS^tX4bQr(iXT$r- zGQgV{T3WyNqmbn@D^-HA5s|QDHy26H?)=*siSs95!(f}c7^|^et--NMc7;{*&W)XC zK)s0T2_ODeMmcGQii$+6b;7jzEiR{z8N8gEejR_r{=|uXP96b%E-o-UE?kMFA>sYG zq{ziwkN2+yf${PBZw@A?AB`bPK|O6-Ok-@f_UswQg)kADH1s(!refComz|Qebo?Y( z)BW$~Joa@!MC)AkPMWGHzTm9Yc8ipS7gJ;l!&z1c`V-hG2$ie_Q)Z@;iS(3m_^CLd z1HR4~uM|`jocU<@Swjh|XKay6Qs~s_!Js)xjKzCv)_TL)=SvVzR3D>8gMhAN?tX{3 zvF@)0C0EDn&^<#&al0;WKF0Z> zTaWXysg);RG?x>@!tLC%H4;jA6pv7Cd_=|U4WO^ymk%>TQ*?3{BUZ+=jP>xY{}O-p znalTWr2MGA4mywUv)$N|EG$+!pj;!eKCAl6Qs2+G+l(^@d{A5eN+^;p`B9H24o?r#3mOEgVRgxumt}Y z?!h2p{4&-L%`{l7c81ADl~Au}VBlhm;22EF{&9QSs0jY_RyFI)0ljeE8B~}&tt05L zvfOs^MX%M26XpZCa|2Qt-n>#E!LBDPJ;Tt-a4&ipDFHtr&OvV!%*4mgx7M(`_HQJ? z@Q?5eA*z!5LCp?t6Z|QB&2fCSMjB&+q8fTX54D`!4A0+)b{YfODk$>*jvlvZ#i@3F zIt_WG?S9%@;QW|}sJo2+o!w-i9YL{shbHZoHM+IFXq#W5i|UU4m?G#L{poV9Wa62_ z(UnEBb9|?4oRt-g)}b7^NRH~abx)qU-c%!X{A3iGAIL^JHc8&mhys=7bv%wp;S%Bh z#yLCixYZ38R+OnjB~j+o*>~y=#ZdR;9*r3ZW9SqC;TV(-9=7OxaT(>j_838mg<0Cr zdP-e)k^1P(PoGT>?TcZ8fN1o2LQGHjUCoh?*FrhRk4KP8 zJx!;}0?g+QaBO?TK{w%A;)+M4C4{bPI^6tuZRW&nFgv7>EvCwLMl-w-dwv+RKy{l^ z?ZfKns)Sa&kSR(JjG7edK%1kk-TzlYNn`i+*yLKBwegQLGlL;~r$@fo*%x5y7d^*H zQ;w~HBFtg%y;IWzp6ou4jqW<)R8NofR)J_q&*BfyHv@45ced#aE8cxekMmB}>feju zJfKs(Pf59t-iSrbKaA3FVLUfkeIqcjy$EQn7WB`D=b4i`0J#3h${`ms&WFn~2bfTX ziDzhHW3nh$m!;g{N zSyv@wA0piuEB-Y*4i8UM$_oHRUas*fNRK~1=Y6n90kpY3-_?GUwIqEHg~_bn*U_Jl z{Dn(mpH!@i@BfqQ3sKR<@<0~wLqaq1JzIbVaG(qU&bXf!?g6sjqP1bVQ`k6o)8Cs& zewa5>APDABA_!f}{s>(w$Qq;Df->am^QEP-V_m2+KIhJIg2oOIzljBlt_sQlrl#;=p=iPrj)`!nN;7 zA1(7B${ofTaddA&K+!m|?!B%3A7(hr0;Rp2ulnp_&nHWGYVn@Rhr8XUie z-PSYRP~;vt88{2#*EZb+c)WjH`A`0CmtPYy`0g1clPQ=k2u{>Pa=RHzeB(J%x<*)+@hp`ky7>Q``FbWOud5**`W;`$1G{CYb`=2 z9?~Mv5^-!!m|3zhwBn9QsB0GLoN$O%?>_j4#{Pltq$?vwBITFJ)^Tjr@IvJ}b55Ub zQDQgtukq{&j9SxysGKO;!waoTeI?NYvYkEVb>_wKqWA*a2l|<0SBlm9=&#*>a_~vQ zi@fZxYLcHgZTBO4(P96iYSZP~fF<}U^0#_kW6^erljiF05yPRGf1@0dzX~SRaMhY( zD#fLq9wQm?^UDyKrul+&Vnj!~9YeKw&TPJ3yi81j z7X7rIF$wwD38TP1S#B|%1h`mMnuulf_=DKx3mbXGm>l6W0m-b<@B+FWBZ!MAf$C#{ zx!is02}4IWcG#yZBx)VL$oorLiadT)(J;x--%=P(<=s!<^sNT)v(c%yp{y#B)=;Y!sEf@BSL9!LS zb}ZkS!yr3$!vu2wjC>6y8D!rC7kV5vxD^Q*tz${qN|;*CdW5RX*j=v)Z8Nbc#J!)s zHLQjnN^1O zj9bdH-sIGyzy1NeK3jV9m_O6$mI#+LklcQ=?j6^@4CKrg8%5fbmaxOEEQ+tV=vO(D zsG~nkYtG2EVe(z4R?kN9K)JD6Fk55JNcrSW_J%OFdrDGnA4yF7I0a$m(B`Yhfm18B z=J3u;yl=x81s79n6G}t?u=a>lE>^iZ*I^)0{8iC*g27uWn?$ zt0eR@-Z+WWXoNLh67bY&`(uvFCmmrYqc;tHq|i1ivLwZxb?&qvctJXM*COG0H};GPBRcuGntWI> zbhiO!J@$Wyd+(?wy0(2Zw1CndP&!dj0a1!T=mAARrCR9_K%`5T8UmtV0V0BQ2uQD? z_l^`n2)#=Sy@d`5ft=y_-uJBEIqO^J-}6t_n!WeT?Ad#A*XzCpcBIo?k0-&a*G-gP zy&7558*)7(Zu7p92H=S~sQ+b>(cZoPf~x8Vfl8s78o$E2-9;p;Q_#j+6I}&(7}B*} zM;}TmuZM5s3109-CLN59E2{=hu;=Yy?i6XiwL7bQ_hizFMa4JsRPw{OZsmq~4xZDR z$Wv#6CslxB!H!!e^D@!No~6hie-xEr)ZqPMt5?J|l;?19#{4Cw?F=x2f&4Qs`i4HV zZ=XrhM0>r;do^hUSm9^|W+^k(q$!r-#oKcL-5KS0E`IR+iDFN0Lt4GiQ5h0HX+BV zk(jH*j7U`UOFpfdy%w<;4Ws4qhOW>gz9CLuD6GIPX!gyAJHLhUd@uRF(fqqKvLdr` z?_m86ab-*Tl_KSrA?A==dANB|IQ&7=)BZn0bVn+14StHLnm_x{MVe1HD7_ii;IsMf z-UC+L){faNPo>=k*CPdFRbmy6r;X5MFGLo#27;X;H#CD8GAemhpB>F1%=?&jLT;L= zo|{k)(B4OLs;hTsz2-jZoEI)Vk~^BR9)ea_{qYDuL63&Z5q0+%I1PU0ZU#_XJ1S#) zLzo);`D$1 zn8r}$EMoBz27e)XnD$J6?i*y;K7y3S95eW)PAv5J>RI1{{ikVHhL9CWJj_q1evz{! zjTuiL1W?!ii}>ON9^p?dv(un8zIMgdgWjyHoysKie&Rh&t2(O<+16-4MV%Wm6DJj* z2wc%^`*>0`dKD;7j)rzoC-6_>#cqO^Gq{P1_H?6TZ?)q7RADixdt& zb=rSz-I8r{z)E%`Rc+wxRvr%f*1Q;`N3Oh_GvNRm)#KI(0sHIKZ*nK4`4~31^eTHauPKxZ7frGi zby(BlsPAzd+=!;I30LtbDSljZSbnij`faXz8-gm(#PRYko~F!u(Nx<^8=v^vkzdg* z-e2KK6ajVAteJXrm7Agnx$=dFT&y_!tV*GEwmTNn{2ZWx*zajg?=}1C1!L!5n80p41 zQbY}gpICQ&2PL^I?>dG5leyKQ1)oy1y@a5m@TC^NxN!+Cu6sl!&H{J7l7@-HRe)*h zo^;Alax>fL7Ci;9%D-I?<8-2jah2`x*>Lr!?bEqV%zFJ9v!P0_vS3QX&Xi!c zy|sUBqVE~KmFIeo_?pC|9_kgNa_!Me3@1{x=P-_N5;+hld4}Nj52ous4uBG8Z@WmW zTepkFG%_8I4_{S2&^Ln;i@VWb{_zLh$N1GG@&PUEMRfR#OnGb~^2%t>8Okc!jxF=@ z1S9)gUCo)VA_&*()a!S|2!h|A88@x}BLju*4|tM?Z~2w;YVeoSM(VXU_ZX>PAM2Ih)}^KNXod5E;KxT6DY{CO+??W^W8U?N zh+Ua34IRH15Wbp%shwHht|XK{QqBWgsbN@u`HonY!fn~>NTG&dTsDC6B_xYI0oD#5 z?Pd*o1<;c0Sy}J(047jl7WhZ5La5*J2x`0m>|Bf$ctc#yrTtic&y{5q$4Ak9AWM8S^uYWg1jJi6$#2$4pJIIZ{xZ}NHiM`{+*p|jddB82wp z{0O@r{m?GhKa8Kdy)u(%Jq=s0`e#ZyoL8f_W1r8-IVR8ogf=82Vyf?d;%n#f)sggc z2|y;X>MI@CjQbNRy9(co)W)CUyV#1jrvom=NM9MVe0-&j!i!@6<-L00CY69KD43c; zi3Q|(2gU-Qq0pdOLv`oAIa9cMdzSOW(U)q!OkC)bxHq`vIm>pr>tT-BsDI#0AAI&z z(w%Ls#fp;|legZ$ny(cb2~PNluIH|kkVzxoqPU#o|EAXBjo7m>M~MMV@0Hq5x#6xla-VN7GV)rbvJGt7(_7+}av{^o7g1 z08I*cyo@Rw4~9_Q`117^DRvW=GXO6?`Gm4Q^`ZdSPj_)0p-7EQ?rc6GtIaL^fGF8% z?YIYAD5#n(Ii2|~(Od)X+zLG_C?r4JD-{BaXfGf@6TP|FWpN(iN~Tch5DDO-#AQA0 zbd8F_P+Wsbg5{eG zT>UK)Or7`Px1>0f#80gfB-1oa8GV71z0TAWdFW{o2gi^!<-9vu7b|=lGo}t_J%1}S z?X=>cLlr1YCDCaQ>vPr?UouqC9R6Ob=z{^{5nTN&9gV#6Bhq! z8cwh2&6zOIlXY%#1p_&FdK0Kt@HJ>y4q3Qfb%R(OzC&Hrk0w`~3EssdBoNSLS&jtb z8uwMkk01HAu3R7H4C-;|;Rd}{<3huI&q#y_+JK9TcQ^8yNX}d`iSVhv3sntZ06%EU z!Dvqqm^9zpE)fkzth)IFhT!i`mytG7D%Q)4_^`$Btn=NNqF?@3b2DNhWc!BC0j+=j z?Ig0rIP7S(r1XwwW2TK4_5Sx1`N5id-x?3JC>W`a;TO9pi)BD4kRV;|^Kdq5>4!%Q z1(QPApH?*O_}jJOdS^7KR{ntRjc)^>Jqh++HSpA{9_n8J{MEGq-cg`e4I1BHSpV)9>)b;odM}8stNsWm?S9P5+hp1V$ zq`%c#+kL|`#DUD$G!Kal4fI|qf^e|A)QuMAF!ir9mOV-jUqHMfj6Wlcaw+i;UX{@+ zGc+*BiH~e@fqo&Z7lJamPo43!CVUK^G^qj{_VdgQI=4e2Q0(Q7ZJ}mI6oP+GCH6X2 z)(;wD54sPq;6_JBn*v>)(=Ad(iTk9l`op;H*X(0g>*DNM%61S1vjhG z)<3)VRMzPTS&^u6G{V@v9BbOU}mPLQd3>398(rfL;nJg0i?Nf<}@KT_UHqH&wP z{6iTiHkw&07dp)+xsClE_+8n}v(2-(+y2!MJM91V4sFprmeE^$BG%ySZoPU>piY%) z|6VF59i8avELzsn3r$5q^j`AvHwuUou$&%|L0LQ9Kb%0X%7-m^@6@zG4+f@Rs zK5_~<3-z}FgsU;#ZF-HoRFqTgf^VxaQ?8?vi^5)6y*ys|0Mmp-!Anf#L#3KV({-eg z{b39ikzwqA`rCC5Kq~QIU@xKjx%_S`R(w=p5wJor{Kr7O>X2eRgg6dUN|0x{0((Qe zOY{&ANF5E82Qwybt|!+FK>{D?7||^MGojKkLaEXef0sC6I(erBMjhZyDPTm&fB-cWbb|OHvyIEz2D!< z_67j(k@E8noUEEUq~c--3cagF#Aq{LCs?q7nuY?lp1{epW7IFV?0ZBmY8cydANL{P zcEL7IASw!v8hiQf7@dr*-^XJ1Kv`R0;L?oYWw^IWkN(`jdaM`K>yK${)+#A8y!;gL z4DHF$&l~l#w@fK~<2OeG1L`R5F5PDRpr@k7@6`MY4#HuMYoq|BLh;_5afVfA;Fmv@ zH{Cl?rL4vG%UyXso=M^ie|7+DHe2O%R&#UE;;&7oQ9MBK)J@gJ5rmds>9Jbf^_Vg; zAS)PV&3NGqxQLi`^>?C4$UfM z1AcBU;ub7a;izu~JaOC^HGiZ$nf0LUifFTGt4`I(rLs)1p4a>1!!w)vF+0}nSZ(sy z9CIk&nTQ>gz13nkUZ}fL+rOcb4NB>F8g@4>p@GS&nr9(2{QeyQg~%gWD8~@&G_w1$1IdjDXah5x|H2 zt&SCqL)kessaoMT)XSOt)I#=#Mrrhh=Sps;5RyVEV!>2%SI44@M*;${^f*>fu5kB@ zi*~uau)yX(&_wv8DD(T81g~qa&3^LAb#Y$Q=KggoF56^N>Y2&}w&UFy?xd194(3B} zKqtI7kCv|b$CwFmh}P>)do?IXQlxVK$}gCCHlj223IMm3=C6AiPY0Dp9@%k__$C~@ zd=2FM)eQ`kMvRu{#V>lwF%Q$)?v|&GhI_h~ZG{^j@z#{?ENRF)FJDS3Ab(*XRhM`Z z%1bhon#X#@2Y7!5MT{tIk%*39RfZ~t#(4`jQ}tynU+7jJwb;CZrBmm3MwhEPpZYi% zzKJ=D&fiS6wDaPa{3|5-59o%o6mHox7#;W~e{bEy|1aLwQ@MjUQhru5h%}N!yg6$c zn?sVOOMH$XFL!YkN|TnWJ`x*I@gTXkpCA_`>sL%upnX46sJ zG1P0{XTkN_hb|Wx^QCPof;y?&)jybkj@Zm0Cu1GGab9#ashszLYP3^bDvxt}><%(Z zlJ|*nx=wdveI;*eP?sbGXjL!x_c%GYbl*yKxWqKX9rPo&UNCcUvmfvyfY)!VTxNAKKDWdIjZtc1^9<-G-E zdOs-{u!Cw2Xy;RjX58i#r~BmG%sTB&r`c+-Q(dBvxz70 z?ay*pUfw!4bz{;DYh`BkEoi}BVOJ~{!%_XF6=zdZj$?68`LFdWDLm45&T9S`8MRzvn} z36%h3(@WQT$x`+gKKtPOcx2ES01fP({St)rPnaH1M;=x$``^El9Iz#vjSb)O=H5l6 zI?0g2tP_`cA^HbF7hq?{ILPqm-*)TtTi@-4sD7-qpZZFYORW{Z4w0q_*r=pY7$&$n zV-ZwPvi7>^wI?$XK^%~0ePlkW7dP?h4%5^!Pw@n=}kdZ5^ z?;RaJ)ALg0%Z5rHac*Cfn3&T0Bm=?(hSktg4qFDK3W`eQvlurj8I;6le&tYJcxn2w z0w2Mn{FDWYGs&^{iup)~krnP3?X_cw6z&iMJl8sA&1wmKo?`--D@()!|ZfDLLI;xe0pANiVXi6r708qdX7# zGenBGC%Hpd>4O+6!vT{?$e7~K{sqC?4E1jhw^>-Map?Jv;VTx-&4`#+HY<|f1;Y-b z1dlD7D0kI8DY#D`&v-uc|H07O`>D~2LGr%6dg&jXve4u#2#Q0s~T_5>`*we%5u5>HO;)&^|?xDFMT!ay>@qHtJ4ya ze1PgPDM}imt$F;*A(>8@Piy~|;~NmHX4IvL0mL7dvQ_P(D)ZqwPU-Q@_oSjflL41A z&@At$Wq{z+wY#wEuKlFjPn55dQ+9v`(p^}m85p^`1?gj0Hy&3ykwBPl=YuIHu4j** zh0AYn(%GmKKzMPKP*ua2z=i5}`k22*vsVLZ2Lj|)FYCPKfs~G>` zVb*b9D&e`!_U-;^TwrVF^k|3@Guy2i{U{$Ckq`1hPy*0b(fn9vGwrJ>{T9Q z_9Hz4-Kyx8f+G#?=3llDiQGAJfzL0OI?YHeRSNmsqVuh>p6&v;uzo>JY8=J;mZHjt zcD05U3G~)8I+NRX6#Ri3(D48IjD90r`&X0}8`g{J9|WjW+(5|I;x1 zZv%QSZO?NTbcO#YYMR;e;@ZREk}R_~7IaqV6eZwYK3w=68Nnqo-ZA2$B6=5J1S3NK ze6+IRzmtU5vX*_|WU@4j&t6SpR$Xp&7E;cX(Wt+y*0i_He4d<8 z!7|c(#70gjs?3ISFrrS~Y$^2=X@LP?>bt}`%ib$jqQUOTb+4A%;w!;L6L-(pPN-`# zr^|iSDoB4k<;U*P^`{ehH_n^025dJ4&xv#>=zJ60#MQ#Ah#j$ZCCTw8Qky zr7NmsZq#BRi2f;^-BX$$!>G5S)mfeeYALHzYfwe1g;ZkA}4^ z>+5N!mnxC9+Pjg~xwTX62y`kgz<8nAJnk*`mLgAlhk3tIjQ0T$?SPsA%rT>GXVR;% zJd(QJx2_y~@=E_8+JdMx&Q_@1LI;B%O1*VW%v-ajmHpW7)pB`$YG zLkkLupXw$ZeVIDT>sDCnwIY2K>)sYSd2lbZ0ek_cL^(mnK}B(S%HXE%Ah_L4a#Ex( znl%`+W-@+!FJAgeK*+||fv~W{u_BQ~dC$ zYEZKYVc`h5^En{JXXf6=gE)Zz-~p`gOoUOnz}CK&i6#2&DVU3Vq~>i@nR;mVKRD>ohGs6Q0dfWfHWOsRgqAy_{A8D)%nUqr^^yZAs5rAir ziMp`EHs@5gqb7t4<*MN73~f%848tXk>~O_jUU3Ej@2VYfuzxa?qpe~G-&d|O=Q{nv zw{nkA4!q;7ugEaMhq#8e$^C-5=xINO$^E|N^%RoYs?Ivg)zdB>wAiHlQ)fjM-I#*# zIv;9I5a79eR%xG^2F+~*SffFZMB(@KrhzuQtw9vK%cM!BVb^Rq2-mF5iHSZ<|FrnN zaUbF8UK5-**+%}S{G)*P)))0L7yN$p7wg@RTX~vt&`9}C>H}&%%I*U_*Bskij)S~# zChpxokoEqEL(t&$hkbW=5W})ZPp@s$_nCVGuq<2(>j7kG+h#12Y{KZ~Z#^ng~!P_0Ek{=)VPK*_hOU(pI&uAA%Gb@c- zy%((Ojq3iV&TSL`twuNBJ04%UnSANd(qQHT;jm=h-hz6+hEL9EQBZ z8Eb>ur6Rn3I%qzxeyn|^oKMz)+aafB$?YNRk<)~P)}nmGvi3LIYe{S^0_yH%gMn-p z!v_LAB~Lj)@CAGQzhipVXT!6W8*y!;F|o0m_#8)PpBetQ{-MgseDkFR%>bPl%%I+t z$Oi zT;%FqQU>L3Q^qTYOCs2mzAI^j*xF8PH=Mj8+e&?~bfdBPaK`H3*U+9rT?z)hvG@cs zV>1`4+rEFBLe`brL2~82RALsTW)MS`q+1O5nj0@KR&Dm7)NnPBixCs*%sJ!DTNIOoo{h ze{yMIbfd?4Xo^`i-(i1o8u?BZ*{!@Z*% z0%!**8v{KNvW5f;5KC@}={n@O&1gI{PT{_Lo6+V~mJ@A}I!I7V4ITErdxH_4meR|e zodCWR5_9E{I=!AUp5xFL?Dc>|rRBx0c|sSaArhl8`C#I%_NlCr!6L)@S*G+Axm9sB zOp{@fW?%5KU+f$ISjj7XzY?8isN#k#_vb=WF$qN3?d)L71&MhQT^<;r7t|DcV87L> zeI}37i`~9c-&@(kA$ZWAnK3yn>`AWuAxa&ZoMz12&iGJquV!KsVpa0Cz;B-SP20Ik z8I5jb?6Kr71E?TrAcOmDY*3Bp^GqR7+XwST*q*f9#q;VMm*xGK88}!SR9EGM>ZNQN ze{!X@e8j2e3kfW|06$qz7EdnhM+ttOSz)=deS&W!|58^b_M=vv-(RwRHb-x!t&sQr zvU!bAV_GI;0P&On6q!6Fd>w#PG<@Bitan#4<0EN~|Mj{PO3tmLoSU_>RVnq0rW#Z1 z1O+e;6+IS@rmH!n zo2I-AkoY?g;(GhsgG01{I{B1E?uuQ&gf6D-U9_okC#62yH=0n?JM2Z~IvD#Hw5#sj ztFLRkQ{jh3b?GS!~x`_;=QpRhg`(7NwsLp|MEk3!Q+n!W~GRR38B`;pLn4tqnWgdRA$k~^pzZUV_mlP7G+iE?Rs>{@u#{|plH8v8>3`gZCJ;}Nr2aP+`C8tZz z4f(6C<1m3BUz*otF^kZ4dKPtt^)xXL@un0IUd80{Y?`Ws(CnS}$Ltb8G<6h-ogJXS zjt@X3IcWKvg$j$bzO4$*o9RO+*XorfbK#Zv2W}h|meU-;kr7{IPc2jc_x4Yz-6VEGvBMzbuEXSQSm`gM!$RZD|QOruU=EyHA)*j(>&n-x{P>Gxl||)Tv_h zfB!`J?sjoI#>1bHI=fPTQjeR#pLd*|M4mhufIlu=`!o92ux5CI=6(>%B*Pmsb_U9z z>-1mSQi?*%sGFG`;FJnCZ}NhvB&R_<UpeSy}YkgI%9q&(a1Tv!n(Yo#_|@AbH%&6M^@$Rz7<5CdtU) zAa|g^U&^m@Q_0^~8DKoXbw_U`&~0j@L&uR{9pu+?Y`OD=B%0Bw*bX7nbmlHTV(?QU4~?2*a`cBs7Rt$RHRRRyCMA_`9}z)b?>u; z3fS+r6w1GSit7=<}5c#yhc4h|kx{=F@2vnmjX%LT-XPSEG}$CN$n6H-GSZnJ zxo>FX3p1ZJzj8A}Q&_xX;d464DL{OC^!JeMKMy1C{PjQCoB^`YX!Hxkdq3%azQV3O zjtG&sA^>j79u^cNu(h!bQp-6FbT+<1cXx31u?AU$0yJjI1M%a&?HZ9}{-zL`n0NlZ zw%A^X*yeO?HpK*k)b}WfJMeB zIx^kl30%zteO#FY`ylpiJ=l0H8TYsq39_cw1hThTkDPTzmgza2Sq6(Q`X>*(ClnE?)=x`H8*WyCT4vFEr5~8*RO3Pa3Ee0H` zsq(<;6$bWCv8im`$Nt{ViIYl6G_7Id`Ycbm7m(~lwu&)1Jl*S6z>3x_J?3~~k<1bm zXrZf}d$%n^E+do#dRgF7jpOkkK320a!^KC(6*5cL^b(KY+AEbjO?&Bs>#OY9d! z_<7H5pa=aNQQx~)b|Y-9Eu{4jLfFmFI^-(O_{%K1iCi$!@O}5UzHG7qZd1crbVP8m zhPx4kjiwk?G)mf&w-{0$%Y-3I{nS8V{0TJm3P4rJ|LJI^Df;m62%DnvV6)FQ}&e~DnF#RD5q&O zkZn2#!t{jp5-_echP)bGpFg>_2}d{FuxC7C0q zfSWqx%U*juGG;Ya-O)IY`mE^PK%D;}=g^c^jQbj_1x89C;Zl2LayI*6f1m83ZHY^Nr$#D=+d@t@ z{zkd`k~@J_MU~)}X5Qttwbp2^#lt&YEm!usJGvKNaM_#W5v62`FqKY9uw?8qp@HD1 zh(;FRPDl+fEGDx7adN0+<2ivX*FOe6*enQQ-hKCSEb>X3lWCxo4qc=mbL|v2%!Iv; z3SL45W{4NbdD_ov?3%1Q%T6lp;6FJ#|9PgF-@Q#gVs(#&!=H0H+50ia9sgT~XSWRz z#(D#(qk5Uivpz}WOaX1<3FI%4LGP-%(X&gQ%SX|Nn|1cK_~Z~rQ!fP&4`rEj-OB(4 zYApe?afXyj^Sz*K6M5U`@G&TB4k1Vq8M2t+ zM;T%7`d4Lgg=dIlQazNgZrvjIUU@9Eb-?DkZL7?~3HFq@gn?anWWc3IhmWDQ`^QfO zeEA~$XPO+Tx7YYF8#{aLt1lY}f%6rx*C*e`_7j+teQa$fkwtohLn|e9UVE6Z{m<;8 z9EerQ(hqO8=gdZY#*k-DI?4kR$MN)4BWRBZCKCwmI};x=k-Tvco>bl5#}c#z-YDpp zKU8LThE0OU4qk}jbY;NGJUf}Jph#g&`a!uK;Q;a5ZZ<;6Eycz}YhM9hUwrWRD%Si* zKZlf|3NfNZFytkeCw$^KLJ9Z%u9)zGT>f3+do{v}`b9MFD=r6FJDmDqs76;^`nTnx zt`&Oj#WaYppR2xA(cH>E$WlbM1G!&J->`)0u)B84aDu$DS;19>wDq3RKDj*i`%M$K zk6mEO7>g#9X0VnzGT4bVKV~B1<0Q#u_g&o1%!O)U@rP-u0@^y4Z34cHo8NU{7yo{n zA~LJ{(x2DIf?RnB`G0<)Ki^h*8rUY{6&a{g(+);dkAqagGf{Crc^d{oC|+kSiy&LO zz3|f((v|yzYnnVt{`_)(w>nM;$1F3fhp^C7B5vTRPQpUPryS9I=6M0LAcB|AlZ4lb z#YcD7RB9E^)*$2d4hNP=x1Q(wgs}n-nA83up9tGY^O1Td+&FSybL}+EZ(m?I@ykn$ z-1&NN*G8GyU%mMedwMiOcKqB*Z)AH>sW`CCV*v_sZ+ZD}L->6zgI6O$c5Z1lo<&TLwpM?? zkCC-*T^^OX&3fbv@f!INetpjPajM?b==8}^f!Ho;zk$E^$A1RI z5Vz{~dJ~=o^U|fo(7J>_1EZx#U=UbD4{50?n9)Sb8)N2AkU?%O`xjO{>Zh)*N`C~b zld@&RNP9?qcs74IP56cZhp->}(E*^OCXS0nS%=dqnj)0euYE+#H=Kn&G~|IC%K!8k z$SB*C@?EqG342XGMrfRPP8XM9w^%kx8Jl;fI|gY+9q8g2RqQZhyknQ3h+%Ym_0DP` z=vr$Hsa2ir-t6+w<2yo_0OLYzW@2xNMu=WHUP(dM^kq1ea`!1f!0? zS2P%BKs-vXk0GReJEW^ZBVdy)bu?I;lb0M1A0vE?79XAH)fZF*K9OT0u@%Tb^qxKF zoJ(Y=c;D;Q zv7n0jShe`*?XjhRr*Kc_O+BBxH??ExAFJ%Jw*)~z)NLVWkBUEo&`c*+W^WwcYhnw7 zbW~B|F1i~8{#7bv4{6}owuYzr6LjjZ#gr|`X3n&n{!ylZkV8ep3sqcbHmCxQCTT%t)?m^sB-{U( zSy7pq?u#CsBA{5sW4Ci*R3K~WHZX`nf)n`rFwJI0YRj(c|M-~e2=-k>FpQk z{D(^RNn(bB<%QM-dj*pOh&egyEv?wiKU8FsqOzkbwappkqm80&;?r77c*$w)W+_co zMZc?ymKM`?le2c!=U_%Eu)EF57=sk5`@tJxy2iUOnT`IkLGc?TZhrirjQloZF#r7% zKjn8K38*q_umaT4XaadG0mZOwxcTZ7wL4WFu-dya7QTLBFtWhx7G$wq4L8NApU>*o zzq4}OC!|$Q@D5tZq{h0s-IY<|QUqJ2he_>?Hqy*$v4BQzQ3d)awvtzX(1zYyke7MH zwW|3Ew)7Exg1fkku=|(ccXWTo(qU$ses-Iuy!x~`n2yP##ki26<-8=Ff_e=4+;yl! zm?e8ZLNrtm94gT1Ch~vI3LIC5Y`MzRWu~1xFG4qcJE36IxHXOE#Ew^uTL9zx`bN0k zs%hkS42wB!fXI*#X>d4nv-Sdy|Kgd-{deVAK0D7~23#mH^%kAAdHaY>h5eYA4OQ=K zr|zdWOL0Y(n~{VLEi~u%G?MKWpA@ADeXroKx!sBo+mUd3VczMG3uSBqyG7e{gBbR| zvqzlf2MvwlHcxi{sIKh|_hyYuk7CbVoby47|2|@$(H(H%8(z*|$ywUM+Z3uYAi9AH z3G2JmS78AYSW2^_d5%dj`(~SxN!qri`?_kT!X7t#$3{W{kL;*AxMKP9Jwu+E(PaW~ zj0w}T$9hMOjNtaXVP7~_?Qz2sZqw<~2M#6ZLX#4`vF=FwKg(d0*Smh{A+H7V@Ync4 zY)--Ws!iea_&Qe!)qGB*`O*`Q*Bj@4#`H(#@$;Kp0B%zE*dBL6RD`sB-7nv<+XT8N zeEeWo^_^=7$2vpb-UO*!+WKMF*#Qwgp+NcBfdDP;QoS0J!J8St7n418r?zOpi-A2< z;rlmK*nDK$!(^t97Z0}O0&JQE>U+FRP&WP%{w6xpUrA` zejLy0=j!gFH{(MC6-abX@@}8ZNm+D)VHYuQ|ag6V$lO)MfHgv(U+UY|P=m90) z!;MCK!%3kwI8yIS{}J!4=bH6dc9DsHAz?XVaasoD6z#oVqiV&U$+8CBdZPkicI{$U zRg<&T)=_v?PH`Ai{K_6B*IX&3oK$Kt!n%kVcH>X)Y~}ou69stds67Y-nW%sxpppKA z_NEe;<`rVz@Sgn5hy3ku?Tn#<5q~8Ua%8gnW~n~6C*1bQ-4*fpn^Uvij?8nJn*m_&YG*i?JuUJD~Lj(1h=>T(+hLz|4Vx!CTly5 zv8eq8!(SFv%UfIAMhNy8lAMgP)9(M;-aGpIxF4)dC#iuNBj?dJ28C2Sq*I$;BzahOW;0vxHM11Y^6ldSwJy?p1|$s;^=6Bx;dC95uz zDm(ndd~%hRs*$UEr<_^Ahj!a-{Rd1Sss+kY5PM7!0zAul(lfvAUGo0c8*v?%W_Z97 z)7tll)Be~mIY&bsA<1{)VX2%o!wg}mRqS8O7nT4{Bzw)aA>UjH_Cak+q0EM8JBGFl zSwprW<=Ou%{#1$>t|j>Yd14Vv=f8XN|3mixpTaw1RI;5v?Y`mXAL~6odsQ1L;4J!* z1{a%~u0jH_Zrt;Pf^|zf>eV?)p9_%0#$DT*;S#3On z5#KL`3;By?b@}-XQ5fX#AM}_*n4wXN8yy7fTjydmV>WmppUsvo&+Nn;4uv9no#7HW zfpS9YD?it}&2);gZZCJ+*OyK2(+uMjW(Z6^!Y2g-#&|6&%?+iS%<;i9Al>lmyuac( z<+rOTuNa*TDxVG_OCHY-ei;mWZ0_v(>v1fB?T&tX#*?y{F*N#(!S6*Z=_+FUGOD8N8G$WzPeh)8r!g_h9$}Y!6?XNVN+*L% z?MjC9!t5X;{0uU~5wvXqTLDiefVxllNMbvlaH?nGF&s|I?=@@Ym%?qz@z_Nx^r~Yy zxJ`AbPd{F@cq0GC2sb-658RXLWlH#2Wf(Ny=~V6m9F^}7iMEViPTKDf?3vnboOpKw zXYF<6$+2Ta%-Hqpb$JN|uP}1#7^tb4a=|^9nW`Je1r_FpV$wX0TTKni?>peAn~v!p z8%<N`U3j>w5jce;&KuO?o~>rrc$w{GL{?v;U$j&I@dROLLTthhp;W$?Q+`Ca{Qn+;hws&T0y6dz#;Yyi-PY7^v9qg-9OQ3A zF;L6RdomYeFUruQ+xk>1fTXTy1xJFS0(bi!+%l?YiE*mktRTxU>Jqp>#E6pqpj(;K z&&!`~YfcvP9pU48MJsn;*Aq31NDagV}-PDo&IX-7X=N79!8FKVCWWvG62Ds=zpMF?UXbka2vHZez{iQlubYQ4wAH0g10gF|awTS1QY;76 zQD>5KbaZfq$xS%(&&f&M|J|{?r(OqitR!CY?7`z660RxjefK%BX)wi~BIh%>h|yclud?@th;?zS#>EUxs_z~60QVf06WNNN>G-<)&}Zq!!J)rD z>;G|qcxENG`oVc^-yHairxMClh)Og(UI9e+DK+YG*`ps6VrD+88DtsnS7*)d;Q_Zq zKR)Gwo50&la0Epd3>PJ2f8ZfB2mqD#9%SclQ_aSA%iM41#b z$t$k)(`Gg!l?ODDttmtErC0)_tcpI!w?5ss_FuBzb_*U1q-h7eRFvlAv|*YpWbSOT z(rhL8q@+05NnL?E=|5GtR2eu#queZtRY&xfJ7{cm{kvE2I&!xKQv}V?P!Ctoeq<>D5dzu>Li2YGn!<* zI6ypf@VYMArAEr{yJnPwCZH2^yAB^>zaJ}8E6qi`B4EBBfSvT$x_RgEk`j92_}N5! z(*$wJvGHbP^!2@D9Y;T5M=iJ`Vz0t1^0CHuZ)_H{A*$kLQALCIq-Wgz!Y~;rZG;%s z`J;E1GDaP+wxl9MIR)~v6BVKAsg;`GeM$T0SBj#LV51*lF~&^)36xom8wSpve~7yl|;YrOLFL)EQfgbRtdZ3 z@L=Hs=}e1>JWYv%!yeEV9*fe!7R2-OSzkih_MgZ7SJaDO0L#t{I3JZw6v{+*{a37;c!#CNo&o10 z9t^8XoW95eRLZbgapiiKJww~R9&o-uF6_%{7BB_hTlVgrpM0Tn?HFV6wS#l zFd&%bK@yBNOE>Fyu9Km@_jMmf8a=xB0Ei@+&6J|hGwp=RqmYUYWJfrk%*5!~nM`TF z9?3@#q;E#R4L43x%-&QHettiiub&Tu&u&)g-FzXGkcvE8MxH&jkMEW>7gxDd`s_!) znlPXO5C`$}`aG}%n6N8r(7Y~WHg$cKM{F0etbF2VL5y9qOybu+NxNIVD6h88x5^bF zRf5m6=6{wH*?Y-b{6%KPeh8pST=bMSADy&+CB=ojSWb6a^gHiB0fMsqYS_jJkgYjy zeaO%7Bj?VkIn|$gQ!jNC1{!v*A(yTW%dHyALC!lg2$~I{$#mSj`ic{v7^df19bNBr z9*U@#&0!u{vpNE~!ls*)7~yQ-Oe1NL>@s1Sbs97N;#Bsw6bFCACqmQl zO8MdR%${n;-i|;66j^ZEM@aK3C!U)s>IV#bVqMDz`VP z;Whi^=k}0cN%uM-^yugk1~6z88z;P!NpsBP!vhTRlw9F;Hr&`q1*EY27IoHSD22kp z&_gi^UQ+8eMLVt9#k*4sD#2kOFP)batA~?L_MP``i8j;gOp zYX{6#XetYfZ$5|x{HA~@@E7X_!Wd6AUMMeXtpS99^@wxp1GR@WGpkYiQk&1g|8-?Y zRg+=hS0}pDOjF?=V8@68HkbOnr>BWBIp_!fxr|pLc|Muk-QCUHh)3X0?o3iE+MzH| zuQtD7=8JMSKTs)xy|tst`YTAAB@_X^Za1*cy0U38w2M=8x-nh}+gp-YMK`Q)R^sl( z#QeHPk0MJI1N+o+CO{_?My9VrH7**>^B&xVB%C?!xWR6zh6C$MF$T%MXL+meGp0CJ z-HJO#_KQyKKVhWQ4!BbCvzXAkNV|Fa+e=RkZu;fsZSHgvvhy=xBk5&Mh))?3QF{5( zBEdbfyRsEx?keB$1^ zP_G6?5P#)Kvq+LRJ*b9FTXqh3Y14F^CM!0%+Qp{&*y!4JEGT=z3{Q+hAAUcN!}v!m z#ph+bgOwjvCw{$t9#=ZO)+4Q_aUOGWljC^&3NVCE9>&`jMxVc4lRBrG@lfL>par^Xv0&B#nQdWX}UP7$Ze z^3_buCnGbXjjEsBq*u@Ts?!^p8v+&TXS9ab)Dzu`@PQF$rnC*x@uqDYP&86O%L_0;uWS}ian4}cKChnr8?A` zS)jeDX%;h{pZ&`Y8@~K&K^cX2ho_)Qg*9u>)I3DOGbJo9KDto_1orseIJ7_>X zT_OvrV2sg_4)Wzi56R|Vk5*Ud^#8fwj|1ruhl$45y&il!>>D$lxJZdQ`S*x4vq8pv z#H3&cr@SUiJQk6sfNZWD)oUN#216)jF0fDat0+d(U+?gc5Mtd;D6+mUzb@35=i&|2 z#{8cY&xc!@I`Nn~bV6^C;Z<&OVkygG2e|Z}3va`XMgWOkKY5N=H%S8Dp8K)lV5&C`3!kENGcv`)3|xng>H?a|zIHCOrvr3+XHlEX`OR2f85rmw2A2wdcP zCyNDuI~I?+s{aTx`XD!T1M*ub!!Ws^hsIs^Wz-d>QTju}`~>fr*`2JD8UP35WPifo z*Ra*WNxG_W!NI%F1#7-Adh<{H%n(3!CfrI)u^j9OU*o67R24u;6Mv^JvRtZ6icBnXci6!Wa<B#Aby7UZbn_xM=E&x;dlJo<{41oVu^Tg&fwam)xdJ-0s_C5V z>wh}0@2`(@r_W#r#ZP%9z+i&ls=lzMN_#SXTrtU=2p7}2N#*-0psh4~Q2`6n!&8|M zo{?3zqxHum(YW|!E}nn(u+Lf}RoFo#f7EaW$GVPAYyIJEy6eQtJ5NKV58>U+3e{;B z6BcKrP8-AJCxQ>=ofwMgRgYWjm&9*9&LmHYi>-ODf+m_L$MbIgOG10WAfl%u;w}&| z!9EFZbw7dkY9$`+P-h8)@@^A(Q$rD_t~)=Q$azzk=$*J#3P}uh{2^QI{>kX?F*%Fu zm$1%OViW;Wj1|8G2+w*mBVti0K|miWP}&-)^kr-V)9^fg&63Ks^cYYZTyxBb{#3tf znEG(oeaH z)2CaC_U{HGh+yBFpxNP&*%zu7xPKUS~yC6sk`J*^QD=5xo6Mj{*@wBOQ;gjXs5 zvr{d->`~av#&`P23`jwM|GkqPGP)XyA4Rc6Y4n6zaU^Jin355_%cgSQC&bcpYEFMM6Q$gP7zGb(4b2t&;BKp{FW# zD?hF%C4{%}3zGMg#?%;^+@Cl)VJOXd%&}A6e`O`aA0U`^&Hl2j$C-&nKbK7Ow)Q9` zB|e}jCGQu1kO7frPa#nqi3{7}uWMhMQQ0t63K)%8Yl4gOA+qwOTX2cXd$Q|qfFrM7 zo6ruDbq$jQ5B|MKT^|Z1ZTtb-_~W!ZRJfe3berOutCpYCKaUBS2gp@9p~`m=Kr;W~ zZ;b^IkVA3Ab0qN`dQrZ~|Ehi#rhJpw#s*8k#qh0swdTS#YGdc3mj?Y|*)c5uK0r@jPgK9FmXLC1XS(=j-Z<;EOF+m5HS#GOksNwuE5G-? zG{X+InVAL6Ld|}1it43@K~Vjjk&5X7V~Pu zyWwoKaNz7*kc1MP==vop2Os(6JM!da4V3hgzdVXmCvCS-#L=8lEGN-=t$v?%^>PuaVF%W~}5q z=G%adzM8r}jMw(`o8|%&?M*8gE$lpR506kBd3Av++rlsF@Bg~9sgiM8ky+~tvuDNt zP{W#W8N-als$*FIC=@{KR)B+|*8b<9{=U%d`Rz`c^?D_<0$D98=xv(LC6W)0Wm~JD zhvQ!bb3J5zcu(2VO~~C5kbu+l(ZIARU?E6rA{aS}Nf1368$8$jYn*~>*mO_)jj_T4 zSxC48N$c5-jq<=-vMtGndJle26;jtciNKn+q8&tahIi(BI-p7ED|xyS`U+r0W-eA~ z0M*eHoF|#+02Outyng!973abrwJw14hZ2&MsXL%O`QU#&-IEua_|^103^PwOn1^Ld z!nfdSleU|4u;A(gomT+l2bMUIME5pSMIB4XO|VWj{WvVp;SZxvdQcYv^ewxWfHsw~ z3o7b~*Lg{3k0Tzuhby!j7R3IUXw>qj%cb_L3|Ei0{H+2uu|~pv1<2b9E`|qNSA4Z) zKBn~B^Mc`H1&dg#Pqxn{x-fpkgvR4&5-8T%bN_8cX1^4uT`_Z7N>zRB)F2vrC9hRl zV`#KLK`ldDk1hkY00`QDI+YDD^d9Y!^F08V%&Y zzRvQkDGS4EfY@W=p$A(%HjdQ)}AB1-?}UwTsR6t%mM;!?>WOocK`{r z9{ocOATK$i9W%v>sHd3`?JqXgGy|-6y3@392O1Dsw$5L7&WRHRr@uP2Xy!59tpZf- z#Zfo2shhcYAq`Ka2?r;i{6uWi4Xlz0NY{7Cn3gf!>+4HbpzN~QMQ=AP`Z4{j4Wm0X zqU$ka7QdVTf`q|TF@vEn!4R9(aZf1KdecW66w{S~iTEXo;Nnk%6xM9c$Rzm#MyP{V z>HY?nN|(u|JI|HcmC8BAZfPUg)oV$d zeH%hH6hFHIa#_x1Lj zHsofx8M1m^cN8}nFc2A9i_H|?=ja!4AFK{>9U^rC$veYC=0sEAsV{gz?HaWTxgn@< z!L+3!w{qFT669Xz_67;~Mq!SIs+IOsLKg~{qK;&nJaYFnM7X~k5V)DzEn=TeF>vcu z+p8}QQ)YYxy9zR&b(5!CVx z&}07pc4#W%Gy=I1bhWRxk#}R>d?OYHj%Kb|Ia`9U6dp;*t)CEw6z6g%SGe#Dc-INn z@XQyH>lsgGx|rJ{CY~WaJr+B>mpn4#66>nHVg* zxvWDC$f+7O!gi|y-xCaK^&&d~2t6^6Sg^dcB41-nE$UsC&6&(VG|g%l90z7K0FtJ%w_`+%`Ax#Nf*FgR5^qc_O zR6CcRm_zr4iHHSuOW9CWt5?qo$5HL*ZWMEuO+0-4mmvQR@k!Fb!A6;+N6-RRK?fbuL?T`#emQF>H^?fOe6hgpIt7SYk}tD2zsUr@c!Mxrx z01)IUAMViHA%t%Rdpr)(^}G6myib1jM^py7y$Q^t{2??)_u{_-<~!=}n}N@c#{C=D zvqQxh;hJ&3K<+0?y|0%y{#2s^U;9*SMngZVP1Jalo^F@8%g573m>EaGM^b>olDv2O zVsb?<##Y*zC_zikegiX|mV1-N6AO%5%&iuPn2KQ4EUCNJzAy@-Y(6VF^8Gn9A%Ln^nJ z1Lk--+YohG4bw|fQvJETinQ*eU=BTPb77cHQ;!}Q2Z?6vnRm8ki>Y+nn#AX!DQql+1Go(#40~}JV0h*=;z<0R#uminrV76c zA+*hYYzf#CIHsS;O~V(aPM|~us~f`#n-C084GAX9<&)V+U`@kOAs~@X!WIvkdAL{R8v(Vcm)=1C_ARJNqtlK#}>qa0N zK?xr40qvz`-37S$6+oN;IxLxSxP?@cx{9Wxa(FISpl@RVDW?pZ!JNlp0Kta4et^S0 zyRx`vY<01gDOLc9mlO4?w>8jB6QSJc*C_B^rto?PVHe{3;Ii%40f)g!+lO*L8{Uaj zhu1|qP23aYv`eLH=^-TRj`}6YFbERbQ5T}5DmT?RF|;b&lm~8#I$XQAv9nh>Y;TCO z83;Oo#UMe?$3~gMXi?`1mh1`4HFQhdB8DO&&u2$tpn3n>H@Q5kuz4@ z>IcT&SccCfIYFLC`FEC&_l@ai&3;g^u3c@;kIlc9zz(!|1&&G`jv-VktHwt%toR6; z272L+X2sS0J$%WzdFoZ}$*J#X&($aN_;>zSlNF@x%-j-eWzKo(qIN!zdLfRetv=>7 z)gP!|s!v&SWo~>etclOe!UiYU;EQfej14Y07$OkMNfdcumMnP4#NjW839hcQY-wVc zJ&AHO;NkYtCt(K!NKadx2k$=$)d zO{2rXvSUsp%#^OPum&cmF@^B!8CHx&=Od!lIO^2RyQe?m1CPAT3(uA6uFdt__7s?3 zME!b`85<%BT?|Tk{-3{Xbi)Z(X!wYZQ?-~Cz}mFk)lU`k1gA8zbhC}Mvmr4PYDE_p zl@c;mY$FpN2B|>ccVc{_LhlWF_aD3V=P{4{0p>p_r}X5j_e%SQEX4*%teaalC+48Q zAk<`<*TVc!*h&>tP=$m5dN?fPy*u+z3B@L4vFu%`A!noU=@6!79T-9xmW>QpHmPrv z{b&P4<8Ov?dxr#=15nO}H6GCjm^Jh#*y>C+iSALsjO0-G;(2!uAD#k!BM~1{gOIEa zCw{mm!y|Yv##u3bB}KF=IcpopC;x<~ro)Cx;B-Tk;TLkS^wp-xhYkYx?z-!pP7(p; zSrJ1>S+!7@GOBOC@AAFK1?FDZ%^LMqkInkCMe@d`KWN2lA$Je-&tc{BpuidtO<;@_ zTT}bdcqHsKAK)vf?E)^J0HV`qsSA`*K`NIdc%e_l*^x-8Y*5|_YAt_-jK9g@jp9o- z08InE7#d4mDMMoCjRZ-25|*3I9JyXU5I0yjTmt6lJ+1sx>R;O2dpG_>*4@{7>ARVb z#C<-K|5wIi!TSs?X}bE?b8&LtOwm(^x^orH4{3=9fnadGmMU7*N3T4B34drfy_~m5 zOYG{Gn|IyXU&z^0P#K??!eMPr0vQ45UHHZ3^do#mLK!zvo z7wb4m->}0SJ)zR~OR6_kN9tGS1#MO1Y_qlI3k_isWGJer=`>Asbosv4^T;pe3~cM^ z+?5=+OXgG19j)AX2XKUwZ!I7BG2zDAw`RfCU865$y>ntKKDz}D*Jg2Yy-2W9T}Du(rN*iR5{@J<-(T%!+)eT$II zg@cSCDA&X^;C-*Zk8A43l6hA-o%7~j@OSsz_hp_i zSy1lgF@g7ogNtDw4(9oN#I-E%vsaJmIruK@TkJiN>O{ENg8bii&F-krFYYU!C%+#e zaPve_mfJ}c^A=rUGrXH4sILX{>K3EqX?mSqe@D!E0oi^@|0M9-8 zN6Xrghf`>a#o^%#k~J?={QJK=w**y9hv|=IUnd9;SZavArsAqu_f^AUKH>f)11;!? zhbSSgL^b)ygOLM2u}|EL?aVb=Eljr6Fphq@5oi8N_NkP3{>GRCjzaLJ>u{e!N;JA# z!D$J_7lZd{Uj337G5rFHaz6FBTCb%-6B^%Eek?S-n>k!E;y=w_O#|(SV7yvMpKRB* zOH&8F%pPUM_jX)w&-hElc*?~;b?tLY%%rX1C)1JkU*jy=9cJxu1+%fZ$Bf=`qZ>s@ zj)Z5cdKusc``2^xeSD77_5~NvDw~;2abj%pxGU_#>u50dW;Jnmu2d5 z3^&207GpEZ^lRA`m?y=EGuTT*thBZ&OW5o&J9o6Ecr$2C)O??0g(vZk2;agC=P^!eOOH9KEcpJ%`X9QRgJB&MUn2 zS*a(@^(7(O!j+`y?D>|MqqzRr9yoE^Cvj72YR>23lg5m5Rh?(ADrRPoH^SPVcL4558YZRBq$NYM&)vL`{IRx@}*| zTy;aGViOXrwL;TFFVgL0lXh9kw;+t>`VP@e9z>bM|5jO#dUZ1KC^09d=1Mm7Lh?k3 zoZ+j!Gtatu{WfkJSZ}7K>_kQdRg(46iG#1n(%n~I-Dth4e0%n?!1bg4=JcdWs*N256s7GfCmYZQ&#LRRg zv2&$qpUuS|lzm11m!*@wJ}E9MR*|ugZHFxV{ZZP^-}vzgXQvOT>F8b#f%}fOzD`lP zV%P`#3=OOG{H4f79d7*-nbv&l$1>rmHjk5ih87vQz&-OKRtsZd9@<*1Ll(99s*`}`AVh8 zV{}-%yR`RXbVC#!l;pfxWR9+1ZAKUuyItGI+U@G2n%DhGRw-dcrF=6x-G%v*NI(C* z>HN!VywW>%n(l_{8^v%r_u-!c;k|Bu5p|?m^O67DE%--^p1ci5E}c zzx?{x#W~6SkpZu8Z3QxQVc6h~l1g!!G2StvX0S5`)0Kyf8Sc|8b{h;a6rI18wwhFs zx{>4LAdND{H1>P3ev*)oSa3LFd*u_Z=lg^B0T$m2uFHWPepc(^Yk2iOxwzCz?!JOF z3p}#G1b=%oCCzTtT3?kKc?ULso>{X}>Xsam;js{9u3;q0=|ArwKDTwK;{f4%Cw(jF z;(v0MLMCuZL#sZr#(Bq;K@B-pQHdwjRc-l+jfk>{_j*Q17V1j>*}tmBKM&ohemL`^ z;R`h)Zl_`I`@|p3^7WD>aUp@-XQvSmrb$Yf%(s#CKOt2wf|Ub<|e%PJ997`8_YHRwiGou8Q*2mWsfhqIT? Kl>g>;@BaWjEq;#x literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/icon-64.png b/extensions/OpenInstagramSafariExtension.appex/images/icon-64.png new file mode 100644 index 0000000000000000000000000000000000000000..995689f728d170a2d41308e7ada97c1be87d927f GIT binary patch literal 5151 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 N002ovPDHLkV1hO#^aua| literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/icon-96.png b/extensions/OpenInstagramSafariExtension.appex/images/icon-96.png new file mode 100644 index 0000000000000000000000000000000000000000..cb079d20876de3e8289025c91fc674139d7bb7df GIT binary patch literal 9749 zcmV+wChFOVP)Hjzp6FetwzhCvdp6Z&fyJwb5Nd#28U%&UgdiCnndv$$P z^?f7qi|{kGK;UiUu4(&0I}7EtX+Mv6(?=<#^@Bc1yOXwR5alQ26?878yRf?kKmRc_ zxZl+P-M*BEB~m95?-{~9W7i{TPzE)62sqP5zV)qdU3~cP;cL_B^d+%aY;QOm9>X;l z3y%hxN79bMW<-kv?DZuA5^2$;Bj1;`|i8%$jr>l=RMDxgn}kO z!8RoRb}tH3nKs@{uFYfP-T1dkw`CI7KpW5&veStb4juYS z(8{>jHL+W=#|YfE0uAgJ)cDbZ2M=bZr>8#;;TA z>f7a4d2n2owxZ2wJJ8vCbnTA(S^#NmI*|hh4rGoVJ^DZ;N(>A_o2{g6_HGb@~pZ)CKkA3W8{~LvfX=Fd>Hd)sI zZu+hAyXpJg+wyIkdNJ5E-uKg&JiS6Mc|$`(*J76S;8RaMwE`I(-B3%LR?R1Frkm%^ zo%VgIgh7gT2!wOeT|uLZQ&to_zAjMda0P1=<31;LV&kapDhm@812d zU4`E10&G?OR{7oXZ1w&^^GR1dr@K1E>uui zn$OQ#zN?SwrSL-NhuP3?Kn}-M+L|^G=q$*zfP3$~_mW5?GLDG8!B)60a~B4c!DzQj zV4TwGwX&?vosiP4mfHcJk--&=( zg2{uBAwT`1EKNQm-qI_$IWEyqRifd5q(U%wFd+DE_{rO>Hl=~aSBS8rT!{7-@APBty&1K5KoKRVCH5;w5 ztx<1CtzH+e_PP}3UX{g(zm=)*xFmL5FWWBuh{QA7je1%ZBVnz3^`rU4pKtlI+3dxT zN4wJj?Y@XNA*|`|A(6o~sGFf%V;QfsmW1BFw*2DK8JTncSthVF75_pZT%Q|p~}BzW6m&X^Y$3PekFtrm66TX0Pwf_K^wQz zlin+Ml@&Sn!e2;p<}t}7JQxIPATW9WeRngM6fcmFN&{CdF4a0N^qBP~`t=|#wgR>a zWWbsqz|1bt^duZ?NGx1OmsJ6*z-6lvjua&lTa{#@EZJ&B3i*GOH^1|^gtmWJc3<;v zBpl`H@y1}6MRA4X_QP%3Mu`}KVPnwg0MnQ27{Hq#cd|4Q1*zAS)(a{x;by|z$wwsr zlfRK%WKPn#hD0OK2?J?OQc`MUq*%pOOG+b@lW2OoBnK~&Ty8JAj~zz5s~_{N*Qyw( z&tjmSkfp`5(kLy#0VPSt$`TJ3;E-iWgjXaMElN6Bk>%xwsLruG9GYtadqM##w0($ceF7zW-pz8Z9^3Q>k%Xrh~V^M`*Xq4|ep zC|Q>{EAyJ+i;V$UuB4?B+9rvii)DDv2PK*u@)NUZT5<7|EKWWv_2OHS4zEf&0td*v z1Z&Gu$1G~KRF&Cf4E$ra%HHdK4{}=-$TjGNDnEA6=5x8+KSM@yxQfh;E1$*z`F#E* zWK4WR*s4N){55nrlRx+)8Sq|`9E=}{pi5zmZ)RnwIv}f!5gFchy$tTUu5ShKldrT5 z%7f5ocrttZ5veR5muw6zAiN~e<~-IXm!w=S$^3Fn^3f|~-!K1xgkxOB?+hxNkfEPL zy76P2EMj^8a){_~KnjErY&2ueXB+}>VFPR8%=3RFL%~VO#OvsHV^XSTWVtpZE42~H zj9)9;4&IDR{R`+8=zO)SRSPnE;vq~~&tkC8ODZrcq1voe%d4`O_hha*F1tVY2k7$l zoo^Pt=`4qNaKK?O(E+R40yqi{udc4X21egCAWML62Cji@{-twAW##2Rk&*bKWD=OL zV^&kBWn`{AB*DR}Wyg`*Fyo1}Yn5LHwmio7%5O9A)#9AYzWzO05w_6f zLRF>l^5DVzT7r8;mAL zWam|%l5*^z%vQ&w92%EkY*4c4m~7AFW#;IAm(_(g+P3H?zn?xDPa8v4tC<7Q(YTwQ zo2F|9go;-&3z>QTf68cLK~gY&BM?VlKPc1XoicjlcVuMWhc;cnO)Ij-$Y&A;LK|O; zUIPKye)tm-8opMhi`%3eq!V&7kc!DzdQ~RB|6kE6xNg=zXqoNuE3HRrPNvrb6;%N) z)Hp2=KI4O%b7kG>?OiGPq{1vMYK@4D=xB)5Z~zNBeLj~tc;o+IWa9+A@>CX~t8X{{juya#WK_izDqW${6_Rd;g;S47c;Ru$ z#;Xzv1!WZvR5QgPJpSJ)vDBzx)wG0u7nzjD#{GEbO@5EcuHm{24}_#vUTUXRep?1{ zH;^wgc7=q8en}RqBjSZdB^pc0NERlVen@71@{mcg6?sh1kH4z}G@(KjX0+(>Q>*5s zc>3!yl)^Fq7ShUCLYP_2$iV)ONP6_rc1p@+*s6RLpy>w{z|+uw{$OT=x_l0wr)WStOm3DLE?JN z{P@deS{Dn|up$|h#d1P|!yiCDd%ZD=@-ZLc-9o?on66S>l*MCT!1Q)mVxcgOBeZeNL(ARa z03{MKkwO(>Lya>`Yd>XdmdN7wB^|Az55#jeYX6cqD%+2IqTOG!Z?yYITc(Zs$+z<& zKJ&sCFi=iPDq6#eD3+k%Pp=u16ds}|i@}y@;~Hm}oS#4-Bm;YHmZi#oR09JN4o77G z>jSl!$ECP*wnMg)wCk;oR|=JJg9B7aGH4-`xgT75k^CuY9UEr z&w!zyJU0(Jjg4n7x*5+fX|qp(b3Is25h+bQZRD#>Tv>kiwoKw0IvhZvCRSV-d%?o& z%Vsev60E^NSYfJ!B|dUxM-s|qQ2DC7I+St5y9LJ0zW7BM44#ry#DnqIc`38rzzhhp zu6XW1hw;0~bLp;rj3l4#qIm4PdKtN>-*{az*sKx8>W69S$Heq0nms1TaBDF1>H%4;#$@oK*0fVI)3hqzmZ3bo;QaBgOQQI!3}Da=!}!{dx8$I8 zz#olZP9llHu4T}?w+iD!N6-ynUm6Yw^JGS}A^CI98FjnKYU$OgbT&?YJ9Id}j&N~J zs2MU|Y4zd+w*2@aNE7q$5M~cMVTjgFmLI<>+m@yLHq1{wFU@mb$1J3RQxe#GU+ec( z4jT?aVU^DyS zje11_<#Q5FdFVpW8oZ!{GkbkA^Gn~4#@SWj^)Ja-aup9-HPn0-q;su>b*hG=DADx# z45**nEyd%xpoyJYukoft;D9KexC6^)Fea6-kHPmA&p&jufL;#JI6@mQLy>3*M?u!+ z;%os)?C07%HojR^nTFel3oqkgD>{uq7kS|LiH1&@O$i0Co!{e_iKbI%r?+v9cVQ^L z4W}~jJcAYdaERxXs#07y>9V!&ZJ9RSW>`ZyS^#Ov%s!U5_O&k5I4_S{fx(x@LTYFM zl^XiU>|P^YWtki2N7HxXVSY0&{e@(kKf%Kmj+@{_oa*3+g7a|*4ks?_XTOLk;-48k z*h=icZmLp`ikEt?9Q@_~)NyQ)e5!{+=A}9^K9m?0uMoy`G=xK9Z~*k-G0F5OcNhJG z%JPqGQ9B%9XQ+u4`Uzp2SDKTAshOCGU@p#cJ9>1)PNQ-ZGS0?rUgC>5As1WuF4pBM z=B!UQ3zK?q%gUtpFVR!N&ptm|YhYHzr^#<0`cWgtD0 z+>XUL^w?{ASRpu|UR~*0iF%dK&S!%IbS_Q-#0eP`cjKjrg)p+9lsq8Y#O4c?*TN}u z=gtAYr4Ux|L7MAHWn8BMpm* zQGkbo@nBl#gJ&zS9OQM&qxWw4HC=}Tl$nu=E3|2v*wCPasT4FmE_0R>K$Xz~P>#~< z^fu0I%*B_#D8rGrBpJoSmg(9y8p}@853l1Dh%$~h&K9w@zw_qyo`j!1er2_3SetHV zXUc-Jr`>{*Rcx)Z=qj=!Rlq>c`ckm&Cn0>v|91a$7LKR5n?!?72q=k9fki| z-*EKV6q{oXMx=J_vgIqzuCUe&n?<{ z-a~7~7g#$?Mv?1zw={O|HD4EQaDX!S2~Zk&6mQ~HVhoiq7Usu%Qmt`A97n~i{*wG| zftg-uob?3ono%=8E?yAdx4Z9aCQWg|_~1cVntEBz6$Y@u!oI)LxKufcwf}Tz z5obAZs2ANFYocG!L3gg4mGJ2GlG?^|up5J&KV#i{xBOoIoW$UO02Zd%kZPEcMpCV# z@|s@LY|O1i(LC(<4GvHbk-48h3JPO>TwA~t4R5e;p^Dp)mF3fTQEqR~^qLQ06f2m! zuli(P{_@$cOQi4?HhtKl>|(U=h8c8YvWP*q96c;M4&K^k>Q^2utLo+Q*Gf~U0CWJS z+uVJNNFu$f?YMsF+ln|yYxJ~;%$OI0LMwU$8Xee!wemQQ9l`;4Ac#jAQl5Xs@Y(4V zC*;LGSE2UJ3O8f|0^>?pm}_Ev2%2qRp~^M9=%2@+yXftb(ZhEb`I;XS5bqW=t{Z=| zI4RL!%Kc`<~YOT==G6Bpkv)rn;=0`Hsm) z`Bk=NPP)>R$A%iG{9gVv)`ht1#%bt!SJJ>>NaxR&2Bew0PBJ@MuR3p4zRPcy*<1LL z#6v~Qg0T3AMZIDb-OtF84kNqzy7Jt3EmwyFG?C3rn%`yf#E0G^6})ET1=8mD1~;xf z9N%a?Icip0gQ|<>T`TCacWa7DItSefYU5%#Bdg)}$k4%mx#>(?`F441yjq->aPc*4 z2I0+o~Mf)tcA9r#;F{)0D}hw-q5mnAO0{ngVKnGkqAdq0x3;D$j)!pn2N3O(T7S1K2g_d|aU` zK!qsO`1pqlh~+BjLNt5^nTu42K%LmX-% z2Hgc1zn1wWNsoVU13%N)GHjf2enHEs<*zLLL=vSJa0G=<-S_}#9q+c4(~{kLONVTg zW#>WMy~w9Lnuf5$0XBo;Du~QVBkmWX$sJOU?v~a1kTkdZoA z+K}?P$*&d8n*JXg?G?@y*(*cu>HbwUm8E&O@_WVe1@6+RubB6V!$BOi#D)&Lo@VBX z_R&Yb^4m0)m4Q&ptZwI(Fh6N%mvGZ5Wi+E+hxynciV?j`oI8ZhBXw}Zb`NSEJE%Qyy`MqKq(9mu2k=U?YxfD@9y zQA;j6aI9IVj7sjnN62PEKY4Z@Hm-T7oDK(&=%l^VED5lD<0iJqb?y6+R$R!g2~ud*e-;rD{%A0taMpZLCqbFG{I{0pJ z6@T<%8GZg-WxEU<_)Uqx_-z6r@kJy z(rMzh(KXIijx8a+=OZ#d`H*<{E?f>yhz6!*Fj7F*RFRd{$7Jz2ynHu$GYs%sWnBgksL597c&7x#@h(m)Xkpe88lp{GV`~FDPH}}~;s;h!dp|1q zvkyzPg4aHyaV+!BN<4_KS4Hq`H+)ntpZ}&TKl_i;NM9-O?bl0U^fDNEW202L3Mt>k z`9xcuJ&L9AXCz*J74Mc+afBV8!9-slfZ=N>YO#^UR5K@)=ppl<#k^FGLR+>?Bi#;m zoZQzl_Lyc39+i}F*^e#W+Ly;tA8~T%iwM^jFhCFCb=LyAiGqY0SOLZH_c*>2-K=9JbZrd>k3MUKYaNa?U(`jOtbEKT z5DZ}j6KDBhbbcOEgSFM^0+*1dgZ5$ z!OMY|PUvU>4Sck30)KLYbQ36~ThKU#etdSE;tI*HcnEK|CwKokzD|YxgV`UUcFtmK zsN%IGyo`;Hu0{i82{uYFDUOBUNLtMIl<>w87^bBpSt(AJN*88_#FtQ!pIa`1!yG5p6{Fv&5Tw%gJ34MxpNA@lLW{b)9Cw|MgS1=<*NI>597 zRzGNNz7@)6?va;Xdg+O16t9N&3Cwsi9`7bb(-QiHe)8S)E`PsxH&2(}jd$lI z`)aj1b;T7|+<>?_T>Kg!Ug~W%1$u)WYZZTH<53FlcY(K~#_)bCf$|x;<)d+i={$a1 z`TgX%{N40YdOvx7Do;*MJ_{{RLjPc!NUnTT;36z|60 zkG@rT96%|PkQakOTUNh(&o|!A!^XSGgKi#e3tmp$XltXiwg6g^Ua5TJ8{asKKVxND z0q)y1RB=KEW~9)@4e>mFY#Dxq(7d{dR^t=ecsKrjbXOlY|99^1?;t8e|*hexbY*E*LsgU-g>(ukdR2HF)Yr$Q~Hjj;OhM%(f z=}(yq3eAo6(>L3#z-;;C|)VDt|YFoH=vmoA|TmzmIr60Wag?`&I4d03C!u zGl8vu&4bNk<@Vce|8MAyo~E)43T>P;r7JYdKjR`SjlAvGH#4G3KKS5+-@Wn18^_lE`j5u9TLWu~U0bLSgH7W- z=}Oy7JbB(u(7XtFF(@S7OCLL}mWOHVVDs3x@}uVe{cX41_F4Q*VQz%e0qlO-gRjc! z<^aaCHP8u-FMs*VKe+PBE0^)dC@;sBT6; zH%eGP{q(U|F9?eKyt8M|e&?Qh?)m%^Pdvc^m$jZxyR7*->tUtt2=*XsN082oP6*@T zz|22$!nNwuhd=z`J)i#cr+*s*^bOcQ7z3@Z!O3HXeh#vE&j){}<*QDp<}MdeJewXlOc$ zm!?q&Qpc2+_doAJYpa=%Kv{(}DlgV-8krXzpcm))J!?JU+hH>fAhYrjYHg;$=?p^K z_>AZEZZi6KHHTx?AHly%Fa3qlWd1^P?%h8dr_2Q0Xr8ll5+)Y0Y<` jIzyqxvs`@*uZ92rgaqDlgn_d400000NkvXXu0mjf%sC=X literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-16.png b/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-16.png new file mode 100644 index 0000000000000000000000000000000000000000..276933ef0d74a5033a182791173e74c14f148c80 GIT binary patch literal 1588 zcmbVMX;f236u$2zAuI`?C;Be8Gc&0^-fnEw zS*ifQ_HcLcqrB#nDbuLY+)Bfra)v?fetdvfQ-HWcfH5i*Cjp4&0dxlfIAjBGB62Ey zmjU1(e7pi&V|j`adS8n9V7+Q9mE4FusG+~Qpxafc*8UudtKn_6#_gwAZ6mAj1r$Gn z$|iX5Qa$}WTzikp|WR?5huq^D{Sn^ z1&-r78;7yfaql_g3abfy|1pT23}TbsMiV9;ldy|C0!XNdme?UrF1g+qaaN&xK!Jb^ ze38>!R1C;Lk7_l>`eTfZ;()ax!D{5HdS9VxpNQFC%;+zn_otfnC0Y!|FBm?=9ggP? zAGH`dWi)V3ukVs}@8xOoLUxZxMP95Vzd@Im(c~3aPo{w+!?62=u{_0m=obA&HLdd| zOze7}Cc3zpCVq_7H{i_;*yFp{vj&*bMpjWLP}GS!u##51yalgl#mZXYMjMni!F`I% z=NhMEjIFc z0zjpJssYtvs0K%+G?Yh284MK1M7z|G?+oN14^NxNCpzH z(CV4UW=@*vaF)Sfu1=jnS~D`_N#FBR|JEtYx-NrF`?*3}i)vZ4tyNqD%#jF|Eg zv#98Q76?-KcRD|2BzX!&TF1u|6)hqq7KP3zYES49(CGqM64gjiLJSc_L<^Hey#*Yd zqz(m1j(H7GbFbSHcfrpDU!X6A5Bd2e#*amSU%Y%ZG(0le+4cI(+ohU2_LoaEx&#z+CWPxoB6vFy z;t&)WLOEE>TWoE`v$f*c1@IO*@RmB**jn;<4m_Snbawnd1>t*x!$O6hE~xJ@a-#~& h|J{KI3lE7RwueW2x+Y6OqrP|m4_9xOqLqR1e*(fss1g7G literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-19.png b/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-19.png new file mode 100644 index 0000000000000000000000000000000000000000..8c539b6608bd1cf93830d1318c39a4d3b84f58b4 GIT binary patch literal 1297 zcmeAS@N?(olHy`uVBq!ia0vp^!XV7S1|*9D%+3HQg=CK)Uj~LMH3o);76yi2K%s^g z3=E|P3=FRl7#OT(FffQ0%-I!a1C(G(@^*J&_z!{$_AZ|c6yYrJh%9Dc&{GCs#)_r( zWk5mp5>H=O_UCNujGTh`oA&ZEFfiZoba4!^IGua>e0ON7%<+%kzyG#*KBs!G@i|}V z`oIL&r+RH-Q*~QTxxT%%l(RL^Yi(1I;1buy#N4;XorHq8w+GJFnNXCme6_@sH9qSe zuUKq+_Kx-Ud7o|Oi*5VzmwiuJzO{krk;#!-UrNfp&DglsBP#xC)>gMo*9*T8mJKoLQJniS9)XPRE`_}J1H1*`x^(x!_pR76j!#FK=b=KF_QNE_9R=rxY zsd15*qM-Alx1GK7DvzX?`|i2!`8WUZ+>N2<%i0e6r`$6+Sf0LJzI(~hu*t!p>Y-ah zOlB?;G;B`S3Erh$+FWsN>ERA#XYKVzJ|@=pe>j=5Z+Dj4@@cF2^8eeO-1|%Qk6*56 z+q_9N>pO)zUoGf-++m(vzGDaP&s4#4Cmw9Qd3Mffy$NfUoH{)_#b;(j`lUIUFKupC zrBw;ud_AdO#?GMdWI*g{*`|{ zL_Y0S${vRI&08PsIR7Z(aZza-Cx1?m-Z#bh=e5eFMa_}h(8hN7gS^9XmmBdLzij5% zD<~{#Chu>7Dwq-iM#k}iOwmo0w?mfG*VeXlto&@j9k!vSQ-d8^)5^=t; zQ^6pq`fBW%Nr8b3K2b^$+q@ZK@3AH@=NBC3YyPUAnl#~&iiG32*fX6^UuDlQIPJzG zdLl1EE%Msp%nJFJ2d8uV|8t6BIjf7xVr%~u z(YCy+lxOrseG7K1Yx;QbXx5`U$*;=Ger||cJoVwi8+)&uo<5I3%tu6!>EboLH4;&+ zPZ_Sn+;2Ggg}3!Uj=iSLg1$yAb?wVMe}4&_+!q)uw1s2cf|xCeJd5w&pMP}A#0k3( ztdafAym#xu#5Mm-x2x4D{T5lgqfhI6;Ihw7w(<7;^LI8Ji)S-&jLlVQe?8IkY~-A| zVwG{H=UfTgP@eXGf8j>df;RdP`( zkYX@0Ff!IPFw-?K4>2&bGO)BVG}ksTure^H_5ae1q9HdwB{QuOw+0>|A9J7v4Y&;@ enYpROC5gEOxb;M{=K{+z1_n=8KbLh*2~7aWw?xzc literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-32.png b/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-32.png new file mode 100644 index 0000000000000000000000000000000000000000..8aca1942bf02b47468c1e2cdc50708595c1c8d6d GIT binary patch literal 2419 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 literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-72.png b/extensions/OpenInstagramSafariExtension.appex/images/toolbar-icon-72.png new file mode 100644 index 0000000000000000000000000000000000000000..2c145fcd9479d1d2436a1b2125d8e8f3f2eef10c GIT binary patch literal 6051 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 literal 0 HcmV?d00001 diff --git a/extensions/OpenInstagramSafariExtension.appex/manifest.json b/extensions/OpenInstagramSafariExtension.appex/manifest.json new file mode 100644 index 0000000..4107cb0 --- /dev/null +++ b/extensions/OpenInstagramSafariExtension.appex/manifest.json @@ -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": [ ] +} diff --git a/extensions/OpenInstagramSafariExtension.appex/popup.css b/extensions/OpenInstagramSafariExtension.appex/popup.css new file mode 100644 index 0000000..edf45a0 --- /dev/null +++ b/extensions/OpenInstagramSafariExtension.appex/popup.css @@ -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; +} diff --git a/extensions/OpenInstagramSafariExtension.appex/popup.html b/extensions/OpenInstagramSafariExtension.appex/popup.html new file mode 100644 index 0000000..25d3f42 --- /dev/null +++ b/extensions/OpenInstagramSafariExtension.appex/popup.html @@ -0,0 +1,12 @@ + + + + + + + + +

+
instagram.com links open in the app.
+ + diff --git a/extensions/OpenInstagramSafariExtension.appex/popup.js b/extensions/OpenInstagramSafariExtension.appex/popup.js new file mode 100644 index 0000000..e69de29 diff --git a/scripts/fetch-ffmpegkit.sh b/scripts/fetch-ffmpegkit.sh new file mode 100755 index 0000000..e322aa7 --- /dev/null +++ b/scripts/fetch-ffmpegkit.sh @@ -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/" diff --git a/scripts/setup-ffmpegkit.sh b/scripts/setup-ffmpegkit.sh new file mode 100755 index 0000000..1f42cb1 --- /dev/null +++ b/scripts/setup-ffmpegkit.sh @@ -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." diff --git a/src/ActionButton/SCIActionButton.h b/src/ActionButton/SCIActionButton.h new file mode 100644 index 0000000..c92b483 --- /dev/null +++ b/src/ActionButton/SCIActionButton.h @@ -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 +#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 diff --git a/src/ActionButton/SCIActionButton.m b/src/ActionButton/SCIActionButton.m new file mode 100644 index 0000000..542fb88 --- /dev/null +++ b/src/ActionButton/SCIActionButton.m @@ -0,0 +1,165 @@ +#import "SCIActionButton.h" +#import "SCIActionMenu.h" +#import "../Utils.h" +#import + +// 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 () +@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 * _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 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 * _Nonnull suggested) { + return [SCIActionButton deferredMenuForContext:ctx + fromView:view + mediaProvider:provider]; + }]; +} + +- (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction + willEndForConfiguration:(UIContextMenuConfiguration *)configuration + animator:(id)animator { + UIView *view = interaction.view; + void (^dismiss)(void) = objc_getAssociatedObject(view, kSCIDismissKey); + if (dismiss) { + if (animator) { + [animator addCompletion:^{ dismiss(); }]; + } else { + dismiss(); + } + } +} + +@end diff --git a/src/ActionButton/SCIActionMenu.h b/src/ActionButton/SCIActionMenu.h new file mode 100644 index 0000000..2f5dfdb --- /dev/null +++ b/src/ActionButton/SCIActionMenu.h @@ -0,0 +1,48 @@ +// SCIActionMenu — reusable action menu model + UIMenu builder. + +#import + +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 *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 *)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 *)actions; + +/// Build a UIMenu with a header title shown at the top of the menu. ++ (UIMenu *)buildMenuWithActions:(NSArray *)actions title:(nullable NSString *)title; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/ActionButton/SCIActionMenu.m b/src/ActionButton/SCIActionMenu.m new file mode 100644 index 0000000..bdb9c5a --- /dev/null +++ b/src/ActionButton/SCIActionMenu.m @@ -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 *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 *)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 *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 *)actions { + return [self buildMenuWithActions:actions title:nil]; +} + ++ (UIMenu *)buildMenuWithActions:(NSArray *)actions title:(NSString *)title { + // Group actions between separators into inline submenus. + NSMutableArray *top = [NSMutableArray array]; + NSMutableArray *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 diff --git a/src/ActionButton/SCIMediaActions.h b/src/ActionButton/SCIMediaActions.h new file mode 100644 index 0000000..dc1d978 --- /dev/null +++ b/src/ActionButton/SCIMediaActions.h @@ -0,0 +1,98 @@ +// SCIMediaActions — shared media extraction + action handlers for the action menu. + +#import +#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 *)urls + title:(NSString *)title + done:(void(^)(NSArray *fileURLs))done; + +/// Save an array of local file URLs to Photos (sequential, respects album pref). ++ (void)bulkSaveFiles:(NSArray *)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 *)actionsForContext:(SCIActionContext)ctx + media:(nullable id)media + fromView:(UIView *)sourceView; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/ActionButton/SCIMediaActions.m b/src/ActionButton/SCIMediaActions.m new file mode 100644 index 0000000..14ded6b --- /dev/null +++ b/src/ActionButton/SCIMediaActions.m @@ -0,0 +1,1198 @@ +#import "SCIMediaActions.h" +#import "SCIMediaViewer.h" +#import "SCIRepostSheet.h" +#import "../SCIDashParser.h" +#import "../SCIFFmpeg.h" +#import "../SCIQualityPicker.h" +#import "../Utils.h" +#import "../Downloader/Download.h" +#import "../PhotoAlbum.h" +#import +#import +#import +#import + +// Retain the active download delegate so ARC doesn't kill it mid-download. +// Replaced on each new download — one active download at a time. +static SCIDownloadDelegate *sciActiveDownloadDelegate = nil; + +// Story audio toggle — defined in StoryAudioToggle.xm (extern "C") +extern void sciToggleStoryAudio(void); +extern BOOL sciIsStoryAudioEnabled(void); + +// Match keys used in the settings-entry title map for openSettingsForContext: +static NSString *sciSettingsTitleForContext(SCIActionContext ctx) { + switch (ctx) { + case SCIActionContextFeed: return @"Feed"; + case SCIActionContextReels: return @"Reels"; + case SCIActionContextStories: return @"Stories"; + } + return @"General"; +} + +// Pull an ivar by name. Returns nil on miss. Safe for any class. +static id sciIvar(id obj, const char *name) { + if (!obj || !name) return nil; + Ivar i = class_getInstanceVariable(object_getClass(obj), name); + if (!i) return nil; + @try { return object_getIvar(obj, i); } @catch (__unused id e) { return nil; } +} + +// Read from IGAPIStorableObject._fieldCache (KVC returns NSNull for many keys). +static id sciFieldCache(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; +} + +// Fresh download delegate (one active download at a time). +static SCIDownloadDelegate *sciMakeDownloader(DownloadAction action, BOOL progress) { + return [[SCIDownloadDelegate alloc] initWithAction:action showProgress:progress]; +} + +// Route a download through the confirm dialog if the pref is on. +static void sciConfirmThen(NSString *title, void(^block)(void)) { + if ([SCIUtils getBoolPref:@"dw_confirm"]) { + [SCIUtils showConfirmation:block title:title]; + } else { + block(); + } +} + + +@implementation SCIMediaActions + +// MARK: - Media extraction + ++ (NSString *)captionForMedia:(id)media { + if (!media) return nil; + + // Try known selectors + for (NSString *sel in @[@"fullCaptionString", @"captionString", @"caption", + @"captionText", @"text"]) { + SEL s = NSSelectorFromString(sel); + if ([media respondsToSelector:s]) { + @try { + id result = ((id(*)(id, SEL))objc_msgSend)(media, s); + if ([result isKindOfClass:[NSString class]] && [(NSString *)result length]) { + return result; + } + // Wrapper objects (IGAPICommentDict, etc.) — try all text accessors + if (result && ![result isKindOfClass:[NSString class]]) { + for (NSString *textSel in @[@"text", @"string", @"commentText", + @"attributedString", @"rawText"]) { + if ([result respondsToSelector:NSSelectorFromString(textSel)]) { + @try { + id text = ((id(*)(id,SEL))objc_msgSend)(result, NSSelectorFromString(textSel)); + // NSAttributedString → .string + if ([text respondsToSelector:@selector(string)] && ![text isKindOfClass:[NSString class]]) + text = ((id(*)(id,SEL))objc_msgSend)(text, @selector(string)); + if ([text isKindOfClass:[NSString class]] && [(NSString *)text length]) + return text; + } @catch (__unused id e) {} + } + } + // Also try reading fieldCache on the wrapper (Pando dict) + id fcText = sciFieldCache(result, @"text"); + if ([fcText isKindOfClass:[NSString class]] && [(NSString *)fcText length]) + return fcText; + } + } @catch (__unused id e) {} + } + } + + // Fieldcache: `caption` → dict with `text`, or direct string + id capObj = sciFieldCache(media, @"caption"); + if ([capObj isKindOfClass:[NSDictionary class]]) { + id text = ((NSDictionary *)capObj)[@"text"]; + if ([text isKindOfClass:[NSString class]] && [(NSString *)text length]) return text; + } else if ([capObj isKindOfClass:[NSString class]] && [(NSString *)capObj length]) { + return capObj; + } + + // Fieldcache: try the caption wrapper object's text + if (capObj && [capObj respondsToSelector:@selector(text)]) { + @try { + id text = ((id(*)(id, SEL))objc_msgSend)(capObj, @selector(text)); + if ([text isKindOfClass:[NSString class]] && [(NSString *)text length]) return text; + } @catch (__unused id e) {} + } + + // Deep scan: check ivars named _caption* on the media object + unsigned int count = 0; + Ivar *ivars = class_copyIvarList(object_getClass(media), &count); + for (unsigned int i = 0; i < count; i++) { + const char *name = ivar_getName(ivars[i]); + if (!name) continue; + NSString *ivarName = [[NSString stringWithUTF8String:name] lowercaseString]; + if (![ivarName containsString:@"caption"]) continue; + const char *type = ivar_getTypeEncoding(ivars[i]); + if (!type || type[0] != '@') continue; + @try { + id val = object_getIvar(media, ivars[i]); + if ([val isKindOfClass:[NSString class]] && [(NSString *)val length]) { + free(ivars); return val; + } + if (val && [val respondsToSelector:@selector(text)]) { + id text = ((id(*)(id, SEL))objc_msgSend)(val, @selector(text)); + if ([text isKindOfClass:[NSString class]] && [(NSString *)text length]) { + free(ivars); return text; + } + } + if (val && [val respondsToSelector:@selector(string)]) { + id str = ((id(*)(id, SEL))objc_msgSend)(val, @selector(string)); + if ([str isKindOfClass:[NSString class]] && [(NSString *)str length]) { + free(ivars); return str; + } + } + } @catch (__unused id e) {} + } + if (ivars) free(ivars); + + return nil; +} + ++ (BOOL)isCarouselMedia:(id)media { + if (!media) return NO; + + if ([media respondsToSelector:@selector(isCarousel)]) { + @try { + BOOL r = ((BOOL(*)(id, SEL))objc_msgSend)(media, @selector(isCarousel)); + if (r) return YES; + } @catch (__unused id e) {} + } + + if ([media respondsToSelector:@selector(mediaType)]) { + @try { + NSInteger t = ((NSInteger(*)(id, SEL))objc_msgSend)(media, @selector(mediaType)); + if (t == 8) return YES; + } @catch (__unused id e) {} + } + + return [self carouselChildrenForMedia:media].count > 0; +} + ++ (NSArray *)carouselChildrenForMedia:(id)media { + if (!media) return @[]; + + for (NSString *sel in @[@"carouselMedia", @"carouselChildren", @"children"]) { + SEL s = NSSelectorFromString(sel); + if ([media respondsToSelector:s]) { + @try { + id val = ((id(*)(id, SEL))objc_msgSend)(media, s); + if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return val; + } @catch (__unused id e) {} + } + } + + static const char * const kCarouselIvars[] = { "_carouselMedia", "_carouselChildren" }; + for (size_t i = 0; i < sizeof(kCarouselIvars)/sizeof(kCarouselIvars[0]); i++) { + id val = sciIvar(media, kCarouselIvars[i]); + if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return val; + } + + id fc = sciFieldCache(media, @"carousel_media"); + if ([fc isKindOfClass:[NSArray class]]) return fc; + + return @[]; +} + ++ (NSURL *)bestURLForMedia:(id)media { + if (!media) return nil; + + NSURL *v = [SCIUtils getVideoUrlForMedia:(IGMedia *)media]; + if (v) return v; + + BOOL hdPhotos = [[SCIUtils getStringPref:@"default_photo_quality"] isEqualToString:@"high"]; + if (hdPhotos) { + NSURL *hd = [self hdPhotoURLForMedia:media]; + if (hd) return hd; + } + + NSURL *p = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media]; + if (p) return p; + + // Carousel children: fieldCache fallback + return [self fieldCachePhotoURLForMedia:media]; +} + ++ (NSURL *)hdPhotoURLForMedia:(id)media { + // fieldCache image_versions2.candidates has multiple sizes — pick largest + id candidates = nil; + id iv2 = sciFieldCache(media, @"image_versions2"); + if ([iv2 isKindOfClass:[NSDictionary class]]) + candidates = ((NSDictionary *)iv2)[@"candidates"]; + if (!candidates) + candidates = sciFieldCache(media, @"candidates"); + + if ([candidates isKindOfClass:[NSArray class]] && [(NSArray *)candidates count]) { + NSDictionary *best = nil; + NSInteger bestW = 0; + for (id c in (NSArray *)candidates) { + if (![c isKindOfClass:[NSDictionary class]]) continue; + NSInteger w = [((NSDictionary *)c)[@"width"] integerValue]; + if (w > bestW) { bestW = w; best = c; } + } + NSString *urlStr = best[@"url"]; + if (urlStr.length) return [NSURL URLWithString:urlStr]; + } + + // Try .photo sub-object imageVersions + id photo = nil; + if ([media respondsToSelector:@selector(photo)]) + photo = ((id(*)(id, SEL))objc_msgSend)(media, @selector(photo)); + + // _originalImageVersions on IGPhoto — array of IGImageURL objects + if (photo) { + Ivar oivIvar = class_getInstanceVariable([photo class], "_originalImageVersions"); + if (oivIvar) { + id oiv = object_getIvar(photo, oivIvar); + if ([oiv isKindOfClass:[NSArray class]] && [(NSArray *)oiv count]) { + NSURL *best = nil; + NSInteger bestW = 0; + for (id item in (NSArray *)oiv) { + NSURL *u = nil; + NSInteger w = 0; + if ([item isKindOfClass:[NSDictionary class]]) { + NSString *s = ((NSDictionary *)item)[@"url"]; + if (s.length) u = [NSURL URLWithString:s]; + w = [((NSDictionary *)item)[@"width"] integerValue]; + } else { + if ([item respondsToSelector:@selector(url)]) + u = [item valueForKey:@"url"]; + if ([item respondsToSelector:@selector(width)]) + w = [[item valueForKey:@"width"] integerValue]; + } + if (u && w > bestW) { bestW = w; best = u; } + } + if (best) return best; + } + } + } + + return nil; +} + ++ (NSURL *)fieldCachePhotoURLForMedia:(id)media { + id candidates = nil; + id iv2 = sciFieldCache(media, @"image_versions2"); + if ([iv2 isKindOfClass:[NSDictionary class]]) + candidates = ((NSDictionary *)iv2)[@"candidates"]; + if (!candidates) + candidates = sciFieldCache(media, @"candidates"); + + if ([candidates isKindOfClass:[NSArray class]] && [(NSArray *)candidates count]) { + NSDictionary *best = nil; + NSInteger bestW = 0; + for (id c in (NSArray *)candidates) { + if (![c isKindOfClass:[NSDictionary class]]) continue; + NSInteger w = [((NSDictionary *)c)[@"width"] integerValue]; + if (w > bestW) { bestW = w; best = c; } + } + NSString *urlStr = best[@"url"]; + if (urlStr.length) return [NSURL URLWithString:urlStr]; + } + return nil; +} + +// MARK: - Enhanced HD download + ++ (void)downloadHDMedia:(id)media action:(DownloadAction)action fromView:(UIView *)sourceView { + if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media")]; return; } + + BOOL isVideo = ([SCIUtils getVideoUrlForMedia:(IGMedia *)media] != nil); + + // Photos: always use best candidates URL (no FFmpeg needed) + if (!isVideo) { + NSURL *url = [self bestURLForMedia:media]; + if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo URL")]; return; } + sciActiveDownloadDelegate = sciMakeDownloader(action, NO); + [sciActiveDownloadDelegate downloadFileWithURL:url + fileExtension:[[url lastPathComponent] pathExtension] + hudLabel:nil]; + return; + } + + // Try enhanced HD path via reusable quality picker + BOOL handled = [SCIQualityPicker pickQualityForMedia:media + fromView:sourceView + picked:^(SCIDashRepresentation *video, SCIDashRepresentation *audio) { + [self downloadDASHVideo:video audio:audio action:action]; + } + fallback:^{ + // No DASH or FFmpeg unavailable — use progressive URL + NSURL *url = [self bestURLForMedia:media]; + if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract video URL")]; return; } + sciActiveDownloadDelegate = sciMakeDownloader(action, YES); + [sciActiveDownloadDelegate downloadFileWithURL:url + fileExtension:[[url lastPathComponent] pathExtension] + hudLabel:nil]; + }]; + + if (!handled) { + // pickQualityForMedia returned NO and already called fallback + } +} + ++ (void)downloadDASHVideo:(SCIDashRepresentation *)videoRep + audio:(SCIDashRepresentation *)audioRep + action:(DownloadAction)action { + if (!videoRep.url) { + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No video URL")]; + return; + } + + SCIDownloadPillView *pill = [SCIDownloadPillView shared]; + __block void (^muxCancel)(void) = nil; + NSString *ticket = [pill beginTicketWithTitle:[NSString stringWithFormat:SCILocalized(@"Downloading %@..."), videoRep.qualityLabel ?: @"HD"] + onCancel:^{ if (muxCancel) muxCancel(); }]; + + NSString *encPreset = [SCIUtils getStringPref:@"ffmpeg_encoding_speed"]; + if (!encPreset.length) encPreset = @"ultrafast"; + + [SCIFFmpeg muxVideoURL:videoRep.url audioURL:audioRep.url preset:encPreset + progress:^(float progress, NSString *stage) { + [pill updateTicket:ticket progress:progress]; + [pill updateTicket:ticket text:stage]; + } completion:^(NSURL *outputURL, NSError *error) { + if (error && error.code == NSUserCancelledError) { + [pill finishTicket:ticket cancelled:@"Cancelled"]; + if (outputURL) [[NSFileManager defaultManager] removeItemAtURL:outputURL error:nil]; + return; + } + if (error || !outputURL) { + [pill finishTicket:ticket errorMessage:error.localizedDescription ?: @"Mux failed"]; + return; + } + + // saveToPhotos finishes the ticket after the PH completion fires. + if (action != saveToPhotos) { + [pill finishTicket:ticket successMessage:SCILocalized(@"HD download complete")]; + } + + switch (action) { + case share: + [SCIUtils showShareVC:outputURL]; + break; + case quickLook: + [SCIUtils showQuickLookVC:@[outputURL]]; + break; + case saveToPhotos: { + [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) { + if (status != PHAuthorizationStatusAuthorized) { + dispatch_async(dispatch_get_main_queue(), ^{ + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")]; + }); + return; + } + + BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"]; + void (^onDone)(BOOL, NSError *) = ^(BOOL ok, NSError *e) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (ok) [pill finishTicket:ticket successMessage:useAlbum ? SCILocalized(@"Saved to RyukGram") : SCILocalized(@"Saved to Photos")]; + else [pill finishTicket:ticket errorMessage:e.localizedDescription ?: @"Failed to save"]; + }); + }; + + if (useAlbum) { + [SCIPhotoAlbum saveFileToAlbum:outputURL completion:^(BOOL ok, NSError *e) { + [[NSFileManager defaultManager] removeItemAtPath:outputURL.path error:nil]; + onDone(ok, e); + }]; + } else { + [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ + PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset]; + PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new]; + opts.shouldMoveFile = YES; + [req addResourceWithType:PHAssetResourceTypeVideo + fileURL:outputURL options:opts]; + } completionHandler:onDone]; + } + }]; + break; + } + } + } cancelOut:^(void (^cb)(void)) { + muxCancel = cb; + }]; +} + ++ (NSURL *)coverURLForMedia:(id)media { + if (!media) return nil; + // For a reel/video, `media.photo` exposes the poster frame URL. + return [SCIUtils getPhotoUrlForMedia:(IGMedia *)media]; +} + + +// MARK: - Primary actions + ++ (void)expandMedia:(id)media fromView:(UIView *)sourceView caption:(NSString *)caption { + if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No media to expand")]; return; } + + NSString *cap = caption ?: [self captionForMedia:media]; + + // Check if this is a carousel — show all items with swiping + if ([self isCarouselMedia:media]) { + NSArray *children = [self 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) { + [items addObject:[SCIMediaViewerItem itemWithVideoURL:v photoURL:p caption:cap]]; + } + } + if (items.count) { + [SCIMediaViewer showItems:items startIndex:0]; + return; + } + } + + // Single item + NSURL *videoUrl = [SCIUtils getVideoUrlForMedia:(IGMedia *)media]; + NSURL *photoUrl = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media]; + if (!videoUrl && !photoUrl) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract media URL")]; return; } + + [SCIMediaViewer showWithVideoURL:videoUrl photoURL:photoUrl caption:cap]; +} + ++ (void)downloadAndShareMedia:(id)media { + [self downloadAndShareMedia:media fromView:nil]; +} + ++ (void)downloadAndShareMedia:(id)media fromView:(UIView *)sourceView { + sciConfirmThen(SCILocalized(@"Download and share?"), ^{ + [self downloadHDMedia:media action:share fromView:sourceView]; + }); +} + ++ (void)downloadAndSaveMedia:(id)media { + [self downloadAndSaveMedia:media fromView:nil]; +} + ++ (void)downloadAndSaveMedia:(id)media fromView:(UIView *)sourceView { + sciConfirmThen(SCILocalized(@"Save to Photos?"), ^{ + [self downloadHDMedia:media action:saveToPhotos fromView:sourceView]; + }); +} + ++ (void)copyURLForMedia:(id)media { + NSURL *url = [self bestURLForMedia:media]; + if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract media URL")]; return; } + [[UIPasteboard generalPasteboard] setString:url.absoluteString]; + [SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Copied download URL")]; +} + ++ (void)copyCaptionForMedia:(id)media { + NSString *caption = [self captionForMedia:media]; + if (!caption.length) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No caption on this post")]; return; } + [[UIPasteboard generalPasteboard] setString:caption]; + [SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Copied caption")]; +} + +// BFS search for a view of a given class within a subtree (bounded depth). +static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxDepth) { + Class cls = NSClassFromString(className); + if (!cls || !root) return nil; + NSMutableArray *queue = [NSMutableArray arrayWithObject:root]; + int processed = 0; + while (queue.count && processed < 200) { + UIView *v = queue.firstObject; [queue removeObjectAtIndex:0]; + if ([v isKindOfClass:cls]) return v; + if (processed < maxDepth * 50) { + for (UIView *sub in v.subviews) [queue addObject:sub]; + } + processed++; + } + return nil; +} + ++ (void)triggerRepostForContext:(SCIActionContext)ctx sourceView:(UIView *)sourceView { + if (ctx == SCIActionContextReels) { + // Walk up to video cell, then BFS for the UFI bar. + Class cellCls = NSClassFromString(@"IGSundialViewerVideoCell"); + if (!cellCls) cellCls = NSClassFromString(@"IGSundialViewerPhotoView"); + UIView *v = sourceView; + while (v && cellCls && ![v isKindOfClass:cellCls]) v = v.superview; + UIView *ufi = v ? sciFindSubviewOfClass(v, @"IGSundialViewerVerticalUFI", 8) : nil; + if (ufi) { + SEL noArg = NSSelectorFromString(@"_didTapRepostButton"); + if ([ufi respondsToSelector:noArg]) { + ((void(*)(id, SEL))objc_msgSend)(ufi, noArg); + return; + } + // Fallback: try the 1-arg variant (older IG?) + SEL oneArg = @selector(_didTapRepostButton:); + if ([ufi respondsToSelector:oneArg]) { + ((void(*)(id, SEL, id))objc_msgSend)(ufi, oneArg, nil); + return; + } + } + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Repost unavailable")]; + return; + } + + // Feed: walk responder chain for IGFeedItemUFICell. + UIResponder *r = sourceView; + Class feedCell = NSClassFromString(@"IGFeedItemUFICell"); + while (r) { + if (feedCell && [r isKindOfClass:feedCell]) break; + r = [r nextResponder]; + } + if (r) { + @try { + SEL s = @selector(UFIButtonBarDidTapOnRepost:); + if ([r respondsToSelector:s]) { + ((void(*)(id, SEL, id))objc_msgSend)(r, s, nil); + return; + } + } @catch (__unused id e) {} + } + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Repost unavailable")]; +} + ++ (void)openSettingsForContext:(SCIActionContext)ctx fromView:(UIView *)sourceView { + UIWindow *win = sourceView.window; + if (!win) { + for (UIWindow *w in [UIApplication sharedApplication].windows) { + if (w.isKeyWindow) { win = w; break; } + } + } + if (!win) { + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if ([scene isKindOfClass:[UIWindowScene class]]) { + for (UIWindow *w in ((UIWindowScene *)scene).windows) { win = w; break; } + } + if (win) break; + } + } + if (!win) return; + [SCIUtils showSettingsVC:win atTopLevelEntry:sciSettingsTitleForContext(ctx)]; +} + + +// MARK: - Carousel bulk actions + +// Download all carousel children in parallel, call `done` when finished. ++ (void)downloadAllChildrenOfMedia:(id)media + progressTitle:(NSString *)title + done:(void(^)(NSArray *fileURLs))done { + NSArray *children = [self carouselChildrenForMedia:media]; + if (!children.count) { + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No carousel children")]; + return; + } + + // Collect URLs first + NSMutableArray *urls = [NSMutableArray array]; + for (id child in children) { + NSURL *u = [self bestURLForMedia:child]; + if (u) [urls addObject:u]; + } + if (!urls.count) { + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract any URLs")]; + return; + } + + sciConfirmThen(title, ^{ + // Show the shared pill with bulk progress + SCIDownloadPillView *pill = [SCIDownloadPillView shared]; + [pill resetState]; + [pill showBulkProgress:0 total:urls.count]; + UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view; + if (hostView) [pill showInView:hostView]; + + __block BOOL cancelled = NO; + pill.onCancel = ^{ cancelled = YES; }; + + dispatch_group_t group = dispatch_group_create(); + NSMutableArray *files = [NSMutableArray array]; + NSLock *lock = [NSLock new]; + __block NSUInteger completed = 0; + + for (NSURL *url in urls) { + if (cancelled) break; + dispatch_group_enter(group); + NSString *ext = [[url lastPathComponent] pathExtension]; + NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], + ext.length ? ext : @"jpg"]]; + NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] + downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) { + if (!err && loc && !cancelled) { + NSError *mv = nil; + [[NSFileManager defaultManager] moveItemAtURL:loc + toURL:[NSURL fileURLWithPath:tmp] + error:&mv]; + if (!mv) { + [lock lock]; + [files addObject:[NSURL fileURLWithPath:tmp]]; + [lock unlock]; + } + } + [lock lock]; + completed++; + NSUInteger c = completed; + NSUInteger t = urls.count; + [lock unlock]; + dispatch_async(dispatch_get_main_queue(), ^{ + [pill showBulkProgress:c total:t]; + }); + dispatch_group_leave(group); + }]; + [task resume]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + if (cancelled) { + [pill showError:SCILocalized(@"Cancelled")]; + [pill dismissAfterDelay:1.0]; + } else if (files.count) { + [pill showSuccess:[NSString stringWithFormat:SCILocalized(@"Downloaded %lu items"), (unsigned long)files.count]]; + [pill dismissAfterDelay:1.5]; + if (done) done([files copy]); + } else { + [pill showError:SCILocalized(@"No files downloaded")]; + [pill dismissAfterDelay:2.0]; + } + }); + }); +} + ++ (void)downloadAllAndShareMedia:(id)carouselMedia { + [self downloadAllChildrenOfMedia:carouselMedia + progressTitle:@"Download all and share?" + done:^(NSArray *files) { + if (!files.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Nothing to share")]; return; } + UIViewController *top = topMostController(); + UIActivityViewController *vc = [[UIActivityViewController alloc] + initWithActivityItems:files applicationActivities:nil]; + if (is_iPad()) { + vc.popoverPresentationController.sourceView = top.view; + vc.popoverPresentationController.sourceRect = + CGRectMake(top.view.bounds.size.width/2.0, top.view.bounds.size.height/2.0, 1, 1); + } + if ([SCIUtils getBoolPref:@"save_to_ryukgram_album"]) { + [SCIPhotoAlbum watchForNextSavedAsset]; + } + [top presentViewController:vc animated:YES completion:nil]; + }]; +} + ++ (void)downloadAllAndSaveMedia:(id)carouselMedia { + [self downloadAllChildrenOfMedia:carouselMedia + progressTitle:@"Save all to Photos?" + done:^(NSArray *files) { + if (!files.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Nothing to save")]; return; } + [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) { + if (status != PHAuthorizationStatusAuthorized) { + dispatch_async(dispatch_get_main_queue(), ^{ + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")]; + }); + return; + } + BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"]; + __block NSUInteger saved = 0; + __block NSUInteger idx = 0; + + // Save sequentially (Photos API doesn't like parallel writes) + __block void (^saveNext)(void) = ^{ + if (idx >= files.count) { + dispatch_async(dispatch_get_main_queue(), ^{ + [SCIUtils showToastForDuration:2.0 + title:[NSString stringWithFormat:SCILocalized(@"Saved %lu items"), (unsigned long)saved]]; + }); + saveNext = nil; // break retain cycle + return; + } + NSURL *f = files[idx]; + idx++; + void (^step)(BOOL, NSError *) = ^(BOOL ok, NSError *e) { + if (ok) saved++; + if (saveNext) saveNext(); + }; + if (useAlbum) { + [SCIPhotoAlbum saveFileToAlbum:f completion:step]; + } else { + [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ + NSString *ext = [[f pathExtension] lowercaseString]; + BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext]; + PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset]; + PHAssetResourceCreationOptions *opts = [[PHAssetResourceCreationOptions alloc] init]; + opts.shouldMoveFile = YES; + [req addResourceWithType:(isVideo ? PHAssetResourceTypeVideo : PHAssetResourceTypePhoto) + fileURL:f options:opts]; + } completionHandler:step]; + } + }; + saveNext(); + }]; + }]; +} + ++ (void)copyAllURLsForMedia:(id)carouselMedia { + NSArray *children = [self carouselChildrenForMedia:carouselMedia]; + if (!children.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Not a carousel")]; return; } + NSMutableArray *urls = [NSMutableArray array]; + for (id child in children) { + NSURL *u = [self bestURLForMedia:child]; + if (u) [urls addObject:u.absoluteString]; + } + if (!urls.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No URLs found")]; return; } + [[UIPasteboard generalPasteboard] setString:[urls componentsJoinedByString:@"\n"]]; + [SCIUtils showToastForDuration:1.5 title:[NSString stringWithFormat:SCILocalized(@"Copied %lu URLs"), (unsigned long)urls.count]]; +} + + +// MARK: - Menu builder + ++ (NSArray *)actionsForContext:(SCIActionContext)ctx + media:(id)media + fromView:(UIView *)sourceView { + NSMutableArray *out = [NSMutableArray array]; + + // Resolve parent media for carousel detection + bulk actions. + id parentMedia = media; + if (media && ![self isCarouselMedia:media]) { + // Path 1: _mediaPassthrough ivar (reels) + UIView *v = sourceView; + while (v) { + Ivar mpi = class_getInstanceVariable([v class], "_mediaPassthrough"); + if (mpi) { + id pm = object_getIvar(v, mpi); + if (pm && [self isCarouselMedia:pm]) { parentMedia = pm; break; } + } + v = v.superview; + } + + // Path 2: sibling IGFeedItemPageCell in the collection view (feed) + if (parentMedia == media) { + v = sourceView; + UICollectionViewCell *ufiCell = nil; + UICollectionView *cv = nil; + while (v) { + if (!ufiCell && [v isKindOfClass:[UICollectionViewCell class]]) + ufiCell = (UICollectionViewCell *)v; + if ([v isKindOfClass:[UICollectionView class]]) { cv = (UICollectionView *)v; break; } + v = v.superview; + } + if (ufiCell && cv) { + NSIndexPath *ufiPath = [cv indexPathForCell:ufiCell]; + if (ufiPath) { + Class mc = NSClassFromString(@"IGMedia"); + for (UICollectionViewCell *cell in cv.visibleCells) { + NSIndexPath *p = [cv indexPathForCell:cell]; + if (!p || p.section != ufiPath.section || cell == ufiCell) continue; + if (![NSStringFromClass([cell class]) containsString:@"Page"]) continue; + Ivar mi = class_getInstanceVariable(object_getClass(cell), "_media"); + if (!mi) continue; + @try { + id pm = object_getIvar(cell, mi); + if (pm && mc && [pm isKindOfClass:mc] && [self isCarouselMedia:pm]) { + parentMedia = pm; + break; + } + } @catch (__unused id e) {} + } + } + } + } + } + + NSString *caption = parentMedia ? [self captionForMedia:parentMedia] : nil; + BOOL isCarousel = parentMedia ? [self isCarouselMedia:parentMedia] : NO; + __weak UIView *weakSource = sourceView; + + // --- Section 1: navigation --- + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Expand") + icon:@"arrow.up.left.and.arrow.down.right" + handler:^{ + if (isCarousel) { + NSArray *children = [SCIMediaActions carouselChildrenForMedia:parentMedia]; + 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]]; + } + } + // Find current page index to start there + NSUInteger startIdx = 0; + if (media != parentMedia) { + NSUInteger idx = [children indexOfObjectIdenticalTo:media]; + if (idx != NSNotFound) startIdx = idx; + } + if (items.count) { + [SCIMediaViewer showItems:items startIndex:startIdx]; + } else { + [SCIMediaActions expandMedia:media fromView:weakSource caption:caption]; + } + } else { + [SCIMediaActions expandMedia:media fromView:weakSource caption:caption]; + } + }]]; + + if (ctx == SCIActionContextReels || (ctx == SCIActionContextFeed && [SCIUtils getVideoUrlForMedia:(IGMedia *)media])) { + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"View cover") + icon:@"photo" + handler:^{ + NSURL *cover = [SCIMediaActions coverURLForMedia:media]; + if (!cover) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No cover image")]; return; } + [SCIMediaViewer showWithVideoURL:nil photoURL:cover caption:nil]; + }]]; + } + + // Repost = save to Photos → open IG's native creation flow + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Repost") + icon:@"arrow.2.squarepath" + handler:^{ + NSURL *vidURL = [SCIUtils getVideoUrlForMedia:(IGMedia *)media]; + NSURL *imgURL = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media]; + [SCIRepostSheet repostWithVideoURL:vidURL photoURL:imgURL]; + }]]; + + if (ctx == SCIActionContextStories) { + if ([SCIUtils getBoolPref:@"view_story_mentions"]) { + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"View mentions") + icon:@"at" + handler:^{ + UIView *v = weakSource; + UIViewController *host = [SCIUtils nearestViewControllerForView:v]; + extern void sciShowStoryMentions(UIViewController *, UIView *); + if (!host) return; + sciShowStoryMentions(host, v); + }]]; + } + + // Mute / unmute story audio + if ([SCIUtils getBoolPref:@"story_audio_toggle"]) { + BOOL audioOn = sciIsStoryAudioEnabled(); + NSString *audioTitle = audioOn ? SCILocalized(@"Mute audio") : SCILocalized(@"Unmute audio"); + NSString *audioIcon = audioOn ? @"speaker.wave.2" : @"speaker.slash"; + [out addObject:[SCIAction actionWithTitle:audioTitle + icon:audioIcon + handler:^{ sciToggleStoryAudio(); }]]; + } + } + + if (ctx != SCIActionContextStories) { + // Caption lives on the parent media (not on carousel children). + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Copy caption") + icon:@"text.quote" + handler:^{ + [SCIMediaActions copyCaptionForMedia:parentMedia]; + }]]; + } + + NSString *settingsTitle = [NSString stringWithFormat:SCILocalized(@"%@ settings"), + sciSettingsTitleForContext(ctx)]; + [out addObject:[SCIAction actionWithTitle:settingsTitle + icon:@"gearshape" + handler:^{ + [SCIMediaActions openSettingsForContext:ctx fromView:weakSource]; + }]]; + + // Section 2 — bulk download (carousels or multi-story reels) + if (isCarousel) { + // Bulk actions use the PARENT media (all children), not the current page + id bulkMedia = parentMedia; + [out addObject:[SCIAction separator]]; + NSArray *bulkChildren = @[ + [SCIAction actionWithTitle:SCILocalized(@"Copy all URLs") icon:@"doc.on.doc" handler:^{ + [SCIMediaActions copyAllURLsForMedia:bulkMedia]; + }], + [SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{ + [SCIMediaActions downloadAllAndShareMedia:bulkMedia]; + }], + [SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{ + [SCIMediaActions downloadAllAndSaveMedia:bulkMedia]; + }], + ]; + NSUInteger childCount = [self carouselChildrenForMedia:bulkMedia].count; + NSString *bulkTitle = childCount > 0 + ? [NSString stringWithFormat:SCILocalized(@"Download all (%lu)"), (unsigned long)childCount] + : @"Download all"; + [out addObject:[SCIAction actionWithTitle:bulkTitle + icon:@"square.stack.3d.down.right" + children:bulkChildren]]; + } + + // Multi-story reel bulk actions + if (ctx == SCIActionContextStories && !isCarousel) { + // Read reel items from the story VC + NSArray *reelItems = nil; + UIViewController *storyVC = [SCIUtils nearestViewControllerForView:sourceView]; + if (!storyVC) { + UIResponder *r = sourceView; + while (r) { + if ([NSStringFromClass([r class]) containsString:@"StoryViewer"]) { + storyVC = (UIViewController *)r; break; + } + r = [r nextResponder]; + } + } + if (storyVC) { + // Walk to IGStoryViewerViewController + UIResponder *r = storyVC; + Class svCls = NSClassFromString(@"IGStoryViewerViewController"); + while (r && !(svCls && [r isKindOfClass:svCls])) r = [r nextResponder]; + if (!r) r = (UIResponder *)storyVC; + + id vm = nil; + if ([r respondsToSelector:@selector(currentViewModel)]) + vm = ((id(*)(id,SEL))objc_msgSend)(r, @selector(currentViewModel)); + + if (vm) { + // Try 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) { + reelItems = val; + break; + } + } @catch (__unused id e) {} + } + } + + // Scan vm ivars for arrays + if (!reelItems) { + 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]) || + (first && [first respondsToSelector:@selector(media)])) { + reelItems = val; + break; + } + } + } @catch (__unused id e) {} + } + if (ivs) free(ivs); + } + } + } + + if (reelItems.count > 1) { + // Extract IGMedia from each item (may be wrapped) + NSMutableArray *storyMedias = [NSMutableArray array]; + Class mc = NSClassFromString(@"IGMedia"); + for (id item in reelItems) { + if (mc && [item isKindOfClass:mc]) { + [storyMedias addObject:item]; + } else { + // Try to extract + for (NSString *sel in @[@"media", @"storyItem", @"item", @"mediaItem"]) { + if ([item respondsToSelector:NSSelectorFromString(sel)]) { + @try { + id m = ((id(*)(id,SEL))objc_msgSend)(item, NSSelectorFromString(sel)); + if (m && mc && [m isKindOfClass:mc]) { [storyMedias addObject:m]; break; } + } @catch (__unused id e) {} + } + } + } + } + + if (storyMedias.count > 1) { + [out addObject:[SCIAction separator]]; + + NSArray *capturedMedias = [storyMedias copy]; + NSArray *storyBulk = @[ + [SCIAction actionWithTitle:SCILocalized(@"Copy all URLs") icon:@"doc.on.doc" handler:^{ + NSMutableArray *urls = [NSMutableArray array]; + for (id m in capturedMedias) { + NSURL *u = [SCIMediaActions bestURLForMedia:m]; + if (u) [urls addObject:u.absoluteString]; + } + if (urls.count) { + [[UIPasteboard generalPasteboard] setString:[urls componentsJoinedByString:@"\n"]]; + [SCIUtils showToastForDuration:1.5 title:[NSString stringWithFormat:SCILocalized(@"Copied %lu URLs"), (unsigned long)urls.count]]; + } + }], + [SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{ + NSMutableArray *urls = [NSMutableArray array]; + for (id m in capturedMedias) { + NSURL *u = [SCIMediaActions bestURLForMedia:m]; + if (u) [urls addObject:u]; + } + if (!urls.count) return; + [SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Download all stories and share?") done:^(NSArray *files) { + if (!files.count) return; + UIViewController *top = topMostController(); + UIActivityViewController *vc = [[UIActivityViewController alloc] + initWithActivityItems:files applicationActivities:nil]; + [top presentViewController:vc animated:YES completion:nil]; + }]; + }], + [SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{ + NSMutableArray *urls = [NSMutableArray array]; + for (id m in capturedMedias) { + NSURL *u = [SCIMediaActions bestURLForMedia:m]; + if (u) [urls addObject:u]; + } + if (!urls.count) return; + [SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Save all stories to Photos?") done:^(NSArray *files) { + [SCIMediaActions bulkSaveFiles:files]; + }]; + }], + ]; + [out addObject:[SCIAction actionWithTitle:[NSString stringWithFormat:SCILocalized(@"Download all (%lu)"), (unsigned long)storyMedias.count] + icon:@"square.stack.3d.down.right" + children:storyBulk]]; + } + } + } + + // --- Section 3: current media actions --- + [out addObject:[SCIAction separator]]; + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Copy download URL") + icon:@"link" + handler:^{ + [SCIMediaActions copyURLForMedia:media]; + }]]; + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download and share") + icon:@"square.and.arrow.up" + handler:^{ + [SCIMediaActions downloadAndShareMedia:media]; + }]]; + [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download to Photos") + icon:@"square.and.arrow.down" + handler:^{ + [SCIMediaActions downloadAndSaveMedia:media]; + }]]; + + return [out copy]; +} + + +// MARK: - Bulk URL download helpers (used by story reel + carousel) + ++ (void)bulkDownloadURLs:(NSArray *)urls + title:(NSString *)title + done:(void(^)(NSArray *fileURLs))done { + if (!urls.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No URLs")]; return; } + + sciConfirmThen(title, ^{ + SCIDownloadPillView *pill = [SCIDownloadPillView shared]; + [pill resetState]; + [pill showBulkProgress:0 total:urls.count]; + UIView *hostView = [UIApplication sharedApplication].keyWindow ?: topMostController().view; + if (hostView) [pill showInView:hostView]; + + __block BOOL cancelled = NO; + pill.onCancel = ^{ cancelled = YES; }; + + dispatch_group_t group = dispatch_group_create(); + NSMutableArray *files = [NSMutableArray array]; + NSLock *lock = [NSLock new]; + __block NSUInteger completed = 0; + + for (NSURL *url in urls) { + if (cancelled) break; + dispatch_group_enter(group); + NSString *ext = [[url lastPathComponent] pathExtension]; + NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], + ext.length ? ext : @"jpg"]]; + NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] + downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) { + if (!err && loc && !cancelled) { + NSError *mv = nil; + [[NSFileManager defaultManager] moveItemAtURL:loc + toURL:[NSURL fileURLWithPath:tmp] + error:&mv]; + if (!mv) { + [lock lock]; [files addObject:[NSURL fileURLWithPath:tmp]]; [lock unlock]; + } + } + [lock lock]; completed++; NSUInteger c = completed; [lock unlock]; + dispatch_async(dispatch_get_main_queue(), ^{ + [pill showBulkProgress:c total:urls.count]; + }); + dispatch_group_leave(group); + }]; + [task resume]; + } + + dispatch_group_notify(group, dispatch_get_main_queue(), ^{ + if (cancelled) { + [pill showError:SCILocalized(@"Cancelled")]; + [pill dismissAfterDelay:1.0]; + } else if (files.count) { + [pill showSuccess:[NSString stringWithFormat:SCILocalized(@"Downloaded %lu items"), (unsigned long)files.count]]; + [pill dismissAfterDelay:1.5]; + if (done) done([files copy]); + } else { + [pill showError:SCILocalized(@"No files downloaded")]; + [pill dismissAfterDelay:2.0]; + } + }); + }); +} + ++ (void)bulkSaveFiles:(NSArray *)files { + if (!files.count) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Nothing to save")]; return; } + [PHPhotoLibrary requestAuthorization:^(PHAuthorizationStatus status) { + if (status != PHAuthorizationStatusAuthorized) { + dispatch_async(dispatch_get_main_queue(), ^{ + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Photo library access denied")]; + }); + return; + } + BOOL useAlbum = [SCIUtils getBoolPref:@"save_to_ryukgram_album"]; + __block NSUInteger saved = 0; + __block NSUInteger idx = 0; + __block void (^saveNext)(void) = ^{ + if (idx >= files.count) { + dispatch_async(dispatch_get_main_queue(), ^{ + [SCIUtils showToastForDuration:2.0 + title:[NSString stringWithFormat:SCILocalized(@"Saved %lu items"), (unsigned long)saved]]; + }); + saveNext = nil; + return; + } + NSURL *f = files[idx]; idx++; + void (^step)(BOOL, NSError *) = ^(BOOL ok, NSError *e) { + if (ok) saved++; + if (saveNext) saveNext(); + }; + if (useAlbum) { + [SCIPhotoAlbum saveFileToAlbum:f completion:step]; + } else { + [[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{ + NSString *ext = [[f pathExtension] lowercaseString]; + BOOL isVideo = [@[@"mp4", @"mov", @"m4v"] containsObject:ext]; + PHAssetCreationRequest *req = [PHAssetCreationRequest creationRequestForAsset]; + PHAssetResourceCreationOptions *opts = [PHAssetResourceCreationOptions new]; + opts.shouldMoveFile = YES; + [req addResourceWithType:(isVideo ? PHAssetResourceTypeVideo : PHAssetResourceTypePhoto) + fileURL:f options:opts]; + } completionHandler:step]; + } + }; + saveNext(); + }]; +} + +@end diff --git a/src/ActionButton/SCIMediaViewer.h b/src/ActionButton/SCIMediaViewer.h new file mode 100644 index 0000000..fc235ad --- /dev/null +++ b/src/ActionButton/SCIMediaViewer.h @@ -0,0 +1,24 @@ +// SCIMediaViewer — full-screen media viewer. Supports single items and carousels. + +#import + +/// 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 *)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 diff --git a/src/ActionButton/SCIMediaViewer.m b/src/ActionButton/SCIMediaViewer.m new file mode 100644 index 0000000..e11f2c5 --- /dev/null +++ b/src/ActionButton/SCIMediaViewer.m @@ -0,0 +1,437 @@ +#import "SCIMediaViewer.h" +#import "../Utils.h" +#import +#import + +// ═══════════════════════════════════════════════════════════════════════════ +#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 +@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 +@property (nonatomic, strong) NSArray *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 *)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 *)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 diff --git a/src/ActionButton/SCIRepostSheet.h b/src/ActionButton/SCIRepostSheet.h new file mode 100644 index 0000000..343aeec --- /dev/null +++ b/src/ActionButton/SCIRepostSheet.h @@ -0,0 +1,10 @@ +// SCIRepostSheet — download media, save to Photos, open IG's creation flow. + +#import + +@interface SCIRepostSheet : NSObject + +/// Download media, save to Photos, open IG's creation flow. ++ (void)repostWithVideoURL:(NSURL *)videoURL photoURL:(NSURL *)photoURL; + +@end diff --git a/src/ActionButton/SCIRepostSheet.m b/src/ActionButton/SCIRepostSheet.m new file mode 100644 index 0000000..db2b234 --- /dev/null +++ b/src/ActionButton/SCIRepostSheet.m @@ -0,0 +1,109 @@ +#import "SCIRepostSheet.h" +#import "../Utils.h" +#import "../Downloader/Download.h" +#import "../PhotoAlbum.h" +#import + +@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 diff --git a/src/Downloader/Download.h b/src/Downloader/Download.h index e275ae3..fe49a0c 100644 --- a/src/Downloader/Download.h +++ b/src/Downloader/Download.h @@ -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 @@ -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; diff --git a/src/Downloader/Download.m b/src/Downloader/Download.m index 26917dd..24dece4 100644 --- a/src/Downloader/Download.m +++ b/src/Downloader/Download.m @@ -2,70 +2,145 @@ #import "../PhotoAlbum.h" #import +#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 *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")]; } }); }; diff --git a/src/Features/ActionButton/FeedActionButton.xm b/src/Features/ActionButton/FeedActionButton.xm new file mode 100644 index 0000000..26c8514 --- /dev/null +++ b/src/Features/ActionButton/FeedActionButton.xm @@ -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 +#import + +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 diff --git a/src/Features/ActionButton/ReelsActionButton.xm b/src/Features/ActionButton/ReelsActionButton.xm new file mode 100644 index 0000000..966bdb9 --- /dev/null +++ b/src/Features/ActionButton/ReelsActionButton.xm @@ -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 +#import + +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 diff --git a/src/Features/Confirm/FollowConfirm.x b/src/Features/Confirm/FollowConfirm.x index 52ca1c3..7b66339 100644 --- a/src/Features/Confirm/FollowConfirm.x +++ b/src/Features/Confirm/FollowConfirm.x @@ -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 diff --git a/src/Features/Confirm/ShhConfirm.x b/src/Features/Confirm/ShhConfirm.x index 50dd6b9..3d3aebf 100644 --- a/src/Features/Confirm/ShhConfirm.x +++ b/src/Features/Confirm/ShhConfirm.x @@ -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 \ No newline at end of file + +- (void)messageListViewControllerDidReplayInShhMode:(id)arg1 { + if ([SCIUtils getBoolPref:@"shh_mode_confirm"]) + [SCIUtils showConfirmation:^(void) { %orig; }]; + else %orig; +} +%end diff --git a/src/Features/Feed/StoryTrayActions.x b/src/Features/Feed/StoryTrayActions.x new file mode 100644 index 0000000..c1bd018 --- /dev/null +++ b/src/Features/Feed/StoryTrayActions.x @@ -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 +#import +#import + +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); +} diff --git a/src/Features/General/CommentActions.xm b/src/Features/General/CommentActions.xm index 410f4fd..04d46ea 100644 --- a/src/Features/General/CommentActions.xm +++ b/src/Features/General/CommentActions.xm @@ -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 *_) { diff --git a/src/Features/General/CopyDescription.x b/src/Features/General/CopyDescription.x index 2dafcc6..9593b81 100644 --- a/src/Features/General/CopyDescription.x +++ b/src/Features/General/CopyDescription.x @@ -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]; diff --git a/src/Features/General/DetailedColorPicker.xm b/src/Features/General/DetailedColorPicker.xm index 7bceaa5..2797d23 100644 --- a/src/Features/General/DetailedColorPicker.xm +++ b/src/Features/General/DetailedColorPicker.xm @@ -27,7 +27,7 @@ UIColorPickerViewController *colorPickerController = [[UIColorPickerViewController alloc] init]; colorPickerController.delegate = (id)self; // cast to suppress warnings - colorPickerController.title = @"Select color"; + colorPickerController.title = SCILocalized(@"Select color"); colorPickerController.modalPresentationStyle = UIModalPresentationPopover; colorPickerController.supportsAlpha = NO; colorPickerController.selectedColor = self.color; diff --git a/src/Features/General/DisableBackgroundRefresh.x b/src/Features/General/DisableBackgroundRefresh.x new file mode 100644 index 0000000..3de60c5 --- /dev/null +++ b/src/Features/General/DisableBackgroundRefresh.x @@ -0,0 +1,210 @@ +// Disable feed refresh — background refresh and home tab refresh. + +#import "../../InstagramHeaders.h" +#import "../../Utils.h" +#import +#import + +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 diff --git a/src/Features/General/DisableHaptics.x b/src/Features/General/DisableHaptics.x new file mode 100644 index 0000000..ad59c93 --- /dev/null +++ b/src/Features/General/DisableHaptics.x @@ -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 \ No newline at end of file diff --git a/src/Features/General/FakeLocation.xm b/src/Features/General/FakeLocation.xm new file mode 100644 index 0000000..414e8cb --- /dev/null +++ b/src/Features/General/FakeLocation.xm @@ -0,0 +1,49 @@ +// Fake location — overrides CLLocationManager so any IG location read returns our coord. + +#import "../../Utils.h" +#import +#import + +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 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 diff --git a/src/Features/General/FakeLocationMapButton.x b/src/Features/General/FakeLocationMapButton.x new file mode 100644 index 0000000..3989772 --- /dev/null +++ b/src/Features/General/FakeLocationMapButton.x @@ -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 +#import +#import + +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 *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(); + }]; +} diff --git a/src/Features/General/FeedDateFormat.x b/src/Features/General/FeedDateFormat.x new file mode 100644 index 0000000..54bc0ed --- /dev/null +++ b/src/Features/General/FeedDateFormat.x @@ -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_) so users can apply the format surface-by-surface. + +#import "../../InstagramHeaders.h" +#import "../../Utils.h" +#import "SCIDateFormatEntries.h" +#import + +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) +} diff --git a/src/Features/General/HideMetaAI.xm b/src/Features/General/HideMetaAI.xm index 1134ca9..b7b426a 100644 --- a/src/Features/General/HideMetaAI.xm +++ b/src/Features/General/HideMetaAI.xm @@ -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]; } diff --git a/src/Features/General/HideMetrics.x b/src/Features/General/HideMetrics.x new file mode 100644 index 0000000..5aba28e --- /dev/null +++ b/src/Features/General/HideMetrics.x @@ -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 \ No newline at end of file diff --git a/src/Features/General/HideNotesTray.x b/src/Features/General/HideNotesTray.x new file mode 100644 index 0000000..e69de29 diff --git a/src/Features/General/HideSuggestedStories.x b/src/Features/General/HideSuggestedStories.x new file mode 100644 index 0000000..eb1a1df --- /dev/null +++ b/src/Features/General/HideSuggestedStories.x @@ -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 +#import +#import + +// 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(); }]; +} diff --git a/src/Features/General/HighlightCoverDownload.xm b/src/Features/General/HighlightCoverDownload.xm index 3870095..f210e42 100644 --- a/src/Features/General/HighlightCoverDownload.xm +++ b/src/Features/General/HighlightCoverDownload.xm @@ -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 #import #import -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]; diff --git a/src/Features/General/LaunchTab.x b/src/Features/General/LaunchTab.x new file mode 100644 index 0000000..d091f46 --- /dev/null +++ b/src/Features/General/LaunchTab.x @@ -0,0 +1,33 @@ +// Force launch into a chosen tab. Ignored while messages_only is active. + +#import "../../Utils.h" +#import "../../InstagramHeaders.h" +#import + +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 diff --git a/src/Features/General/MediaZoom.x b/src/Features/General/MediaZoom.x new file mode 100644 index 0000000..54384ef --- /dev/null +++ b/src/Features/General/MediaZoom.x @@ -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 +#import + +// 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 diff --git a/src/Features/General/MessagesOnly.x b/src/Features/General/MessagesOnly.x new file mode 100644 index 0000000..37e6a5d --- /dev/null +++ b/src/Features/General/MessagesOnly.x @@ -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 +#import + +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 diff --git a/src/Features/General/Navigation.xm b/src/Features/General/Navigation.xm index ccf9d04..bccbde8 100644 --- a/src/Features/General/Navigation.xm +++ b/src/Features/General/Navigation.xm @@ -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 \ No newline at end of file diff --git a/src/Features/General/NoRecentSearches.x b/src/Features/General/NoRecentSearches.x index 6f92159..ec5e889 100644 --- a/src/Features/General/NoRecentSearches.x +++ b/src/Features/General/NoRecentSearches.x @@ -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 \ No newline at end of file diff --git a/src/Features/General/NoSuggestedUsers.x b/src/Features/General/NoSuggestedUsers.x index f09facf..50a0c61 100644 --- a/src/Features/General/NoSuggestedUsers.x +++ b/src/Features/General/NoSuggestedUsers.x @@ -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)"); diff --git a/src/Features/General/NotesCustomization.x b/src/Features/General/NotesCustomization.x index 36d5bd9..7736419 100644 --- a/src/Features/General/NotesCustomization.x +++ b/src/Features/General/NotesCustomization.x @@ -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]]; diff --git a/src/Features/General/ProfileCopyButton.x b/src/Features/General/ProfileCopyButton.x index 5388052..74642e6 100644 --- a/src/Features/General/ProfileCopyButton.x +++ b/src/Features/General/ProfileCopyButton.x @@ -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; diff --git a/src/Features/General/SCIDateFormatEntries.h b/src/Features/General/SCIDateFormatEntries.h new file mode 100644 index 0000000..e2c126e --- /dev/null +++ b/src/Features/General/SCIDateFormatEntries.h @@ -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") diff --git a/src/Features/General/SCSettingsMenuEntry.x b/src/Features/General/SCSettingsMenuEntry.x index 460315a..914d4bb 100644 --- a/src/Features/General/SCSettingsMenuEntry.x +++ b/src/Features/General/SCSettingsMenuEntry.x @@ -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; diff --git a/src/Features/Media/MediaDownload.xm b/src/Features/Media/MediaDownload.xm index 3a35709..e279e22 100644 --- a/src/Features/Media/MediaDownload.xm +++ b/src/Features/Media/MediaDownload.xm @@ -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 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(self.delegate, "_configuration"); if (!_configuration) return; photo = MSHookIvar(_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(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(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(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] diff --git a/src/Features/Profile/FollowIndicator.x b/src/Features/Profile/FollowIndicator.x new file mode 100644 index 0000000..c3a02d1 --- /dev/null +++ b/src/Features/Profile/FollowIndicator.x @@ -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 +#import + +// 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 diff --git a/src/Features/Profile/ProfileNoteCopy.x b/src/Features/Profile/ProfileNoteCopy.x new file mode 100644 index 0000000..0992237 --- /dev/null +++ b/src/Features/Profile/ProfileNoteCopy.x @@ -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 + +// 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 diff --git a/src/Features/Reels/PasswordedReels.xm b/src/Features/Reels/PasswordedReels.xm index 73400d3..7c48d8f 100644 --- a/src/Features/Reels/PasswordedReels.xm +++ b/src/Features/Reels/PasswordedReels.xm @@ -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]; } diff --git a/src/Features/Reels/ReelsPlayback.xm b/src/Features/Reels/ReelsPlayback.xm index 2759bb5..d22fd7f 100644 --- a/src/Features/Reels/ReelsPlayback.xm +++ b/src/Features/Reels/ReelsPlayback.xm @@ -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); diff --git a/src/Features/StoriesAndMessages/DisableStorySeen.x b/src/Features/StoriesAndMessages/DisableStorySeen.x index 38cf960..a373c24 100644 --- a/src/Features/StoriesAndMessages/DisableStorySeen.x +++ b/src/Features/StoriesAndMessages/DisableStorySeen.x @@ -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 { diff --git a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm index 3aa1d95..73b3375 100644 --- a/src/Features/StoriesAndMessages/DownloadAudioMessage.xm +++ b/src/Features/StoriesAndMessages/DownloadAudioMessage.xm @@ -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]; }); diff --git a/src/Features/StoriesAndMessages/ExcludeFromSeen.x b/src/Features/StoriesAndMessages/ExcludeFromSeen.x index cd52370..0b037bb 100644 --- a/src/Features/StoriesAndMessages/ExcludeFromSeen.x +++ b/src/Features/StoriesAndMessages/ExcludeFromSeen.x @@ -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 diff --git a/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x b/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x index c507c11..f62168c 100644 --- a/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x +++ b/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x @@ -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); } diff --git a/src/Features/StoriesAndMessages/FullLastActive.x b/src/Features/StoriesAndMessages/FullLastActive.x new file mode 100644 index 0000000..06cb08e --- /dev/null +++ b/src/Features/StoriesAndMessages/FullLastActive.x @@ -0,0 +1,108 @@ +// Full last active — replaces "Active Xm ago" with full date in DM chats. + +#import "../../InstagramHeaders.h" +#import "../../Utils.h" +#import +#import + +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 diff --git a/src/Features/StoriesAndMessages/HideCallButtons.x b/src/Features/StoriesAndMessages/HideCallButtons.x new file mode 100644 index 0000000..77a6e6b --- /dev/null +++ b/src/Features/StoriesAndMessages/HideCallButtons.x @@ -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 diff --git a/src/Features/StoriesAndMessages/InboxRefreshWarning.x b/src/Features/StoriesAndMessages/InboxRefreshWarning.x index 6529021..ae3d5ba 100644 --- a/src/Features/StoriesAndMessages/InboxRefreshWarning.x +++ b/src/Features/StoriesAndMessages/InboxRefreshWarning.x @@ -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; diff --git a/src/Features/StoriesAndMessages/KeepDeletedMessages.x b/src/Features/StoriesAndMessages/KeepDeletedMessages.x index f7ed6a7..58dbb5e 100644 --- a/src/Features/StoriesAndMessages/KeepDeletedMessages.x +++ b/src/Features/StoriesAndMessages/KeepDeletedMessages.x @@ -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; diff --git a/src/Features/StoriesAndMessages/NotesActions.x b/src/Features/StoriesAndMessages/NotesActions.x new file mode 100644 index 0000000..80410b9 --- /dev/null +++ b/src/Features/StoriesAndMessages/NotesActions.x @@ -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 +#import +#import + +@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); +} diff --git a/src/Features/StoriesAndMessages/OverlayButtons.xm b/src/Features/StoriesAndMessages/OverlayButtons.xm index 470c148..c3f80aa 100644 --- a/src/Features/StoriesAndMessages/OverlayButtons.xm +++ b/src/Features/StoriesAndMessages/OverlayButtons.xm @@ -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 *)) { + 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]]; } } diff --git a/src/Features/StoriesAndMessages/SeenButtons.x b/src/Features/StoriesAndMessages/SeenButtons.x index f477723..62fc956 100644 --- a/src/Features/StoriesAndMessages/SeenButtons.x +++ b/src/Features/StoriesAndMessages/SeenButtons.x @@ -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 *)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 ============ diff --git a/src/Features/StoriesAndMessages/SeenOnStoryReply.x b/src/Features/StoriesAndMessages/SeenOnStoryReply.x new file mode 100644 index 0000000..08245d8 --- /dev/null +++ b/src/Features/StoriesAndMessages/SeenOnStoryReply.x @@ -0,0 +1,148 @@ +// Mark seen + advance when replying or reacting to a story. + +#import "../../Utils.h" +#import "StoryHelpers.h" +#import +#import +#import + +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(); +} diff --git a/src/Features/StoriesAndMessages/SendAudioAsFile.xm b/src/Features/StoriesAndMessages/SendAudioAsFile.xm index 9cba0cc..d29569d 100644 --- a/src/Features/StoriesAndMessages/SendAudioAsFile.xm +++ b/src/Features/StoriesAndMessages/SendAudioAsFile.xm @@ -5,6 +5,7 @@ #import "../../Utils.h" #import "../../InstagramHeaders.h" +#import "../../SCIFFmpeg.h" #import #import #import @@ -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 *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)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 *)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 diff --git a/src/Features/StoriesAndMessages/SendFile.x b/src/Features/StoriesAndMessages/SendFile.x new file mode 100644 index 0000000..ce0fe76 --- /dev/null +++ b/src/Features/StoriesAndMessages/SendFile.x @@ -0,0 +1,103 @@ +// Send files in DMs — adds a "Send File" option to the plus menu. + +#import "../../InstagramHeaders.h" +#import "../../Utils.h" +#import +#import + +static BOOL sciFileMenuPending = NO; +static __weak UIViewController *sciFileThreadVC = nil; + +@interface _SCIFilePickerDelegate : NSObject +@property (nonatomic, weak) UIViewController *threadVC; +@end + +static _SCIFilePickerDelegate *sciFilePickerDelegate = nil; + +@implementation _SCIFilePickerDelegate + +- (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)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 diff --git a/src/Features/StoriesAndMessages/StoryAudioToggle.xm b/src/Features/StoriesAndMessages/StoryAudioToggle.xm index 1b7f68e..e480db6 100644 --- a/src/Features/StoriesAndMessages/StoryAudioToggle.xm +++ b/src/Features/StoriesAndMessages/StoryAudioToggle.xm @@ -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; diff --git a/src/Features/StoriesAndMessages/StoryMentions.x b/src/Features/StoriesAndMessages/StoryMentions.x new file mode 100644 index 0000000..68fda3a --- /dev/null +++ b/src/Features/StoriesAndMessages/StoryMentions.x @@ -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 +#import + +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 +@property (nonatomic, strong) NSArray *userInfos; +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) NSString *currentUsername; +@property (nonatomic, strong) NSMutableDictionary *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]; +} diff --git a/src/InstagramHeaders.h b/src/InstagramHeaders.h index 3f04510..5fce058 100644 --- a/src/InstagramHeaders.h +++ b/src/InstagramHeaders.h @@ -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 + ///////////////////////////////////////////////////////////////////////////// diff --git a/src/Localization/Resources/en.lproj/Localizable.strings b/src/Localization/Resources/en.lproj/Localizable.strings new file mode 100644 index 0000000..5b4eac0 --- /dev/null +++ b/src/Localization/Resources/en.lproj/Localizable.strings @@ -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/.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"; + diff --git a/src/Localization/SCILocalization.h b/src/Localization/SCILocalization.h new file mode 100644 index 0000000..7fd9410 --- /dev/null +++ b/src/Localization/SCILocalization.h @@ -0,0 +1,38 @@ +#import + +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 *> *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)) diff --git a/src/Localization/SCILocalization.m b/src/Localization/SCILocalization.m new file mode 100644 index 0000000..de5da9f --- /dev/null +++ b/src/Localization/SCILocalization.m @@ -0,0 +1,99 @@ +#import "SCILocalization.h" +#import + +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 *shipped = [resource localizations]; + NSArray *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 *> *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; +} diff --git a/src/Networking/SCIInstagramAPI.h b/src/Networking/SCIInstagramAPI.h new file mode 100644 index 0000000..b258859 --- /dev/null +++ b/src/Networking/SCIInstagramAPI.h @@ -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 + +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 *)pks + completion:(nullable SCIAPIStatusesCompletion)completion; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Networking/SCIInstagramAPI.x b/src/Networking/SCIInstagramAPI.x new file mode 100644 index 0000000..a274264 --- /dev/null +++ b/src/Networking/SCIInstagramAPI.x @@ -0,0 +1,190 @@ +// Reusable IG private API helper. See SCIInstagramAPI.h. + +#import "SCIInstagramAPI.h" +#import +#import +#import +#import + +#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 *)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 diff --git a/src/SCIDashParser.h b/src/SCIDashParser.h new file mode 100644 index 0000000..b108fd4 --- /dev/null +++ b/src/SCIDashParser.h @@ -0,0 +1,33 @@ +// SCIDashParser — parses DASH MPD manifests from IGMedia for HD streams. + +#import + +@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 *)parseManifest:(NSString *)xmlString; ++ (SCIDashRepresentation *)bestVideoFromRepresentations:(NSArray *)reps; ++ (SCIDashRepresentation *)bestAudioFromRepresentations:(NSArray *)reps; ++ (NSArray *)videoRepresentations:(NSArray *)reps; ++ (SCIDashRepresentation *)representationForQuality:(SCIVideoQuality)quality + fromRepresentations:(NSArray *)reps; ++ (NSString *)dashManifestForMedia:(id)media; + +@end diff --git a/src/SCIDashParser.m b/src/SCIDashParser.m new file mode 100644 index 0000000..b13e868 --- /dev/null +++ b/src/SCIDashParser.m @@ -0,0 +1,217 @@ +#import "SCIDashParser.h" +#import +#import + +@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 *)parseManifest:(NSString *)xmlString { + if (!xmlString.length) return @[]; + + NSMutableArray *results = [NSMutableArray array]; + + NSError *err = nil; + + // AdaptationSet blocks (handles both contentType= and mimeType= patterns) + NSRegularExpression *adaptRE = [NSRegularExpression + regularExpressionWithPattern:@"(]*>)(.*?)" + 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:@"]*>" + options:0 error:nil]; + + NSRegularExpression *baseURLRE = [NSRegularExpression + regularExpressionWithPattern:@"(.*?)" + 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 *repMatches = + [repRE matchesInString:adaptBody options:0 range:NSMakeRange(0, adaptBody.length)]; + NSArray *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:@"&" 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 *)reps { + return [[self videoRepresentations:reps] firstObject]; +} + ++ (SCIDashRepresentation *)bestAudioFromRepresentations:(NSArray *)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 *)videoRepresentations:(NSArray *)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 *)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 diff --git a/src/SCIFFmpeg.h b/src/SCIFFmpeg.h new file mode 100644 index 0000000..e254701 --- /dev/null +++ b/src/SCIFFmpeg.h @@ -0,0 +1,40 @@ +// SCIFFmpeg — runtime FFmpegKit wrapper (loads dynamically via dlopen). + +#import + +@interface SCIFFmpeg : NSObject + ++ (BOOL)isAvailable; + +// Cancel any in-flight downloads and running FFmpeg sessions. ++ (void)cancelAll; ++ (BOOL)isCancelled; + ++ (void)executeCommand:(NSString *)command + completion:(void(^)(BOOL success, NSString *output))completion; + ++ (void)probeCommand:(NSString *)command + completion:(void(^)(BOOL success, NSString *output))completion; + ++ (void)muxVideoURL:(NSURL *)videoURL + audioURL:(NSURL *)audioURL + preset:(NSString *)preset + progress:(void(^)(float progress, NSString *stage))progressBlock + completion:(void(^)(NSURL *outputURL, NSError *error))completion; + +// Same as above but publishes a per-session cancel block via cancelOut (called once, +// synchronously or on main, before the mux starts). Tapping the pill's ticket cancel +// invokes this — cancels only THIS mux, not other in-flight downloads. ++ (void)muxVideoURL:(NSURL *)videoURL + audioURL:(NSURL *)audioURL + preset:(NSString *)preset + progress:(void(^)(float progress, NSString *stage))progressBlock + completion:(void(^)(NSURL *outputURL, NSError *error))completion + cancelOut:(void(^)(void (^cancelBlock)(void)))cancelOut; + ++ (void)convertAudioAtPath:(NSString *)inputPath + toFormat:(NSString *)format + bitrate:(NSString *)bitrate + completion:(void(^)(NSURL *outputURL, NSError *error))completion; + +@end diff --git a/src/SCIFFmpeg.m b/src/SCIFFmpeg.m new file mode 100644 index 0000000..6a8c73d --- /dev/null +++ b/src/SCIFFmpeg.m @@ -0,0 +1,597 @@ +#import "SCIFFmpeg.h" +#import +#import +#import +#import +#import + +static Class FFmpegKitClass = nil; +static Class FFmpegSessionClass = nil; +static Class ReturnCodeClass = nil; +static BOOL sciFFmpegLoaded = NO; +static BOOL sciFFmpegChecked = NO; + +// Cancellation state. All access to sciActiveURLSessions goes through sciCancelQueue. +static volatile int32_t sciCancelRequested = 0; +static NSHashTable *sciActiveURLSessions = nil; + +static dispatch_queue_t sciCancelQueue(void) { + static dispatch_queue_t q; + static dispatch_once_t once; + dispatch_once(&once, ^{ + q = dispatch_queue_create("com.ryuk.scinsta.ffmpeg.cancel", DISPATCH_QUEUE_SERIAL); + sciActiveURLSessions = [NSHashTable weakObjectsHashTable]; + }); + return q; +} + +static void sciRegisterSession(NSURLSession *session) { + if (!session) return; + dispatch_queue_t q = sciCancelQueue(); + dispatch_sync(q, ^{ [sciActiveURLSessions addObject:session]; }); +} + +static void sciUnregisterSession(NSURLSession *session) { + if (!session) return; + dispatch_queue_t q = sciCancelQueue(); + dispatch_sync(q, ^{ [sciActiveURLSessions removeObject:session]; }); +} + +static NSArray *sciActiveSessionsSnapshot(void) { + __block NSArray *out = @[]; + dispatch_queue_t q = sciCancelQueue(); + dispatch_sync(q, ^{ out = [sciActiveURLSessions allObjects] ?: @[]; }); + return out; +} + +// Resolve the directory our dylib lives in (works for any injection method) +static NSString *sciDylibDir(void) { + Dl_info info; + if (dladdr((void *)sciDylibDir, &info) && info.dli_fname) { + NSString *path = [[NSString stringWithUTF8String:info.dli_fname] stringByDeletingLastPathComponent]; + return path; + } + return nil; +} + +static void sciLoadFFmpegKit(void) { + if (sciFFmpegChecked) return; + sciFFmpegChecked = YES; + + NSMutableArray *paths = [NSMutableArray arrayWithArray:@[ + // Sideload (Feather): .bundle copied to app root + [[[NSBundle mainBundle] bundlePath] stringByAppendingPathComponent:@"RyukGram.bundle/ffmpegkit.framework/ffmpegkit"], + // Sideload (cyan): injected into Frameworks/ + [[[NSBundle mainBundle] privateFrameworksPath] stringByAppendingPathComponent:@"ffmpegkit.framework/ffmpegkit"], + // Jailbreak rootless + @"/var/jb/Library/Application Support/RyukGram.bundle/ffmpegkit.framework/ffmpegkit", + @"/var/jb/Library/MobileSubstrate/DynamicLibraries/ffmpegkit.framework/ffmpegkit", + // Jailbreak rootful + @"/Library/Application Support/RyukGram.bundle/ffmpegkit.framework/ffmpegkit", + @"/Library/MobileSubstrate/DynamicLibraries/ffmpegkit.framework/ffmpegkit", + ]]; + + // Relative to our own dylib + NSString *dylibDir = sciDylibDir(); + if (dylibDir) { + [paths insertObject:[dylibDir stringByAppendingPathComponent:@"ffmpegkit.framework/ffmpegkit"] atIndex:0]; + } + + NSFileManager *fm = [NSFileManager defaultManager]; + void *handle = NULL; + NSMutableArray *dlErrors = [NSMutableArray array]; + for (NSString *fwPath in paths) { + if (![fm fileExistsAtPath:fwPath]) continue; + + // Preload deps (renamed _sci dir, original binary name) + NSString *fwDir = [[fwPath stringByDeletingLastPathComponent] stringByDeletingLastPathComponent]; + NSArray *deps = @[@"libavutil", @"libswresample", @"libswscale", + @"libavcodec", @"libavformat", @"libavfilter", @"libavdevice"]; + for (NSString *dep in deps) { + // Try _sci first (sideload), then original (jailbreak) + NSString *sciPath = [NSString stringWithFormat:@"%@/%@_sci.framework/%@", fwDir, dep, dep]; + NSString *origPath = [NSString stringWithFormat:@"%@/%@.framework/%@", fwDir, dep, dep]; + if ([fm fileExistsAtPath:sciPath]) dlopen(sciPath.UTF8String, RTLD_NOW | RTLD_GLOBAL); + else if ([fm fileExistsAtPath:origPath]) dlopen(origPath.UTF8String, RTLD_NOW | RTLD_GLOBAL); + } + + handle = dlopen(fwPath.UTF8String, RTLD_NOW | RTLD_GLOBAL); + if (handle) { + NSLog(@"[SCInsta] FFmpegKit loaded from %@", fwPath); + break; + } + const char *err = dlerror(); + [dlErrors addObject:[NSString stringWithFormat:@"%@\n%s", [fwPath lastPathComponent], err ?: "unknown"]]; + } + + if (!handle) { + NSLog(@"[SCInsta] FFmpegKit not available"); + for (NSString *e in dlErrors) NSLog(@"[SCInsta] dlopen: %@", e); + + dispatch_async(dispatch_get_main_queue(), ^{ + NSMutableString *msg = [NSMutableString stringWithString:@"dlopen errors:\n"]; + for (NSString *e in dlErrors) [msg appendFormat:@"%@\n\n", e]; + [msg appendString:@"\nTried paths:\n"]; + NSFileManager *fm2 = [NSFileManager defaultManager]; + for (NSString *p in paths) { + BOOL exists = [fm2 fileExistsAtPath:p]; + [msg appendFormat:@"%@ %@\n", exists ? @"✓" : @"✗", [p lastPathComponent]]; + if (!exists) { + NSString *parent = [p stringByDeletingLastPathComponent]; + NSString *grandparent = [parent stringByDeletingLastPathComponent]; + [msg appendFormat:@" dir: %@ %@\n dir: %@ %@\n", + [fm2 fileExistsAtPath:parent] ? @"✓" : @"✗", [parent lastPathComponent], + [fm2 fileExistsAtPath:grandparent] ? @"✓" : @"✗", [grandparent lastPathComponent]]; + } + } + NSString *bundlePath = [[NSBundle mainBundle] bundlePath]; + NSArray *rootContents = [fm2 contentsOfDirectoryAtPath:bundlePath error:nil]; + [msg appendString:@"\nApp bundle root:\n"]; + for (NSString *item in rootContents) + if ([item containsString:@"RyukGram"] || [item containsString:@"ffmpeg"] || [item containsString:@".bundle"]) + [msg appendFormat:@" %@\n", item]; + NSString *fwPath = [[NSBundle mainBundle] privateFrameworksPath]; + NSArray *fwContents = [fm2 contentsOfDirectoryAtPath:fwPath error:nil]; + [msg appendString:@"\nFrameworks/:\n"]; + for (NSString *item in fwContents) + if ([item containsString:@"ffmpeg"] || [item containsString:@"libav"] || [item containsString:@"libsw"] || [item containsString:@"RyukGram"]) + [msg appendFormat:@" %@\n", item]; + + UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"FFmpegKit Debug") + message:msg preferredStyle:UIAlertControllerStyleAlert]; + NSString *copyMsg = [msg copy]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Copy") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) { + [UIPasteboard generalPasteboard].string = copyMsg; + }]]; + [alert addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + UIViewController *root = [UIApplication sharedApplication].keyWindow.rootViewController; + while (root.presentedViewController) root = root.presentedViewController; + [root presentViewController:alert animated:YES completion:nil]; + }); + }); + return; + } + + FFmpegKitClass = NSClassFromString(@"FFmpegKit"); + FFmpegSessionClass = NSClassFromString(@"FFmpegSession"); + ReturnCodeClass = NSClassFromString(@"ReturnCode"); + + if (FFmpegKitClass) { + sciFFmpegLoaded = YES; + NSLog(@"[SCInsta] FFmpegKit ready"); + } else { + NSLog(@"[SCInsta] FFmpegKit classes not found after dlopen"); + dlclose(handle); + } +} + +@implementation SCIFFmpeg + ++ (BOOL)isAvailable { + sciLoadFFmpegKit(); + return sciFFmpegLoaded; +} + ++ (BOOL)isCancelled { + return sciCancelRequested == 1; +} + ++ (void)cancelAll { + OSAtomicCompareAndSwap32(0, 1, &sciCancelRequested); + + for (NSURLSession *s in sciActiveSessionsSnapshot()) { + @try { [s invalidateAndCancel]; } @catch (__unused id e) {} + } + + // Class-level cancel stops any running FFmpeg session. + if (FFmpegKitClass) { + SEL cancelSel = NSSelectorFromString(@"cancel"); + if ([FFmpegKitClass respondsToSelector:cancelSel]) { + @try { ((void(*)(id, SEL))objc_msgSend)(FFmpegKitClass, cancelSel); } + @catch (__unused id e) {} + } + } + + // Grace period so the next download can proceed. + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + OSAtomicCompareAndSwap32(1, 0, &sciCancelRequested); + }); +} + ++ (void)executeCommand:(NSString *)command + completion:(void(^)(BOOL success, NSString *output))completion { + if (![self isAvailable]) { + if (completion) completion(NO, @"FFmpegKit not available"); + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + SEL executeSel = NSSelectorFromString(@"execute:"); + if (![FFmpegKitClass respondsToSelector:executeSel]) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(NO, @"FFmpegKit execute: not found"); + }); + return; + } + + id session = ((id(*)(id, SEL, id))objc_msgSend)(FFmpegKitClass, executeSel, command); + if (!session) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(NO, @"FFmpegKit session nil"); + }); + return; + } + + id returnCode = nil; + SEL rcSel = NSSelectorFromString(@"getReturnCode"); + if ([session respondsToSelector:rcSel]) { + returnCode = ((id(*)(id, SEL))objc_msgSend)(session, rcSel); + } + + BOOL success = NO; + if (ReturnCodeClass && returnCode) { + SEL isSuccessSel = NSSelectorFromString(@"isSuccess:"); + if ([ReturnCodeClass respondsToSelector:isSuccessSel]) { + success = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, returnCode); + } + } + + NSString *output = nil; + SEL outputSel = NSSelectorFromString(@"getOutput"); + if ([session respondsToSelector:outputSel]) { + output = ((id(*)(id, SEL))objc_msgSend)(session, outputSel); + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(success, output); + }); + } @catch (NSException *e) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(NO, [NSString stringWithFormat:@"Exception: %@", e.reason]); + }); + } + }); +} + ++ (void)probeCommand:(NSString *)command + completion:(void(^)(BOOL success, NSString *output))completion { + if (![self isAvailable]) { + if (completion) completion(NO, @"FFmpegKit not available"); + return; + } + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + @try { + Class probeClass = NSClassFromString(@"FFprobeKit"); + SEL executeSel = NSSelectorFromString(@"execute:"); + if (!probeClass || ![probeClass respondsToSelector:executeSel]) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(NO, @"FFprobeKit not found"); + }); + return; + } + + id session = ((id(*)(id, SEL, id))objc_msgSend)(probeClass, executeSel, command); + NSString *output = nil; + SEL outputSel = NSSelectorFromString(@"getOutput"); + if (session && [session respondsToSelector:outputSel]) { + output = ((id(*)(id, SEL))objc_msgSend)(session, outputSel); + } + + id returnCode = nil; + SEL rcSel = NSSelectorFromString(@"getReturnCode"); + if (session && [session respondsToSelector:rcSel]) { + returnCode = ((id(*)(id, SEL))objc_msgSend)(session, rcSel); + } + BOOL success = NO; + if (ReturnCodeClass && returnCode) { + SEL isSuccessSel = NSSelectorFromString(@"isSuccess:"); + if ([ReturnCodeClass respondsToSelector:isSuccessSel]) + success = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, returnCode); + } + + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(success, output); + }); + } @catch (NSException *e) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(NO, e.reason); + }); + } + }); +} + ++ (void)convertAudioAtPath:(NSString *)inputPath + toFormat:(NSString *)format + bitrate:(NSString *)bitrate + completion:(void(^)(NSURL *outputURL, NSError *error))completion { + if (![self isAvailable]) { + if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:1 + userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit not available"}]); + return; + } + + NSString *outputPath = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"sci_audio_%@.%@", [[NSUUID UUID] UUIDString], format]]; + + NSString *codecFlag; + if ([format isEqualToString:@"mp3"]) { + codecFlag = [NSString stringWithFormat:@"-c:a libmp3lame -b:a %@", bitrate ?: @"192k"]; + } else { + codecFlag = [NSString stringWithFormat:@"-c:a aac -b:a %@", bitrate ?: @"192k"]; + } + + NSString *cmd = [NSString stringWithFormat: + @"-y -hide_banner -loglevel error -i '%@' -vn -map a %@ '%@'", + inputPath, codecFlag, outputPath]; + + [self executeCommand:cmd completion:^(BOOL success, NSString *output) { + if (success && [[NSFileManager defaultManager] fileExistsAtPath:outputPath]) { + if (completion) completion([NSURL fileURLWithPath:outputPath], nil); + } else { + if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:4 + userInfo:@{NSLocalizedDescriptionKey: output ?: @"Audio conversion failed"}]); + } + }]; +} + ++ (void)muxVideoURL:(NSURL *)videoURL + audioURL:(NSURL *)audioURL + preset:(NSString *)preset + progress:(void(^)(float progress, NSString *stage))progressBlock + completion:(void(^)(NSURL *outputURL, NSError *error))completion { + [self muxVideoURL:videoURL audioURL:audioURL preset:preset + progress:progressBlock completion:completion cancelOut:nil]; +} + ++ (void)muxVideoURL:(NSURL *)videoURL + audioURL:(NSURL *)audioURL + preset:(NSString *)preset + progress:(void(^)(float progress, NSString *stage))progressBlock + completion:(void(^)(NSURL *outputURL, NSError *error))completion + cancelOut:(void(^)(void (^cancelBlock)(void)))cancelOut { + if (![self isAvailable]) { + if (completion) completion(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:1 + userInfo:@{NSLocalizedDescriptionKey: @"FFmpegKit not available"}]); + return; + } + + __block BOOL completionCalled = NO; + void (^finish)(NSURL *, NSError *) = ^(NSURL *url, NSError *err) { + if (completionCalled) return; + completionCalled = YES; + dispatch_async(dispatch_get_main_queue(), ^{ + if (completion) completion(url, err); + }); + }; + + // Per-call cancellation — scoped to this mux only. + __block volatile int32_t thisCancelled = 0; + __block NSURLSession *bgSessionRef = nil; + __block long ffmpegSidRef = 0; + BOOL (^isCancelledLocal)(void) = ^BOOL{ return thisCancelled == 1; }; + + void (^cancelSelf)(void) = ^{ + OSAtomicCompareAndSwap32(0, 1, &thisCancelled); + NSURLSession *s = bgSessionRef; + if (s) { @try { [s invalidateAndCancel]; } @catch (__unused id e) {} } + long sid = ffmpegSidRef; + if (sid && FFmpegKitClass) { + SEL cancelSel = NSSelectorFromString(@"cancel:"); + if ([FFmpegKitClass respondsToSelector:cancelSel]) { + @try { ((void(*)(id, SEL, long))objc_msgSend)(FFmpegKitClass, cancelSel, sid); } + @catch (__unused id e) {} + } + } + }; + if (cancelOut) cancelOut(cancelSelf); + + void (^report)(float, NSString *) = ^(float p, NSString *s) { + if (!progressBlock || isCancelledLocal()) return; + dispatch_async(dispatch_get_main_queue(), ^{ progressBlock(p, s); }); + }; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSString *tmpDir = NSTemporaryDirectory(); + NSString *videoPath = [tmpDir stringByAppendingPathComponent: + [NSString stringWithFormat:@"sci_video_%@.mp4", [[NSUUID UUID] UUIDString]]]; + NSString *audioPath = [tmpDir stringByAppendingPathComponent: + [NSString stringWithFormat:@"sci_audio_%@.m4a", [[NSUUID UUID] UUIDString]]]; + NSString *outputPath = [tmpDir stringByAppendingPathComponent: + [NSString stringWithFormat:@"sci_muxed_%@.mp4", [[NSUUID UUID] UUIDString]]]; + + NSError *(^cancelledError)(void) = ^NSError *{ + return [NSError errorWithDomain:@"SCIFFmpeg" code:NSUserCancelledError + userInfo:@{NSLocalizedDescriptionKey: @"Cancelled"}]; + }; + + void (^cleanupTmp)(void) = ^{ + NSFileManager *fm = [NSFileManager defaultManager]; + [fm removeItemAtPath:videoPath error:nil]; + [fm removeItemAtPath:audioPath error:nil]; + [fm removeItemAtPath:outputPath error:nil]; + }; + + report(0.0, @"Downloading video..."); + + dispatch_semaphore_t sem = dispatch_semaphore_create(0); + __block NSMutableData *videoAccum = [NSMutableData data]; + __block NSError *videoErr = nil; + + NSURLSession *bgSession = [NSURLSession sessionWithConfiguration: + [NSURLSessionConfiguration ephemeralSessionConfiguration]]; + bgSessionRef = bgSession; + sciRegisterSession(bgSession); + + NSURLSessionDownloadTask *videoTask = [bgSession downloadTaskWithURL:videoURL + completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) { + videoErr = err; + if (loc) videoAccum = [[NSMutableData alloc] initWithContentsOfURL:loc]; + dispatch_semaphore_signal(sem); + }]; + [videoTask resume]; + + while (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) { + if (isCancelledLocal()) { + [videoTask cancel]; + break; + } + int64_t received = videoTask.countOfBytesReceived; + int64_t expected = videoTask.countOfBytesExpectedToReceive; + if (expected > 0) { + float frac = (float)received / (float)expected; + report(frac * 0.8f, @"Downloading video..."); + } + } + + if (isCancelledLocal()) { + sciUnregisterSession(bgSession); + [bgSession invalidateAndCancel]; + cleanupTmp(); + finish(nil, cancelledError()); + return; + } + + if (!videoAccum.length) { + sciUnregisterSession(bgSession); + [bgSession invalidateAndCancel]; + cleanupTmp(); + NSString *desc = videoErr ? videoErr.localizedDescription : @"Empty response"; + finish(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:2 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Failed to download video: %@", desc]}]); + return; + } + [videoAccum writeToFile:videoPath atomically:YES]; + + report(0.8f, @"Downloading audio..."); + BOOL hasAudio = (audioURL != nil); + if (hasAudio) { + __block NSMutableData *audioAccum = nil; + __block NSURLSessionDownloadTask *audioTask = nil; + audioTask = [bgSession downloadTaskWithURL:audioURL + completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) { + if (loc) audioAccum = [[NSMutableData alloc] initWithContentsOfURL:loc]; + dispatch_semaphore_signal(sem); + }]; + [audioTask resume]; + + while (dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 200 * NSEC_PER_MSEC)) != 0) { + if (isCancelledLocal()) { [audioTask cancel]; break; } + } + + if (isCancelledLocal()) { + sciUnregisterSession(bgSession); + [bgSession invalidateAndCancel]; + cleanupTmp(); + finish(nil, cancelledError()); + return; + } + + if (audioAccum.length) { + [audioAccum writeToFile:audioPath atomically:YES]; + } else { + hasAudio = NO; + } + } + + sciUnregisterSession(bgSession); + [bgSession invalidateAndCancel]; + + report(0.9f, @"Encoding..."); + + // Encoding speed → videotoolbox bitrate + NSString *encFlags; + if ([preset isEqualToString:@"max"]) { + encFlags = @"-b:v 50M -profile:v high -level 5.1 -coder cabac"; + } else if ([preset isEqualToString:@"fast"]) { + encFlags = @"-b:v 20M"; + } else if ([preset isEqualToString:@"veryfast"]) { + encFlags = @"-b:v 12M"; + } else { + encFlags = @"-b:v 8M -realtime 1"; + } + + NSString *cmd; + if (hasAudio) { + cmd = [NSString stringWithFormat: + @"-y -hide_banner " + @"-analyzeduration 1M -probesize 1M -fflags +genpts " + @"-i '%@' -i '%@' " + @"-map 0:v:0 -map 1:a:0 " + @"-c:a copy -c:v h264_videotoolbox %@ -allow_sw 1 " + @"-movflags +faststart -shortest '%@'", + videoPath, audioPath, encFlags, outputPath]; + } else { + cmd = [NSString stringWithFormat: + @"-y -hide_banner " + @"-analyzeduration 1M -probesize 1M -fflags +genpts " + @"-i '%@' " + @"-c:v h264_videotoolbox %@ -allow_sw 1 " + @"-movflags +faststart '%@'", + videoPath, encFlags, outputPath]; + } + + // executeAsync returns the session synchronously so we can capture its id + // for per-session cancel. + __block BOOL ffSuccess = NO; + __block NSString *ffOutput = nil; + dispatch_semaphore_t ffSem = dispatch_semaphore_create(0); + + id (^ffCallback)(id) = ^id(id session) { + SEL rcSel = NSSelectorFromString(@"getReturnCode"); + if ([session respondsToSelector:rcSel]) { + id rc = ((id(*)(id, SEL))objc_msgSend)(session, rcSel); + if (ReturnCodeClass && rc) { + SEL isSuccessSel = NSSelectorFromString(@"isSuccess:"); + if ([ReturnCodeClass respondsToSelector:isSuccessSel]) + ffSuccess = ((BOOL(*)(id, SEL, id))objc_msgSend)(ReturnCodeClass, isSuccessSel, rc); + } + } + SEL outSel = NSSelectorFromString(@"getOutput"); + if ([session respondsToSelector:outSel]) + ffOutput = ((id(*)(id, SEL))objc_msgSend)(session, outSel); + dispatch_semaphore_signal(ffSem); + return nil; + }; + + SEL asyncSel = NSSelectorFromString(@"executeAsync:withCompleteCallback:"); + if ([FFmpegKitClass respondsToSelector:asyncSel]) { + id session = ((id(*)(id, SEL, id, id))objc_msgSend)(FFmpegKitClass, asyncSel, cmd, ffCallback); + SEL sidSel = NSSelectorFromString(@"getSessionId"); + if (session && [session respondsToSelector:sidSel]) { + ffmpegSidRef = ((long(*)(id, SEL))objc_msgSend)(session, sidSel); + } + dispatch_semaphore_wait(ffSem, DISPATCH_TIME_FOREVER); + } else { + // Fallback: synchronous execute (coarse cancel only). + [SCIFFmpeg executeCommand:cmd completion:^(BOOL ok, NSString *out) { + ffSuccess = ok; ffOutput = out; dispatch_semaphore_signal(ffSem); + }]; + dispatch_semaphore_wait(ffSem, DISPATCH_TIME_FOREVER); + } + + NSFileManager *fm = [NSFileManager defaultManager]; + [fm removeItemAtPath:videoPath error:nil]; + [fm removeItemAtPath:audioPath error:nil]; + + if (isCancelledLocal()) { + [fm removeItemAtPath:outputPath error:nil]; + finish(nil, cancelledError()); + return; + } + + if (ffSuccess && [fm fileExistsAtPath:outputPath]) { + finish([NSURL fileURLWithPath:outputPath], nil); + } else { + [fm removeItemAtPath:outputPath error:nil]; + finish(nil, [NSError errorWithDomain:@"SCIFFmpeg" code:3 + userInfo:@{NSLocalizedDescriptionKey: ffOutput ?: @"FFmpeg mux failed"}]); + } + }); +} + +@end diff --git a/src/SCIPrefix.h b/src/SCIPrefix.h new file mode 100644 index 0000000..18bde7d --- /dev/null +++ b/src/SCIPrefix.h @@ -0,0 +1,5 @@ +// Precompiled prefix for RyukGram — imported into every TU so SCILocalized +// is callable from every feature file without per-file imports. +#ifdef __OBJC__ +#import "Localization/SCILocalization.h" +#endif diff --git a/src/SCIQualityPicker.h b/src/SCIQualityPicker.h new file mode 100644 index 0000000..b9496eb --- /dev/null +++ b/src/SCIQualityPicker.h @@ -0,0 +1,15 @@ +// SCIQualityPicker — quality selection bottom sheet for HD downloads. + +#import +#import "SCIDashParser.h" + +@interface SCIQualityPicker : NSObject + +/// Show quality picker or auto-pick based on prefs. Returns NO if +/// enhanced downloads are off or no DASH manifest found (calls fallback). ++ (BOOL)pickQualityForMedia:(id)media + fromView:(UIView *)sourceView + picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked + fallback:(void(^)(void))fallback; + +@end diff --git a/src/SCIQualityPicker.m b/src/SCIQualityPicker.m new file mode 100644 index 0000000..a822c40 --- /dev/null +++ b/src/SCIQualityPicker.m @@ -0,0 +1,475 @@ +#import "SCIQualityPicker.h" +#import "SCIFFmpeg.h" +#import "Utils.h" +#import "InstagramHeaders.h" +#import +#import +#import + +// MARK: - Row cell + +@interface _SCIQualityCell : UITableViewCell +@property (nonatomic, strong) UIButton *playButton; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *subtitleLabel; +@property (nonatomic, strong) UIButton *menuButton; +@property (nonatomic, strong) UIActivityIndicatorView *spinner; +@end + +@implementation _SCIQualityCell + +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (!self) return nil; + + self.selectionStyle = UITableViewCellSelectionStyleDefault; + self.backgroundColor = [UIColor secondarySystemGroupedBackgroundColor]; + + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium]; + _playButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_playButton setImage:[UIImage systemImageNamed:@"play.fill" withConfiguration:cfg] forState:UIControlStateNormal]; + _playButton.tintColor = [UIColor labelColor]; + _playButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.contentView addSubview:_playButton]; + + _spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleMedium]; + _spinner.translatesAutoresizingMaskIntoConstraints = NO; + _spinner.hidesWhenStopped = YES; + [self.contentView addSubview:_spinner]; + + _titleLabel = [UILabel new]; + _titleLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightSemibold]; + _titleLabel.textColor = [UIColor labelColor]; + _titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.contentView addSubview:_titleLabel]; + + _subtitleLabel = [UILabel new]; + _subtitleLabel.font = [UIFont systemFontOfSize:11]; + _subtitleLabel.textColor = [UIColor secondaryLabelColor]; + _subtitleLabel.numberOfLines = 1; + _subtitleLabel.adjustsFontSizeToFitWidth = YES; + _subtitleLabel.minimumScaleFactor = 0.85; + _subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.contentView addSubview:_subtitleLabel]; + + UIImageSymbolConfiguration *menuCfg = [UIImageSymbolConfiguration configurationWithPointSize:17 weight:UIFontWeightMedium]; + _menuButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [_menuButton setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:menuCfg] forState:UIControlStateNormal]; + _menuButton.tintColor = [UIColor secondaryLabelColor]; + _menuButton.translatesAutoresizingMaskIntoConstraints = NO; + _menuButton.showsMenuAsPrimaryAction = YES; + [self.contentView addSubview:_menuButton]; + + [NSLayoutConstraint activateConstraints:@[ + [_playButton.leadingAnchor constraintEqualToAnchor:self.contentView.leadingAnchor constant:14], + [_playButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + [_playButton.widthAnchor constraintEqualToConstant:32], + [_playButton.heightAnchor constraintEqualToConstant:32], + + [_spinner.centerXAnchor constraintEqualToAnchor:_playButton.centerXAnchor], + [_spinner.centerYAnchor constraintEqualToAnchor:_playButton.centerYAnchor], + + [_titleLabel.leadingAnchor constraintEqualToAnchor:_playButton.trailingAnchor constant:12], + [_titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10], + + [_subtitleLabel.leadingAnchor constraintEqualToAnchor:_titleLabel.leadingAnchor], + [_subtitleLabel.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:2], + [_subtitleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_menuButton.leadingAnchor constant:-8], + + [_menuButton.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor constant:-10], + [_menuButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + [_menuButton.widthAnchor constraintEqualToConstant:32], + [_menuButton.heightAnchor constraintEqualToConstant:32], + ]]; + + return self; +} + +- (void)setLoading:(BOOL)loading { + if (loading) { + self.playButton.hidden = YES; + [self.spinner startAnimating]; + } else { + [self.spinner stopAnimating]; + self.playButton.hidden = NO; + } +} + +@end + +// MARK: - Sheet VC + +@interface _SCIQualitySheetVC : UIViewController +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UIButton *closeButton; +@property (nonatomic, strong) NSArray *videoReps; +@property (nonatomic, strong) SCIDashRepresentation *audioRep; +@property (nonatomic, strong) NSURL *standardURL; // progressive 720p +@property (nonatomic, copy) void (^onPickStandard)(void); +@property (nonatomic, copy) void (^onPickHD)(SCIDashRepresentation *video, SCIDashRepresentation *audio); +@end + +@implementation _SCIQualitySheetVC + +- (void)viewDidLoad { + [super viewDidLoad]; + + // Match the expanded-sheet grey so the initial state doesn't look glass-transparent. + UIColor *sheetGrey = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) { + return tc.userInterfaceStyle == UIUserInterfaceStyleDark + ? [UIColor colorWithRed:0.11 green:0.11 blue:0.12 alpha:1.0] + : [UIColor colorWithRed:0.95 green:0.95 blue:0.97 alpha:1.0]; + }]; + self.view.backgroundColor = sheetGrey; + self.view.opaque = YES; + + UIView *solidCard = [UIView new]; + solidCard.backgroundColor = sheetGrey; + solidCard.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:solidCard]; + [self.view sendSubviewToBack:solidCard]; + [NSLayoutConstraint activateConstraints:@[ + [solidCard.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [solidCard.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + [solidCard.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [solidCard.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + ]]; + + self.titleLabel = [UILabel new]; + self.titleLabel.text = SCILocalized(@"Download Quality"); + self.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + self.titleLabel.textColor = [UIColor labelColor]; + self.titleLabel.textAlignment = NSTextAlignmentCenter; + self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.titleLabel]; + + self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; + self.tableView.translatesAutoresizingMaskIntoConstraints = NO; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.backgroundColor = [UIColor clearColor]; + self.tableView.rowHeight = 56; + self.tableView.sectionHeaderTopPadding = 8; + [self.tableView registerClass:[_SCIQualityCell class] forCellReuseIdentifier:@"q"]; + [self.view addSubview:self.tableView]; + + [NSLayoutConstraint activateConstraints:@[ + [self.titleLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.titleLabel.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor constant:14], + + [self.tableView.topAnchor constraintEqualToAnchor:self.titleLabel.bottomAnchor constant:8], + [self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [self.tableView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + ]]; +} + +- (void)dismiss { [self dismissViewControllerAnimated:YES completion:nil]; } + +// MARK: - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; } +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { + return section == 0 ? 1 : (NSInteger)self.videoReps.count; +} + +- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section { + return section == 0 ? @"Standard" : @"HD"; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip { + _SCIQualityCell *cell = [tv dequeueReusableCellWithIdentifier:@"q" forIndexPath:ip]; + [cell setLoading:NO]; + + if (ip.section == 0) { + cell.titleLabel.text = SCILocalized(@"Standard"); + cell.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + cell.subtitleLabel.text = SCILocalized(@"720p • progressive • fastest"); + cell.playButton.hidden = (self.standardURL == nil); + cell.menuButton.hidden = (self.standardURL == nil); + cell.accessoryType = UITableViewCellAccessoryNone; + + cell.playButton.tag = -1; + [cell.playButton removeTarget:self action:NULL forControlEvents:UIControlEventTouchUpInside]; + [cell.playButton addTarget:self action:@selector(playStandardPreview:) forControlEvents:UIControlEventTouchUpInside]; + + cell.menuButton.menu = [self menuForStandard]; + } else { + SCIDashRepresentation *rep = self.videoReps[ip.row]; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.playButton.hidden = NO; + cell.menuButton.hidden = NO; + + NSString *label = rep.qualityLabel ?: @""; + if (rep.height > 0) { + NSInteger shortSide = MIN(rep.width, rep.height); + if (shortSide > 0) label = [NSString stringWithFormat:@"%ldp", (long)shortSide]; + } + + NSString *bw = rep.bandwidth > 1000000 + ? [NSString stringWithFormat:@"%.1f Mbps", rep.bandwidth / 1000000.0] + : [NSString stringWithFormat:@"%ld Kbps", (long)(rep.bandwidth / 1000)]; + cell.titleLabel.text = [NSString stringWithFormat:@"%@ • %@", label, bw]; + cell.titleLabel.font = [UIFont monospacedDigitSystemFontOfSize:15 weight:UIFontWeightSemibold]; + + NSMutableArray *parts = [NSMutableArray array]; + if (rep.width > 0 && rep.height > 0) + [parts addObject:[NSString stringWithFormat:@"%ld×%ld", (long)rep.width, (long)rep.height]]; + if (rep.frameRate > 0) + [parts addObject:[NSString stringWithFormat:@"%.0ffps", rep.frameRate]]; + if (rep.codecs.length) { + NSString *codec = [[rep.codecs componentsSeparatedByString:@"."] firstObject] ?: rep.codecs; + [parts addObject:codec]; + } + cell.subtitleLabel.text = [parts componentsJoinedByString:@" • "]; + + cell.playButton.tag = ip.row; + [cell.playButton removeTarget:self action:NULL forControlEvents:UIControlEventTouchUpInside]; + [cell.playButton addTarget:self action:@selector(playPreview:) forControlEvents:UIControlEventTouchUpInside]; + + cell.menuButton.menu = [self menuForRow:ip.row videoRep:rep]; + } + return cell; +} + +- (UIMenu *)menuForStandard { + NSURL *url = self.standardURL; + if (!url) return nil; + UIAction *copy = [UIAction actionWithTitle:SCILocalized(@"Copy video URL") + image:[UIImage systemImageNamed:@"video.fill"] + identifier:nil + handler:^(__unused UIAction *a) { + [UIPasteboard generalPasteboard].string = url.absoluteString; + }]; + return [UIMenu menuWithTitle:@"" children:@[copy]]; +} + +- (void)playStandardPreview:(UIButton *)sender { + NSURL *url = self.standardURL; + if (!url) return; + AVPlayerViewController *playerVC = [AVPlayerViewController new]; + playerVC.player = [AVPlayer playerWithURL:url]; + playerVC.modalPresentationStyle = UIModalPresentationOverFullScreen; + [self presentViewController:playerVC animated:YES completion:^{ [playerVC.player play]; }]; +} + +- (UIMenu *)menuForRow:(NSInteger)row videoRep:(SCIDashRepresentation *)videoRep { + NSURL *vURL = videoRep.url; + NSURL *aURL = self.audioRep.url; + + UIAction *copyV = [UIAction actionWithTitle:SCILocalized(@"Copy video URL") + image:[UIImage systemImageNamed:@"video.fill"] + identifier:nil + handler:^(__unused UIAction *a) { + if (vURL) [UIPasteboard generalPasteboard].string = vURL.absoluteString; + }]; + + NSMutableArray *items = [NSMutableArray arrayWithObject:copyV]; + if (aURL) { + UIAction *copyA = [UIAction actionWithTitle:SCILocalized(@"Copy audio URL") + image:[UIImage systemImageNamed:@"waveform"] + identifier:nil + handler:^(__unused UIAction *a) { + [UIPasteboard generalPasteboard].string = aURL.absoluteString; + }]; + [items addObject:copyA]; + } + + UIAction *copyMPD = [UIAction actionWithTitle:SCILocalized(@"Copy quality info") + image:[UIImage systemImageNamed:@"info.circle"] + identifier:nil + handler:^(__unused UIAction *a) { + NSString *info = [NSString stringWithFormat:@"%ldp — %ld×%ld — %.1f Mbps", + (long)MIN(videoRep.width, videoRep.height), + (long)videoRep.width, (long)videoRep.height, + videoRep.bandwidth / 1000000.0]; + [UIPasteboard generalPasteboard].string = info; + }]; + [items addObject:copyMPD]; + + return [UIMenu menuWithTitle:@"" children:items]; +} + +// MARK: - Selection + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip { + [tv deselectRowAtIndexPath:ip animated:YES]; + [self dismissViewControllerAnimated:YES completion:^{ + if (ip.section == 0) { + if (self.onPickStandard) self.onPickStandard(); + } else { + SCIDashRepresentation *rep = self.videoReps[ip.row]; + if (self.onPickHD) self.onPickHD(rep, self.audioRep); + } + }]; +} + +// MARK: - Preview + +- (void)playPreview:(UIButton *)sender { + NSInteger idx = sender.tag; + if (idx < 0 || idx >= (NSInteger)self.videoReps.count) return; + + _SCIQualityCell *cell = (_SCIQualityCell *)[self.tableView cellForRowAtIndexPath: + [NSIndexPath indexPathForRow:idx inSection:1]]; + [cell setLoading:YES]; + + SCIDashRepresentation *videoRep = self.videoReps[idx]; + NSURL *videoURL = videoRep.url; + NSURL *audioURL = self.audioRep.url; + + dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ + NSString *tmp = NSTemporaryDirectory(); + NSString *vPath = [tmp stringByAppendingPathComponent: + [NSString stringWithFormat:@"sci_preview_v_%@.mp4", [[NSUUID UUID] UUIDString]]]; + NSString *aPath = [tmp stringByAppendingPathComponent: + [NSString stringWithFormat:@"sci_preview_a_%@.m4a", [[NSUUID UUID] UUIDString]]]; + NSString *oPath = [tmp stringByAppendingPathComponent: + [NSString stringWithFormat:@"sci_preview_%@.mp4", [[NSUUID UUID] UUIDString]]]; + + NSData *vData = [NSURLConnection sendSynchronousRequest: + [NSURLRequest requestWithURL:videoURL] returningResponse:nil error:nil]; + if (!vData.length) { + dispatch_async(dispatch_get_main_queue(), ^{ [self restorePlayButton:idx]; }); + return; + } + [vData writeToFile:vPath atomically:YES]; + + NSString *cmd; + if (audioURL) { + NSData *aData = [NSURLConnection sendSynchronousRequest: + [NSURLRequest requestWithURL:audioURL] returningResponse:nil error:nil]; + if (aData.length) { + [aData writeToFile:aPath atomically:YES]; + cmd = [NSString stringWithFormat: + @"-y -hide_banner " + @"-analyzeduration 1M -probesize 1M -fflags +genpts " + @"-i '%@' -i '%@' -map 0:v:0 -map 1:a:0 " + @"-c:a copy -c:v h264_videotoolbox -b:v 8M -realtime 1 -allow_sw 1 " + @"-movflags +faststart -shortest '%@'", + vPath, aPath, oPath]; + } else { + cmd = [NSString stringWithFormat: + @"-y -hide_banner " + @"-analyzeduration 1M -probesize 1M -fflags +genpts " + @"-i '%@' -c:v h264_videotoolbox -b:v 8M -realtime 1 -allow_sw 1 " + @"-movflags +faststart '%@'", + vPath, oPath]; + } + } else { + cmd = [NSString stringWithFormat: + @"-y -hide_banner " + @"-analyzeduration 1M -probesize 1M -fflags +genpts " + @"-i '%@' -c:v h264_videotoolbox -b:v 8M -realtime 1 -allow_sw 1 " + @"-movflags +faststart '%@'", + vPath, oPath]; + } + + [SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) { + [[NSFileManager defaultManager] removeItemAtPath:vPath error:nil]; + [[NSFileManager defaultManager] removeItemAtPath:aPath error:nil]; + + if (success && [[NSFileManager defaultManager] fileExistsAtPath:oPath]) { + AVPlayerViewController *playerVC = [AVPlayerViewController new]; + playerVC.player = [AVPlayer playerWithURL:[NSURL fileURLWithPath:oPath]]; + playerVC.modalPresentationStyle = UIModalPresentationOverFullScreen; + [self presentViewController:playerVC animated:YES completion:^{ + [playerVC.player play]; + }]; + } + [self restorePlayButton:idx]; + }]; + }); +} + +- (void)restorePlayButton:(NSInteger)idx { + dispatch_async(dispatch_get_main_queue(), ^{ + _SCIQualityCell *cell = (_SCIQualityCell *)[self.tableView cellForRowAtIndexPath: + [NSIndexPath indexPathForRow:idx inSection:1]]; + [cell setLoading:NO]; + }); +} + +@end + + +// MARK: - Public API + +@implementation SCIQualityPicker + ++ (BOOL)pickQualityForMedia:(id)media + fromView:(UIView *)sourceView + picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked + fallback:(void(^)(void))fallback { + if (!media) { if (fallback) fallback(); return NO; } + + BOOL prefOn = [SCIUtils getBoolPref:@"enhance_download_quality"]; + BOOL ffmpegOK = [SCIFFmpeg isAvailable]; + if (!prefOn || !ffmpegOK) { if (fallback) fallback(); return NO; } + + BOOL isVideo = ([SCIUtils getVideoUrlForMedia:(IGMedia *)media] != nil); + if (!isVideo) { if (fallback) fallback(); return NO; } + + NSString *manifest = [SCIDashParser dashManifestForMedia:media]; + if (!manifest.length) { if (fallback) fallback(); return NO; } + + NSArray *allReps = [SCIDashParser parseManifest:manifest]; + NSArray *videoReps = [SCIDashParser videoRepresentations:allReps]; + SCIDashRepresentation *audioRep = [SCIDashParser bestAudioFromRepresentations:allReps]; + if (!videoReps.count) { if (fallback) fallback(); return NO; } + + NSString *qualityPref = [SCIUtils getStringPref:@"default_video_quality"]; + if (!qualityPref.length) qualityPref = @"always_ask"; + + if ([qualityPref isEqualToString:@"always_ask"]) { + NSURL *standardURL = [SCIUtils getVideoUrlForMedia:(IGMedia *)media]; + [self showSheetWithVideoReps:videoReps + audioRep:audioRep + standardURL:standardURL + picked:picked + fallback:fallback]; + } else { + SCIVideoQuality q = SCIVideoQualityHighest; + if ([qualityPref isEqualToString:@"medium"]) q = SCIVideoQualityMedium; + else if ([qualityPref isEqualToString:@"low"]) q = SCIVideoQualityLowest; + + SCIDashRepresentation *videoRep = [SCIDashParser representationForQuality:q fromRepresentations:allReps]; + if (picked) picked(videoRep, audioRep); + } + return YES; +} + ++ (void)showSheetWithVideoReps:(NSArray *)videoReps + audioRep:(SCIDashRepresentation *)audioRep + standardURL:(NSURL *)standardURL + picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked + fallback:(void(^)(void))fallback { + dispatch_async(dispatch_get_main_queue(), ^{ + _SCIQualitySheetVC *vc = [_SCIQualitySheetVC new]; + vc.videoReps = videoReps; + vc.audioRep = audioRep; + vc.standardURL = standardURL; + vc.onPickStandard = fallback; + vc.onPickHD = picked; + + vc.modalPresentationStyle = UIModalPresentationPageSheet; + + if (@available(iOS 15.0, *)) { + UISheetPresentationController *sheetPC = vc.sheetPresentationController; + sheetPC.detents = @[ + UISheetPresentationControllerDetent.mediumDetent, + UISheetPresentationControllerDetent.largeDetent, + ]; + SEL grabberSel = NSSelectorFromString(@"setPrefersGrabberIndicator:"); + if ([sheetPC respondsToSelector:grabberSel]) { + ((void(*)(id,SEL,BOOL))objc_msgSend)(sheetPC, grabberSel, YES); + } + sheetPC.prefersScrollingExpandsWhenScrolledToEdge = YES; + } + + [topMostController() presentViewController:vc animated:YES completion:nil]; + }); +} + +@end diff --git a/src/Settings/SCIDateFormatPickerVC.h b/src/Settings/SCIDateFormatPickerVC.h new file mode 100644 index 0000000..4067a83 --- /dev/null +++ b/src/Settings/SCIDateFormatPickerVC.h @@ -0,0 +1,8 @@ +#import + +@interface SCIDateFormatPickerVC : UIViewController + +/// Returns the formatted example string for the currently selected format. ++ (NSString *)currentFormatExample; + +@end diff --git a/src/Settings/SCIDateFormatPickerVC.m b/src/Settings/SCIDateFormatPickerVC.m new file mode 100644 index 0000000..42260a7 --- /dev/null +++ b/src/Settings/SCIDateFormatPickerVC.m @@ -0,0 +1,171 @@ +#import "SCIDateFormatPickerVC.h" +#import "../Utils.h" +#import "../Features/General/SCIDateFormatEntries.h" + +static NSString *const kFmtKey = @"feed_date_format"; +static NSString *const kSecKey = @"feed_date_show_seconds"; + +// [key, pattern, pattern_with_seconds] +static NSArray *sciDateFormatOptions(void) { + static NSArray *opts = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + opts = @[ + @[@"default", @"", @""], + @[@"short", @"MMM d", @"MMM d"], + @[@"medium", @"MMM d, yyyy", @"MMM d, yyyy"], + @[@"full", @"MMM d, yyyy 'at' h:mm a", @"MMM d, yyyy 'at' h:mm:ss a"], + @[@"time_12", @"MMM d 'at' h:mm a", @"MMM d 'at' h:mm:ss a"], + @[@"time_24", @"MMM d 'at' HH:mm", @"MMM d 'at' HH:mm:ss"], + @[@"dd_mmm", @"dd-MMM-yyyy 'at' h:mm a", @"dd-MMM-yyyy 'at' h:mm:ss a"], + @[@"day_slash", @"dd/MM/yyyy h:mm a", @"dd/MM/yyyy h:mm:ss a"], + @[@"month_slash", @"MM/dd/yyyy h:mm a", @"MM/dd/yyyy h:mm:ss a"], + @[@"euro", @"dd.MM.yyyy HH:mm", @"dd.MM.yyyy HH:mm:ss"], + @[@"iso", @"yyyy-MM-dd", @"yyyy-MM-dd"], + @[@"iso_time", @"yyyy-MM-dd HH:mm", @"yyyy-MM-dd HH:mm:ss"], + ]; + }); + return opts; +} + +// [pref_key, label] +static NSArray *> *sciSurfaceEntries(void) { + static NSArray *entries = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSMutableArray *m = [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + #define SCI_EMIT(NAME, SEL_, LABEL, ARITY, PREF) \ + if (strlen(LABEL) && ![seen containsObject:@PREF]) { \ + [seen addObject:@PREF]; \ + [m addObject:@[@PREF, @LABEL]]; \ + } + SCI_DATE_FORMAT_ENTRIES(SCI_EMIT) + #undef SCI_EMIT + entries = [m copy]; + }); + return entries; +} + +static NSDate *sciRefDate(void) { + static NSDate *ref = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ ref = [NSDate dateWithTimeIntervalSince1970:1736348730]; }); + return ref; +} + +static NSString *sciExampleForKey(NSString *key) { + if (!key.length || [key isEqualToString:@"default"]) return @"Default"; + BOOL sec = [[NSUserDefaults standardUserDefaults] boolForKey:kSecKey]; + for (NSArray *opt in sciDateFormatOptions()) { + if ([opt[0] isEqualToString:key]) { + NSString *pattern = sec ? opt[2] : opt[1]; + if (!pattern.length) return SCILocalized(@"Default"); + NSDateFormatter *df = [NSDateFormatter new]; + df.dateFormat = pattern; + return [df stringFromDate:sciRefDate()]; + } + } + return SCILocalized(@"Default"); +} + +@implementation SCIDateFormatPickerVC { + UITableView *_tableView; +} + ++ (NSString *)currentFormatExample { + return sciExampleForKey([SCIUtils getStringPref:kFmtKey]); +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = SCILocalized(@"Date Format"); + _tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped]; + _tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + _tableView.dataSource = self; + _tableView.delegate = self; + [self.view addSubview:_tableView]; +} + +// Sections: 0 = format options, 1 = show seconds, 2 = surface toggles +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 3; } + +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)s { + if (s == 0) return (NSInteger)sciDateFormatOptions().count; + if (s == 1) return 1; + return (NSInteger)sciSurfaceEntries().count; +} + +- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)s { + if (s == 0) return SCILocalized(@"Format"); + if (s == 2) return SCILocalized(@"Apply to"); + return @""; +} + +- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)s { + if (s == 2) return SCILocalized(@"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."); + return nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip { + if (ip.section == 1) { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"sec"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"sec"]; + cell.textLabel.text = SCILocalized(@"Show seconds"); + UISwitch *sw = [UISwitch new]; + sw.on = [[NSUserDefaults standardUserDefaults] boolForKey:kSecKey]; + [sw addTarget:self action:@selector(secondsToggled:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + if (ip.section == 2) { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"surf"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"surf"]; + NSArray *entry = sciSurfaceEntries()[ip.row]; + cell.textLabel.text = entry[1]; + cell.textLabel.numberOfLines = 0; + cell.textLabel.font = [UIFont systemFontOfSize:15]; + UISwitch *sw = [UISwitch new]; + sw.on = [[NSUserDefaults standardUserDefaults] boolForKey:entry[0]]; + sw.tag = ip.row; + [sw addTarget:self action:@selector(surfaceToggled:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"df"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"df"]; + + NSString *key = sciDateFormatOptions()[ip.row][0]; + cell.textLabel.text = sciExampleForKey(key); + cell.textLabel.font = [UIFont systemFontOfSize:16]; + + NSString *current = [SCIUtils getStringPref:kFmtKey]; + if (!current.length) current = @"default"; + cell.accessoryType = [current isEqualToString:key] + ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; + + return cell; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip { + [tv deselectRowAtIndexPath:ip animated:YES]; + if (ip.section != 0) return; + [[NSUserDefaults standardUserDefaults] setObject:sciDateFormatOptions()[ip.row][0] forKey:kFmtKey]; + [tv reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationNone]; +} + +- (void)secondsToggled:(UISwitch *)sw { + [[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:kSecKey]; + [_tableView reloadSections:[NSIndexSet indexSetWithIndex:0] withRowAnimation:UITableViewRowAnimationNone]; +} + +- (void)surfaceToggled:(UISwitch *)sw { + NSArray *entry = sciSurfaceEntries()[sw.tag]; + [[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:entry[0]]; +} + +@end diff --git a/src/Settings/SCIEmbedDomainViewController.m b/src/Settings/SCIEmbedDomainViewController.m index 080c1b7..eded9ee 100644 --- a/src/Settings/SCIEmbedDomainViewController.m +++ b/src/Settings/SCIEmbedDomainViewController.m @@ -16,7 +16,7 @@ static NSArray *sciPresetDomains(void) { - (void)viewDidLoad { [super viewDidLoad]; - self.title = @"Embed domain"; + self.title = SCILocalized(@"Embed domain"); self.view.backgroundColor = [UIColor systemBackgroundColor]; self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; @@ -44,7 +44,7 @@ static NSArray *sciPresetDomains(void) { } - (void)addCustom { - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Add custom domain" + UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Add custom domain") message:nil preferredStyle:UIAlertControllerStyleAlert]; [alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { @@ -53,7 +53,7 @@ static NSArray *sciPresetDomains(void) { tf.autocorrectionType = UITextAutocorrectionTypeNo; tf.keyboardType = UIKeyboardTypeURL; }]; - [alert addAction:[UIAlertAction actionWithTitle:@"Add" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Add") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { NSString *domain = alert.textFields.firstObject.text; domain = [domain stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; domain = [domain stringByReplacingOccurrencesOfString:@"https://" withString:@""]; @@ -66,7 +66,7 @@ static NSArray *sciPresetDomains(void) { [[NSUserDefaults standardUserDefaults] setObject:domain forKey:@"embed_link_domain"]; [self reload]; }]]; - [alert addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; [self presentViewController:alert animated:YES completion:nil]; } @@ -75,7 +75,7 @@ static NSArray *sciPresetDomains(void) { - (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; } - (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section { - return section == 0 ? @"Presets" : @"Custom"; + return section == 0 ? SCILocalized(@"Presets") : SCILocalized(@"Custom"); } - (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { @@ -108,7 +108,7 @@ static NSArray *sciPresetDomains(void) { if (indexPath.section == 0) return nil; NSString *domain = self.customDomains[indexPath.row]; UIContextualAction *del = [UIContextualAction - contextualActionWithStyle:UIContextualActionStyleDestructive title:@"Delete" + contextualActionWithStyle:UIContextualActionStyleDestructive title:SCILocalized(@"Delete") handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) { NSMutableArray *all = [self.customDomains mutableCopy]; [all removeObject:domain]; diff --git a/src/Settings/SCIExcludedChatsViewController.m b/src/Settings/SCIExcludedChatsViewController.m index a33d619..002c635 100644 --- a/src/Settings/SCIExcludedChatsViewController.m +++ b/src/Settings/SCIExcludedChatsViewController.m @@ -16,12 +16,12 @@ - (void)viewDidLoad { [super viewDidLoad]; - self.title = @"Chats"; + self.title = SCILocalized(@"Chats"); self.view.backgroundColor = [UIColor systemBackgroundColor]; self.searchBar = [[UISearchBar alloc] init]; self.searchBar.delegate = self; - self.searchBar.placeholder = @"Search by name or username"; + self.searchBar.placeholder = SCILocalized(@"Search by name or username"); [self.searchBar sizeToFit]; self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; @@ -51,7 +51,7 @@ initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"] style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)]; self.editBtn = [[UIBarButtonItem alloc] - initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)]; + initWithTitle:SCILocalized(@"Select") style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)]; self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn]; [self reload]; @@ -60,7 +60,7 @@ - (void)toggleEdit { BOOL entering = !self.tableView.isEditing; [self.tableView setEditing:entering animated:YES]; - self.editBtn.title = entering ? @"Done" : @"Select"; + self.editBtn.title = entering ? SCILocalized(@"Done") : SCILocalized(@"Select"); self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain; self.batchToolbar.hidden = !entering; if (entering) [self updateToolbar]; @@ -68,9 +68,9 @@ - (void)updateToolbar { UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; - UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)]; + UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:SCILocalized(@"Remove") style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)]; del.tintColor = [UIColor systemRedColor]; - UIBarButtonItem *kd = [[UIBarButtonItem alloc] initWithTitle:@"Keep-deleted" style:UIBarButtonItemStylePlain target:self action:@selector(batchKeepDeleted)]; + UIBarButtonItem *kd = [[UIBarButtonItem alloc] initWithTitle:SCILocalized(@"Keep-deleted") style:UIBarButtonItemStylePlain target:self action:@selector(batchKeepDeleted)]; self.batchToolbar.items = @[del, flex, kd]; } @@ -88,7 +88,7 @@ - (void)batchKeepDeleted { NSArray *sel = self.tableView.indexPathsForSelectedRows; if (!sel.count) return; - UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Set keep-deleted override" message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Set keep-deleted override") message:nil preferredStyle:UIAlertControllerStyleActionSheet]; void (^apply)(SCIKeepDeletedOverride) = ^(SCIKeepDeletedOverride mode) { for (NSIndexPath *ip in sel) { NSDictionary *e = self.filtered[ip.row]; @@ -97,22 +97,22 @@ [self toggleEdit]; [self reload]; }; - [sheet addAction:[UIAlertAction actionWithTitle:@"Follow default" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Follow default") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { apply(SCIKeepDeletedOverrideDefault); }]]; - [sheet addAction:[UIAlertAction actionWithTitle:@"Force ON (preserve unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Force ON (preserve unsends)") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { apply(SCIKeepDeletedOverrideIncluded); }]]; - [sheet addAction:[UIAlertAction actionWithTitle:@"Force OFF (allow unsends)" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Force OFF (allow unsends)") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { apply(SCIKeepDeletedOverrideExcluded); }]]; - [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; sheet.popoverPresentationController.barButtonItem = self.batchToolbar.items.lastObject; [self presentViewController:sheet animated:YES completion:nil]; } - (void)toggleSort { - UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by" + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by") message:nil preferredStyle:UIAlertControllerStyleActionSheet]; NSArray *titles = @[@"Recently added", @"Name (A–Z)"]; @@ -126,7 +126,7 @@ if (i == self.sortMode) [a setValue:@YES forKey:@"checked"]; [sheet addAction:a]; } - [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; sheet.popoverPresentationController.barButtonItem = self.sortBtn; [self presentViewController:sheet animated:YES completion:nil]; } @@ -155,7 +155,7 @@ } self.filtered = all; BOOL bs = [SCIExcludedThreads isBlockSelectedMode]; - NSString *label = bs ? @"Included chats" : @"Excluded chats"; + NSString *label = bs ? SCILocalized(@"Included chats") : SCILocalized(@"Excluded chats"); self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count]; [self.tableView reloadData]; } @@ -220,7 +220,7 @@ NSString *tid = e[@"threadId"]; UIContextualAction *del = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive - title:@"Remove" + title:SCILocalized(@"Remove") handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) { [SCIExcludedThreads removeThreadId:tid]; [self reload]; @@ -246,7 +246,7 @@ if (v == mode) a.state = UIMenuElementStateOn; return a; }; - UIMenu *kdMenu = [UIMenu menuWithTitle:@"Keep-deleted override" + UIMenu *kdMenu = [UIMenu menuWithTitle:SCILocalized(@"Keep-deleted override") image:[UIImage systemImageNamed:@"trash.slash"] identifier:nil options:0 @@ -255,7 +255,7 @@ kdAction(@"Force ON (preserve unsends)", SCIKeepDeletedOverrideIncluded), kdAction(@"Force OFF (allow unsends)", SCIKeepDeletedOverrideExcluded), ]]; - UIAction *remove = [UIAction actionWithTitle:@"Remove from list" + UIAction *remove = [UIAction actionWithTitle:SCILocalized(@"Remove from list") image:[UIImage systemImageNamed:@"trash"] identifier:nil handler:^(__kindof UIAction *_) { @@ -273,8 +273,8 @@ SCIKeepDeletedOverride mode = [e[@"keepDeletedOverride"] integerValue]; SCIKeepDeletedOverride next = (mode + 1) % 3; NSString *title = (next == SCIKeepDeletedOverrideExcluded) ? @"KD: OFF" - : (next == SCIKeepDeletedOverrideIncluded) ? @"KD: ON" - : @"KD: default"; + : (next == SCIKeepDeletedOverrideIncluded) ? SCILocalized(@"KD: ON") + : SCILocalized(@"KD: default"); UIContextualAction *toggle = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleNormal title:title diff --git a/src/Settings/SCIExcludedStoryUsersViewController.m b/src/Settings/SCIExcludedStoryUsersViewController.m index 14fdd3f..88ae2fb 100644 --- a/src/Settings/SCIExcludedStoryUsersViewController.m +++ b/src/Settings/SCIExcludedStoryUsersViewController.m @@ -16,12 +16,12 @@ - (void)viewDidLoad { [super viewDidLoad]; - self.title = @"Story users"; + self.title = SCILocalized(@"Story users"); self.view.backgroundColor = [UIColor systemBackgroundColor]; self.searchBar = [[UISearchBar alloc] init]; self.searchBar.delegate = self; - self.searchBar.placeholder = @"Search by username or name"; + self.searchBar.placeholder = SCILocalized(@"Search by username or name"); [self.searchBar sizeToFit]; self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; @@ -51,7 +51,7 @@ initWithImage:[UIImage systemImageNamed:@"arrow.up.arrow.down"] style:UIBarButtonItemStylePlain target:self action:@selector(toggleSort)]; self.editBtn = [[UIBarButtonItem alloc] - initWithTitle:@"Select" style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)]; + initWithTitle:SCILocalized(@"Select") style:UIBarButtonItemStylePlain target:self action:@selector(toggleEdit)]; self.navigationItem.rightBarButtonItems = @[self.editBtn, self.sortBtn]; [self reload]; @@ -60,12 +60,12 @@ - (void)toggleEdit { BOOL entering = !self.tableView.isEditing; [self.tableView setEditing:entering animated:YES]; - self.editBtn.title = entering ? @"Done" : @"Select"; + self.editBtn.title = entering ? SCILocalized(@"Done") : SCILocalized(@"Select"); self.editBtn.style = entering ? UIBarButtonItemStyleDone : UIBarButtonItemStylePlain; self.batchToolbar.hidden = !entering; if (entering) { UIBarButtonItem *flex = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemFlexibleSpace target:nil action:nil]; - UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:@"Remove Selected" style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)]; + UIBarButtonItem *del = [[UIBarButtonItem alloc] initWithTitle:SCILocalized(@"Remove Selected") style:UIBarButtonItemStylePlain target:self action:@selector(removeSelected)]; del.tintColor = [UIColor systemRedColor]; self.batchToolbar.items = @[flex, del, flex]; } @@ -83,7 +83,7 @@ } - (void)toggleSort { - UIAlertController *sheet = [UIAlertController alertControllerWithTitle:@"Sort by" + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by") message:nil preferredStyle:UIAlertControllerStyleActionSheet]; NSArray *titles = @[@"Recently added", @"Username (A–Z)"]; @@ -97,7 +97,7 @@ if (i == self.sortMode) [a setValue:@YES forKey:@"checked"]; [sheet addAction:a]; } - [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; sheet.popoverPresentationController.barButtonItem = self.sortBtn; [self presentViewController:sheet animated:YES completion:nil]; } @@ -123,7 +123,7 @@ } self.filtered = all; BOOL bs = [SCIExcludedStoryUsers isBlockSelectedMode]; - NSString *label = bs ? @"Included users" : @"Excluded users"; + NSString *label = bs ? SCILocalized(@"Included users") : SCILocalized(@"Excluded users"); self.title = [NSString stringWithFormat:@"%@ (%lu)", label, (unsigned long)self.filtered.count]; [self.tableView reloadData]; } @@ -169,7 +169,7 @@ NSString *pk = e[@"pk"]; UIContextualAction *del = [UIContextualAction contextualActionWithStyle:UIContextualActionStyleDestructive - title:@"Remove" + title:SCILocalized(@"Remove") handler:^(UIContextualAction *_, UIView *__, void (^cb)(BOOL)) { [SCIExcludedStoryUsers removePK:pk]; [self reload]; diff --git a/src/Settings/SCIFakeLocationPickerVC.h b/src/Settings/SCIFakeLocationPickerVC.h new file mode 100644 index 0000000..e5b7398 --- /dev/null +++ b/src/Settings/SCIFakeLocationPickerVC.h @@ -0,0 +1,12 @@ +// Map picker — long-press to drop a draggable pin, search suggestions via MKLocalSearchCompleter. + +#import +#import + +@interface SCIFakeLocationPickerVC : UIViewController + +@property (nonatomic, copy) void (^onPick)(double lat, double lon, NSString *name); +@property (nonatomic, assign) CLLocationCoordinate2D initialCoord; +@property (nonatomic, copy) NSString *titleText; + +@end diff --git a/src/Settings/SCIFakeLocationPickerVC.m b/src/Settings/SCIFakeLocationPickerVC.m new file mode 100644 index 0000000..96ba355 --- /dev/null +++ b/src/Settings/SCIFakeLocationPickerVC.m @@ -0,0 +1,388 @@ +#import "SCIFakeLocationPickerVC.h" +#import +#import "../Localization/SCILocalization.h" + +#pragma mark - Search results + +@interface SCIFakeLocationSearchResultsVC : UITableViewController +@property (nonatomic, strong) MKLocalSearchCompleter *completer; +@property (nonatomic, copy) NSArray *results; +@property (nonatomic, copy) void (^onSelect)(MKLocalSearchCompletion *completion); +@property (nonatomic, assign) MKCoordinateRegion region; +@end + +@implementation SCIFakeLocationSearchResultsVC + +- (instancetype)init { + self = [super initWithStyle:UITableViewStylePlain]; + if (self) { + self.completer = [MKLocalSearchCompleter new]; + self.completer.delegate = self; + self.results = @[]; + } + return self; +} + +- (void)setRegion:(MKCoordinateRegion)region { + _region = region; + if (CLLocationCoordinate2DIsValid(region.center)) self.completer.region = region; +} + +- (void)setQuery:(NSString *)q { + if (!q.length) { self.results = @[]; [self.tableView reloadData]; return; } + self.completer.queryFragment = q; +} + +- (void)completerDidUpdateResults:(MKLocalSearchCompleter *)c { + self.results = c.results; + [self.tableView reloadData]; +} + +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)s { return self.results.count; } + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"r"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"r"]; + MKLocalSearchCompletion *r = self.results[ip.row]; + cell.textLabel.text = r.title; + cell.detailTextLabel.text = r.subtitle; + cell.imageView.image = [UIImage systemImageNamed:@"mappin.circle"]; + cell.imageView.tintColor = [UIColor systemRedColor]; + return cell; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip { + [tv deselectRowAtIndexPath:ip animated:YES]; + if (self.onSelect) self.onSelect(self.results[ip.row]); +} + +@end + +#pragma mark - Picker + +@interface SCIFakeLocationPickerVC () +@property (nonatomic, strong) MKMapView *mapView; +@property (nonatomic, strong) MKPointAnnotation *pin; +@property (nonatomic, strong) UISearchController *searchController; +@property (nonatomic, strong) SCIFakeLocationSearchResultsVC *resultsVC; +@property (nonatomic, strong) UIButton *locateButton; +@property (nonatomic, strong) UIVisualEffectView *cardView; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *subtitleLabel; +@property (nonatomic, strong) UIButton *useButton; +@property (nonatomic, copy) NSString *resolvedName; +@property (nonatomic, strong) CLLocationManager *locationManager; +@property (nonatomic, assign) BOOL didRequestAuth; +@end + +@implementation SCIFakeLocationPickerVC + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + self.title = self.titleText.length ? self.titleText : SCILocalized(@"Pick location"); + + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] + initWithBarButtonSystemItem:UIBarButtonSystemItemCancel target:self action:@selector(cancel)]; + + self.locationManager = [CLLocationManager new]; + self.locationManager.delegate = self; + + [self setupMap]; + [self setupSearch]; + [self setupLocateButton]; + [self setupCard]; + + CLLocationCoordinate2D coord = CLLocationCoordinate2DIsValid(self.initialCoord) + ? self.initialCoord : CLLocationCoordinate2DMake(48.8584, 2.2945); + [self.mapView setRegion:MKCoordinateRegionMakeWithDistance(coord, 1500, 1500) animated:NO]; + self.resultsVC.region = self.mapView.region; + + if (CLLocationCoordinate2DIsValid(self.initialCoord)) { + [self dropPinAt:self.initialCoord name:nil reverseGeocode:YES]; + } +} + +#pragma mark - Setup + +- (void)setupMap { + self.mapView = [[MKMapView alloc] initWithFrame:self.view.bounds]; + self.mapView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.mapView.delegate = self; + self.mapView.showsUserLocation = YES; + self.mapView.showsCompass = YES; + [self.view addSubview:self.mapView]; + + UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc] + initWithTarget:self action:@selector(onLongPress:)]; + lp.minimumPressDuration = 0.35; + [self.mapView addGestureRecognizer:lp]; +} + +- (void)setupSearch { + self.resultsVC = [SCIFakeLocationSearchResultsVC new]; + __weak typeof(self) weakSelf = self; + self.resultsVC.onSelect = ^(MKLocalSearchCompletion *r) { [weakSelf performSearchForCompletion:r]; }; + + UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:self.resultsVC]; + sc.searchResultsUpdater = self; + sc.delegate = self; + sc.obscuresBackgroundDuringPresentation = YES; + sc.searchBar.placeholder = SCILocalized(@"Search address or place"); + self.navigationItem.searchController = sc; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.definesPresentationContext = YES; + self.searchController = sc; +} + +- (void)setupLocateButton { + self.locateButton = [UIButton buttonWithType:UIButtonTypeSystem]; + self.locateButton.backgroundColor = [UIColor secondarySystemBackgroundColor]; + self.locateButton.tintColor = [UIColor systemBlueColor]; + [self.locateButton setImage:[UIImage systemImageNamed:@"location"] forState:UIControlStateNormal]; + self.locateButton.layer.cornerRadius = 8; + self.locateButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.locateButton addTarget:self action:@selector(onLocateTap) forControlEvents:UIControlEventTouchUpInside]; + [self.view addSubview:self.locateButton]; + + [NSLayoutConstraint activateConstraints:@[ + [self.locateButton.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor constant:-12], + [self.locateButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-140], + [self.locateButton.widthAnchor constraintEqualToConstant:40], + [self.locateButton.heightAnchor constraintEqualToConstant:40], + ]]; +} + +- (void)onLocateTap { + CLAuthorizationStatus status = self.locationManager.authorizationStatus; + if (status == kCLAuthorizationStatusNotDetermined) { + self.didRequestAuth = YES; + [self.locationManager requestWhenInUseAuthorization]; + return; + } + if (status == kCLAuthorizationStatusDenied || status == kCLAuthorizationStatusRestricted) { + [self showLocationDeniedAlert]; + return; + } + if (!CLLocationManager.locationServicesEnabled) { + [self showServicesDisabledAlert]; + return; + } + self.mapView.showsUserLocation = YES; + self.mapView.userTrackingMode = MKUserTrackingModeFollow; + CLLocation *loc = self.mapView.userLocation.location ?: self.locationManager.location; + if (loc) { + [self.mapView setRegion:MKCoordinateRegionMakeWithDistance(loc.coordinate, 800, 800) animated:YES]; + } +} + +- (void)showLocationDeniedAlert { + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Location access denied") + message:SCILocalized(@"Enable Location Services for Instagram in Settings to use your current location.") + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Open Settings") style:UIAlertActionStyleDefault handler:^(UIAlertAction *x) { + [[UIApplication sharedApplication] openURL:[NSURL URLWithString:UIApplicationOpenSettingsURLString] options:@{} completionHandler:nil]; + }]]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)showServicesDisabledAlert { + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Location Services off") + message:SCILocalized(@"Turn Location Services on in Settings → Privacy to use your current location.") + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleCancel handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)locationManagerDidChangeAuthorization:(CLLocationManager *)manager { + CLAuthorizationStatus s = manager.authorizationStatus; + if (!self.didRequestAuth) return; + self.didRequestAuth = NO; + if (s == kCLAuthorizationStatusAuthorizedWhenInUse || s == kCLAuthorizationStatusAuthorizedAlways) { + [self onLocateTap]; + } else if (s == kCLAuthorizationStatusDenied || s == kCLAuthorizationStatusRestricted) { + [self showLocationDeniedAlert]; + } +} + +- (void)setupCard { + UIBlurEffect *blur = [UIBlurEffect effectWithStyle:UIBlurEffectStyleSystemThickMaterial]; + self.cardView = [[UIVisualEffectView alloc] initWithEffect:blur]; + self.cardView.layer.cornerRadius = 16; + self.cardView.layer.cornerCurve = kCACornerCurveContinuous; + self.cardView.clipsToBounds = YES; + self.cardView.translatesAutoresizingMaskIntoConstraints = NO; + self.cardView.hidden = YES; + [self.view addSubview:self.cardView]; + + self.titleLabel = [UILabel new]; + self.titleLabel.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + self.titleLabel.numberOfLines = 1; + self.titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + + self.subtitleLabel = [UILabel new]; + self.subtitleLabel.font = [UIFont monospacedDigitSystemFontOfSize:13 weight:UIFontWeightRegular]; + self.subtitleLabel.textColor = [UIColor secondaryLabelColor]; + self.subtitleLabel.numberOfLines = 1; + self.subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + + self.useButton = [UIButton buttonWithType:UIButtonTypeSystem]; + [self.useButton setTitle:SCILocalized(@"Use this location") forState:UIControlStateNormal]; + self.useButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + self.useButton.backgroundColor = [UIColor systemBlueColor]; + [self.useButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.useButton.layer.cornerRadius = 12; + self.useButton.translatesAutoresizingMaskIntoConstraints = NO; + [self.useButton addTarget:self action:@selector(commit) forControlEvents:UIControlEventTouchUpInside]; + + UIView *content = self.cardView.contentView; + [content addSubview:self.titleLabel]; + [content addSubview:self.subtitleLabel]; + [content addSubview:self.useButton]; + + [NSLayoutConstraint activateConstraints:@[ + [self.cardView.leadingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.leadingAnchor constant:12], + [self.cardView.trailingAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.trailingAnchor constant:-12], + [self.cardView.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-12], + + [self.titleLabel.topAnchor constraintEqualToAnchor:content.topAnchor constant:14], + [self.titleLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:16], + [self.titleLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-16], + + [self.subtitleLabel.topAnchor constraintEqualToAnchor:self.titleLabel.bottomAnchor constant:2], + [self.subtitleLabel.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:16], + [self.subtitleLabel.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-16], + + [self.useButton.topAnchor constraintEqualToAnchor:self.subtitleLabel.bottomAnchor constant:12], + [self.useButton.leadingAnchor constraintEqualToAnchor:content.leadingAnchor constant:12], + [self.useButton.trailingAnchor constraintEqualToAnchor:content.trailingAnchor constant:-12], + [self.useButton.bottomAnchor constraintEqualToAnchor:content.bottomAnchor constant:-12], + [self.useButton.heightAnchor constraintEqualToConstant:46], + ]]; +} + +#pragma mark - Pin + +- (void)onLongPress:(UILongPressGestureRecognizer *)g { + if (g.state != UIGestureRecognizerStateBegan) return; + CGPoint p = [g locationInView:self.mapView]; + CLLocationCoordinate2D c = [self.mapView convertPoint:p toCoordinateFromView:self.mapView]; + [self dropPinAt:c name:nil reverseGeocode:YES]; +} + +- (void)dropPinAt:(CLLocationCoordinate2D)coord name:(NSString *)name reverseGeocode:(BOOL)resolve { + if (self.pin) [self.mapView removeAnnotation:self.pin]; + self.pin = [MKPointAnnotation new]; + self.pin.coordinate = coord; + self.pin.title = name; + [self.mapView addAnnotation:self.pin]; + [self.mapView selectAnnotation:self.pin animated:YES]; + self.resolvedName = name; + [self updateCard]; + + if (resolve && !name.length) { + CLLocation *loc = [[CLLocation alloc] initWithLatitude:coord.latitude longitude:coord.longitude]; + CLGeocoder *g = [CLGeocoder new]; + [g reverseGeocodeLocation:loc completionHandler:^(NSArray *pm, NSError *err) { + if (err || !pm.count) return; + CLPlacemark *p = pm.firstObject; + NSString *resolved = p.name ?: p.locality ?: p.country; + if (!resolved.length) return; + if (!self.pin || + fabs(self.pin.coordinate.latitude - coord.latitude) > 0.0001 || + fabs(self.pin.coordinate.longitude - coord.longitude) > 0.0001) return; + self.resolvedName = resolved; + self.pin.title = resolved; + [self updateCard]; + }]; + } +} + +- (void)updateCard { + if (!self.pin) { self.cardView.hidden = YES; return; } + self.cardView.hidden = NO; + CLLocationCoordinate2D c = self.pin.coordinate; + self.titleLabel.text = self.resolvedName.length ? self.resolvedName : SCILocalized(@"Dropped pin"); + self.subtitleLabel.text = [NSString stringWithFormat:@"%.5f, %.5f", c.latitude, c.longitude]; +} + +#pragma mark - Search + +- (void)updateSearchResultsForSearchController:(UISearchController *)sc { + self.resultsVC.region = self.mapView.region; + [self.resultsVC setQuery:sc.searchBar.text]; +} + +- (void)performSearchForCompletion:(MKLocalSearchCompletion *)completion { + MKLocalSearchRequest *req = [[MKLocalSearchRequest alloc] initWithCompletion:completion]; + MKLocalSearch *search = [[MKLocalSearch alloc] initWithRequest:req]; + [search startWithCompletionHandler:^(MKLocalSearchResponse *resp, NSError *err) { + if (err || !resp.mapItems.count) return; + MKMapItem *item = resp.mapItems.firstObject; + CLLocationCoordinate2D c = item.placemark.coordinate; + NSString *name = item.name ?: completion.title; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.searchController setActive:NO]; + [self.mapView setRegion:MKCoordinateRegionMakeWithDistance(c, 1500, 1500) animated:YES]; + [self dropPinAt:c name:name reverseGeocode:NO]; + }); + }]; +} + +#pragma mark - Map delegate (draggable pin) + +- (MKAnnotationView *)mapView:(MKMapView *)mapView viewForAnnotation:(id)annotation { + if ([annotation isKindOfClass:[MKUserLocation class]]) return nil; + static NSString *kID = @"scipin"; + MKMarkerAnnotationView *v = (MKMarkerAnnotationView *)[mapView dequeueReusableAnnotationViewWithIdentifier:kID]; + if (!v) { + v = [[MKMarkerAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:kID]; + } else { + v.annotation = annotation; + } + v.draggable = YES; + v.canShowCallout = YES; + v.markerTintColor = [UIColor systemRedColor]; + v.animatesWhenAdded = YES; + return v; +} + +- (void)mapView:(MKMapView *)mapView annotationView:(MKAnnotationView *)view + didChangeDragState:(MKAnnotationViewDragState)newState fromOldState:(MKAnnotationViewDragState)oldState { + if (newState == MKAnnotationViewDragStateEnding) { + view.dragState = MKAnnotationViewDragStateNone; + CLLocationCoordinate2D c = view.annotation.coordinate; + self.resolvedName = nil; + [self updateCard]; + CLLocation *loc = [[CLLocation alloc] initWithLatitude:c.latitude longitude:c.longitude]; + [[CLGeocoder new] reverseGeocodeLocation:loc completionHandler:^(NSArray *pm, NSError *err) { + if (err || !pm.count || !self.pin) return; + if (fabs(self.pin.coordinate.latitude - c.latitude) > 0.0001 || + fabs(self.pin.coordinate.longitude - c.longitude) > 0.0001) return; + CLPlacemark *p = pm.firstObject; + NSString *name = p.name ?: p.locality ?: p.country; + if (!name.length) return; + self.resolvedName = name; + self.pin.title = name; + [self updateCard]; + }]; + } +} + +#pragma mark - Actions + +- (void)cancel { [self dismissViewControllerAnimated:YES completion:nil]; } + +- (void)commit { + if (!self.pin) return; + CLLocationCoordinate2D c = self.pin.coordinate; + NSString *name = self.resolvedName.length ? self.resolvedName + : [NSString stringWithFormat:@"%.4f, %.4f", c.latitude, c.longitude]; + void (^cb)(double, double, NSString *) = self.onPick; + [self dismissViewControllerAnimated:YES completion:^{ + if (cb) cb(c.latitude, c.longitude, name); + }]; +} + +@end diff --git a/src/Settings/SCIFakeLocationSettingsVC.h b/src/Settings/SCIFakeLocationSettingsVC.h new file mode 100644 index 0000000..d7348c2 --- /dev/null +++ b/src/Settings/SCIFakeLocationSettingsVC.h @@ -0,0 +1,4 @@ +#import + +@interface SCIFakeLocationSettingsVC : UIViewController +@end diff --git a/src/Settings/SCIFakeLocationSettingsVC.m b/src/Settings/SCIFakeLocationSettingsVC.m new file mode 100644 index 0000000..d5045bb --- /dev/null +++ b/src/Settings/SCIFakeLocationSettingsVC.m @@ -0,0 +1,234 @@ +#import "SCIFakeLocationSettingsVC.h" +#import "SCIFakeLocationPickerVC.h" +#import "../Utils.h" + +static NSString *const kEnabled = @"fake_location_enabled"; +static NSString *const kShowBtn = @"show_fake_location_map_button"; +static NSString *const kLat = @"fake_location_lat"; +static NSString *const kLon = @"fake_location_lon"; +static NSString *const kName = @"fake_location_name"; +static NSString *const kPresets = @"fake_location_presets"; + +@interface SCIFakeLocationSettingsVC () +@property (nonatomic, strong) UITableView *tableView; +@end + +@implementation SCIFakeLocationSettingsVC + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = SCILocalized(@"Fake location"); + self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; + + self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped]; + self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.tableView.dataSource = self; + self.tableView.delegate = self; + [self.view addSubview:self.tableView]; +} + +// MARK: - Storage helpers + +- (NSArray *)presets { + NSArray *raw = [[NSUserDefaults standardUserDefaults] objectForKey:kPresets]; + return [raw isKindOfClass:[NSArray class]] ? raw : @[]; +} + +- (void)setPresets:(NSArray *)presets { + [[NSUserDefaults standardUserDefaults] setObject:presets forKey:kPresets]; +} + +- (void)applyCoord:(double)lat lon:(double)lon name:(NSString *)name { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + [d setObject:@(lat) forKey:kLat]; + [d setObject:@(lon) forKey:kLon]; + [d setObject:(name ?: @"") forKey:kName]; + [self.tableView reloadData]; +} + +// Sections: 0 toggle • 1 current + select • 2 presets + add + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 3; } + +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)s { + if (s == 0) return 2; + if (s == 1) return 2; + return self.presets.count + 1; +} + +- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)s { + if (s == 1) return SCILocalized(@"Current location"); + if (s == 2) return SCILocalized(@"Saved locations"); + return nil; +} + +- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)s { + if (s == 0) return SCILocalized(@"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."); + if (s == 2) return SCILocalized(@"Saved presets are reusable. Tap a preset to make it the active location."); + return nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip { + if (ip.section == 0) { + if (ip.row == 0) { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"sw"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"sw"]; + cell.textLabel.text = SCILocalized(@"Enable fake location"); + UISwitch *sw = [UISwitch new]; + sw.on = [SCIUtils getBoolPref:kEnabled]; + [sw addTarget:self action:@selector(masterToggled:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"swShow"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"swShow"]; + cell.textLabel.text = SCILocalized(@"Show map button"); + UISwitch *sw = [UISwitch new]; + sw.on = [SCIUtils getBoolPref:kShowBtn]; + [sw addTarget:self action:@selector(showBtnToggled:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + return cell; + } + + if (ip.section == 1) { + if (ip.row == 0) { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"cur"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"cur"]; + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + double lat = [[d objectForKey:kLat] doubleValue]; + double lon = [[d objectForKey:kLon] doubleValue]; + NSString *name = [d objectForKey:kName] ?: @""; + cell.textLabel.text = name.length ? name : @"(unset)"; + cell.detailTextLabel.text = [NSString stringWithFormat:@"%.5f, %.5f", lat, lon]; + cell.detailTextLabel.font = [UIFont monospacedDigitSystemFontOfSize:12 weight:UIFontWeightRegular]; + cell.imageView.image = [UIImage systemImageNamed:@"location.fill"]; + cell.imageView.tintColor = [UIColor systemGreenColor]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.accessoryType = UITableViewCellAccessoryNone; + return cell; + } + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"sel"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"sel"]; + cell.textLabel.text = SCILocalized(@"Select location on map"); + cell.textLabel.textColor = [UIColor systemBlueColor]; + cell.imageView.image = [UIImage systemImageNamed:@"map"]; + cell.imageView.tintColor = [UIColor systemBlueColor]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; + } + + // Presets + NSArray *presets = self.presets; + if (ip.row < (NSInteger)presets.count) { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"p"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"p"]; + NSDictionary *p = presets[ip.row]; + cell.textLabel.text = p[@"name"] ?: @"Preset"; + cell.detailTextLabel.text = [NSString stringWithFormat:@"%.5f, %.5f", + [p[@"lat"] doubleValue], [p[@"lon"] doubleValue]]; + cell.detailTextLabel.font = [UIFont monospacedDigitSystemFontOfSize:12 weight:UIFontWeightRegular]; + cell.imageView.image = [UIImage systemImageNamed:@"mappin.circle.fill"]; + cell.imageView.tintColor = [UIColor systemRedColor]; + cell.accessoryType = UITableViewCellAccessoryNone; + return cell; + } + + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"add"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:@"add"]; + cell.textLabel.text = SCILocalized(@"Add preset…"); + cell.textLabel.textColor = [UIColor systemBlueColor]; + cell.imageView.image = [UIImage systemImageNamed:@"plus.circle.fill"]; + cell.imageView.tintColor = [UIColor systemBlueColor]; + return cell; +} + +- (BOOL)tableView:(UITableView *)tv canEditRowAtIndexPath:(NSIndexPath *)ip { + return ip.section == 2 && ip.row < (NSInteger)self.presets.count; +} + +- (void)tableView:(UITableView *)tv commitEditingStyle:(UITableViewCellEditingStyle)style forRowAtIndexPath:(NSIndexPath *)ip { + if (style != UITableViewCellEditingStyleDelete) return; + NSMutableArray *presets = [self.presets mutableCopy]; + [presets removeObjectAtIndex:ip.row]; + [self setPresets:presets]; + [tv deleteRowsAtIndexPaths:@[ip] withRowAnimation:UITableViewRowAnimationAutomatic]; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip { + [tv deselectRowAtIndexPath:ip animated:YES]; + + if (ip.section == 1 && ip.row == 1) { + [self openPickerForCurrent]; + } else if (ip.section == 2) { + NSArray *presets = self.presets; + if (ip.row < (NSInteger)presets.count) { + NSDictionary *p = presets[ip.row]; + [self applyCoord:[p[@"lat"] doubleValue] lon:[p[@"lon"] doubleValue] name:p[@"name"]]; + } else { + [self openPickerForNewPreset]; + } + } +} + +// MARK: - Actions + +- (void)masterToggled:(UISwitch *)sw { + [[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:kEnabled]; +} + +- (void)showBtnToggled:(UISwitch *)sw { + [[NSUserDefaults standardUserDefaults] setBool:sw.on forKey:kShowBtn]; + [[NSNotificationCenter defaultCenter] postNotificationName:@"SCIFakeLocationMapBtnPrefChanged" object:nil]; +} + +- (void)openPickerForCurrent { + SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new]; + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:kLat] doubleValue], + [[d objectForKey:kLon] doubleValue]); + vc.titleText = SCILocalized(@"Set current location"); + __weak typeof(self) weakSelf = self; + vc.onPick = ^(double lat, double lon, NSString *name) { + [weakSelf applyCoord:lat lon:lon name:name]; + }; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + nav.modalPresentationStyle = UIModalPresentationPageSheet; + [self presentViewController:nav animated:YES completion:nil]; +} + +- (void)openPickerForNewPreset { + SCIFakeLocationPickerVC *vc = [SCIFakeLocationPickerVC new]; + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + vc.initialCoord = CLLocationCoordinate2DMake([[d objectForKey:kLat] doubleValue], + [[d objectForKey:kLon] doubleValue]); + vc.titleText = SCILocalized(@"Add preset"); + __weak typeof(self) weakSelf = self; + vc.onPick = ^(double lat, double lon, NSString *name) { + // Confirm name via simple alert + UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Save preset") + message:nil + preferredStyle:UIAlertControllerStyleAlert]; + [alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { + tf.placeholder = SCILocalized(@"Name"); + tf.text = name; + tf.autocapitalizationType = UITextAutocapitalizationTypeSentences; + }]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save") style:UIAlertActionStyleDefault handler:^(UIAlertAction *a) { + NSString *finalName = alert.textFields.firstObject.text.length ? alert.textFields.firstObject.text : name; + NSDictionary *preset = @{@"name": finalName ?: @"", @"lat": @(lat), @"lon": @(lon)}; + NSMutableArray *presets = [weakSelf.presets mutableCopy]; + [presets addObject:preset]; + [weakSelf setPresets:presets]; + [weakSelf.tableView reloadData]; + }]]; + [weakSelf presentViewController:alert animated:YES completion:nil]; + }; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + nav.modalPresentationStyle = UIModalPresentationPageSheet; + [self presentViewController:nav animated:YES completion:nil]; +} + +@end diff --git a/src/Settings/SCISearchBarStyler.h b/src/Settings/SCISearchBarStyler.h new file mode 100644 index 0000000..e5f883f --- /dev/null +++ b/src/Settings/SCISearchBarStyler.h @@ -0,0 +1,10 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Gives the settings search bar an opaque pill when liquid glass is off. +@interface SCISearchBarStyler : NSObject ++ (void)styleSearchBar:(UISearchBar *)searchBar; +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Settings/SCISearchBarStyler.m b/src/Settings/SCISearchBarStyler.m new file mode 100644 index 0000000..2f724f1 --- /dev/null +++ b/src/Settings/SCISearchBarStyler.m @@ -0,0 +1,45 @@ +#import "SCISearchBarStyler.h" +#import "../Utils.h" + +@implementation SCISearchBarStyler + ++ (UIColor *)fieldColor { + return [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) { + if (tc.userInterfaceStyle == UIUserInterfaceStyleDark) { + return [UIColor colorWithRed:58/255.0 green:58/255.0 blue:60/255.0 alpha:1.0]; + } + return [UIColor colorWithRed:190/255.0 green:190/255.0 blue:195/255.0 alpha:1.0]; + }]; +} + ++ (void)styleSearchBar:(UISearchBar *)sb { + // Liquid glass already gives the field a proper backdrop. + if ([SCIUtils getBoolPref:@"liquid_glass_buttons"]) return; + + UITextField *tf = sb.searchTextField; + if (!tf) return; + + UIColor *fill = [self fieldColor]; + + // Hide UIKit's wide rectangular bg; we paint the text field itself + // so the rounded pill shape survives and UIKit keeps owning layout. + for (UIView *v in sb.subviews) { + for (UIView *c in v.subviews) { + if ([NSStringFromClass(c.class) isEqualToString:@"UISearchBarBackground"]) c.hidden = YES; + } + } + for (UIView *v in tf.subviews) { + NSString *n = NSStringFromClass(v.class); + if ([n containsString:@"Background"] || [n containsString:@"Backdrop"]) v.hidden = YES; + } + + tf.borderStyle = UITextBorderStyleNone; + tf.backgroundColor = fill; + tf.layer.backgroundColor = [fill resolvedColorWithTraitCollection:sb.traitCollection].CGColor; + tf.layer.cornerCurve = kCACornerCurveContinuous; + tf.layer.cornerRadius = 18; + tf.layer.masksToBounds = YES; + tf.opaque = YES; +} + +@end diff --git a/src/Settings/SCISetting.h b/src/Settings/SCISetting.h index 354f436..29f2539 100644 --- a/src/Settings/SCISetting.h +++ b/src/Settings/SCISetting.h @@ -30,6 +30,7 @@ typedef NS_ENUM(NSInteger, SCITableCell) { @property (nonatomic, strong) NSURL *imageUrl; @property (nonatomic) BOOL requiresRestart; +@property (nonatomic) BOOL disabled; @property (nonatomic) double min; @property (nonatomic) double max; diff --git a/src/Settings/SCISettingsBackup.h b/src/Settings/SCISettingsBackup.h index 3bce3aa..8d6404f 100644 --- a/src/Settings/SCISettingsBackup.h +++ b/src/Settings/SCISettingsBackup.h @@ -6,6 +6,7 @@ NS_ASSUME_NONNULL_BEGIN + (void)presentExport; + (void)presentImport; ++ (void)presentReset; @end diff --git a/src/Settings/SCISettingsBackup.m b/src/Settings/SCISettingsBackup.m index 8c1153e..62fc8c0 100644 --- a/src/Settings/SCISettingsBackup.m +++ b/src/Settings/SCISettingsBackup.m @@ -6,6 +6,7 @@ #import #import #import "../../modules/JGProgressHUD/JGProgressHUD.h" +#import "SCISearchBarStyler.h" // Settings backup/restore: export/import prefs as JSON file // or photo. Import resets known prefs to defaults then applies imported ones. @@ -43,7 +44,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { + (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw; @end -@interface SCIBackupPreviewVC : UIViewController +@interface SCIBackupPreviewVC : UIViewController @property (nonatomic, strong) NSMutableDictionary *mutableSettings; @property (nonatomic, copy) NSString *primaryActionTitle; @property (nonatomic, copy) void (^primaryAction)(SCIBackupPreviewVC *vc); @@ -100,16 +101,42 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil]; sc.searchResultsUpdater = self; + sc.delegate = self; sc.obscuresBackgroundDuringPresentation = NO; - sc.searchBar.placeholder = @"Search settings"; + sc.searchBar.placeholder = SCILocalized(@"Search settings"); self.navigationItem.searchController = sc; self.navigationItem.hidesSearchBarWhenScrolling = NO; + if (![SCIUtils getBoolPref:@"liquid_glass_buttons"]) { + self.definesPresentationContext = YES; + } self.searchController = sc; self.allGroups = [SCISettingsBackup buildPreviewGroupsForSettings:self.mutableSettings]; self.visibleGroups = self.allGroups; } +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self sciStyleSearchBar]; +} + +- (void)viewWillDisappear:(BOOL)animated { + [super viewWillDisappear:animated]; + if (![SCIUtils getBoolPref:@"liquid_glass_buttons"] && self.searchController.isActive) { + self.searchController.active = NO; + } +} + +- (void)sciStyleSearchBar { [SCISearchBarStyler styleSearchBar:self.searchController.searchBar]; } + +- (void)willPresentSearchController:(UISearchController *)searchController { [self sciStyleSearchBar]; } +- (void)didPresentSearchController:(UISearchController *)searchController { + [self sciStyleSearchBar]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self sciStyleSearchBar]; + }); +} + #pragma mark Search - (void)updateSearchResultsForSearchController:(UISearchController *)searchController { @@ -194,14 +221,14 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { - (UIMenu *)buildMoreMenu { __weak typeof(self) weakSelf = self; - UIAction *editAction = [UIAction actionWithTitle:(self.editMode ? @"Done editing" : @"Edit values") + UIAction *editAction = [UIAction actionWithTitle:(self.editMode ? SCILocalized(@"Done editing") : SCILocalized(@"Edit values")) image:[UIImage systemImageNamed:(self.editMode ? @"checkmark" : @"pencil")] identifier:nil handler:^(__kindof UIAction *_) { [weakSelf toggleEditMode]; }]; if (self.jsonMode) editAction.attributes = UIMenuElementAttributesDisabled; - UIAction *jsonAction = [UIAction actionWithTitle:(self.jsonMode ? @"Form view" : @"Raw JSON view") + UIAction *jsonAction = [UIAction actionWithTitle:(self.jsonMode ? SCILocalized(@"Form view") : SCILocalized(@"Raw JSON view")) image:[UIImage systemImageNamed:(self.jsonMode ? @"list.bullet" : @"curlybraces")] identifier:nil handler:^(__kindof UIAction *_) { @@ -273,7 +300,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { withRowAnimation:UITableViewRowAnimationFade]; }]]; } - [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; sheet.popoverPresentationController.sourceView = cell; sheet.popoverPresentationController.sourceRect = cell.bounds; @@ -372,7 +399,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { - (void)documentPicker:(UIDocumentPickerViewController *)controller didPickDocumentsAtURLs:(NSArray *)urls { if (self.expectingExportPick) { self.expectingExportPick = NO; - [SCISettingsBackup showSuccessHUD:@"Settings exported"]; + [SCISettingsBackup showSuccessHUD:SCILocalized(@"Settings exported")]; return; } NSURL *url = urls.firstObject; @@ -381,7 +408,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { NSData *data = [NSData dataWithContentsOfURL:url]; if (access) [url stopAccessingSecurityScopedResource]; if (!data) { - [SCISettingsBackup showError:@"Could not read file."]; + [SCISettingsBackup showError:SCILocalized(@"Could not read file.")]; return; } [SCISettingsBackup presentApplyConfirmationForData:data]; @@ -413,7 +440,11 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { + (NSSet *)allPrefKeys { NSMutableSet *keys = [NSMutableSet set]; + // Settings UI (recursive — picks up every cell + menu) [self collectKeysFromSections:[SCITweakSettings sections] into:keys]; + // Every default registered by Tweak.x — covers prefs without a UI cell + [keys addObjectsFromArray:[[SCIUtils sciRegisteredDefaults] allKeys]]; + // Manually-tracked storage (lists/dicts not exposed via registerDefaults) [keys addObjectsFromArray:[self extraDataKeys]]; return keys; } @@ -574,7 +605,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { r.kind = SCIBackupPreviewRowKindSwitch; id raw = values[s.defaultsKey]; BOOL on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO; - r.value = on ? @"On" : @"Off"; + r.value = on ? SCILocalized(@"On") : SCILocalized(@"Off"); } else if (s.type == SCITableCellStepper) { r.kind = SCIBackupPreviewRowKindReadOnly; id raw = values[s.defaultsKey]; @@ -623,7 +654,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { if ([raw isKindOfClass:[NSNumber class]]) { NSNumber *n = raw; const char *t = n.objCType; - if (t && strcmp(t, "c") == 0) return n.boolValue ? @"On" : @"Off"; + if (t && strcmp(t, "c") == 0) return n.boolValue ? SCILocalized(@"On") : SCILocalized(@"Off"); return n.stringValue; } if ([raw isKindOfClass:[NSString class]]) return raw; @@ -705,7 +736,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { } + (void)showError:(NSString *)message { - UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Import failed" + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Import failed") message:message preferredStyle:UIAlertControllerStyleAlert]; [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleDefault handler:nil]]; @@ -718,7 +749,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { NSDictionary *snap = [self snapshotCurrentSettings]; SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init]; - vc.title = @"Export settings"; + vc.title = SCILocalized(@"Export settings"); vc.mutableSettings = [snap mutableCopy]; vc.primaryActionTitle = @"Save"; vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) { @@ -727,7 +758,7 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname]; NSError *err = nil; [data writeToURL:tmp options:NSDataWritingAtomic error:&err]; - if (err) { [self showError:@"Could not write temporary file."]; return; } + if (err) { [self showError:SCILocalized(@"Could not write temporary file.")]; return; } UIDocumentPickerViewController *p = [[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]]; SCIBackupHelper *helper = [SCIBackupHelper shared]; @@ -747,6 +778,23 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { [self pickFromFiles]; } ++ (void)presentReset { + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:SCILocalized(@"Reset all settings?") + message:SCILocalized(@"Every RyukGram preference will revert to its built-in default. This can't be undone.") + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Reset") + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *a) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + for (NSString *key in [self allPrefKeys]) [d removeObjectForKey:key]; + [d synchronize]; + [SCIUtils showRestartConfirmation]; + }]]; + [topMostController() presentViewController:alert animated:YES completion:nil]; +} + + (void)pickFromFiles { UIDocumentPickerViewController *p = [[UIDocumentPickerViewController alloc] initWithDocumentTypes:@[@"public.json", @"public.text", @"public.data"] @@ -759,24 +807,24 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { + (void)presentApplyConfirmationForData:(NSData *)data { NSDictionary *settings = [self parseSettingsFromData:data]; if (!settings) { - [self showError:@"File is not a valid RyukGram settings export."]; + [self showError:SCILocalized(@"File is not a valid RyukGram settings export.")]; return; } SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init]; - vc.title = @"Import preview"; + vc.title = SCILocalized(@"Import preview"); vc.mutableSettings = [settings mutableCopy]; vc.primaryActionTitle = @"Apply"; vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) { UIAlertController *confirm = - [UIAlertController alertControllerWithTitle:@"Apply imported settings?" - message:@"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." + [UIAlertController alertControllerWithTitle:SCILocalized(@"Apply imported settings?") + message:SCILocalized(@"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.") preferredStyle:UIAlertControllerStyleAlert]; - [confirm addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; - [confirm addAction:[UIAlertAction actionWithTitle:@"Apply" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { + [confirm addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [confirm addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Apply") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { [SCISettingsBackup applySettings:previewVC.mutableSettings]; [previewVC dismissViewControllerAnimated:YES completion:^{ - [SCISettingsBackup showSuccessHUD:@"Settings imported"]; + [SCISettingsBackup showSuccessHUD:SCILocalized(@"Settings imported")]; [SCIUtils showRestartConfirmation]; }]; }]]; diff --git a/src/Settings/SCISettingsViewController.m b/src/Settings/SCISettingsViewController.m index 8e11f34..7551f8d 100644 --- a/src/Settings/SCISettingsViewController.m +++ b/src/Settings/SCISettingsViewController.m @@ -1,8 +1,9 @@ #import "SCISettingsViewController.h" +#import "SCISearchBarStyler.h" static char rowStaticRef[] = "row"; -@interface SCISettingsViewController () +@interface SCISettingsViewController () @property (nonatomic, strong) UITableView *tableView; @property (nonatomic, copy) NSArray *sections; @@ -73,18 +74,90 @@ static char rowStaticRef[] = "row"; if (self.isRoot) { UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil]; sc.searchResultsUpdater = self; + sc.delegate = self; sc.obscuresBackgroundDuringPresentation = NO; - sc.searchBar.placeholder = @"Search settings"; + sc.searchBar.placeholder = SCILocalized(@"settings.search.placeholder"); self.navigationItem.searchController = sc; self.navigationItem.hidesSearchBarWhenScrolling = NO; + if (![SCIUtils getBoolPref:@"liquid_glass_buttons"]) { + self.definesPresentationContext = YES; + } self.searchController = sc; self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemClose target:self action:@selector(sciDismissSettings)]; + + // Compact globe button — English is the only shipped language for now, + // so the tap shows an info alert instead of a picker. Re-enable the + // menu below once additional translations land. + UIImage *globe = [UIImage systemImageNamed:@"globe"]; + UIBarButtonItem *langItem = [[UIBarButtonItem alloc] initWithImage:globe + style:UIBarButtonItemStylePlain + target:self + action:@selector(sciShowLanguageInfo)]; + self.navigationItem.rightBarButtonItem = langItem; } } +- (void)sciShowLanguageInfo { + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:SCILocalized(@"settings.language.title") + message:SCILocalized(@"settings.language.english_only") + preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"settings.language.ok") style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"settings.language.help_translate") style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *a) { + NSURL *url = [NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram#translating-ryukgram"]; + if (url) [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + }]]; + [self presentViewController:alert animated:YES completion:nil]; +} + +- (UIMenu *)sciBuildLanguageMenu { + NSString *current = [[NSUserDefaults standardUserDefaults] stringForKey:SCILanguagePrefKey] ?: @"system"; + NSMutableArray *actions = [NSMutableArray array]; + + for (NSDictionary *lang in SCIAvailableLanguages()) { + NSString *code = lang[@"code"]; + NSString *title = [code isEqualToString:@"system"] + ? SCILocalized(@"settings.language.system") + : lang[@"native"]; + + UIAction *action = [UIAction actionWithTitle:title + image:nil + identifier:nil + handler:^(UIAction * _Nonnull a) { + NSString *prev = [[NSUserDefaults standardUserDefaults] stringForKey:SCILanguagePrefKey] ?: @"system"; + if ([prev isEqualToString:code]) return; + [[NSUserDefaults standardUserDefaults] setObject:code forKey:SCILanguagePrefKey]; + SCILocalizationReset(); + [self sciApplyLanguageChange]; + // Most IG-side hooks cache their labels at load time, so a full + // restart is the only way to flip every menu/button cleanly. + [SCIUtils showRestartConfirmation]; + }]; + action.state = [code isEqualToString:current] ? UIMenuElementStateOn : UIMenuElementStateOff; + [actions addObject:action]; + } + + return [UIMenu menuWithTitle:SCILocalized(@"settings.language.title") children:actions]; +} + +- (void)sciApplyLanguageChange { + // Root title + search placeholder reflect the new language immediately. + self.title = SCILocalized(@"settings.title"); + self.searchController.searchBar.placeholder = SCILocalized(@"settings.search.placeholder"); + if (self.navigationItem.rightBarButtonItem.menu) { + self.navigationItem.rightBarButtonItem.menu = [self sciBuildLanguageMenu]; + } + [self.tableView reloadData]; + + // Features watching for runtime label refreshes (IG menu items, overlay + // buttons, toasts) can subscribe to this to re-read their strings. + [[NSNotificationCenter defaultCenter] postNotificationName:@"SCILanguageDidChange" object:nil]; +} + - (void)sciDismissSettings { [self dismissViewControllerAnimated:YES completion:nil]; } @@ -92,6 +165,17 @@ static char rowStaticRef[] = "row"; - (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self.tableView reloadData]; + [self sciStyleSearchBar]; +} + +- (void)sciStyleSearchBar { [SCISearchBarStyler styleSearchBar:self.searchController.searchBar]; } + +- (void)willPresentSearchController:(UISearchController *)searchController { [self sciStyleSearchBar]; } +- (void)didPresentSearchController:(UISearchController *)searchController { + [self sciStyleSearchBar]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self sciStyleSearchBar]; + }); } #pragma mark - Search @@ -163,13 +247,17 @@ static char rowStaticRef[] = "row"; - (void)viewWillDisappear:(BOOL)animated { [super viewWillDisappear:animated]; - + // Without this the search bar strands itself as a floating bar on return. + if (![SCIUtils getBoolPref:@"liquid_glass_buttons"] && self.searchController.isActive) { + self.searchController.active = NO; + } + if (![[[NSUserDefaults standardUserDefaults] objectForKey:@"SCInstaFirstRun"] isEqualToString:SCIVersionString]) { - UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"RyukGram Settings Info" - message:@"In the future: Hold down on the three lines at the top right of your profile page, to re-open RyukGram settings." + UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"settings.firstrun.title") + message:SCILocalized(@"settings.firstrun.message") preferredStyle:UIAlertControllerStyleAlert]; - - [alert addAction:[UIAlertAction actionWithTitle:@"I understand!" + + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"settings.firstrun.ok") style:UIAlertActionStyleDefault handler:nil]]; @@ -235,15 +323,19 @@ static char rowStaticRef[] = "row"; case SCITableCellSwitch: { UISwitch *toggle = [UISwitch new]; - toggle.on = [[NSUserDefaults standardUserDefaults] boolForKey:row.defaultsKey]; + toggle.on = row.disabled ? NO : [[NSUserDefaults standardUserDefaults] boolForKey:row.defaultsKey]; toggle.onTintColor = [SCIUtils SCIColor_Primary]; - + toggle.enabled = !row.disabled; + objc_setAssociatedObject(toggle, rowStaticRef, row, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - + [toggle addTarget:self action:@selector(switchChanged:) forControlEvents:UIControlEventValueChanged]; - + cell.accessoryView = toggle; cell.selectionStyle = UITableViewCellSelectionStyleNone; + if (row.disabled) { + cell.contentView.alpha = 0.4; + } break; } @@ -288,9 +380,13 @@ static char rowStaticRef[] = "row"; menuButton.configuration = config; [menuButton sizeToFit]; - + cell.accessoryView = menuButton; cell.selectionStyle = UITableViewCellSelectionStyleNone; + if (row.disabled) { + menuButton.enabled = NO; + cell.contentView.alpha = 0.4; + } break; } @@ -313,7 +409,9 @@ static char rowStaticRef[] = "row"; - (NSString *)tableView:(UITableView *)tableView titleForHeaderInSection:(NSInteger)section { if ([self isSearching]) { NSUInteger n = self.searchResults.count; - return n ? [NSString stringWithFormat:@"%lu result%@", (unsigned long)n, n == 1 ? @"" : @"s"] : @"No results"; + if (n == 0) return SCILocalized(@"settings.results.none"); + NSString *fmt = n == 1 ? SCILocalized(@"settings.results.one") : SCILocalized(@"settings.results.many"); + return [NSString stringWithFormat:fmt, (unsigned long)n]; } return self.sections[section][@"header"]; } @@ -367,6 +465,10 @@ static char rowStaticRef[] = "row"; if (row.requiresRestart) { [SCIUtils showRestartConfirmation]; } + + if ([row.defaultsKey isEqualToString:@"hide_suggested_stories"]) { + [[NSNotificationCenter defaultCenter] postNotificationName:@"SCISuggestedStoriesReload" object:nil]; + } } - (void)stepperChanged:(UIStepper *)sender { diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index 9583081..b7019c0 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -1,10 +1,13 @@ #import "TweakSettings.h" #import "SCISettingsBackup.h" +#import "SCIFakeLocationSettingsVC.h" #import "SCIExcludedChatsViewController.h" #import "../Features/StoriesAndMessages/SCIExcludedThreads.h" #import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h" #import "SCIExcludedStoryUsersViewController.h" #import "SCIEmbedDomainViewController.h" +#import "SCIDateFormatPickerVC.h" +#import "../SCIFFmpeg.h" @implementation SCITweakSettings @@ -24,39 +27,46 @@ @{ @"header": @"", @"rows": @[ - [SCISetting linkCellWithTitle:@"RyukGram on GitHub" subtitle:[NSString stringWithFormat:@"%@ — view source, report issues, see releases", SCIVersionString] imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled/RyukGram"] + [SCISetting linkCellWithTitle:SCILocalized(@"RyukGram on GitHub") subtitle:[NSString stringWithFormat:SCILocalized(@"%@ — view source, report issues, see releases"), SCIVersionString] imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled/RyukGram"] ] }, @{ @"header": @"", @"rows": @[ - [SCISetting navigationCellWithTitle:@"General" + [SCISetting navigationCellWithTitle:SCILocalized(@"General") subtitle:@"" icon:[SCISymbol symbolWithName:@"gear"] navSections:@[@{ @"header": @"", @"rows": @[ - [SCISetting switchCellWithTitle:@"Hide ads" subtitle:@"Removes all ads from the Instagram app" defaultsKey:@"hide_ads"], - [SCISetting switchCellWithTitle:@"Hide Meta AI" subtitle:@"Hides the meta ai buttons/functionality within the app" defaultsKey:@"hide_meta_ai"], - [SCISetting switchCellWithTitle:@"Do not save recent searches" subtitle:@"Search bars will no longer save your recent searches" defaultsKey:@"no_recent_searches"], - [SCISetting switchCellWithTitle:@"Copy description" subtitle:@"Copy description text fields by long-pressing on them" defaultsKey:@"copy_description"], - [SCISetting switchCellWithTitle:@"Profile copy button" subtitle:@"Adds a button next to the burger menu on profiles to copy username, name or bio" defaultsKey:@"profile_copy_button"], - [SCISetting switchCellWithTitle:@"Use detailed color picker" subtitle:@"Long press on the eyedropper tool in stories to customize the text color more precisely" defaultsKey:@"detailed_color_picker"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide ads") subtitle:SCILocalized(@"Removes all ads from the Instagram app") defaultsKey:@"hide_ads"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide Meta AI") subtitle:SCILocalized(@"Hides the meta ai buttons/functionality within the app") defaultsKey:@"hide_meta_ai"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide metrics") subtitle:SCILocalized(@"Hides like/comment/share counts on posts and reels") defaultsKey:@"hide_metrics"], + [SCISetting switchCellWithTitle:SCILocalized(@"Do not save recent searches") subtitle:SCILocalized(@"Search bars will no longer save your recent searches") defaultsKey:@"no_recent_searches"], + [SCISetting switchCellWithTitle:SCILocalized(@"Copy description") subtitle:SCILocalized(@"Copy description text fields by long-pressing on them") defaultsKey:@"copy_description"], + [SCISetting switchCellWithTitle:SCILocalized(@"Use detailed color picker") subtitle:SCILocalized(@"Long press on the eyedropper tool in stories to customize the text color more precisely") defaultsKey:@"detailed_color_picker"], ] }, @{ - @"header": @"Browser", + @"header": SCILocalized(@"Date format"), + @"footer": SCILocalized(@"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker."), @"rows": @[ - [SCISetting switchCellWithTitle:@"Open links in external browser" subtitle:@"Opens links in Safari instead of Instagram's in-app browser" defaultsKey:@"open_links_external"], - [SCISetting switchCellWithTitle:@"Strip tracking from links" subtitle:@"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" defaultsKey:@"strip_browser_tracking"], + [self dateFormatNavCell], ] }, @{ - @"header": @"Sharing", + @"header": SCILocalized(@"Browser"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Replace domain in shared links" subtitle:@"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." defaultsKey:@"embed_links"], + [SCISetting switchCellWithTitle:SCILocalized(@"Open links in external browser") subtitle:SCILocalized(@"Opens links in Safari instead of Instagram's in-app browser") defaultsKey:@"open_links_external"], + [SCISetting switchCellWithTitle:SCILocalized(@"Strip tracking from links") subtitle:SCILocalized(@"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs") defaultsKey:@"strip_browser_tracking"], + ] + }, + @{ + @"header": SCILocalized(@"Sharing"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Replace domain in shared links") subtitle:SCILocalized(@"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc.") defaultsKey:@"embed_links"], ({ - SCISetting *s = [SCISetting buttonCellWithTitle:@"Embed domain" + SCISetting *s = [SCISetting buttonCellWithTitle:SCILocalized(@"Embed domain") subtitle:@"" icon:[SCISymbol symbolWithName:@"globe"] action:^(void) { @@ -70,155 +80,210 @@ else if (top.navigationController) [top.navigationController pushViewController:[SCIEmbedDomainViewController new] animated:YES]; }]; - s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Embed domain: %@", [SCIUtils getStringPref:@"embed_link_domain"] ?: @"kkinstagram.com"]; }; + s.dynamicTitle = ^{ return [NSString stringWithFormat:SCILocalized(@"Embed domain: %@"), [SCIUtils getStringPref:@"embed_link_domain"] ?: @"kkinstagram.com"]; }; s; }), - [SCISetting switchCellWithTitle:@"Strip tracking params" subtitle:@"Removes igsh, utm_source, and other tracking parameters from shared links" defaultsKey:@"strip_tracking_params"], + [SCISetting switchCellWithTitle:SCILocalized(@"Strip tracking params") subtitle:SCILocalized(@"Removes igsh, utm_source, and other tracking parameters from shared links") defaultsKey:@"strip_tracking_params"], ] }, @{ - @"header": @"Comments", + @"header": SCILocalized(@"Comments"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Copy comment text" subtitle:@"Adds a copy option to the comment long-press menu" defaultsKey:@"copy_comment"], - [SCISetting switchCellWithTitle:@"Download GIF comments" subtitle:@"Adds a download option for GIF comments" defaultsKey:@"download_gif_comment"], + [SCISetting switchCellWithTitle:SCILocalized(@"Copy comment text") subtitle:SCILocalized(@"Adds a copy option to the comment long-press menu") defaultsKey:@"copy_comment"], + [SCISetting switchCellWithTitle:SCILocalized(@"Download GIF comments") subtitle:SCILocalized(@"Adds a download option for GIF comments") defaultsKey:@"download_gif_comment"], ] }, @{ - @"header": @"Notes", + @"header": SCILocalized(@"Notes"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Hide notes tray" subtitle:@"Hides the notes tray in the dm inbox" defaultsKey:@"hide_notes_tray"], - [SCISetting switchCellWithTitle:@"Hide friends map" subtitle:@"Hides the friends map icon in the notes tray" defaultsKey:@"hide_friends_map"], - [SCISetting switchCellWithTitle:@"Enable note theming" subtitle:@"Enables the ability to use the notes theme picker" defaultsKey:@"enable_notes_customization"], - [SCISetting switchCellWithTitle:@"Custom note themes" subtitle:@"Provides an option to set custom emojis and background/text colors" defaultsKey:@"custom_note_themes"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide notes tray") subtitle:SCILocalized(@"Hides the notes tray in the DM inbox") defaultsKey:@"hide_notes_tray"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide friends map") subtitle:SCILocalized(@"Hides the friends map icon in the notes tray") defaultsKey:@"hide_friends_map"], ] }, @{ - @"header": @"Focus/distractions", + @"header": SCILocalized(@"Focus/distractions"), @"rows": @[ - [SCISetting switchCellWithTitle:@"No suggested users" subtitle:@"Hides all suggested users for you to follow, outside your feed" defaultsKey:@"no_suggested_users"], - [SCISetting switchCellWithTitle:@"No suggested chats" subtitle:@"Hides the suggested broadcast channels in direct messages" defaultsKey:@"no_suggested_chats"], - [SCISetting switchCellWithTitle:@"Hide explore posts grid" subtitle:@"Hides the grid of suggested posts on the explore/search tab" defaultsKey:@"hide_explore_grid"], - [SCISetting switchCellWithTitle:@"Hide trending searches" subtitle:@"Hides the trending searches under the explore search bar" defaultsKey:@"hide_trending_searches"], + [SCISetting switchCellWithTitle:SCILocalized(@"No suggested users") subtitle:SCILocalized(@"Hides all suggested users for you to follow, outside your feed") defaultsKey:@"no_suggested_users"], + [SCISetting switchCellWithTitle:SCILocalized(@"No suggested chats") subtitle:SCILocalized(@"Hides the suggested broadcast channels in direct messages") defaultsKey:@"no_suggested_chats"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide explore posts grid") subtitle:SCILocalized(@"Hides the grid of suggested posts on the explore/search tab") defaultsKey:@"hide_explore_grid"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide trending searches") subtitle:SCILocalized(@"Hides the trending searches under the explore search bar") defaultsKey:@"hide_trending_searches"], ] }, @{ - @"header": @"Experimental features", - @"footer": @"These features rely on hidden Instagram flags and may not work on all accounts or versions.", + @"header": SCILocalized(@"Experimental features"), + @"footer": SCILocalized(@"These features rely on hidden Instagram flags and may not work on all accounts or versions.\nExperimental flags research by @euoradan (Radan)."), @"rows": @[ - [SCISetting switchCellWithTitle:@"Enable liquid glass buttons" subtitle:@"Enables experimental liquid glass buttons" defaultsKey:@"liquid_glass_buttons" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Enable liquid glass surfaces" subtitle:@"Enables liquid glass for other elements" defaultsKey:@"liquid_glass_surfaces" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Enable teen app icons" subtitle:@"Hold down on the Instagram logo to change the app icon" defaultsKey:@"teen_app_icons" requiresRestart:YES] + [SCISetting switchCellWithTitle:SCILocalized(@"Enable liquid glass buttons") subtitle:SCILocalized(@"Enables experimental liquid glass buttons") defaultsKey:@"liquid_glass_buttons" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Enable liquid glass surfaces") subtitle:SCILocalized(@"Enables liquid glass tab bar, floating navigation, and other UI elements") defaultsKey:@"liquid_glass_surfaces" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Enable teen app icons") subtitle:SCILocalized(@"Hold down on the Instagram logo to change the app icon") defaultsKey:@"teen_app_icons" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable app haptics") subtitle:SCILocalized(@"Disables haptics/vibrations within the app") defaultsKey:@"disable_haptics"] ] }] ], - [SCISetting navigationCellWithTitle:@"Feed" + [SCISetting navigationCellWithTitle:SCILocalized(@"Feed") subtitle:@"" icon:[SCISymbol symbolWithName:@"rectangle.stack"] navSections:@[@{ - @"header": @"", + @"header": SCILocalized(@"Action button"), + @"footer": SCILocalized(@"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."), @"rows": @[ - [SCISetting switchCellWithTitle:@"Hide stories tray" subtitle:@"Hides the story tray at the top and within your feed" defaultsKey:@"hide_stories_tray"], - [SCISetting switchCellWithTitle:@"Hide entire feed" subtitle:@"Removes all content from your home feed, including posts" defaultsKey:@"hide_entire_feed"], - [SCISetting switchCellWithTitle:@"No suggested posts" subtitle:@"Removes suggested posts from your feed" defaultsKey:@"no_suggested_post"], - [SCISetting switchCellWithTitle:@"No suggested for you" subtitle:@"Hides suggested accounts for you to follow" defaultsKey:@"no_suggested_account"], - [SCISetting switchCellWithTitle:@"No suggested reels" subtitle:@"Hides suggested reels to watch" defaultsKey:@"no_suggested_reels"], - [SCISetting switchCellWithTitle:@"No suggested threads posts" subtitle:@"Hides suggested threads posts" defaultsKey:@"no_suggested_threads"], - [SCISetting switchCellWithTitle:@"Disable video autoplay" subtitle:@"Prevents videos on your feed from playing automatically" defaultsKey:@"disable_feed_autoplay" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Hide repost button" subtitle:@"Hides the repost button on feed posts" defaultsKey:@"hide_feed_repost" requiresRestart:YES] + [SCISetting switchCellWithTitle:SCILocalized(@"Show action button") subtitle:SCILocalized(@"Inserts a button row below like/comment/share on each post") defaultsKey:@"feed_action_button"], + [SCISetting menuCellWithTitle:SCILocalized(@"Default tap action") subtitle:SCILocalized(@"What happens on a single tap. Long-press always opens the full menu") menu:[self menus][@"feed_action_default"]], + ] + }, + @{ + @"header": SCILocalized(@"Media"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Media zoom") subtitle:SCILocalized(@"Long press on media to expand in full-screen viewer") defaultsKey:@"feed_media_zoom"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable video autoplay") subtitle:SCILocalized(@"Prevents videos from playing automatically") defaultsKey:@"disable_feed_autoplay" requiresRestart:YES], + ] + }, + @{ + @"header": SCILocalized(@"Stories tray"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Tray long-press actions") subtitle:SCILocalized(@"Adds 'View profile picture' and 'View cover' to story tray long-press menus") defaultsKey:@"story_tray_actions"], + ] + }, + @{ + @"header": SCILocalized(@"Hide"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Hide suggested stories") subtitle:SCILocalized(@"Removes suggested accounts from the stories tray") defaultsKey:@"hide_suggested_stories"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide stories tray") subtitle:SCILocalized(@"Hides the story tray at the top") defaultsKey:@"hide_stories_tray"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide entire feed") subtitle:SCILocalized(@"Removes all content from your home feed") defaultsKey:@"hide_entire_feed"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide repost button") subtitle:SCILocalized(@"Hides the repost button on feed posts") defaultsKey:@"hide_feed_repost" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"No suggested posts") subtitle:SCILocalized(@"Removes suggested posts") defaultsKey:@"no_suggested_post"], + [SCISetting switchCellWithTitle:SCILocalized(@"No suggested for you") subtitle:SCILocalized(@"Hides suggested accounts") defaultsKey:@"no_suggested_account"], + [SCISetting switchCellWithTitle:SCILocalized(@"No suggested reels") subtitle:SCILocalized(@"Hides suggested reels") defaultsKey:@"no_suggested_reels"], + [SCISetting switchCellWithTitle:SCILocalized(@"No suggested threads") subtitle:SCILocalized(@"Hides suggested threads posts") defaultsKey:@"no_suggested_threads"], + ] + }, + @{ + @"header": SCILocalized(@"Refresh"), + @"footer": SCILocalized(@"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."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Disable background refresh") subtitle:SCILocalized(@"Prevents feed from reloading when returning from background") defaultsKey:@"disable_bg_refresh" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable home button refresh") subtitle:SCILocalized(@"Scroll to top without refreshing when tapping Home") defaultsKey:@"disable_home_refresh"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable home button scroll") subtitle:SCILocalized(@"Tapping Home does nothing when already on feed") defaultsKey:@"disable_home_scroll"], ] }] ], - [SCISetting navigationCellWithTitle:@"Reels" + [SCISetting navigationCellWithTitle:SCILocalized(@"Reels") subtitle:@"" icon:[SCISymbol symbolWithName:@"film.stack"] navSections:@[@{ + @"header": SCILocalized(@"Action button"), + @"footer": SCILocalized(@"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."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Show action button") subtitle:SCILocalized(@"Places a button above the like/comment/share column on each reel") defaultsKey:@"reels_action_button"], + [SCISetting menuCellWithTitle:SCILocalized(@"Default tap action") subtitle:SCILocalized(@"What happens on a single tap. Long-press always opens the full menu") menu:[self menus][@"reels_action_default"]], + ] + }, + @{ @"header": @"", @"rows": @[ - [SCISetting menuCellWithTitle:@"Tap Controls" subtitle:@"Change what happens when you tap on a reel" menu:[self menus][@"reels_tap_control"]], - [SCISetting switchCellWithTitle:@"Always show progress scrubber" subtitle:@"Forces the progress bar to appear on every reel" defaultsKey:@"reels_show_scrubber"], - [SCISetting switchCellWithTitle:@"Disable auto-unmuting reels" subtitle:@"Prevents reels from unmuting when the volume/silent button is pressed" defaultsKey:@"disable_auto_unmuting_reels" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Confirm reel refresh" subtitle:@"Shows an alert when you trigger a reels refresh" defaultsKey:@"refresh_reel_confirm"], - [SCISetting switchCellWithTitle:@"Unlock password-locked reels" subtitle:@"Shows buttons to reveal and auto-fill the password on locked reels" defaultsKey:@"unlock_password_reels"], + [SCISetting menuCellWithTitle:SCILocalized(@"Tap Controls") subtitle:SCILocalized(@"Change what happens when you tap on a reel") menu:[self menus][@"reels_tap_control"]], + [SCISetting switchCellWithTitle:SCILocalized(@"Always show progress scrubber") subtitle:SCILocalized(@"Forces the progress bar to appear on every reel") defaultsKey:@"reels_show_scrubber"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable auto-unmuting reels") subtitle:SCILocalized(@"Prevents reels from unmuting when the volume/silent button is pressed") defaultsKey:@"disable_auto_unmuting_reels" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm reel refresh") subtitle:SCILocalized(@"Shows an alert when you trigger a reels refresh") defaultsKey:@"refresh_reel_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable tab button refresh") subtitle:SCILocalized(@"Tapping the Reels tab while on reels does nothing") defaultsKey:@"disable_reels_tab_refresh"], + [SCISetting switchCellWithTitle:SCILocalized(@"Unlock password-locked reels") subtitle:SCILocalized(@"Shows buttons to reveal and auto-fill the password on locked reels") defaultsKey:@"unlock_password_reels"], ] }, @{ - @"header": @"Hiding", + @"header": SCILocalized(@"Hiding"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Hide reels header" subtitle:@"Hides the top navigation bar when watching reels" defaultsKey:@"hide_reels_header"], - [SCISetting switchCellWithTitle:@"Hide repost button" subtitle:@"Hides the repost button on the reels sidebar" defaultsKey:@"hide_reels_repost" requiresRestart:YES] + [SCISetting switchCellWithTitle:SCILocalized(@"Hide reels header") subtitle:SCILocalized(@"Hides the top navigation bar when watching reels") defaultsKey:@"hide_reels_header"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide repost button") subtitle:SCILocalized(@"Hides the repost button on the reels sidebar") defaultsKey:@"hide_reels_repost" requiresRestart:YES] ] }, @{ - @"header": @"Limits", + @"header": SCILocalized(@"Limits"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Disable scrolling reels" subtitle:@"Prevents reels from being scrolled to the next video" defaultsKey:@"disable_scrolling_reels" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Prevent doom scrolling" subtitle:@"Limits the amount of reels available to scroll at any given time, and prevents refreshing" defaultsKey:@"prevent_doom_scrolling"], - [SCISetting stepperCellWithTitle:@"Doom scrolling limit" subtitle:@"Only loads %@ %@" defaultsKey:@"doom_scrolling_reel_count" min:1 max:100 step:1 label:@"reels" singularLabel:@"reel"] + [SCISetting switchCellWithTitle:SCILocalized(@"Disable scrolling reels") subtitle:SCILocalized(@"Prevents reels from being scrolled to the next video") defaultsKey:@"disable_scrolling_reels" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Prevent doom scrolling") subtitle:SCILocalized(@"Limits the amount of reels available to scroll at any given time, and prevents refreshing") defaultsKey:@"prevent_doom_scrolling"], + [SCISetting stepperCellWithTitle:SCILocalized(@"Doom scrolling limit") subtitle:SCILocalized(@"Only loads %@ %@") defaultsKey:@"doom_scrolling_reel_count" min:1 max:100 step:1 label:@"reels" singularLabel:@"reel"] ] }] ], - [SCISetting navigationCellWithTitle:@"Saving" + [SCISetting navigationCellWithTitle:SCILocalized(@"Profile") + subtitle:@"" + icon:[SCISymbol symbolWithName:@"person.crop.circle"] + navSections:@[@{ + @"header": @"", + @"footer": SCILocalized(@"Long-press gestures on profile elements — kept separate from the per-feature action buttons."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Zoom profile photo") subtitle:SCILocalized(@"Long press a profile picture to open it in full-screen with zoom, share, and save") defaultsKey:@"zoom_profile_photo"], + [SCISetting switchCellWithTitle:SCILocalized(@"Save profile picture") subtitle:SCILocalized(@"Long press to download directly (ignored when zoom is on)") defaultsKey:@"save_profile"], + [SCISetting switchCellWithTitle:SCILocalized(@"View highlight cover") subtitle:SCILocalized(@"Adds a view option to the highlight long-press menu to open the cover in full-screen") defaultsKey:@"download_highlight_cover"], + [SCISetting switchCellWithTitle:SCILocalized(@"Profile copy button") subtitle:SCILocalized(@"Adds a button next to the burger menu on profiles to copy username, name or bio") defaultsKey:@"profile_copy_button"], + [SCISetting switchCellWithTitle:SCILocalized(@"Follow indicator") subtitle:SCILocalized(@"Shows whether the profile user follows you") defaultsKey:@"follow_indicator"], + [SCISetting switchCellWithTitle:SCILocalized(@"Copy note on long press") subtitle:SCILocalized(@"Long press the note bubble on a profile to copy the text") defaultsKey:@"profile_note_copy"], + ] + }] + ], + [SCISetting navigationCellWithTitle:SCILocalized(@"Saving") subtitle:@"" icon:[SCISymbol symbolWithName:@"tray.and.arrow.down"] navSections:@[@{ - @"header": @"", + @"header": SCILocalized(@"Downloads"), + @"footer": SCILocalized(@"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."), @"rows": @[ - [SCISetting switchCellWithTitle:@"Download feed posts" subtitle:@"Long-press with finger(s) to download posts in the home tab" defaultsKey:@"dw_feed_posts"], - [SCISetting switchCellWithTitle:@"Download reels" subtitle:@"Long-press with finger(s) on a reel to download" defaultsKey:@"dw_reels"], - [SCISetting switchCellWithTitle:@"Download stories" subtitle:@"Long-press with finger(s) while viewing someone's story to download" defaultsKey:@"dw_story"], - [SCISetting switchCellWithTitle:@"Save profile picture" subtitle:@"On someone's profile, click their profile picture to enlarge it, then hold to download" defaultsKey:@"save_profile"], - [SCISetting switchCellWithTitle:@"Download highlight cover" subtitle:@"Adds a download option to the highlight long-press menu on profiles" defaultsKey:@"download_highlight_cover"] + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm before download") subtitle:SCILocalized(@"Show a confirmation dialog before starting a download") defaultsKey:@"dw_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Save to RyukGram album") subtitle:SCILocalized(@"Route saves into a dedicated album in Photos instead of the camera roll root") defaultsKey:@"save_to_ryukgram_album"] ] }, + [self enhancedDownloadsSection], @{ - @"header": @"Download method", - @"footer": @"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.", + @"header": SCILocalized(@"Legacy long-press gesture"), + @"footer": SCILocalized(@"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."), @"rows": @[ - [SCISetting menuCellWithTitle:@"Download method" subtitle:@"How to trigger downloads" menu:[self menus][@"dw_method"]], - [SCISetting menuCellWithTitle:@"Save action" subtitle:@"What happens after downloading" menu:[self menus][@"dw_save_action"]], - [SCISetting switchCellWithTitle:@"Confirm before download" subtitle:@"Show a confirmation dialog before starting a download" defaultsKey:@"dw_confirm"], - [SCISetting switchCellWithTitle:@"Save to RyukGram album" subtitle:@"Route saves into a dedicated album in Photos instead of the camera roll root" defaultsKey:@"save_to_ryukgram_album"] - ] - }, - @{ - @"header": @"Customize gestures", - @"footer": @"Only applies when download method is set to \"Long-press gesture\"", - @"rows": @[ - [SCISetting stepperCellWithTitle:@"Finger count for long-press" subtitle:@"Downloads with %@ %@" defaultsKey:@"dw_finger_count" min:1 max:5 step:1 label:@"fingers" singularLabel:@"finger"], - [SCISetting stepperCellWithTitle:@"Long-press hold time" subtitle:@"Press finger(s) for %@ %@" defaultsKey:@"dw_finger_duration" min:0 max:10 step:0.25 label:@"sec" singularLabel:@"sec"] + [SCISetting switchCellWithTitle:SCILocalized(@"Enable long-press gesture") subtitle:SCILocalized(@"Master toggle for the deprecated gesture workflow (off by default)") defaultsKey:@"dw_legacy_gesture"], + [SCISetting menuCellWithTitle:SCILocalized(@"Save action") subtitle:SCILocalized(@"What happens after the gesture downloads") menu:[self menus][@"dw_save_action"]], + [SCISetting stepperCellWithTitle:SCILocalized(@"Finger count for long-press") subtitle:SCILocalized(@"Downloads with %@ %@") defaultsKey:@"dw_finger_count" min:1 max:5 step:1 label:@"fingers" singularLabel:@"finger"], + [SCISetting stepperCellWithTitle:SCILocalized(@"Long-press hold time") subtitle:SCILocalized(@"Press finger(s) for %@ %@") defaultsKey:@"dw_finger_duration" min:0 max:10 step:0.25 label:@"sec" singularLabel:@"sec"] ] }] ], - [SCISetting navigationCellWithTitle:@"Stories" + [SCISetting navigationCellWithTitle:SCILocalized(@"Stories") subtitle:@"" icon:[SCISymbol symbolWithName:@"circle.dashed"] navSections:@[@{ - @"header": @"Seen receipts", + @"header": SCILocalized(@"Action button"), + @"footer": SCILocalized(@"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."), @"rows": @[ - [SCISetting switchCellWithTitle:@"Disable story seen receipt" subtitle:@"Hides the notification for others when you view their story" defaultsKey:@"no_seen_receipt"], - [SCISetting switchCellWithTitle:@"Keep stories visually unseen" subtitle:@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" defaultsKey:@"no_seen_visual"], - [SCISetting switchCellWithTitle:@"Mark seen on story like" subtitle:@"Marks a story as seen the moment you tap the heart, even with seen blocking on" defaultsKey:@"seen_on_story_like"], - [SCISetting menuCellWithTitle:@"Manual seen button mode" subtitle:@"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" menu:[self menus][@"story_seen_mode"]], + [SCISetting switchCellWithTitle:SCILocalized(@"Show action button") subtitle:SCILocalized(@"Inserts a button next to the seen/eye button on story overlays") defaultsKey:@"stories_action_button"], + [SCISetting menuCellWithTitle:SCILocalized(@"Default tap action") subtitle:SCILocalized(@"What happens on a single tap. Long-press always opens the full menu") menu:[self menus][@"stories_action_default"]], ] }, @{ - @"header": @"Playback", + @"header": SCILocalized(@"Seen receipts"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Stop story auto-advance" subtitle:@"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" defaultsKey:@"stop_story_auto_advance"], - [SCISetting switchCellWithTitle:@"Advance when marking as seen" subtitle:@"Tapping the eye button to mark a story as seen advances to the next story automatically" defaultsKey:@"advance_on_mark_seen"], - [SCISetting switchCellWithTitle:@"Advance on story like" subtitle:@"Liking a story automatically advances to the next one after a short delay" defaultsKey:@"advance_on_story_like"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable story seen receipt") subtitle:SCILocalized(@"Hides the notification for others when you view their story") defaultsKey:@"no_seen_receipt"], + [SCISetting switchCellWithTitle:SCILocalized(@"Keep stories visually unseen") subtitle:SCILocalized(@"Prevents stories from visually marking as seen in the tray (keeps colorful ring)") defaultsKey:@"no_seen_visual"], + [SCISetting switchCellWithTitle:SCILocalized(@"Mark seen on story like") subtitle:SCILocalized(@"Marks a story as seen the moment you tap the heart, even with seen blocking on") defaultsKey:@"seen_on_story_like"], + [SCISetting switchCellWithTitle:SCILocalized(@"Mark seen on story reply") subtitle:SCILocalized(@"Marks a story as seen when you send a reply or emoji reaction, even with seen blocking on") defaultsKey:@"seen_on_story_reply"], + [SCISetting menuCellWithTitle:SCILocalized(@"Manual seen button mode") subtitle:SCILocalized(@"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)") menu:[self menus][@"story_seen_mode"]], ] }, @{ - @"header": @"Story user list", - @"footer": @"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.", + @"header": SCILocalized(@"Playback"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Enable story user list" subtitle:@"Master toggle. When off, the list is ignored" defaultsKey:@"enable_story_user_exclusions"], - [SCISetting menuCellWithTitle:@"Blocking mode" subtitle:@"Which stories get seen-receipt blocking" menu:[self menus][@"story_blocking_mode"]], - [SCISetting switchCellWithTitle:@"Quick list button in stories" subtitle:@"Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only" defaultsKey:@"story_excluded_show_unexclude_eye"], + [SCISetting switchCellWithTitle:SCILocalized(@"Stop story auto-advance") subtitle:SCILocalized(@"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually") defaultsKey:@"stop_story_auto_advance"], + [SCISetting switchCellWithTitle:SCILocalized(@"Advance when marking as seen") subtitle:SCILocalized(@"Tapping the eye button to mark a story as seen advances to the next story automatically") defaultsKey:@"advance_on_mark_seen"], + [SCISetting switchCellWithTitle:SCILocalized(@"Advance on story like") subtitle:SCILocalized(@"Liking a story automatically advances to the next one after a short delay") defaultsKey:@"advance_on_story_like"], + [SCISetting switchCellWithTitle:SCILocalized(@"Advance on story reply") subtitle:SCILocalized(@"Sending a reply or emoji reaction automatically advances to the next story") defaultsKey:@"advance_on_story_reply"], + ] + }, + @{ + @"header": SCILocalized(@"Story user list"), + @"footer": SCILocalized(@"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."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Enable story user list") subtitle:SCILocalized(@"Master toggle. When off, the list is ignored") defaultsKey:@"enable_story_user_exclusions"], + [SCISetting menuCellWithTitle:SCILocalized(@"Blocking mode") subtitle:SCILocalized(@"Which stories get seen-receipt blocking") menu:[self menus][@"story_blocking_mode"]], + [SCISetting switchCellWithTitle:SCILocalized(@"Quick list button in stories") subtitle:SCILocalized(@"Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only") defaultsKey:@"story_excluded_show_unexclude_eye"], ({ - SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list" - subtitle:@"Search, sort, swipe to remove" + SCISetting *s = [SCISetting buttonCellWithTitle:SCILocalized(@"Manage list") + subtitle:SCILocalized(@"Search, sort, swipe to remove") icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"] action:^(void) { UIWindow *win = nil; @@ -233,81 +298,85 @@ [top.navigationController pushViewController:[SCIExcludedStoryUsersViewController new] animated:YES]; } }]; - s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedStoryUsers count]]; }; + s.dynamicTitle = ^{ return [NSString stringWithFormat:SCILocalized(@"Manage list (%lu)"), (unsigned long)[SCIExcludedStoryUsers count]]; }; s; }), ] }, @{ - @"header": @"Audio", + @"header": SCILocalized(@"Audio"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Story audio toggle" subtitle:@"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu" defaultsKey:@"story_audio_toggle"], + [SCISetting switchCellWithTitle:SCILocalized(@"Story audio toggle") subtitle:SCILocalized(@"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu") defaultsKey:@"story_audio_toggle"], ] }, @{ - @"header": @"Other", + @"header": SCILocalized(@"Other"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Disable instants creation" subtitle:@"Hides the functionality to create/send instants" defaultsKey:@"disable_instants_creation" requiresRestart:YES] + [SCISetting switchCellWithTitle:SCILocalized(@"Disable instants creation") subtitle:SCILocalized(@"Hides the functionality to create/send instants") defaultsKey:@"disable_instants_creation" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"View story mentions") subtitle:SCILocalized(@"Show mentioned users in eye button and story menu") defaultsKey:@"view_story_mentions"] ] }] ], - [SCISetting navigationCellWithTitle:@"Messages" + [SCISetting navigationCellWithTitle:SCILocalized(@"Messages") subtitle:@"" icon:[SCISymbol symbolWithName:@"bubble.left.and.bubble.right"] navSections:@[@{ - @"header": @"Threads", + @"header": SCILocalized(@"Threads"), @"rows": @[ - [SCISetting navigationCellWithTitle:@"Keep deleted messages" - subtitle:@"Preserve messages that others unsend" + [SCISetting navigationCellWithTitle:SCILocalized(@"Read receipts") + subtitle:SCILocalized(@"Control when messages are marked as seen") icon:nil navSections:@[@{ @"header": @"", - @"footer": @"⚠️ WARNING: Pull-to-refresh in the DMs tab CLEARS all preserved messages. Enable \"Warn before clearing on refresh\" below to get a confirmation dialog before this happens.", @"rows": @[ - [SCISetting switchCellWithTitle:@"Keep deleted messages" subtitle:@"Preserves messages that others unsend" defaultsKey:@"keep_deleted_message"], - [SCISetting switchCellWithTitle:@"Indicate unsent messages" subtitle:@"Shows an \"Unsent\" label on preserved messages" defaultsKey:@"indicate_unsent_messages"], - [SCISetting switchCellWithTitle:@"Unsent message notification" subtitle:@"Shows a notification pill when a message is unsent" defaultsKey:@"unsent_message_toast"], - [SCISetting switchCellWithTitle:@"Warn before clearing on refresh" subtitle:@"Show a confirmation dialog when pulling to refresh the DMs tab if preserved messages would be cleared" defaultsKey:@"warn_refresh_clears_preserved"], + [SCISetting switchCellWithTitle:SCILocalized(@"Manually mark messages as seen") subtitle:SCILocalized(@"Adds a button to DM threads to mark messages as seen") defaultsKey:@"remove_lastseen"], + [SCISetting menuCellWithTitle:SCILocalized(@"Read receipt mode") subtitle:SCILocalized(@"How the seen button behaves") menu:[self menus][@"seen_mode"]], + [SCISetting switchCellWithTitle:SCILocalized(@"Auto mark seen on interact") subtitle:SCILocalized(@"Marks messages as seen when you send any message") defaultsKey:@"seen_auto_on_interact"], + [SCISetting switchCellWithTitle:SCILocalized(@"Auto mark seen on typing") subtitle:SCILocalized(@"Marks messages as seen when you start typing") defaultsKey:@"seen_auto_on_typing"], ] }] ], - [SCISetting navigationCellWithTitle:@"Read receipts" - subtitle:@"Control when messages are marked as seen" + [SCISetting navigationCellWithTitle:SCILocalized(@"Keep deleted messages") + subtitle:SCILocalized(@"Preserve messages that others unsend") icon:nil navSections:@[@{ @"header": @"", + @"footer": SCILocalized(@"⚠️ Pull-to-refresh in the DMs tab clears all preserved messages. Enable the warning below to get a confirmation dialog."), @"rows": @[ - [SCISetting switchCellWithTitle:@"Manually mark messages as seen" subtitle:@"Adds a button to DM threads to mark messages as seen" defaultsKey:@"remove_lastseen"], - [SCISetting menuCellWithTitle:@"Read receipt mode" subtitle:@"How the seen button behaves" menu:[self menus][@"seen_mode"]], - [SCISetting switchCellWithTitle:@"Auto mark seen on interact" subtitle:@"Locally marks messages as seen when you send any message" defaultsKey:@"seen_auto_on_interact"], - [SCISetting switchCellWithTitle:@"Auto mark seen on typing" subtitle:@"Marks messages as seen the moment you start typing in a DM (works even when typing status is hidden)" defaultsKey:@"seen_auto_on_typing"], - ] + [SCISetting switchCellWithTitle:SCILocalized(@"Keep deleted messages") subtitle:SCILocalized(@"Preserves messages that others unsend") defaultsKey:@"keep_deleted_message"], + [SCISetting switchCellWithTitle:SCILocalized(@"Indicate unsent messages") subtitle:SCILocalized(@"Shows an \"Unsent\" label on preserved messages") defaultsKey:@"indicate_unsent_messages"], + [SCISetting switchCellWithTitle:SCILocalized(@"Unsent message notification") subtitle:SCILocalized(@"Shows a notification pill when a message is unsent") defaultsKey:@"unsent_message_toast"], + [SCISetting switchCellWithTitle:SCILocalized(@"Warn before clearing on refresh") subtitle:SCILocalized(@"Confirmation dialog before clearing preserved messages") defaultsKey:@"warn_refresh_clears_preserved"], + ] }] ], - [SCISetting switchCellWithTitle:@"Disable typing status" subtitle:@"Prevents the typing indicator from being shown to others when you're typing in DMs" defaultsKey:@"disable_typing_status"], - [SCISetting switchCellWithTitle:@"Hide reels blend button" subtitle:@"Hides the button in DMs to open a reels blend" defaultsKey:@"hide_reels_blend"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable typing status") subtitle:SCILocalized(@"Hides typing indicator from others") defaultsKey:@"disable_typing_status"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable disappearing mode swipe") subtitle:SCILocalized(@"Prevents accidental swipe-up activation of disappearing mode") defaultsKey:@"disable_disappearing_mode_swipe"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide voice call button") subtitle:SCILocalized(@"Removes the audio call button from DM thread header") defaultsKey:@"hide_voice_call_button"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide video call button") subtitle:SCILocalized(@"Removes the video call button from DM thread header") defaultsKey:@"hide_video_call_button"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide reels blend button") subtitle:SCILocalized(@"Hides the blend button in DMs") defaultsKey:@"hide_reels_blend"], ] }, @{ - @"header": @"Chat list", - @"footer": @"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.", + @"header": SCILocalized(@"Chat list"), + @"footer": SCILocalized(@"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."), @"rows": @[ - [SCISetting switchCellWithTitle:@"Enable chat list" subtitle:@"Master toggle. When off, the list is ignored" defaultsKey:@"enable_chat_exclusions"], - [SCISetting menuCellWithTitle:@"Blocking mode" subtitle:@"Which chats get read-receipt blocking" menu:[self menus][@"chat_blocking_mode"]], + [SCISetting switchCellWithTitle:SCILocalized(@"Enable chat list") subtitle:SCILocalized(@"Master toggle. When off, the list is ignored") defaultsKey:@"enable_chat_exclusions"], + [SCISetting menuCellWithTitle:SCILocalized(@"Blocking mode") subtitle:SCILocalized(@"Which chats get read-receipt blocking") menu:[self menus][@"chat_blocking_mode"]], ({ SCISetting *s = [SCISetting switchCellWithTitle:@"" subtitle:@"" defaultsKey:@"exclusions_default_keep_deleted"]; s.dynamicTitle = ^{ BOOL bs = [[SCIUtils getStringPref:@"chat_blocking_mode"] isEqualToString:@"block_selected"]; - return bs ? @"Block keep-deleted for unlisted chats" - : @"Block keep-deleted for excluded chats"; + return bs ? SCILocalized(@"Block keep-deleted for unlisted chats") + : SCILocalized(@"Block keep-deleted for excluded chats"); }; s.subtitle = @"Each chat can override this in the list"; s; }), - [SCISetting switchCellWithTitle:@"Quick list button in chats" subtitle:@"Shows a button in DM threads to add/remove chats from the list. Long-press for more options" defaultsKey:@"chat_quick_list_button"], + [SCISetting switchCellWithTitle:SCILocalized(@"Quick list button in chats") subtitle:SCILocalized(@"Shows a button in DM threads to add/remove chats from the list. Long-press for more options") defaultsKey:@"chat_quick_list_button"], ({ - SCISetting *s = [SCISetting buttonCellWithTitle:@"Manage list" - subtitle:@"Search, sort, swipe to remove or toggle keep-deleted" + SCISetting *s = [SCISetting buttonCellWithTitle:SCILocalized(@"Manage list") + subtitle:SCILocalized(@"Search, sort, swipe to remove or toggle keep-deleted") icon:[SCISymbol symbolWithName:@"list.bullet.rectangle"] action:^(void) { UIWindow *win = nil; @@ -322,69 +391,100 @@ [top.navigationController pushViewController:[SCIExcludedChatsViewController new] animated:YES]; } }]; - s.dynamicTitle = ^{ return [NSString stringWithFormat:@"Manage list (%lu)", (unsigned long)[SCIExcludedThreads count]]; }; + s.dynamicTitle = ^{ return [NSString stringWithFormat:SCILocalized(@"Manage list (%lu)"), (unsigned long)[SCIExcludedThreads count]]; }; s; }), ] }, @{ - @"header": @"Voice messages", + @"header": SCILocalized(@"Activity"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Send audio as file" subtitle:@"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages" defaultsKey:@"send_audio_as_file"], - [SCISetting switchCellWithTitle:@"Download voice messages" subtitle:@"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" defaultsKey:@"download_audio_message"], + [SCISetting switchCellWithTitle:SCILocalized(@"Full last active date") subtitle:SCILocalized(@"Show full date instead of \"Active 2h ago\"") defaultsKey:@"dm_full_last_active"], ] }, @{ - @"header": @"Visual messages", + @"header": SCILocalized(@"Files"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Unlimited replay of visual messages" subtitle:@"Replays direct visual messages normal/once stories unlimited times (toggle with image check icon)" defaultsKey:@"unlimited_replay"], - [SCISetting switchCellWithTitle:@"Disable view-once limitations" subtitle:@"Makes view-once messages behave like normal visual messages (loopable/pauseable)" defaultsKey:@"disable_view_once_limitations"], - [SCISetting switchCellWithTitle:@"Disable screenshot detection" subtitle:@"Removes the screenshot-prevention features for visual messages in DMs" defaultsKey:@"remove_screenshot_alert"], + [SCISetting switchCellWithTitle:SCILocalized(@"Send files (experimental)") subtitle:SCILocalized(@"Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram") defaultsKey:@"send_file"], + ] + }, + @{ + @"header": SCILocalized(@"Voice messages"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Send audio as file") subtitle:SCILocalized(@"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages") defaultsKey:@"send_audio_as_file"], + [SCISetting switchCellWithTitle:SCILocalized(@"Download voice messages") subtitle:SCILocalized(@"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio") defaultsKey:@"download_audio_message"], + ] + }, + @{ + @"header": SCILocalized(@"Notes"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Note actions") subtitle:SCILocalized(@"Adds copy text, download GIF/audio to the note long-press menu") defaultsKey:@"note_actions"], + [SCISetting switchCellWithTitle:SCILocalized(@"Copy text on hold") subtitle:SCILocalized(@"Copies note text directly on long press without opening the menu") defaultsKey:@"note_copy_on_hold"], + [SCISetting switchCellWithTitle:SCILocalized(@"Enable note theming") subtitle:SCILocalized(@"Enables the notes theme picker") defaultsKey:@"enable_notes_customization"], + [SCISetting switchCellWithTitle:SCILocalized(@"Custom note themes") subtitle:SCILocalized(@"Custom emojis and background/text colors") defaultsKey:@"custom_note_themes"], + ] + }, + @{ + @"header": SCILocalized(@"Visual messages"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Unlimited replay of visual messages") subtitle:SCILocalized(@"Replay visual messages without expiring. Toggle in the eye button menu, or as a standalone button when the eye button is disabled") defaultsKey:@"unlimited_replay"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable view-once limitations") subtitle:SCILocalized(@"Makes view-once messages behave like normal visual messages (loopable/pauseable)") defaultsKey:@"disable_view_once_limitations"], + [SCISetting switchCellWithTitle:SCILocalized(@"Disable screenshot detection") subtitle:SCILocalized(@"Removes the screenshot-prevention features for visual messages in DMs") defaultsKey:@"remove_screenshot_alert"], ] }] ], - [SCISetting navigationCellWithTitle:@"Navigation" + [SCISetting navigationCellWithTitle:SCILocalized(@"Navigation") subtitle:@"" icon:[SCISymbol symbolWithName:@"hand.draw.fill"] navSections:@[@{ @"header": @"", @"rows": @[ - [SCISetting menuCellWithTitle:@"Icon order" subtitle:@"The order of the icons on the bottom navigation bar" menu:[self menus][@"nav_icon_ordering"]], - [SCISetting menuCellWithTitle:@"Swipe between tabs" subtitle:@"Lets you swipe to switch between navigation bar tabs" menu:[self menus][@"swipe_nav_tabs"]], + [SCISetting menuCellWithTitle:SCILocalized(@"Icon order") subtitle:SCILocalized(@"The order of the icons on the bottom navigation bar") menu:[self menus][@"nav_icon_ordering"]], + [SCISetting menuCellWithTitle:SCILocalized(@"Swipe between tabs") subtitle:SCILocalized(@"Lets you swipe to switch between navigation bar tabs") menu:[self menus][@"swipe_nav_tabs"]], + [SCISetting menuCellWithTitle:SCILocalized(@"Launch tab") subtitle:SCILocalized(@"Tab the app opens to. Ignored when Messages-only is on") menu:[self menus][@"launch_tab"]], ] }, @{ - @"header": @"Hiding tabs", + @"header": SCILocalized(@"Hiding tabs"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Hide feed tab" subtitle:@"Hides the feed/home tab on the bottom navigation bar" defaultsKey:@"hide_feed_tab" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Hide explore tab" subtitle:@"Hides the explore/search tab on the bottom navigation bar" defaultsKey:@"hide_explore_tab" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Hide reels tab" subtitle:@"Hides the reels tab on the bottom navigation bar" defaultsKey:@"hide_reels_tab" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Hide create tab" subtitle:@"Hides the create tab on the bottom navigation bar" defaultsKey:@"hide_create_tab" requiresRestart:YES] + [SCISetting switchCellWithTitle:SCILocalized(@"Hide feed tab") subtitle:SCILocalized(@"Hides the feed/home tab on the bottom navigation bar") defaultsKey:@"hide_feed_tab" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide explore tab") subtitle:SCILocalized(@"Hides the explore/search tab on the bottom navigation bar") defaultsKey:@"hide_explore_tab" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide reels tab") subtitle:SCILocalized(@"Hides the reels tab on the bottom navigation bar") defaultsKey:@"hide_reels_tab" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide create tab") subtitle:SCILocalized(@"Hides the create tab on the bottom navigation bar") defaultsKey:@"hide_create_tab" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide messages tab") subtitle:SCILocalized(@"Hides the direct messages tab on the bottom navigation bar") defaultsKey:@"hide_messages_tab" requiresRestart:YES] + ] + }, + @{ + @"header": SCILocalized(@"Messages-only mode"), + @"footer": SCILocalized(@"Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Messages only") subtitle:SCILocalized(@"Turn IG into a DM-only client") defaultsKey:@"messages_only" requiresRestart:YES], ] }] ], - [SCISetting navigationCellWithTitle:@"Confirm actions" + [SCISetting navigationCellWithTitle:SCILocalized(@"Confirm actions") subtitle:@"" icon:[SCISymbol symbolWithName:@"checkmark"] navSections:@[@{ @"header": @"", @"rows": @[ - [SCISetting switchCellWithTitle:@"Confirm like: Posts/Stories" subtitle:@"Shows an alert when you click the like button on posts or stories to confirm the like" defaultsKey:@"like_confirm"], - [SCISetting switchCellWithTitle:@"Confirm like: Reels" subtitle:@"Shows an alert when you click the like button on reels to confirm the like" defaultsKey:@"like_confirm_reels"] + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm like: Posts/Stories") subtitle:SCILocalized(@"Shows an alert when you click the like button on posts or stories to confirm the like") defaultsKey:@"like_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm like: Reels") subtitle:SCILocalized(@"Shows an alert when you click the like button on reels to confirm the like") defaultsKey:@"like_confirm_reels"] ] }, @{ @"header": @"", @"rows": @[ - [SCISetting switchCellWithTitle:@"Confirm follow" subtitle:@"Shows an alert when you click the follow button to confirm the follow" defaultsKey:@"follow_confirm"], - [SCISetting switchCellWithTitle:@"Confirm repost" subtitle:@"Shows an alert when you click the repost button to confirm before resposting" defaultsKey:@"repost_confirm"], - [SCISetting switchCellWithTitle:@"Confirm call" subtitle:@"Shows an alert when you click the audio/video call button to confirm before calling" defaultsKey:@"call_confirm"], - [SCISetting switchCellWithTitle:@"Confirm voice messages" subtitle:@"Shows an alert to confirm before sending a voice message" defaultsKey:@"voice_message_confirm"], - [SCISetting switchCellWithTitle:@"Confirm follow requests" subtitle:@"Shows an alert when you accept/decline a follow request" defaultsKey:@"follow_request_confirm"], - [SCISetting switchCellWithTitle:@"Confirm shh mode" subtitle:@"Shows an alert to confirm before toggling disappearing messages" defaultsKey:@"shh_mode_confirm"], - [SCISetting switchCellWithTitle:@"Confirm posting comment" subtitle:@"Shows an alert when you click the post comment button to confirm" defaultsKey:@"post_comment_confirm"], - [SCISetting switchCellWithTitle:@"Confirm changing theme" subtitle:@"Shows an alert when you change a chat theme to confirm" defaultsKey:@"change_direct_theme_confirm"], - [SCISetting switchCellWithTitle:@"Confirm sticker interaction" subtitle:@"Shows an alert when you click a sticker on someone's story to confirm the action" defaultsKey:@"sticker_interact_confirm"] + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm follow") subtitle:SCILocalized(@"Shows an alert when you click the follow button to confirm the follow") defaultsKey:@"follow_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm unfollow") subtitle:SCILocalized(@"Shows an alert when you click the unfollow button to confirm") defaultsKey:@"unfollow_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm repost") subtitle:SCILocalized(@"Shows an alert when you click the repost button to confirm before resposting") defaultsKey:@"repost_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm call") subtitle:SCILocalized(@"Shows an alert when you click the audio/video call button to confirm before calling") defaultsKey:@"call_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm voice messages") subtitle:SCILocalized(@"Shows an alert to confirm before sending a voice message") defaultsKey:@"voice_message_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm follow requests") subtitle:SCILocalized(@"Shows an alert when you accept/decline a follow request") defaultsKey:@"follow_request_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm shh mode") subtitle:SCILocalized(@"Shows an alert to confirm before toggling disappearing messages") defaultsKey:@"shh_mode_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm posting comment") subtitle:SCILocalized(@"Shows an alert when you click the post comment button to confirm") defaultsKey:@"post_comment_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm changing theme") subtitle:SCILocalized(@"Shows an alert when you change a chat theme to confirm") defaultsKey:@"change_direct_theme_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm sticker interaction") subtitle:SCILocalized(@"Shows an alert when you click a sticker on someone's story to confirm the action") defaultsKey:@"sticker_interact_confirm"] ] }] ] @@ -393,32 +493,46 @@ @{ @"header": @"", @"rows": @[ - [SCISetting navigationCellWithTitle:@"Backup & Restore" + [SCISetting navigationCellWithTitle:SCILocalized(@"Fake location") + subtitle:@"" + icon:[SCISymbol symbolWithName:@"location.fill.viewfinder"] + viewController:[[SCIFakeLocationSettingsVC alloc] init]], + ] + }, + @{ + @"header": @"", + @"rows": @[ + [SCISetting navigationCellWithTitle:SCILocalized(@"Backup & Restore") subtitle:@"" icon:[SCISymbol symbolWithName:@"arrow.up.arrow.down.square"] navSections:@[@{ @"header": @"", - @"footer": @"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.", + @"footer": SCILocalized(@"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."), @"rows": @[ - [SCISetting buttonCellWithTitle:@"Export settings" - subtitle:@"Save settings as a JSON file" + [SCISetting buttonCellWithTitle:SCILocalized(@"Export settings") + subtitle:SCILocalized(@"Save settings as a JSON file") icon:[SCISymbol symbolWithName:@"square.and.arrow.up"] action:^(void) { [SCISettingsBackup presentExport]; } ], - [SCISetting buttonCellWithTitle:@"Import settings" - subtitle:@"Load settings from a JSON file" + [SCISetting buttonCellWithTitle:SCILocalized(@"Import settings") + subtitle:SCILocalized(@"Load settings from a JSON file") icon:[SCISymbol symbolWithName:@"square.and.arrow.down"] action:^(void) { [SCISettingsBackup presentImport]; } + ], + [SCISetting buttonCellWithTitle:SCILocalized(@"Reset to defaults") + subtitle:SCILocalized(@"Revert every RyukGram preference") + icon:[SCISymbol symbolWithName:@"arrow.counterclockwise.circle"] + action:^(void) { [SCISettingsBackup presentReset]; } ] ] }] ], - // [SCISetting navigationCellWithTitle:@"Experimental" + // [SCISetting navigationCellWithTitle:SCILocalized(@"Experimental") // subtitle:@"" // icon:[SCISymbol symbolWithName:@"testtube.2"] // navSections:@[@{ - // @"header": @"Warning", - // @"footer": @"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" + // @"header": SCILocalized(@"Warning"), + // @"footer": SCILocalized(@"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!") // }, // @{ // @"header": @"", @@ -428,22 +542,22 @@ // } // ] // ], - [SCISetting navigationCellWithTitle:@"Advanced" + [SCISetting navigationCellWithTitle:SCILocalized(@"Advanced") subtitle:@"" icon:[SCISymbol symbolWithName:@"gearshape.2"] navSections:@[@{ - @"header": @"Settings", + @"header": SCILocalized(@"Settings"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Enable tweak settings quick-access" subtitle:@"Hold on the home tab to open RyukGram settings" defaultsKey:@"settings_shortcut" requiresRestart:YES], - [SCISetting switchCellWithTitle:@"Show tweak settings on app launch" subtitle:@"Automatically opens settings when the app launches" defaultsKey:@"tweak_settings_app_launch"], - [SCISetting switchCellWithTitle:@"Pause playback when opening settings" subtitle:@"Pauses any playing video/audio when settings opens" defaultsKey:@"settings_pause_playback"], + [SCISetting switchCellWithTitle:SCILocalized(@"Enable tweak settings quick-access") subtitle:SCILocalized(@"Hold on the home tab to open RyukGram settings") defaultsKey:@"settings_shortcut" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Show tweak settings on app launch") subtitle:SCILocalized(@"Automatically opens settings when the app launches") defaultsKey:@"tweak_settings_app_launch"], + [SCISetting switchCellWithTitle:SCILocalized(@"Pause playback when opening settings") subtitle:SCILocalized(@"Pauses any playing video/audio when settings opens") defaultsKey:@"settings_pause_playback"], ] }, @{ - @"header": @"Instagram", + @"header": SCILocalized(@"Instagram"), @"rows": @[ - [SCISetting switchCellWithTitle:@"Disable safe mode" subtitle:@"Prevents Instagram from resetting settings after crashes (at your own risk)" defaultsKey:@"disable_safe_mode"], - [SCISetting buttonCellWithTitle:@"Reset onboarding state" + [SCISetting switchCellWithTitle:SCILocalized(@"Disable safe mode") subtitle:SCILocalized(@"Prevents Instagram from resetting settings after crashes (at your own risk)") defaultsKey:@"disable_safe_mode"], + [SCISetting buttonCellWithTitle:SCILocalized(@"Reset onboarding state") subtitle:@"" icon:nil action:^(void) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"SCInstaFirstRun"]; [SCIUtils showRestartConfirmation];} @@ -451,33 +565,33 @@ ] }] ], - [SCISetting navigationCellWithTitle:@"Debug" + [SCISetting navigationCellWithTitle:SCILocalized(@"Debug") subtitle:@"" icon:[SCISymbol symbolWithName:@"ladybug"] navSections:@[@{ @"header": @"FLEX", @"rows": @[ - [SCISetting switchCellWithTitle:@"Enable FLEX gesture" subtitle:@"Hold 5 fingers on the screen to open FLEX" defaultsKey:@"flex_instagram"], - [SCISetting switchCellWithTitle:@"Open FLEX on app launch" subtitle:@"Opens FLEX when the app launches" defaultsKey:@"flex_app_launch"], - [SCISetting switchCellWithTitle:@"Open FLEX on app focus" subtitle:@"Opens FLEX when the app is focused" defaultsKey:@"flex_app_start"] + [SCISetting switchCellWithTitle:SCILocalized(@"Enable FLEX gesture") subtitle:SCILocalized(@"Hold 5 fingers on the screen to open FLEX") defaultsKey:@"flex_instagram"], + [SCISetting switchCellWithTitle:SCILocalized(@"Open FLEX on app launch") subtitle:SCILocalized(@"Opens FLEX when the app launches") defaultsKey:@"flex_app_launch"], + [SCISetting switchCellWithTitle:SCILocalized(@"Open FLEX on app focus") subtitle:SCILocalized(@"Opens FLEX when the app is focused") defaultsKey:@"flex_app_start"] ] }, @{ - @"header": @"_ Example", + @"header": SCILocalized(@"_ Example"), @"rows": @[ - [SCISetting staticCellWithTitle:@"Static Cell" subtitle:@"" icon:[SCISymbol symbolWithName:@"tablecells"]], - [SCISetting switchCellWithTitle:@"Switch Cell" subtitle:@"Tap the switch" defaultsKey:@"test_switch_cell"], - [SCISetting switchCellWithTitle:@"Switch Cell (Restart)" subtitle:@"Tap the switch" defaultsKey:@"test_switch_cell_restart" requiresRestart:YES], - [SCISetting stepperCellWithTitle:@"Stepper cell" subtitle:@"I have %@%@" defaultsKey:@"test_stepper_cell" min:-10 max:1000 step:5.5 label:@"$" singularLabel:@"$"], - [SCISetting linkCellWithTitle:@"Link Cell" subtitle:@"Using icon" icon:[SCISymbol symbolWithName:@"link" color:[UIColor systemTealColor] size:20.0] url:@"https://google.com"], - [SCISetting linkCellWithTitle:@"Link Cell" subtitle:@"Using image" imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://google.com"], - [SCISetting buttonCellWithTitle:@"Button Cell" + [SCISetting staticCellWithTitle:SCILocalized(@"Static Cell") subtitle:@"" icon:[SCISymbol symbolWithName:@"tablecells"]], + [SCISetting switchCellWithTitle:SCILocalized(@"Switch Cell") subtitle:SCILocalized(@"Tap the switch") defaultsKey:@"test_switch_cell"], + [SCISetting switchCellWithTitle:SCILocalized(@"Switch Cell (Restart)") subtitle:SCILocalized(@"Tap the switch") defaultsKey:@"test_switch_cell_restart" requiresRestart:YES], + [SCISetting stepperCellWithTitle:SCILocalized(@"Stepper cell") subtitle:SCILocalized(@"I have %@%@") defaultsKey:@"test_stepper_cell" min:-10 max:1000 step:5.5 label:@"$" singularLabel:@"$"], + [SCISetting linkCellWithTitle:SCILocalized(@"Link Cell") subtitle:SCILocalized(@"Using icon") icon:[SCISymbol symbolWithName:@"link" color:[UIColor systemTealColor] size:20.0] url:@"https://google.com"], + [SCISetting linkCellWithTitle:SCILocalized(@"Link Cell") subtitle:SCILocalized(@"Using image") imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://google.com"], + [SCISetting buttonCellWithTitle:SCILocalized(@"Button Cell") subtitle:@"" icon:[SCISymbol symbolWithName:@"oval.inset.filled"] action:^(void) { [SCIUtils showConfirmation:^(void){}]; } ], - [SCISetting menuCellWithTitle:@"Menu Cell" subtitle:@"Change the value on the right" menu:[self menus][@"test"]], - [SCISetting navigationCellWithTitle:@"Navigation Cell" + [SCISetting menuCellWithTitle:SCILocalized(@"Menu Cell") subtitle:SCILocalized(@"Change the value on the right") menu:[self menus][@"test"]], + [SCISetting navigationCellWithTitle:SCILocalized(@"Navigation Cell") subtitle:@"" icon:[SCISymbol symbolWithName:@"rectangle.stack"] navSections:@[@{ @@ -486,26 +600,78 @@ }] ] ], - @"footer": @"_ Example" + @"footer": SCILocalized(@"_ Example") } ] ] ] }, @{ - @"header": @"Credits", + @"header": SCILocalized(@"Credits"), @"rows": @[ - [SCISetting linkCellWithTitle:@"Ryuk" subtitle:@"Developer" imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled"], - [SCISetting linkCellWithTitle:@"View Repo" subtitle:@"View the source code on GitHub" imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"], - [SCISetting linkCellWithTitle:@"SoCuul" subtitle:@"Original SCInsta developer" imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://github.com/SoCuul/SCInsta"], - [SCISetting linkCellWithTitle:@"Donate to SoCuul" subtitle:@"Support the original developer" icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"] + [SCISetting linkCellWithTitle:SCILocalized(@"Ryuk") subtitle:SCILocalized(@"Developer") imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled"], + [SCISetting linkCellWithTitle:SCILocalized(@"View Repo") subtitle:SCILocalized(@"View the source code on GitHub") imageUrl:@"https://i.imgur.com/BBUNzeP.png" url:@"https://github.com/faroukbmiled/RyukGram"], + [SCISetting linkCellWithTitle:SCILocalized(@"SoCuul") subtitle:SCILocalized(@"Original SCInsta developer") imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://github.com/SoCuul/SCInsta"], + [SCISetting linkCellWithTitle:SCILocalized(@"Donate to SoCuul") subtitle:SCILocalized(@"Support the original developer") icon:[SCISymbol symbolWithName:@"heart.circle.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"] ], - @"footer": [NSString stringWithFormat:@"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul", SCIVersionString, [SCIUtils IGVersionString]] + @"footer": [NSString stringWithFormat:SCILocalized(@"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul"), SCIVersionString, [SCIUtils IGVersionString]] } ]; } +// MARK: - Enhanced downloads section + ++ (NSDictionary *)enhancedDownloadsSection { + BOOL ffmpegAvailable = [SCIFFmpeg isAvailable]; + BOOL disabled = !ffmpegAvailable; + + NSString *footer = ffmpegAvailable + ? SCILocalized(@"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit.") + : SCILocalized(@"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable."); + + SCISetting *toggle = [SCISetting switchCellWithTitle:SCILocalized(@"Enhanced downloads") + subtitle:SCILocalized(@"Download video at the highest available quality") + defaultsKey:@"enhance_download_quality"]; + toggle.disabled = disabled; + + SCISetting *videoQuality = [SCISetting menuCellWithTitle:SCILocalized(@"Video quality") + subtitle:SCILocalized(@"Which quality to download") + menu:[self menus][@"default_video_quality"]]; + videoQuality.disabled = disabled; + + SCISetting *photoQuality = [SCISetting menuCellWithTitle:SCILocalized(@"Photo quality") + subtitle:SCILocalized(@"Use highest resolution available") + menu:[self menus][@"default_photo_quality"]]; + photoQuality.disabled = disabled; + + SCISetting *encodingSpeed = [SCISetting menuCellWithTitle:SCILocalized(@"Encoding speed") + subtitle:SCILocalized(@"Faster = lower quality") + menu:[self menus][@"ffmpeg_encoding_speed"]]; + encodingSpeed.disabled = disabled; + + return @{ + @"header": SCILocalized(@"Enhanced downloads"), + @"footer": footer, + @"rows": @[toggle, videoQuality, photoQuality, encodingSpeed] + }; +} + + +// MARK: - Date format + ++ (SCISetting *)dateFormatNavCell { + SCISetting *cell = [SCISetting navigationCellWithTitle:SCILocalized(@"Date format") + subtitle:@"" + icon:nil + viewController:[[SCIDateFormatPickerVC alloc] init]]; + cell.dynamicTitle = ^{ + NSString *ex = [SCIDateFormatPickerVC currentFormatExample]; + return [NSString stringWithFormat:SCILocalized(@"Date format — %@"), ex]; + }; + return cell; +} + // MARK: - Title /// @@ -513,7 +679,7 @@ /// + (NSString *)title { - return @"RyukGram Settings"; + return SCILocalized(@"settings.title"); } @@ -536,12 +702,12 @@ + (NSDictionary *)menus { return @{ @"chat_blocking_mode": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Block all" + [UICommand commandWithTitle:SCILocalized(@"Block all") image:nil action:@selector(menuChanged:) propertyList:@{ @"defaultsKey": @"chat_blocking_mode", @"value": @"block_all" } ], - [UICommand commandWithTitle:@"Block selected" + [UICommand commandWithTitle:SCILocalized(@"Block selected") image:nil action:@selector(menuChanged:) propertyList:@{ @"defaultsKey": @"chat_blocking_mode", @"value": @"block_selected" } @@ -549,12 +715,12 @@ ]], @"story_blocking_mode": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Block all" + [UICommand commandWithTitle:SCILocalized(@"Block all") image:nil action:@selector(menuChanged:) propertyList:@{ @"defaultsKey": @"story_blocking_mode", @"value": @"block_all" } ], - [UICommand commandWithTitle:@"Block selected" + [UICommand commandWithTitle:SCILocalized(@"Block selected") image:nil action:@selector(menuChanged:) propertyList:@{ @"defaultsKey": @"story_blocking_mode", @"value": @"block_selected" } @@ -562,7 +728,7 @@ ]], @"story_seen_mode": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Button" + [UICommand commandWithTitle:SCILocalized(@"Button") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -570,7 +736,7 @@ @"value": @"button" } ], - [UICommand commandWithTitle:@"Toggle" + [UICommand commandWithTitle:SCILocalized(@"Toggle") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -581,7 +747,7 @@ ]], @"seen_mode": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Button" + [UICommand commandWithTitle:SCILocalized(@"Button") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -589,7 +755,7 @@ @"value": @"button" } ], - [UICommand commandWithTitle:@"Toggle" + [UICommand commandWithTitle:SCILocalized(@"Toggle") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -600,7 +766,7 @@ ]], @"dw_save_action": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Share sheet" + [UICommand commandWithTitle:SCILocalized(@"Share sheet") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -608,7 +774,7 @@ @"value": @"share" } ], - [UICommand commandWithTitle:@"Save to Photos" + [UICommand commandWithTitle:SCILocalized(@"Save to Photos") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -618,29 +784,68 @@ ] ]], - @"dw_method": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Download button" - image:nil - action:@selector(menuChanged:) - propertyList:@{ - @"defaultsKey": @"dw_method", - @"value": @"button", - @"requiresRestart": @YES - } - ], - [UICommand commandWithTitle:@"Long-press gesture" - image:nil - action:@selector(menuChanged:) - propertyList:@{ - @"defaultsKey": @"dw_method", - @"value": @"gesture", - @"requiresRestart": @YES - } - ] + // Per-context action button default tap mode. Each feature page gets + // its own key so users can pick different defaults per context. + @"feed_action_default": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Open menu") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"feed_action_default", @"value": @"menu"}], + [UICommand commandWithTitle:SCILocalized(@"Expand") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"feed_action_default", @"value": @"expand"}], + [UICommand commandWithTitle:SCILocalized(@"Download and share") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"feed_action_default", @"value": @"download_share"}], + [UICommand commandWithTitle:SCILocalized(@"Download to Photos") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"feed_action_default", @"value": @"download_photos"}], + ]], + @"reels_action_default": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Open menu") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"reels_action_default", @"value": @"menu"}], + [UICommand commandWithTitle:SCILocalized(@"Expand") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"reels_action_default", @"value": @"expand"}], + [UICommand commandWithTitle:SCILocalized(@"Download and share") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"reels_action_default", @"value": @"download_share"}], + [UICommand commandWithTitle:SCILocalized(@"Download to Photos") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"reels_action_default", @"value": @"download_photos"}], + ]], + @"stories_action_default": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Open menu") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"stories_action_default", @"value": @"menu"}], + [UICommand commandWithTitle:SCILocalized(@"Expand") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"stories_action_default", @"value": @"expand"}], + [UICommand commandWithTitle:SCILocalized(@"Download and share") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"stories_action_default", @"value": @"download_share"}], + [UICommand commandWithTitle:SCILocalized(@"Download to Photos") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"stories_action_default", @"value": @"download_photos"}], + ]], + + @"default_video_quality": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Always ask") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"default_video_quality", @"value": @"always_ask"}], + [UICommand commandWithTitle:SCILocalized(@"High") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"default_video_quality", @"value": @"high"}], + [UICommand commandWithTitle:SCILocalized(@"Medium") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"default_video_quality", @"value": @"medium"}], + [UICommand commandWithTitle:SCILocalized(@"Low") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"default_video_quality", @"value": @"low"}], + ]], + @"default_photo_quality": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"High") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"default_photo_quality", @"value": @"high"}], + [UICommand commandWithTitle:SCILocalized(@"Standard") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"default_photo_quality", @"value": @"standard"}], + ]], + @"ffmpeg_encoding_speed": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Fast") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"ffmpeg_encoding_speed", @"value": @"ultrafast"}], + [UICommand commandWithTitle:SCILocalized(@"Balanced") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"ffmpeg_encoding_speed", @"value": @"veryfast"}], + [UICommand commandWithTitle:SCILocalized(@"Quality") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"ffmpeg_encoding_speed", @"value": @"fast"}], + [UICommand commandWithTitle:SCILocalized(@"Max") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"ffmpeg_encoding_speed", @"value": @"max"}], ]], @"reels_tap_control": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Default" + [UICommand commandWithTitle:SCILocalized(@"Default") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -654,7 +859,7 @@ identifier:nil options:UIMenuOptionsDisplayInline children:@[ - [UICommand commandWithTitle:@"Pause/Play" + [UICommand commandWithTitle:SCILocalized(@"Pause/Play") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -663,7 +868,7 @@ @"requiresRestart": @YES } ], - [UICommand commandWithTitle:@"Mute/Unmute" + [UICommand commandWithTitle:SCILocalized(@"Mute/Unmute") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -676,8 +881,22 @@ ] ]], + @"launch_tab": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Default") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"launch_tab", @"value": @"default"}], + [UICommand commandWithTitle:SCILocalized(@"Feed") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"launch_tab", @"value": @"feed"}], + [UICommand commandWithTitle:SCILocalized(@"Explore") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"launch_tab", @"value": @"explore"}], + [UICommand commandWithTitle:SCILocalized(@"Reels") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"launch_tab", @"value": @"reels"}], + [UICommand commandWithTitle:SCILocalized(@"Inbox") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"launch_tab", @"value": @"inbox"}], + [UICommand commandWithTitle:SCILocalized(@"Profile") image:nil action:@selector(menuChanged:) + propertyList:@{@"defaultsKey": @"launch_tab", @"value": @"profile"}], + ]], @"nav_icon_ordering": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Default" + [UICommand commandWithTitle:SCILocalized(@"Default") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -691,7 +910,7 @@ identifier:nil options:UIMenuOptionsDisplayInline children:@[ - [UICommand commandWithTitle:@"Classic" + [UICommand commandWithTitle:SCILocalized(@"Classic") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -700,7 +919,7 @@ @"requiresRestart": @YES } ], - [UICommand commandWithTitle:@"Standard" + [UICommand commandWithTitle:SCILocalized(@"Standard") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -709,7 +928,7 @@ @"requiresRestart": @YES } ], - [UICommand commandWithTitle:@"Alternate" + [UICommand commandWithTitle:SCILocalized(@"Alternate") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -722,7 +941,7 @@ ] ]], @"swipe_nav_tabs": [UIMenu menuWithChildren:@[ - [UICommand commandWithTitle:@"Default" + [UICommand commandWithTitle:SCILocalized(@"Default") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -736,7 +955,7 @@ identifier:nil options:UIMenuOptionsDisplayInline children:@[ - [UICommand commandWithTitle:@"Enabled" + [UICommand commandWithTitle:SCILocalized(@"Enabled") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -745,7 +964,7 @@ @"requiresRestart": @YES } ], - [UICommand commandWithTitle:@"Disabled" + [UICommand commandWithTitle:SCILocalized(@"Disabled") image:nil action:@selector(menuChanged:) propertyList:@{ @@ -782,7 +1001,7 @@ ] ] ], - [UICommand commandWithTitle:@"Requires restart" + [UICommand commandWithTitle:SCILocalized(@"Requires restart") image:nil action:@selector(menuChanged:) propertyList:@{ diff --git a/src/Tweak.x b/src/Tweak.x index 4fec044..cc65227 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -2,6 +2,7 @@ #import "InstagramHeaders.h" #import "Tweak.h" #import "Utils.h" +#include "../modules/fishhook/fishhook.h" /////////////////////////////////////////////////////////// @@ -13,7 +14,7 @@ /////////////////////////////////////////////////////////// // * Tweak version * -NSString *SCIVersionString = @"v1.1.5.1"; +NSString *SCIVersionString = @"v1.2.0"; // Variables that work across features BOOL dmVisualMsgsViewedButtonEnabled = false; @@ -30,12 +31,52 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"remove_screenshot_alert": @(YES), @"call_confirm": @(YES), @"keep_deleted_message": @(NO), - @"dw_feed_posts": @(YES), - @"dw_reels": @(YES), - @"dw_story": @(YES), + @"hide_suggested_stories": @(NO), + @"story_tray_actions": @(NO), + @"zoom_profile_photo": @(NO), + @"follow_indicator": @(NO), + @"profile_note_copy": @(NO), + @"disable_disappearing_mode_swipe": @(NO), + @"hide_voice_call_button": @(NO), + @"hide_video_call_button": @(NO), + @"fake_location_enabled": @(NO), + @"show_fake_location_map_button": @(NO), + @"fake_location_lat": @(48.8584), + @"fake_location_lon": @(2.2945), + @"fake_location_name": @"Eiffel Tower", + @"fake_location_presets": @[], + @"messages_only": @(NO), + @"launch_tab": @"default", @"save_profile": @(YES), - @"dw_method": @"button", - @"dw_confirm": @(YES), + // Per-context action buttons (new in 1.1.6) + @"feed_media_zoom": @(NO), + @"disable_bg_refresh": @(NO), + @"disable_home_refresh": @(NO), + @"disable_home_scroll": @(NO), + @"disable_reels_tab_refresh": @(NO), + @"dm_full_last_active": @(NO), + @"send_file": @(NO), + @"note_actions": @(NO), + @"note_copy_on_hold": @(NO), + @"feed_date_format": @"default", + // Per-surface date format toggles (see SCIDateFormatEntries.h) + @"date_fmt_mixed": @(YES), + @"date_fmt_notes_comments_stories": @(NO), + @"date_fmt_dms": @(NO), + @"feed_action_button": @(YES), + @"feed_action_default": @"menu", + @"reels_action_button": @(YES), + @"reels_action_default": @"menu", + @"stories_action_button": @(YES), + @"stories_action_default": @"menu", + // Legacy long-press gesture (off by default — kept for users who prefer it) + @"dw_legacy_gesture": @(NO), + @"dw_confirm": @(NO), + @"enhance_download_quality": @(YES), + @"default_video_quality": @"always_ask", + @"default_photo_quality": @"high", + @"ffmpeg_encoding_speed": @"ultrafast", + @"unfollow_confirm": @(NO), @"dw_save_action": @"share", @"dw_finger_count": @(3), @"dw_finger_duration": @(0.5), @@ -56,6 +97,8 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"seen_auto_on_interact": @(NO), @"seen_auto_on_typing": @(NO), @"seen_on_story_like": @(NO), + @"seen_on_story_reply": @(NO), + @"advance_on_story_reply": @(NO), @"advance_on_mark_seen": @(NO), @"advance_on_story_like": @(NO), @"indicate_unsent_messages": @(NO), @@ -70,6 +113,7 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"story_excluded_show_unexclude_eye": @(YES), @"story_seen_mode": @"button", @"story_audio_toggle": @(NO), + @"view_story_mentions": @(YES), @"settings_pause_playback": @(YES), @"embed_links": @(NO), @"embed_link_domain": @"kkinstagram.com", @@ -79,9 +123,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"strip_browser_tracking": @(NO), @"hide_feed_repost": @(NO), @"copy_comment": @(YES), - @"download_gif_comment": @(YES) + @"download_gif_comment": @(YES), + @"sci_language": @"system" }; [[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults]; + [SCIUtils setSciRegisteredDefaults:sciDefaults]; // Override instagram defaults if ([SCIUtils getBoolPref:@"liquid_glass_buttons"]) { @@ -608,31 +654,27 @@ shouldPersistLastBugReportId:(id)arg6 for (id obj in originalObjs) { BOOL shouldHide = NO; - // Meta AI - if ( - [[obj valueForKey:@"title"] isEqualToString:@"AI images"] - || [[obj valueForKey:@"title"] isEqualToString:@"Meta AI"] - ) { - - if ([SCIUtils getBoolPref:@"hide_meta_ai"]) { - NSLog(@"[SCInsta] Hiding meta ai from IGDS menu"); + NSString *itemTitle = nil; + @try { itemTitle = [obj valueForKey:@"title"]; } @catch (__unused id e) {} + // Meta AI + if ([itemTitle isEqualToString:@"AI images"] || [itemTitle isEqualToString:@"Meta AI"]) { + if ([SCIUtils getBoolPref:@"hide_meta_ai"]) { shouldHide = YES; } - } - // Populate new objs array if (!shouldHide) { [filteredObjs addObject:obj]; } - } extern NSArray *sciMaybeAppendStoryExcludeMenuItem(NSArray *); extern NSArray *sciMaybeAppendStoryAudioMenuItem(NSArray *); + extern NSArray *sciMaybeAppendStoryMentionsMenuItem(NSArray *); NSArray *finalObjs = sciMaybeAppendStoryExcludeMenuItem([filteredObjs copy]); finalObjs = sciMaybeAppendStoryAudioMenuItem(finalObjs); + finalObjs = sciMaybeAppendStoryMentionsMenuItem(finalObjs); return %orig(finalObjs, edr, headerLabelText); } %end @@ -798,7 +840,42 @@ static BOOL new_expHelper_isHomeFeed(id self, SEL _cmd) { return orig_expHelper_isHomeFeed(self, _cmd); } +// Liquid glass tab bar — C function hooks via fishhook +// Credits: @euoradan (Radan) for discovering these flags +static BOOL (*orig_IGFloatingTabBarEnabled)(void) = NULL; +static BOOL (*orig_IGTabBarDynamicSizingEnabled)(void) = NULL; +static BOOL (*orig_IGTabBarEnhancedDynamicSizingEnabled)(void) = NULL; +static BOOL (*orig_IGTabBarHomecomingWithFloatingTabEnabled)(void) = NULL; +static BOOL (*orig_IGTabBarViewPointFixEnabled)(void) = NULL; +static NSInteger (*orig_IGTabBarStyleForLauncherSet)(NSInteger) = NULL; + +static BOOL hook_IGFloatingTabBarEnabled(void) { + if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES; + return orig_IGFloatingTabBarEnabled ? orig_IGFloatingTabBarEnabled() : NO; +} +static BOOL hook_IGTabBarDynamicSizingEnabled(void) { + if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES; + return orig_IGTabBarDynamicSizingEnabled ? orig_IGTabBarDynamicSizingEnabled() : NO; +} +static BOOL hook_IGTabBarEnhancedDynamicSizingEnabled(void) { + if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES; + return orig_IGTabBarEnhancedDynamicSizingEnabled ? orig_IGTabBarEnhancedDynamicSizingEnabled() : NO; +} +static BOOL hook_IGTabBarHomecomingWithFloatingTabEnabled(void) { + if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES; + return orig_IGTabBarHomecomingWithFloatingTabEnabled ? orig_IGTabBarHomecomingWithFloatingTabEnabled() : NO; +} +static BOOL hook_IGTabBarViewPointFixEnabled(void) { + if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return YES; + return orig_IGTabBarViewPointFixEnabled ? orig_IGTabBarViewPointFixEnabled() : NO; +} +static NSInteger hook_IGTabBarStyleForLauncherSet(NSInteger set) { + if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) return 1; + return orig_IGTabBarStyleForLauncherSet ? orig_IGTabBarStyleForLauncherSet(set) : set; +} + %ctor { + // ObjC hooks for liquid glass buttons Class swizzleToggle = objc_getClass("IGLiquidGlassSwizzle.IGLiquidGlassSwizzleToggle"); if (swizzleToggle) { MSHookMessageEx(swizzleToggle, @selector(isEnabled), @@ -812,4 +889,20 @@ static BOOL new_expHelper_isHomeFeed(id self, SEL _cmd) { MSHookMessageEx(expHelper, @selector(isHomeFeedHeaderEnabled), (IMP)new_expHelper_isHomeFeed, (IMP *)&orig_expHelper_isHomeFeed); } + + // C function hooks for liquid glass tab bar / surfaces (fishhook) + if ([SCIUtils getBoolPref:@"liquid_glass_surfaces"]) { + int result = rebind_symbols((struct rebinding[]){ + {"IGFloatingTabBarEnabled", (void *)hook_IGFloatingTabBarEnabled, (void **)&orig_IGFloatingTabBarEnabled}, + {"IGTabBarDynamicSizingEnabled", (void *)hook_IGTabBarDynamicSizingEnabled, (void **)&orig_IGTabBarDynamicSizingEnabled}, + {"IGTabBarEnhancedDynamicSizingEnabled", (void *)hook_IGTabBarEnhancedDynamicSizingEnabled, (void **)&orig_IGTabBarEnhancedDynamicSizingEnabled}, + {"IGTabBarHomecomingWithFloatingTabEnabled", (void *)hook_IGTabBarHomecomingWithFloatingTabEnabled, (void **)&orig_IGTabBarHomecomingWithFloatingTabEnabled}, + {"IGTabBarViewPointFixEnabled", (void *)hook_IGTabBarViewPointFixEnabled, (void **)&orig_IGTabBarViewPointFixEnabled}, + {"IGTabBarStyleForLauncherSet", (void *)hook_IGTabBarStyleForLauncherSet, (void **)&orig_IGTabBarStyleForLauncherSet}, + }, 6); + NSLog(@"[SCInsta] Liquid glass fishhook result=%d floating=%p dynamic=%p enhanced=%p homecoming=%p viewpoint=%p style=%p", + result, orig_IGFloatingTabBarEnabled, orig_IGTabBarDynamicSizingEnabled, + orig_IGTabBarEnhancedDynamicSizingEnabled, orig_IGTabBarHomecomingWithFloatingTabEnabled, + orig_IGTabBarViewPointFixEnabled, orig_IGTabBarStyleForLauncherSet); + } } diff --git a/src/Utils.h b/src/Utils.h index 388e49d..db2b4a0 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -8,6 +8,7 @@ #import "InstagramHeaders.h" #import "QuickLook.h" +#import "Localization/SCILocalization.h" #import "Settings/SCISettingsViewController.h" @@ -25,6 +26,11 @@ + (double)getDoublePref:(NSString *)key; + (NSString *)getStringPref:(NSString *)key; +// Registered SCInsta defaults (set once at app launch by Tweak.x). Used by +// the settings backup so any new pref is included automatically. ++ (NSDictionary *)sciRegisteredDefaults; ++ (void)setSciRegisteredDefaults:(NSDictionary *)defaults; + + (_Bool)liquidGlassEnabledBool:(_Bool)fallback; + (void)cleanCache; diff --git a/src/Utils.m b/src/Utils.m index 8bccab8..d08d548 100644 --- a/src/Utils.m +++ b/src/Utils.m @@ -20,6 +20,13 @@ return [[NSUserDefaults standardUserDefaults] stringForKey:key]; } +static NSDictionary *sciRegisteredDefaultsRef = nil; + ++ (NSDictionary *)sciRegisteredDefaults { return sciRegisteredDefaultsRef ?: @{}; } ++ (void)setSciRegisteredDefaults:(NSDictionary *)defaults { + sciRegisteredDefaultsRef = [defaults copy]; +} + + (_Bool)liquidGlassEnabledBool:(_Bool)fallback { BOOL setting = [SCIUtils getBoolPref:@"liquid_glass_surfaces"]; return setting ? true : fallback; @@ -271,22 +278,22 @@ // Alerts + (BOOL)showConfirmation:(void(^)(void))okHandler title:(NSString *)title { - UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:@"Are you sure?" preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:@"Yes" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:SCILocalized(@"Are you sure?") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Yes") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { okHandler(); }]]; - [alert addAction:[UIAlertAction actionWithTitle:@"No!" style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"No!") style:UIAlertActionStyleCancel handler:nil]]; [topMostController() presentViewController:alert animated:YES completion:nil]; return nil; }; + (BOOL)showConfirmation:(void(^)(void))okHandler cancelHandler:(void(^)(void))cancelHandler title:(NSString *)title { - UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:@"Are you sure?" preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:@"Yes" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:title message:SCILocalized(@"Are you sure?") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Yes") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { okHandler(); }]]; - [alert addAction:[UIAlertAction actionWithTitle:@"No!" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"No!") style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) { if (cancelHandler != nil) { cancelHandler(); } @@ -303,11 +310,11 @@ return [self showConfirmation:okHandler cancelHandler:cancelHandler title:nil]; } + (void)showRestartConfirmation { - UIAlertController* alert = [UIAlertController alertControllerWithTitle:@"Restart required" message:@"You must restart the app to apply this change" preferredStyle:UIAlertControllerStyleAlert]; - [alert addAction:[UIAlertAction actionWithTitle:@"Restart" style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { + UIAlertController* alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Restart required") message:SCILocalized(@"You must restart the app to apply this change") preferredStyle:UIAlertControllerStyleAlert]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Restart") style:UIAlertActionStyleDefault handler:^(UIAlertAction * _Nonnull action) { exit(0); }]]; - [alert addAction:[UIAlertAction actionWithTitle:@"Later" style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Later") style:UIAlertActionStyleCancel handler:nil]]; [topMostController() presentViewController:alert animated:YES completion:nil]; };