diff --git a/.github/ISSUE_TEMPLATE/1-bug.yaml b/.github/ISSUE_TEMPLATE/1-bug.yaml index 9a88a9b..8c5639c 100755 --- a/.github/ISSUE_TEMPLATE/1-bug.yaml +++ b/.github/ISSUE_TEMPLATE/1-bug.yaml @@ -4,7 +4,7 @@ title: 'bug: ' labels: - bug assignees: - - SoCuul + - faroukbmiled body: - type: markdown attributes: @@ -12,7 +12,7 @@ body:
>[!TIP] - > If you are looking for support with the tweak, make sure to visit the [SCInsta discussions page](https://github.com/SoCuul/SCInsta/discussions) to get help. + > If you are looking for support with the tweak, make sure to visit the [RyukGram discussions page](https://github.com/faroukbmiled/RyukGram/discussions) to get help. - type: checkboxes id: before-start attributes: @@ -21,13 +21,13 @@ body: options: - label: >- I have read through the - [FAQ](https://github.com/SoCuul/SCInsta/wiki/FAQ) + [FAQ](https://github.com/faroukbmiled/RyukGram/wiki/FAQ) required: true - label: I have made sure this issue has not already been reported previously required: true - label: >- I have made sure this issue is present in the latest version of - SCInsta + RyukGram required: true - label: >- I am confident that this bug presents unintended behaviour within @@ -57,9 +57,9 @@ body: attributes: value: '---' - type: input - id: info-scinsta-version + id: info-ryukgram-version attributes: - label: SCInsta Version + label: RyukGram Version description: This can be found at the bottom of the tweak settings placeholder: e.g. v0.7.0 validations: @@ -76,7 +76,7 @@ body: id: info-install-type attributes: label: Install Type - description: The method used to use to install SCInsta + description: The method used to use to install RyukGram options: - Sideloaded - TrollStore diff --git a/.github/ISSUE_TEMPLATE/2-feat.yaml b/.github/ISSUE_TEMPLATE/2-feat.yaml index a65714b..0c1ad1a 100755 --- a/.github/ISSUE_TEMPLATE/2-feat.yaml +++ b/.github/ISSUE_TEMPLATE/2-feat.yaml @@ -4,7 +4,7 @@ title: 'feat: ' labels: - enhancement assignees: - - SoCuul + - faroukbmiled body: - type: checkboxes id: before-start @@ -14,7 +14,7 @@ body: options: - label: >- I have read through the - [FAQ](https://github.com/SoCuul/SCInsta/wiki/FAQ) + [FAQ](https://github.com/faroukbmiled/RyukGram/wiki/FAQ) required: true - label: I have made sure this feature has not already been already suggested required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index df9474b..fad261a 100755 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,8 +1,8 @@ blank_issues_enabled: false contact_links: - name: '💬 Browse Q&A' - url: https://github.com/SoCuul/SCInsta/wiki/FAQ + url: https://github.com/faroukbmiled/RyukGram/wiki/FAQ about: Find answers to the most commonly asked questions - name: '❓ Need Help?' - url: https://github.com/SoCuul/SCInsta/discussions - about: Visit the SCInsta discussions form to get support + url: https://github.com/faroukbmiled/RyukGram/discussions + about: Visit the RyukGram discussions form to get support diff --git a/.github/workflows/auto-assign-pr.yml b/.github/workflows/auto-assign-pr.yml index 6d91e9c..2deb04e 100644 --- a/.github/workflows/auto-assign-pr.yml +++ b/.github/workflows/auto-assign-pr.yml @@ -1,7 +1,7 @@ name: PR assignment on: - pull_request: + pull_request_target: types: [opened, reopened] jobs: @@ -9,6 +9,7 @@ jobs: runs-on: ubuntu-latest permissions: contents: read + issues: write pull-requests: write steps: - name: 'Auto-assign PR' diff --git a/.github/workflows/buildapp.yml b/.github/workflows/buildapp.yml index e7bf886..882dc81 100644 --- a/.github/workflows/buildapp.yml +++ b/.github/workflows/buildapp.yml @@ -96,7 +96,10 @@ jobs: pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip cd main - # ipapatch disabled — upstream issues. + curl -Lo ipapatch https://github.com/asdfzxcvbn/ipapatch/releases/download/v2.1.3/ipapatch.macos-arm64 + chmod +x ipapatch + export PATH=.:$PATH + ./build.sh sideload ls -la packages env: diff --git a/.github/workflows/delete-workflow-runs.yml b/.github/workflows/delete-workflow-runs.yml index 3550d80..b965bcb 100644 --- a/.github/workflows/delete-workflow-runs.yml +++ b/.github/workflows/delete-workflow-runs.yml @@ -13,7 +13,7 @@ on: delete_workflow_pattern: description: 'Name or filename of the workflow (if not set, all workflows are targeted)' required: false - default: 'Build and Package SCInsta' + default: 'Build and Package RyukGram' delete_workflow_by_state_pattern: description: 'Filter workflows by state: active, deleted, disabled_fork, disabled_inactivity, disabled_manually' required: true diff --git a/.gitignore b/.gitignore index 1724892..2a6242e 100644 --- a/.gitignore +++ b/.gitignore @@ -39,8 +39,7 @@ upstream-scinsta *.ipa *.dylib deploy.sh -PENDING_CHANGES.md -PENDING_CHANGES.md.bk +PENDING_CHANGES.* wrapper/ scripts/*.py scripts/__pycache__/ @@ -50,3 +49,9 @@ modules/ffmpegkit/ # External reference tweaks exp_flags/ + +# Source packaging +zip-src.sh +RyukGram-src-*.zip +*.zip +*_diff.txt diff --git a/.vscode/tasks.json b/.vscode/tasks.json index ff607cf..ce92c10 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -22,7 +22,7 @@ } }, { - "label": "Build SCInsta and deploy with IPA", + "label": "Build RyukGram and deploy with IPA", "type": "shell", "command": "./build-dev.sh true", "group": { diff --git a/README.md b/README.md index 4523d76..14a9c69 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.2.0` | `Tested on Instagram 425.0.0` +`Version v1.2.2` | `Tested on Instagram 426.0.0` --- @@ -27,14 +27,15 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Copy comment text from long-press menu **\*** - Download GIF comments **\*** - Profile copy button **\*** -- Replace domain in shared links — rewrite copied/shared links for embeds in Discord, Telegram, etc. with preset or custom domains **\*** -- Strip tracking params from shared links (igsh, utm) **\*** +- Replace domain in shared links for embeds (Discord, Telegram, etc.) **\*** +- Strip tracking params from shared links **\*** - Open links in external browser **\*** - Strip tracking from browser links **\*** - Do not save recent searches +- Open link from clipboard — long-press the search tab **\*** - Use detailed (native) color picker - Enable liquid glass buttons -- Enable liquid glass surfaces — floating tab bar, dynamic sizing, and other UI elements **\*** +- Enable liquid glass surfaces **\*** - Enable teen app icons - IG Notes: - Hide notes tray @@ -46,28 +47,33 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - No suggested chats - Hide trending searches - Hide explore posts grid +- Live + - Anonymous live viewing **\*** + - Toggle live comments **\*** +- Privacy + - Hide RyukGram UI on screenshots, screen recordings, and mirroring **\*** ### Feed - Hide stories tray -- Hide suggested stories — removes suggested accounts from the stories tray **\*** -- View profile picture from story tray long-press menu (HD via API) **\*** +- Hide suggested stories **\*** +- View profile picture from story tray long-press menu **\*** - Hide entire feed - No suggested posts - No suggested for you (accounts) - No suggested reels - No suggested threads posts - Disable video autoplay -- Media zoom — long press on media to expand in full-screen viewer **\*** -- Custom date format (moved to General > Date format, now supports feed, notes/comments/stories, and DMs) **\*** +- Media zoom — long press media to expand in full-screen viewer **\*** +- Custom date format — feed, notes/comments/stories, and DMs **\*** - Disable background refresh, home button refresh, and home button scroll **\*** - Disable reels tab button refresh **\*** - Hide repost button in feed **\*** ### Reels - Modify tap controls -- Auto-scroll reels — IG default or RyukGram mode (keeps advancing after swiping back) **\*** +- Auto-scroll reels mode **\*** - Always show progress scrubber -- Disable auto-unmuting reels (properly blocks mute switch, volume buttons, and announcer broadcasts) **\*** +- Disable auto-unmuting reels **\*** - Confirm reel refresh - Unlock password-locked reels **\*** - Hide reels header @@ -76,14 +82,14 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Disable scrolling reels - Prevent doom scrolling (limit maximum viewable reels) - Enhanced Pause/Play mode (when Pause/Play tap control is set): **\*** - - Mute toggle auto-hidden, only play/pause icon visible + - Mute toggle auto-hidden - Audio forced on in reels tab - - Play indicator properly hidden when video plays (fixes IG bug after hold/zoom) + - Play indicator hidden during playback - Playback toggle synced with overlay during hold/zoom - - Works across IG A/B test variants + - Optional tap-to-mute on photo reels ### Action buttons **\*** -- Context-aware action menu on feed, reels, and stories (expand, repost, download, copy caption, etc.) **\*** +- Context-aware action menu on feed, reels, and stories **\*** - Configurable default tap action per context **\*** - Carousel and multi-story reel support with bulk download **\*** - Repost via IG's native creation flow **\*** @@ -91,59 +97,65 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Story playback pauses when menus are open **\*** ### Profile **\*** -- Zoom profile photo — long press to view full-screen with user info **\*** +- Zoom profile photo — long press to view full-screen **\*** - Save profile picture - View highlight cover from profile long-press menu **\*** - Profile copy button **\*** - Follow indicator — shows whether the user follows you **\*** -- Copy note on long press — long-press the note bubble to copy text **\*** +- Copy note on long press **\*** +- Fake profile stats — verified badge and follower/following/post counts **\*** +- Profile Analyzer (beta) — follower/following scans with mutuals, non-followbacks, new/lost trackers, and profile change history; searchable lists with batch follow/unfollow **\*** ### Saving -- Enhanced HD downloads — up to 1080p via DASH + FFmpegKit **\*** +- Enhanced HD downloads up to 1080p **\*** - Quality picker with preview playback **\*** + - Audio-only and raw photo download options **\*** - Fallback to 720p without FFmpegKit **\*** -- Download pill with frosted glass, progress bar, bulk counter, success/error states **\*** -- Save to RyukGram album — routes downloads into a dedicated album in Photos **\*** -- Download confirmation — optional dialog before downloading **\*** -- Legacy long-press gesture — deprecated, off by default. Finger count + hold time customizable **\*** +- Download pill with progress bar and bulk counter **\*** +- Save to RyukGram album **\*** +- Download confirmation dialog **\*** +- Output filenames formatted as `@username_context_timestamp` **\*** +- Legacy long-press gesture (deprecated, customizable finger count + hold time) **\*** ### Stories and messages -- Keep deleted messages (preserves unsent messages with visual indicator and notification pill) **\*** +- Keep deleted messages **\*** - Hide trailing action buttons on preserved messages -- Warn before clearing on refresh — optional confirmation when pulling to refresh the DMs tab if preserved messages would be cleared **\*** +- Warn before pull-to-refresh clears preserved messages **\*** - Manually mark messages as seen (button or toggle mode) **\*** - Long-press the seen button for quick actions **\*** -- Auto mark seen on send (marks messages as read when you send any message) **\*** -- Auto mark seen on typing (marks messages as read the moment you start typing, even when typing status is hidden) **\*** +- Auto mark seen on send **\*** +- Auto mark seen on typing **\*** - Mark seen on story like **\*** -- Mark seen on story reply — also covers text replies and emoji reactions **\*** -- Advance to next story when marking as seen — tapping the eye button auto-skips to the next story **\*** -- Advance on story like — liking a story auto-skips to the next one **\*** -- Advance on story reply — sending a reply or emoji reaction auto-skips to the next story **\*** -- Per-chat read-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Long-press any DM chat to add/remove. Settings page with search, sort, multi-select, and per-entry keep-deleted override **\*** -- Send audio as file — send audio files as voice messages from the DM plus menu **\*** -- Download voice messages — adds a Download option to the long-press menu on voice messages, saves as M4A via share sheet **\*** +- Mark seen on story reply **\*** +- Advance to next story when marking as seen **\*** +- Advance on story like **\*** +- Advance on story reply **\*** +- Per-chat read-receipt exclusion list with Block all / Block selected mode **\*** +- Send audio as file from DM plus menu **\*** +- Download voice messages **\*** - Disable typing status -- Disable disappearing messages mode — blocks the swipe-to-enable gesture in DMs **\*** -- Hide voice/video call buttons — independent toggles for each, remaining nav items reflow dynamically **\*** -- Unlimited replay of direct stories (toggle in eye button menu) **\*** -- Full last active date — show full date instead of relative time **\*** -- Send files in DMs (experimental) — send select file types via the plus menu **\*** -- Notes actions — copy text, download GIF/audio from notes long-press menu **\*** +- Disable vanish mode swipe **\*** +- Hide voice/video call buttons (independent toggles) **\*** +- Unlimited replay of direct stories **\*** +- Full last active date **\*** +- Send files in DMs (experimental) **\*** +- Notes actions — copy text, download GIF/audio **\*** - Copy note text on long press **\*** - Disable view-once limitations - Disable screenshot detection -- Disable story seen receipt (blocks network upload, toggleable at runtime without restart) **\*** -- Keep stories visually seen locally — mark stories as seen locally (grey ring) while the seen receipt is still blocked on the server **\*** -- Manual mark story as seen — button on story overlay to selectively mark stories as seen (button or toggle mode) **\*** +- Disable story seen receipt **\*** +- Keep stories visually seen locally **\*** +- Manual mark story as seen (button or toggle mode) **\*** - Long-press the story seen button for quick actions **\*** -- Per-user story seen-receipt list with blocking mode — "Block all" (exclude list) or "Block selected only" (include list). Manage via 3-dot menu, eye button long-press, or settings list **\*** -- Story audio mute/unmute toggle — button on overlay and in action menu to toggle audio **\*** -- View story mentions — bottom sheet with profile pic, follow/unfollow, tap-to-open profile **\*** -- Stop story auto-advance — stories won't auto-skip when the timer ends **\*** -- Download disappearing DM media (photos + videos) — expand, share, or save from action menu **\*** -- Mark disappearing messages as viewed button **\*** -- Upload audio as voice message — send audio files, extract audio from videos, with built-in trim editor **\*** +- Per-user story seen-receipt exclusion list with Block all / Block selected mode **\*** +- Story audio mute/unmute toggle **\*** +- View story mentions **\*** +- Stop story auto-advance **\*** +- Reveal poll/slider vote counts and quiz answers on stories and reels before interacting **\*** +- Force legacy Quiz sticker back into the story composer tray **\*** +- Disappearing DM media overlay — action button, mark-as-viewed eye, and audio toggle **\*** +- Download disappearing DM media **\*** +- Upload audio as voice message with built-in trim editor **\*** - Disable instants creation ### Navigation @@ -155,47 +167,61 @@ A feature-rich iOS tweak for Instagram, forked from [SCInsta](https://github.com - Hide reels tab - Hide create tab - Hide messages tab -- Messages-only mode — keep DM inbox + profile, hide everything else, force launch into inbox **\*** -- Launch tab — pick which tab the app opens to (ignored in Messages-only mode) **\*** +- Messages-only mode — inbox + profile only, launch straight into inbox **\*** + - Hide tab bar sub-toggle — floating settings gear replaces it **\*** +- Launch tab — pick which tab the app opens to **\*** ### Confirm actions - Confirm like: Posts/Stories +- Confirm story emoji reaction **\*** - Confirm like: Reels - Confirm follow - Confirm unfollow **\*** - Confirm repost -- Confirm call +- Confirm voice call **\*** +- Confirm video call **\*** - Confirm voice messages - Confirm follow requests -- Confirm shh mode (disappearing messages) +- Confirm vanish mode - Confirm posting comment - Confirm changing direct message theme -- Confirm sticker interaction +- Confirm sticker interaction (stories / highlights, separate toggles) **\*** ### Fake location **\*** -- Overrides CoreLocation app-wide so any IG feature reading a coord (Friends Map, posts, etc.) gets your chosen location +- Override location app-wide for any IG feature reading coordinates - MapKit picker with search + reverse-geocoded names -- Saved presets — tap to apply -- Quick toggle button injected into the Friends Map: enable/disable, swap presets, change location, open settings +- Saved presets +- Quick toggle button on the Friends Map + +### Theme **\*** +- Force dark mode +- Full OLED — pure black app-wide +- OLED chat theme — pure black DM thread and incoming bubbles +- Keyboard theme — dark or OLED +- Apply & restart button ### Tweak settings **\*** -- Search bar in the main settings page — recursively finds any setting across nested pages with a breadcrumb to its location -- Pause playback when opening settings (toggleable) **\*** +- Search bar with breadcrumbs across nested pages +- Pause playback when opening settings **\*** - Quick-access via long-press on feed tab **\*** +### Advanced experimental features **\*** +- Toggle hidden Instagram experiments: QuickSnap (Instants), Direct Notes reply types, Friend Map, Homecoming, Prism +- Batched changes with an Apply & restart button +- Auto-reset after 3 consecutive launch crashes + ### Backup & Restore **\*** -- Export RyukGram settings as a JSON file -- Import settings from a JSON file -- Searchable, collapsible, editable preview before saving or applying +- Export RyukGram settings as JSON +- Import settings from JSON +- Preview before saving or applying ### Localization **\*** -- Multi-language UI — every user-facing string in RyukGram flows through a central translation layer **\*** -- Built-in language picker — globe icon in the top-right of Settings; pick System default or any shipped language **\*** -- Falls back to English when a translation is missing, so nothing ever breaks **\*** -- Currently shipping: **English**, **Spanish** — other languages land as translators submit them (see below). +- Multi-language UI with fallback to English **\*** +- Built-in language picker in Settings **\*** +- Currently shipping: **English**, **Spanish**, **Russian**, **Korean**, **Arabic**, **Chinese (Traditional)** ### Optimization -- Automatically clears unneeded cache folders, reducing the size of your Instagram installation +- Clear Instagram cache on demand with optional auto-clear interval **\*** # Translating RyukGram Want to see RyukGram in your language? Two ways: @@ -221,6 +247,7 @@ If you find a string that still renders in English on a translated build, open a ## 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. +- With Liquid Glass buttons + Hide UI on capture both on, the DM eye leaves an empty glass bubble in captures — IG draws that backdrop, not the tweak, so it's outside our redaction. # Opening Tweak Settings @@ -258,4 +285,9 @@ $ ./build.sh - [@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 +- [@asdfzxcvbn](https://github.com/asdfzxcvbn) — [ipapatch](https://github.com/asdfzxcvbn/ipapatch) and [zxPluginsInject](https://github.com/asdfzxcvbn/zxPluginsInject) - Furamako — Spanish translation +- [@ch1tmdgus](https://github.com/ch1tmdgus) (N4C) — Korean translation +- [ZomkaDEV](https://github.com/ZomkaDEV) — Russian translation +- [@bruuhim](https://github.com/bruuhim) — Arabic translation +- [@jaydenjcpy](https://github.com/jaydenjcpy) — Chinese (Traditional) translation diff --git a/build-dev.sh b/build-dev.sh index 8c45998..40b3216 100755 --- a/build-dev.sh +++ b/build-dev.sh @@ -22,7 +22,7 @@ else make DEV=1 # Change framework locations to @rpath - install_name_tool -change "/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate" "@rpath/CydiaSubstrate.framework/CydiaSubstrate" ".theos/obj/debug/SCInsta.dylib" 2>/dev/null || true + install_name_tool -change "/Library/Frameworks/CydiaSubstrate.framework/CydiaSubstrate" "@rpath/CydiaSubstrate.framework/CydiaSubstrate" ".theos/obj/debug/RyukGram.dylib" 2>/dev/null || true _scinsta_devquick_after fi \ No newline at end of file diff --git a/build.sh b/build.sh index 1ac9e3b..4578e87 100755 --- a/build.sh +++ b/build.sh @@ -28,6 +28,18 @@ copy_localization_into_bundle() { done } +# Copy generic static assets (PNGs, etc.) into a RyukGram.bundle. Used for +# bundled images the tweak loads via SCILocalizationBundle(). +# Arg 1: destination bundle directory (created if missing). +copy_bundle_assets() { + local DEST="$1" + local SRC="src/BundleAssets" + [ -d "$SRC" ] || return 0 + mkdir -p "$DEST" + find "$SRC" -maxdepth 1 -type f \( -iname '*.png' -o -iname '*.jpg' -o -iname '*.pdf' \) \ + -exec cp {} "$DEST/" \; +} + # Collect all FFmpegKit frameworks for injection ffmpegkit_frameworks() { local fws="" @@ -57,7 +69,7 @@ inject_bundle_into_deb() { local BUNDLE_DIR="$TMPDIR/${PREFIX}Library/Application Support/RyukGram.bundle" mkdir -p "$BUNDLE_DIR" - ( cd .. && copy_localization_into_bundle "$BUNDLE_DIR" ) + ( cd .. && copy_localization_into_bundle "$BUNDLE_DIR" && copy_bundle_assets "$BUNDLE_DIR" ) if [ -d "../modules/ffmpegkit/ffmpegkit.framework" ]; then for fw in ../modules/ffmpegkit/*.framework; do @@ -89,6 +101,28 @@ inject_bundle_into_deb() { rm -rf "$TMPDIR" } +# Build zxPluginsInject.dylib -> packages/zxPluginsInject.dylib +build_zxpi_dylib() { + ./wrapper/build-zxpi.sh >/dev/null + [ -f packages/zxPluginsInject.dylib ] || { + echo -e '\033[1m\033[0;31mzxPluginsInject.dylib build failed\033[0m' >&2 + exit 1 + } +} + +# LC-inject zxPluginsInject.dylib into main exec + every .appex in the IPA. +# Arg 1: path to the IPA +run_ipapatch() { + local IPA="$1" + if ! command -v ipapatch &> /dev/null; then + echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m' + echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest' + exit 1 + fi + echo -e '\033[1m\033[32mRunning ipapatch (zxPluginsInject LC injection)\033[0m' + ipapatch --input "$IPA" --inplace --noconfirm --dylib packages/zxPluginsInject.dylib +} + # Build just the dylib (for Feather/manual injection) if [ "$1" == "dylib" ]; then @@ -108,6 +142,7 @@ then # Ship localization bundle next to the dylib so Feather/manual installs work. copy_localization_into_bundle "packages/RyukGram.bundle" + copy_bundle_assets "packages/RyukGram.bundle" echo -e "\033[1m\033[32mDone!\033[0m\n\nDylib at: $(pwd)/packages/RyukGram.dylib\nBundle at: $(pwd)/packages/RyukGram.bundle" @@ -194,13 +229,21 @@ then echo -e '\033[0;33mOr use ./build.sh dylib to build the dylib for Feather injection.\033[0m' exit 1 fi - # ipapatch disabled — upstream issues. + if ! command -v ipapatch &> /dev/null; then + echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m' + echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest' + exit 1 + fi fi echo -e '\033[1m\033[32mBuilding RyukGram tweak for sideloading (as IPA)\033[0m' make $MAKEARGS + # Build zxPluginsInject.dylib so ipapatch can inject it after cyan + echo -e '\033[1m\033[32mBuilding zxPluginsInject.dylib\033[0m' + build_zxpi_dylib + # Copy dylib to packages mkdir -p packages cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib @@ -216,6 +259,7 @@ then rm -rf "$BUNDLE_PATH" mkdir -p "$BUNDLE_PATH" copy_localization_into_bundle "$BUNDLE_PATH" + copy_bundle_assets "$BUNDLE_PATH" if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then echo -e '\033[1m\033[32mBuilding RyukGram.bundle\033[0m' for fw in modules/ffmpegkit/*.framework; do @@ -271,7 +315,7 @@ then rm -rf "$INJECT_TMP" fi - # ipapatch disabled — upstream issues. + run_ipapatch packages/RyukGram-sideloaded.ipa echo -e "\033[1m\033[32mDone, enjoy RyukGram!\033[0m\n\nYou can find the ipa file at: $(pwd)/packages" @@ -365,15 +409,24 @@ then echo ' pip install --force-reinstall https://github.com/asdfzxcvbn/pyzule-rw/archive/main.zip' exit 1 fi + if ! command -v ipapatch &> /dev/null; then + echo -e '\033[1m\033[0;31mipapatch not found. Install it from:\033[0m' + echo ' https://github.com/asdfzxcvbn/ipapatch/releases/latest' + exit 1 + fi echo -e '\033[1m\033[32mBuilding RyukGram tweak for TrollStore (.tipa)\033[0m' make $MAKEARGS cp .theos/obj/debug/RyukGram.dylib packages/RyukGram.dylib + echo -e '\033[1m\033[32mBuilding zxPluginsInject.dylib\033[0m' + build_zxpi_dylib + BUNDLE_PATH="packages/RyukGram.bundle" rm -rf "$BUNDLE_PATH" mkdir -p "$BUNDLE_PATH" copy_localization_into_bundle "$BUNDLE_PATH" + copy_bundle_assets "$BUNDLE_PATH" if [ -d "modules/ffmpegkit/ffmpegkit.framework" ]; then for fw in modules/ffmpegkit/*.framework; do cp -R "$fw" "$BUNDLE_PATH/" @@ -424,6 +477,8 @@ then rm -rf "$INJECT_TMP" fi + run_ipapatch packages/RyukGram-trollstore.ipa + mv packages/RyukGram-trollstore.ipa packages/RyukGram-trollstore.tipa echo -e "\033[1m\033[32mDone!\033[0m\n\nTIPA at: $(pwd)/packages/RyukGram-trollstore.tipa" diff --git a/control b/control index 152e10b..d9aee6d 100644 --- a/control +++ b/control @@ -1,6 +1,6 @@ Package: com.faroukbmiled.ryukgram Name: RyukGram -Version: 1.2.0 +Version: 1.2.2 Architecture: iphoneos-arm Description: A feature-rich tweak for Instagram on iOS, based on SCInsta Homepage: https://github.com/faroukbmiled/RyukGram diff --git a/modules/zxPluginsInject/Makefile b/modules/zxPluginsInject/Makefile new file mode 100644 index 0000000..cd9bfc1 --- /dev/null +++ b/modules/zxPluginsInject/Makefile @@ -0,0 +1,12 @@ +TARGET := iphone:clang:16.2:14.0 +ARCHS := arm64 + +include $(THEOS)/makefiles/common.mk + +TWEAK_NAME := zxPluginsInject + +$(TWEAK_NAME)_FILES := $(shell find src -type f -name "*.*m") ../fishhook/fishhook.c +$(TWEAK_NAME)_CFLAGS := -fobjc-arc -Os +$(TWEAK_NAME)_LOGOS_DEFAULT_GENERATOR := internal + +include $(THEOS_MAKE_PATH)/tweak.mk diff --git a/modules/zxPluginsInject/src/Header.h b/modules/zxPluginsInject/src/Header.h new file mode 100644 index 0000000..49db8a4 --- /dev/null +++ b/modules/zxPluginsInject/src/Header.h @@ -0,0 +1,15 @@ +#import + +extern NSString *accessGroupId; +extern NSString *bundleId; + +extern void rebindSecFuncs(); + +extern BOOL createDirectoryIfNotExists(NSString *path); +extern NSURL *getAppGroupPathIfExists(); + +@interface LSBundleProxy: NSObject +@property(nonatomic, assign, readonly) NSDictionary *entitlements; +@property(nonatomic, assign, readonly) NSDictionary *groupContainerURLs; ++ (instancetype)bundleProxyForCurrentProcess; +@end diff --git a/modules/zxPluginsInject/src/Paths.mm b/modules/zxPluginsInject/src/Paths.mm new file mode 100644 index 0000000..362334c --- /dev/null +++ b/modules/zxPluginsInject/src/Paths.mm @@ -0,0 +1,38 @@ +#import + +#import "Header.h" + +BOOL createDirectoryIfNotExists(NSString *path) { + NSFileManager *fileManager = [NSFileManager defaultManager]; + if ([fileManager fileExistsAtPath:path]) return YES; + + NSError *error = nil; + [fileManager createDirectoryAtPath:path + withIntermediateDirectories:YES + attributes:nil + error:&error]; + return error == nil; +} + +NSURL *getAppGroupPathIfExists() { + static NSURL *cachedAppGroupPath = nil; + static dispatch_once_t onceToken; + + dispatch_once(&onceToken, ^{ + LSBundleProxy *bundleProxy = [objc_getClass("LSBundleProxy") bundleProxyForCurrentProcess]; + if (!bundleProxy) return; + + NSDictionary *entitlements = bundleProxy.entitlements; + if (![entitlements isKindOfClass:[NSDictionary class]]) return; + + NSArray *appGroups = entitlements[@"com.apple.security.application-groups"]; + if (appGroups.count == 0) return; + + NSDictionary *appGroupsPaths = bundleProxy.groupContainerURLs; + if (![appGroupsPaths isKindOfClass:[NSDictionary class]]) return; + + cachedAppGroupPath = appGroupsPaths[[appGroups firstObject]]; + }); + + return cachedAppGroupPath; +} diff --git a/modules/zxPluginsInject/src/SecRebinds.xm b/modules/zxPluginsInject/src/SecRebinds.xm new file mode 100644 index 0000000..16ff38b --- /dev/null +++ b/modules/zxPluginsInject/src/SecRebinds.xm @@ -0,0 +1,43 @@ +#import + +#import "Header.h" +#import "../../fishhook/fishhook.h" + +static OSStatus (*origSecItemAdd)(CFDictionaryRef attributes, CFTypeRef *result); +static OSStatus (*origSecItemCopyMatching)(CFDictionaryRef query, CFTypeRef *result); +static OSStatus (*origSecItemUpdate)(CFDictionaryRef query, CFDictionaryRef attributesToUpdate); +static OSStatus (*origSecItemDelete)(CFDictionaryRef query); + +static OSStatus zxSecItemAdd(CFDictionaryRef attributes, CFTypeRef *result) { + NSMutableDictionary *mutableAttributes = [(__bridge NSDictionary *)attributes mutableCopy]; + mutableAttributes[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId; + return origSecItemAdd((__bridge CFDictionaryRef)mutableAttributes, result); +} + +static OSStatus zxSecItemCopyMatching(CFDictionaryRef query, CFTypeRef *result) { + NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy]; + mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId; + return origSecItemCopyMatching((__bridge CFDictionaryRef)mutableQuery, result); +} + +static OSStatus zxSecItemUpdate(CFDictionaryRef query, CFDictionaryRef attributesToUpdate) { + NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy]; + mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId; + return origSecItemUpdate((__bridge CFDictionaryRef)mutableQuery, attributesToUpdate); +} + +static OSStatus zxSecItemDelete(CFDictionaryRef query) { + NSMutableDictionary *mutableQuery = [(__bridge NSDictionary *)query mutableCopy]; + mutableQuery[(__bridge NSString *)kSecAttrAccessGroup] = accessGroupId; + return origSecItemDelete((__bridge CFDictionaryRef)mutableQuery); +} + +void rebindSecFuncs() { + struct rebinding rebinds[4] = { + {"SecItemAdd", (void *)zxSecItemAdd, (void **)&origSecItemAdd}, + {"SecItemCopyMatching", (void *)zxSecItemCopyMatching, (void **)&origSecItemCopyMatching}, + {"SecItemUpdate", (void *)zxSecItemUpdate, (void **)&origSecItemUpdate}, + {"SecItemDelete", (void *)zxSecItemDelete, (void **)&origSecItemDelete} + }; + rebind_symbols(rebinds, 4); +} diff --git a/modules/zxPluginsInject/src/SideloadFix.xm b/modules/zxPluginsInject/src/SideloadFix.xm new file mode 100644 index 0000000..d8c243d --- /dev/null +++ b/modules/zxPluginsInject/src/SideloadFix.xm @@ -0,0 +1,57 @@ +#import "Header.h" + +%hook CKContainer +- (id)_setupWithContainerID:(id)a options:(id)b { return nil; } +- (id)_initWithContainerIdentifier:(id)a { return nil; } +%end + +%hook CKEntitlements +- (id)initWithEntitlementsDict:(NSDictionary *)entitlements { + NSMutableDictionary *mutEntitlements = [entitlements mutableCopy]; + [mutEntitlements removeObjectForKey:@"com.apple.developer.icloud-container-environment"]; + [mutEntitlements removeObjectForKey:@"com.apple.developer.icloud-services"]; + return %orig([mutEntitlements copy]); +} +%end + +%hook NSFileManager +- (NSURL *)containerURLForSecurityApplicationGroupIdentifier:(NSString *)groupIdentifier { + if (NSURL *ourAppGroupURL = getAppGroupPathIfExists()) { + NSURL *fakeAppGroupURL = [ourAppGroupURL URLByAppendingPathComponent:groupIdentifier]; + createDirectoryIfNotExists(fakeAppGroupURL.path); + return fakeAppGroupURL; + } + + NSArray *paths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES); + NSString *fakePath = [[paths lastObject] stringByAppendingPathComponent:groupIdentifier]; + createDirectoryIfNotExists(fakePath); + return [NSURL fileURLWithPath:fakePath]; +} +%end + +// Scoped to app-extension processes only. Appex needs the suite redirect so +// it reads the group.* defaults the main app wrote (rich push previews depend +// on it). Applying it in the main process breaks UI-dismiss flag persistence +// on IG 423+ (Friends Map "Not now" reappears every launch). +static BOOL sciIsAppExtensionProcess(void) { + static BOOL cached = NO; + static dispatch_once_t token; + dispatch_once(&token, ^{ + cached = ([[NSBundle mainBundle] infoDictionary][@"NSExtension"] != nil); + }); + return cached; +} + +%hook NSUserDefaults +- (id)_initWithSuiteName:(NSString *)suiteName container:(NSURL *)container { + if (!sciIsAppExtensionProcess()) return %orig(suiteName, container); + + NSURL *appGroupURL = getAppGroupPathIfExists(); + if (!appGroupURL || ![suiteName hasPrefix:@"group"]) return %orig(suiteName, container); + + if (NSURL *customContainerURL = [appGroupURL URLByAppendingPathComponent:suiteName]) { + return %orig(suiteName, customContainerURL); + } + return %orig(suiteName, container); +} +%end diff --git a/modules/zxPluginsInject/src/zxPluginsInject.mm b/modules/zxPluginsInject/src/zxPluginsInject.mm new file mode 100644 index 0000000..248fb22 --- /dev/null +++ b/modules/zxPluginsInject/src/zxPluginsInject.mm @@ -0,0 +1,29 @@ +#import "Header.h" + +NSString *accessGroupId; +NSString *bundleId; + +static void setRequiredIDs() { + NSDictionary *query = @{ + (__bridge NSString *)kSecClass: (__bridge NSString *)kSecClassGenericPassword, + (__bridge NSString *)kSecAttrAccount: @"zxPluginsInjectGenericEntry", + (__bridge NSString *)kSecAttrService: @"", + (__bridge id)kSecReturnAttributes: (id)kCFBooleanTrue + }; + + CFDictionaryRef result = nil; + OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, (CFTypeRef *)&result); + if (status == errSecItemNotFound) { + status = SecItemAdd((__bridge CFDictionaryRef)query, (CFTypeRef *)&result); + } + if (status != errSecSuccess) return; + + bundleId = [[NSBundle mainBundle] bundleIdentifier]; + accessGroupId = [(__bridge NSDictionary *)result objectForKey:(__bridge NSString *)kSecAttrAccessGroup]; + if (result) CFRelease(result); +} + +__attribute__((constructor)) static void init() { + setRequiredIDs(); + rebindSecFuncs(); +} diff --git a/src/ActionButton/SCIActionButton.m b/src/ActionButton/SCIActionButton.m index 0f7820f..66f94da 100644 --- a/src/ActionButton/SCIActionButton.m +++ b/src/ActionButton/SCIActionButton.m @@ -118,11 +118,16 @@ const void *kSCIDismissKey = &kSCIDismissKey; id media = provider(sender); if (media == (id)kCFNull) return; + SCIActionContext tapCtx = (SCIActionContext)ctxNum.integerValue; + NSString *tapCtxLabel = [SCIMediaActions contextLabelForContext:tapCtx]; + if ([tap isEqualToString:@"expand"]) { [SCIMediaActions expandMedia:media fromView:sender caption:nil]; } else if ([tap isEqualToString:@"download_share"]) { + [SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]]; [SCIMediaActions downloadAndShareMedia:media]; } else if ([tap isEqualToString:@"download_photos"]) { + [SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:media contextLabel:tapCtxLabel]]; [SCIMediaActions downloadAndSaveMedia:media]; } else if ([tap isEqualToString:@"copy_link"]) { [SCIMediaActions copyURLForMedia:media]; diff --git a/src/ActionButton/SCIMediaActions.h b/src/ActionButton/SCIMediaActions.h index dc1d978..7120247 100644 --- a/src/ActionButton/SCIMediaActions.h +++ b/src/ActionButton/SCIMediaActions.h @@ -2,6 +2,7 @@ #import #import "../InstagramHeaders.h" +#import "../Downloader/Download.h" #import "SCIActionMenu.h" NS_ASSUME_NONNULL_BEGIN @@ -16,6 +17,18 @@ typedef NS_ENUM(NSInteger, SCIActionContext) { @interface SCIMediaActions : NSObject +// MARK: - Filename naming + +// `@username_context_yyyyMMdd_HHmmss` (sanitized). UUID fallback on failure. ++ (NSString *)filenameStemForMedia:(nullable id)media contextLabel:(NSString *)ctxLabel; + +// "feed" / "reels" / "stories". ++ (NSString *)contextLabelForContext:(SCIActionContext)ctx; + +// Stem read by the download + mux write sites to name output files. ++ (nullable NSString *)currentFilenameStem; ++ (void)setCurrentFilenameStem:(nullable NSString *)stem; + // MARK: - Media extraction /// Return the post's caption string. Tries selectors first, falls back to @@ -28,6 +41,16 @@ typedef NS_ENUM(NSInteger, SCIActionContext) { /// Ordered children of a carousel IGMedia. Empty array for non-carousels. + (NSArray *)carouselChildrenForMedia:(id)media; +/// YES if the media has an audio track (`has_audio` fieldCache == 1). ++ (BOOL)mediaHasAudio:(id)media; + +/// Download the raw photo URL, skipping any video route. ++ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action; + +/// Extract the audio-only track from the DASH manifest via FFmpeg. Photos +/// library can't hold audio, so both actions end at the share sheet. ++ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action; + /// Best URL for a single (non-carousel) media item. Prefers video URL, falls /// back to photo URL. Returns nil if nothing extractable. + (nullable NSURL *)bestURLForMedia:(id)media; diff --git a/src/ActionButton/SCIMediaActions.m b/src/ActionButton/SCIMediaActions.m index ab9f5f4..e10b285 100644 --- a/src/ActionButton/SCIMediaActions.m +++ b/src/ActionButton/SCIMediaActions.m @@ -21,6 +21,57 @@ static SCIDownloadDelegate *sciActiveDownloadDelegate = nil; extern void sciToggleStoryAudio(void); extern BOOL sciIsStoryAudioEnabled(void); +// MARK: - Filename naming + +static NSString *sciCurrentFilenameStem = nil; + +static NSString *sciSanitizeFilenameComponent(NSString *s) { + if (!s.length) return @""; + NSMutableCharacterSet *bad = [NSMutableCharacterSet alphanumericCharacterSet]; + [bad addCharactersInString:@"._-"]; + NSCharacterSet *drop = bad.invertedSet; + NSArray *parts = [s componentsSeparatedByCharactersInSet:drop]; + NSString *out = [parts componentsJoinedByString:@""]; + if (out.length > 30) out = [out substringToIndex:30]; + return out; +} + +// IGAPIStorableObject's backing dict. +static NSDictionary *sciMediaFieldCache(id obj) { + if (!obj) return nil; + static Ivar fcIvar = NULL; + static dispatch_once_t once; + dispatch_once(&once, ^{ + Class c = NSClassFromString(@"IGAPIStorableObject"); + if (c) fcIvar = class_getInstanceVariable(c, "_fieldCache"); + }); + if (!fcIvar) return nil; + id v = object_getIvar(obj, fcIvar); + return [v isKindOfClass:[NSDictionary class]] ? v : nil; +} + +static NSString *sciUsernameForMedia(id media) { + if (!media) return nil; + @try { + id user = nil; + @try { user = [media valueForKey:@"user"]; } @catch (__unused id e) {} + if (!user) { + NSDictionary *fc = sciMediaFieldCache(media); + user = fc[@"user"]; + } + if (!user) return nil; + NSString *u = nil; + @try { u = [user valueForKey:@"username"]; } @catch (__unused id e) {} + if (![u isKindOfClass:[NSString class]] || !u.length) { + NSDictionary *ufc = sciMediaFieldCache(user); + id v = ufc[@"username"]; + if ([v isKindOfClass:[NSString class]]) u = v; + else if ([user isKindOfClass:[NSDictionary class]]) u = ((NSDictionary *)user)[@"username"]; + } + return [u isKindOfClass:[NSString class]] ? u : nil; + } @catch (__unused id e) { return nil; } +} + // Match keys used in the settings-entry title map for openSettingsForContext: static NSString *sciSettingsTitleForContext(SCIActionContext ctx) { switch (ctx) { @@ -74,6 +125,38 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) { @implementation SCIMediaActions ++ (NSString *)contextLabelForContext:(SCIActionContext)ctx { + switch (ctx) { + case SCIActionContextFeed: return @"feed"; + case SCIActionContextReels: return @"reels"; + case SCIActionContextStories: return @"stories"; + } + return @"media"; +} + ++ (NSString *)filenameStemForMedia:(id)media contextLabel:(NSString *)ctxLabel { + @try { + NSString *user = sciSanitizeFilenameComponent(sciUsernameForMedia(media)); + NSString *userPart = user.length ? [@"@" stringByAppendingString:user] : @"media"; + NSString *ctxPart = sciSanitizeFilenameComponent(ctxLabel); + if (!ctxPart.length) ctxPart = @"media"; + static NSDateFormatter *fmt = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + fmt = [NSDateFormatter new]; + fmt.dateFormat = @"yyyyMMdd_HHmmss"; + fmt.locale = [NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]; + }); + NSString *ts = [fmt stringFromDate:[NSDate date]]; + return [NSString stringWithFormat:@"%@_%@_%@", userPart, ctxPart, ts]; + } @catch (__unused id e) { + return [[NSUUID UUID] UUIDString]; + } +} + ++ (NSString *)currentFilenameStem { return sciCurrentFilenameStem; } ++ (void)setCurrentFilenameStem:(NSString *)stem { sciCurrentFilenameStem = [stem copy]; } + // MARK: - Media extraction + (NSString *)captionForMedia:(id)media { @@ -209,6 +292,96 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) { return @[]; } ++ (BOOL)mediaHasAudio:(id)media { + if (!media) return NO; + // fieldCache on media (old IG path). + id v = sciFieldCache(media, @"has_audio"); + if ([v respondsToSelector:@selector(boolValue)] && [v boolValue]) return YES; + + // IGVideo.isAudioDetected — positive signal only; NO often means "IG + // hasn't decoded the manifest yet" for stories, not actually silent. + @try { + id video = nil; + if ([media respondsToSelector:@selector(video)]) + video = ((id(*)(id, SEL))objc_msgSend)(media, @selector(video)); + if (video && [video respondsToSelector:@selector(isAudioDetected)]) { + if (((BOOL(*)(id, SEL))objc_msgSend)(video, @selector(isAudioDetected))) return YES; + } + } @catch (__unused id e) {} + + // Stories often carry audio but don't surface it in fieldCache. If any + // of these music/audio hints are present, treat as audio-bearing. + for (NSString *key in @[@"music_metadata", @"story_music_stickers", + @"is_story_image_with_music", @"story_sound_on", + @"spotify_stickers", @"story_music_lyric_stickers"]) { + id val = sciFieldCache(media, key); + if (val && ![val isKindOfClass:[NSNull class]]) { + if ([val respondsToSelector:@selector(boolValue)] && [val boolValue]) return YES; + if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count]) return YES; + if ([val isKindOfClass:[NSDictionary class]] && [(NSDictionary *)val count]) return YES; + } + } + + // Last resort: if a DASH manifest exists, assume audio is present. + return [SCIDashParser dashManifestForMedia:media].length > 0; +} + ++ (void)downloadPhotoOnlyForMedia:(id)media action:(DownloadAction)action { + NSURL *url = [self hdPhotoURLForMedia:media]; + if (!url) url = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media]; + if (!url) url = [self fieldCachePhotoURLForMedia:media]; + if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not extract photo URL")]; return; } + NSString *ext = [[url lastPathComponent] pathExtension]; + if (!ext.length) ext = @"jpg"; + sciActiveDownloadDelegate = sciMakeDownloader(action, NO); + [sciActiveDownloadDelegate downloadFileWithURL:url fileExtension:ext hudLabel:nil]; +} + +// Photos library can't hold audio — save action falls back to share sheet. ++ (void)downloadAudioOnlyForMedia:(id)media action:(DownloadAction)action { + NSString *manifest = [SCIDashParser dashManifestForMedia:media]; + if (!manifest.length) { + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio stream available")]; + return; + } + NSArray *reps = [SCIDashParser parseManifest:manifest]; + SCIDashRepresentation *audio = [SCIDashParser bestAudioFromRepresentations:reps]; + if (!audio.url) { + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"No audio track found")]; + return; + } + if (![SCIFFmpeg isAvailable]) { + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"FFmpeg not available")]; + return; + } + + SCIDownloadPillView *pill = [SCIDownloadPillView shared]; + NSString *ticket = [pill beginTicketWithTitle:SCILocalized(@"Downloading audio...") + onCancel:^{ [SCIFFmpeg cancelAll]; }]; + + NSString *audioStem = [self currentFilenameStem] ?: [[NSUUID UUID] UUIDString]; + NSString *outPath = [NSTemporaryDirectory() stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.m4a", audioStem]]; + NSString *cmd = [NSString stringWithFormat:@"-i \"%@\" -vn -c:a copy -y \"%@\"", + audio.url.absoluteString, outPath]; + [SCIFFmpeg executeCommand:cmd completion:^(BOOL success, NSString *output) { + dispatch_async(dispatch_get_main_queue(), ^{ + if (!success) { + [pill finishTicket:ticket errorMessage:SCILocalized(@"Audio extract failed")]; + return; + } + [pill finishTicket:ticket successMessage:SCILocalized(@"Audio ready")]; + NSURL *fileURL = [NSURL fileURLWithPath:outPath]; + switch (action) { + case quickLook: [SCIUtils showQuickLookVC:@[fileURL]]; break; + case share: + case saveToPhotos: + default: [SCIUtils showShareVC:fileURL]; break; + } + }); + }]; +} + + (NSURL *)bestURLForMedia:(id)media { if (!media) return nil; @@ -328,6 +501,7 @@ static void sciConfirmThen(NSString *title, void(^block)(void)) { // Try enhanced HD path via reusable quality picker BOOL handled = [SCIQualityPicker pickQualityForMedia:media fromView:sourceView + action:action picked:^(SCIDashRepresentation *video, SCIDashRepresentation *audio) { [self downloadDASHVideo:video audio:audio action:action]; } @@ -621,13 +795,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD NSMutableArray *files = [NSMutableArray array]; NSLock *lock = [NSLock new]; __block NSUInteger completed = 0; + NSString *bulkStem = [self currentFilenameStem]; + NSUInteger __idx = 0; for (NSURL *url in urls) { if (cancelled) break; dispatch_group_enter(group); NSString *ext = [[url lastPathComponent] pathExtension]; + NSString *name = bulkStem + ? [NSString stringWithFormat:@"%@_%lu", bulkStem, (unsigned long)(++__idx)] + : [[NSUUID UUID] UUIDString]; NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], + [NSString stringWithFormat:@"%@.%@", name, ext.length ? ext : @"jpg"]]; NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) { @@ -763,6 +942,12 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD fromView:(UIView *)sourceView { NSMutableArray *out = [NSMutableArray array]; + NSString *ctxLabel = [self contextLabelForContext:ctx]; + // Stamp the filename stem before a download fires. + void (^stampStemForMedia)(id) = ^(id m) { + [SCIMediaActions setCurrentFilenameStem:[SCIMediaActions filenameStemForMedia:m contextLabel:ctxLabel]]; + }; + // Resolve parent media for carousel detection + bulk actions. id parentMedia = media; if (media && ![self isCarouselMedia:media]) { @@ -946,9 +1131,11 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD [SCIMediaActions copyAllURLsForMedia:bulkMedia]; }], [SCIAction actionWithTitle:SCILocalized(@"Download and share all") icon:@"square.and.arrow.up.on.square" handler:^{ + stampStemForMedia(bulkMedia); [SCIMediaActions downloadAllAndShareMedia:bulkMedia]; }], [SCIAction actionWithTitle:SCILocalized(@"Download all to Photos") icon:@"square.and.arrow.down.on.square" handler:^{ + stampStemForMedia(bulkMedia); [SCIMediaActions downloadAllAndSaveMedia:bulkMedia]; }], ]; @@ -1068,6 +1255,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD if (u) [urls addObject:u]; } if (!urls.count) return; + stampStemForMedia(capturedMedias.firstObject); [SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Download all stories and share?") done:^(NSArray *files) { if (!files.count) return; UIViewController *top = topMostController(); @@ -1083,6 +1271,7 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD if (u) [urls addObject:u]; } if (!urls.count) return; + stampStemForMedia(capturedMedias.firstObject); [SCIMediaActions bulkDownloadURLs:urls title:SCILocalized(@"Save all stories to Photos?") done:^(NSArray *files) { [SCIMediaActions bulkSaveFiles:files]; }]; @@ -1105,11 +1294,13 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download and share") icon:@"square.and.arrow.up" handler:^{ + stampStemForMedia(media); [SCIMediaActions downloadAndShareMedia:media]; }]]; [out addObject:[SCIAction actionWithTitle:SCILocalized(@"Download to Photos") icon:@"square.and.arrow.down" handler:^{ + stampStemForMedia(media); [SCIMediaActions downloadAndSaveMedia:media]; }]]; @@ -1138,13 +1329,18 @@ static UIView *sciFindSubviewOfClass(UIView *root, NSString *className, int maxD NSMutableArray *files = [NSMutableArray array]; NSLock *lock = [NSLock new]; __block NSUInteger completed = 0; + NSString *bulkStem2 = [self currentFilenameStem]; + NSUInteger __idx2 = 0; for (NSURL *url in urls) { if (cancelled) break; dispatch_group_enter(group); NSString *ext = [[url lastPathComponent] pathExtension]; + NSString *name = bulkStem2 + ? [NSString stringWithFormat:@"%@_%lu", bulkStem2, (unsigned long)(++__idx2)] + : [[NSUUID UUID] UUIDString]; NSString *tmp = [NSTemporaryDirectory() stringByAppendingPathComponent: - [NSString stringWithFormat:@"%@.%@", [[NSUUID UUID] UUIDString], + [NSString stringWithFormat:@"%@.%@", name, ext.length ? ext : @"jpg"]]; NSURLSessionDownloadTask *task = [[NSURLSession sharedSession] downloadTaskWithURL:url completionHandler:^(NSURL *loc, NSURLResponse *resp, NSError *err) { diff --git a/src/ActionButton/SCIMediaViewer.m b/src/ActionButton/SCIMediaViewer.m index 7381ea4..1f77ae2 100644 --- a/src/ActionButton/SCIMediaViewer.m +++ b/src/ActionButton/SCIMediaViewer.m @@ -1,5 +1,6 @@ #import "SCIMediaViewer.h" #import "../Utils.h" +#import "../SCIImageCache.h" #import #import @@ -57,15 +58,10 @@ [self.view addSubview:self.spinner]; [self.spinner startAnimating]; - NSURL *url = [self.photoURL copy]; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - NSData *data = [NSData dataWithContentsOfURL:url]; - UIImage *img = data ? [UIImage imageWithData:data] : nil; - dispatch_async(dispatch_get_main_queue(), ^{ - [self.spinner stopAnimating]; - if (img) self.imageView.image = img; - }); - }); + [SCIImageCache loadImageFromURL:self.photoURL completion:^(UIImage *img) { + [self.spinner stopAnimating]; + if (img) self.imageView.image = img; + }]; // Double-tap to zoom UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTap:)]; diff --git a/src/BundleAssets/profile_analyzer_icon@2x.png b/src/BundleAssets/profile_analyzer_icon@2x.png new file mode 100644 index 0000000..d7dc050 Binary files /dev/null and b/src/BundleAssets/profile_analyzer_icon@2x.png differ diff --git a/src/BundleAssets/profile_analyzer_icon@3x.png b/src/BundleAssets/profile_analyzer_icon@3x.png new file mode 100644 index 0000000..5582566 Binary files /dev/null and b/src/BundleAssets/profile_analyzer_icon@3x.png differ diff --git a/src/BundleAssets/ryukgram.png b/src/BundleAssets/ryukgram.png new file mode 100644 index 0000000..795919f Binary files /dev/null and b/src/BundleAssets/ryukgram.png differ diff --git a/src/Downloader/Manager.m b/src/Downloader/Manager.m index 6d394ce..d12301d 100644 --- a/src/Downloader/Manager.m +++ b/src/Downloader/Manager.m @@ -1,4 +1,5 @@ #import "Manager.h" +#import "../ActionButton/SCIMediaActions.h" @implementation SCIDownloadManager @@ -31,8 +32,6 @@ // URLSession methods - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite { - NSLog(@"Task wrote %lld bytes of %lld bytes", bytesWritten, totalBytesExpectedToWrite); - float progress = (float)totalBytesWritten / (float)totalBytesExpectedToWrite; [self.delegate downloadDidProgress:progress]; @@ -46,8 +45,7 @@ } - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error { - NSLog(@"Task completed with error: %@", error); - + if (error) NSLog(@"[SCInsta] Download error: %@", error); [self.delegate downloadDidFinishWithError:error]; } @@ -56,7 +54,8 @@ NSFileManager *fileManager = [NSFileManager defaultManager]; NSString *cacheDirectoryPath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; - NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", NSUUID.UUID.UUIDString, self.fileExtension]]; + NSString *stem = [SCIMediaActions currentFilenameStem] ?: NSUUID.UUID.UUIDString; + NSURL *newPath = [[NSURL fileURLWithPath:cacheDirectoryPath] URLByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", stem, self.fileExtension]]; NSLog(@"[SCInsta] Download Handler: Moving file from: %@ to: %@", oldPath.absoluteString, newPath.absoluteString); diff --git a/src/Features/ActionButton/FeedActionButton.xm b/src/Features/ActionButton/FeedActionButton.xm index 26c8514..3405cc5 100644 --- a/src/Features/ActionButton/FeedActionButton.xm +++ b/src/Features/ActionButton/FeedActionButton.xm @@ -4,6 +4,7 @@ #import "../../InstagramHeaders.h" #import "../../Utils.h" +#import "../../SCIChrome.h" #import "../../ActionButton/SCIActionButton.h" #import "../../ActionButton/SCIMediaActions.h" #import @@ -212,16 +213,13 @@ static IGMedia *sciFeedMediaFromButton(UIView *button) { if (![SCIUtils getBoolPref:@"feed_action_button"]) return; - UIButton *btn = (UIButton *)[self viewWithTag:kFeedActionBtnTag]; + SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kFeedActionBtnTag]; + if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil; if (!btn) { - btn = [UIButton buttonWithType:UIButtonTypeCustom]; + btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:21 diameter:36]; btn.tag = kFeedActionBtnTag; - - UIImageSymbolConfiguration *cfg = - [UIImageSymbolConfiguration configurationWithPointSize:21 weight:UIImageSymbolWeightRegular]; - [btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal]; - btn.tintColor = [UIColor labelColor]; - btn.translatesAutoresizingMaskIntoConstraints = NO; + btn.iconTint = [UIColor labelColor]; + btn.bubbleColor = [UIColor clearColor]; [self addSubview:btn]; // Position: right side, left of bookmark. Shifted up 4pt to diff --git a/src/Features/ActionButton/ReelsActionButton.xm b/src/Features/ActionButton/ReelsActionButton.xm index 966bdb9..b91ea4d 100644 --- a/src/Features/ActionButton/ReelsActionButton.xm +++ b/src/Features/ActionButton/ReelsActionButton.xm @@ -3,6 +3,7 @@ #import "../../InstagramHeaders.h" #import "../../Utils.h" +#import "../../SCIChrome.h" #import "../../ActionButton/SCIActionButton.h" #import "../../ActionButton/SCIMediaActions.h" #import @@ -119,17 +120,14 @@ static id sciReelsMediaProvider(UIView *sourceView) { if (![SCIUtils getBoolPref:@"reels_action_button"]) return; if (!self.superview) return; - UIButton *btn = (UIButton *)[self viewWithTag:kReelActionBtnTag]; + SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:kReelActionBtnTag]; + if (![btn isKindOfClass:[SCIChromeButton class]]) btn = nil; if (!btn) { - btn = [UIButton buttonWithType:UIButtonTypeCustom]; - btn.tag = kReelActionBtnTag; - UIImageSymbolConfiguration *symCfg = [UIImageSymbolConfiguration configurationWithPointSize:24 weight:UIImageSymbolWeightSemibold]; UIImage *base = [UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:symCfg]; - // Bake the drop shadow into a single UIImage so no CALayer shadow is - // applied to the button itself. + // Bake the drop shadow into the image so no CALayer shadow is needed. CGFloat pad = 8; CGSize sz = CGSizeMake(base.size.width + pad * 2, base.size.height + pad * 2); UIGraphicsImageRenderer *r = [[UIGraphicsImageRenderer alloc] initWithSize:sz]; @@ -144,11 +142,20 @@ static id sciReelsMediaProvider(UIView *sourceView) { CGContextRestoreGState(c); }]; - [btn setImage:icon forState:UIControlStateNormal]; - btn.tintColor = [UIColor whiteColor]; + btn = [[SCIChromeButton alloc] initWithSymbol:@"" pointSize:0 diameter:40]; + btn.tag = kReelActionBtnTag; + btn.bubbleColor = [UIColor clearColor]; + btn.iconView.image = icon; + + // Capsule configuration gives us the native dark platter animation + // when the menu opens/closes — behaviour parity with IG's own chrome. + UIButtonConfiguration *cfg = [UIButtonConfiguration plainButtonConfiguration]; + cfg.cornerStyle = UIButtonConfigurationCornerStyleCapsule; + cfg.background.backgroundColor = [UIColor clearColor]; + cfg.contentInsets = NSDirectionalEdgeInsetsZero; + btn.configuration = cfg; self.clipsToBounds = NO; - btn.translatesAutoresizingMaskIntoConstraints = NO; [self addSubview:btn]; [NSLayoutConstraint activateConstraints:@[ @@ -159,7 +166,6 @@ static id sciReelsMediaProvider(UIView *sourceView) { ]]; } - // Reconfigure with fresh media provider. [SCIActionButton configureButton:btn context:SCIActionContextReels prefKey:@"reels_action_default" diff --git a/src/Features/Confirm/CallConfirm.x b/src/Features/Confirm/CallConfirm.x index 48f78a5..be530c2 100644 --- a/src/Features/Confirm/CallConfirm.x +++ b/src/Features/Confirm/CallConfirm.x @@ -1,22 +1,34 @@ #import "../../Utils.h" %hook IGDirectThreadCallButtonsCoordinator -// Voice Call -- (void)_didTapAudioButton:(id)arg1 { - if ([SCIUtils getBoolPref:@"call_confirm"]) { - NSLog(@"[SCInsta] Call confirm triggered"); - +// 426+ dropped the sender arg +- (void)_didTapAudioButton { + if ([SCIUtils getBoolPref:@"voice_call_confirm"]) { + [SCIUtils showConfirmation:^(void) { %orig; }]; + } else { + return %orig; + } +} + +- (void)_didTapVideoButton { + if ([SCIUtils getBoolPref:@"video_call_confirm"]) { + [SCIUtils showConfirmation:^(void) { %orig; }]; + } else { + return %orig; + } +} + +// Pre-426 signatures +- (void)_didTapAudioButton:(id)arg1 { + if ([SCIUtils getBoolPref:@"voice_call_confirm"]) { [SCIUtils showConfirmation:^(void) { %orig; }]; } else { return %orig; } } -// Video Call - (void)_didTapVideoButton:(id)arg1 { - if ([SCIUtils getBoolPref:@"call_confirm"]) { - NSLog(@"[SCInsta] Call confirm triggered"); - + if ([SCIUtils getBoolPref:@"video_call_confirm"]) { [SCIUtils showConfirmation:^(void) { %orig; }]; } else { return %orig; diff --git a/src/Features/Confirm/LikeConfirm.x b/src/Features/Confirm/LikeConfirm.x index 0223db7..6866dba 100644 --- a/src/Features/Confirm/LikeConfirm.x +++ b/src/Features/Confirm/LikeConfirm.x @@ -1,4 +1,54 @@ #import "../../Utils.h" +#import +#import +#import + +// Reels like tap goes through a Swift class method on +// IGSundialViewerLikeButtonActionHandler since IG 426. +typedef void (*SciHandleTapFn)(Class, SEL, id, id, BOOL); +typedef void (*SciHandleTapCompFn)(Class, SEL, id, id, BOOL, id); +static SciHandleTapFn orig_sciHandleTap = NULL; +static SciHandleTapCompFn orig_sciHandleTapComp = NULL; + +static void new_sciHandleTap(Class cls, SEL _cmd, id ctx, id btn, BOOL anim) { + if (![SCIUtils getBoolPref:@"like_confirm_reels"]) { + orig_sciHandleTap(cls, _cmd, ctx, btn, anim); + return; + } + __strong id sCtx = ctx; + __strong id sBtn = btn; + [SCIUtils showConfirmation:^{ + @try { orig_sciHandleTap(cls, _cmd, sCtx, sBtn, anim); } + @catch (__unused id e) {} + }]; +} + +// Copy the completion block — it's a stack block and won't survive the alert. +static void new_sciHandleTapComp(Class cls, SEL _cmd, id ctx, id btn, BOOL anim, id comp) { + if (![SCIUtils getBoolPref:@"like_confirm_reels"]) { + orig_sciHandleTapComp(cls, _cmd, ctx, btn, anim, comp); + return; + } + __strong id sCtx = ctx; + __strong id sBtn = btn; + id sComp = comp ? [comp copy] : nil; + [SCIUtils showConfirmation:^{ + @try { orig_sciHandleTapComp(cls, _cmd, sCtx, sBtn, anim, sComp); } + @catch (__unused id e) {} + }]; +} + +__attribute__((constructor)) static void _sciHookReelsLikeHandler(void) { + Class c = NSClassFromString(@"_TtC30IGSundialOverlayActionHandlers38IGSundialViewerLikeButtonActionHandler"); + if (!c) return; + Class meta = object_getClass(c); + SEL s1 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:"); + SEL s2 = NSSelectorFromString(@"handleTapWithActionContext:likeButton:willPlayRingsCustomLikeAnimation:completion:"); + if (class_getClassMethod(c, s1)) + MSHookMessageEx(meta, s1, (IMP)new_sciHandleTap, (IMP *)&orig_sciHandleTap); + if (class_getClassMethod(c, s2)) + MSHookMessageEx(meta, s2, (IMP)new_sciHandleTapComp, (IMP *)&orig_sciHandleTapComp); +} #define CONFIRMPOSTLIKE(orig) \ if ([SCIUtils getBoolPref:@"like_confirm"]) \ @@ -15,11 +65,17 @@ - (void)_onLikeButtonPressed:(id)arg1 { CONFIRMPOSTLIKE(%orig); } +- (void)_onLikeButtonPressed { + CONFIRMPOSTLIKE(%orig); +} %end %hook IGFeedPhotoView - (void)_onDoubleTap:(id)arg1 { CONFIRMPOSTLIKE(%orig); } +- (void)_onDoubleTap { + CONFIRMPOSTLIKE(%orig); +} %end %hook IGVideoPlayerOverlayContainerView - (void)_handleDoubleTapGesture:(id)arg1 { @@ -32,9 +88,6 @@ - (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 { CONFIRMREELSLIKE(%orig); } -- (void)controlsOverlayControllerDidLongPressLikeButton:(id)arg1 gestureRecognizer:(id)arg2 { - CONFIRMREELSLIKE(%orig); -} - (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 { CONFIRMREELSLIKE(%orig); } @@ -46,6 +99,9 @@ - (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 { CONFIRMREELSLIKE(%orig); } +- (void)swift_photoCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 { + CONFIRMREELSLIKE(%orig); +} %end %hook IGSundialViewerCarouselCell - (void)controlsOverlayControllerDidTapLikeButton:(id)arg1 { @@ -54,6 +110,9 @@ - (void)gestureController:(id)arg1 didObserveDoubleTap:(id)arg2 { CONFIRMREELSLIKE(%orig); } +- (void)carouselCell:(id)arg1 didObserveDoubleTapWithLocationInfo:(id)arg2 gestureRecognizer:(id)arg3 { + CONFIRMREELSLIKE(%orig); +} %end // Liking comments diff --git a/src/Features/Confirm/StickerInteractConfirm.x b/src/Features/Confirm/StickerInteractConfirm.x index a7f1c28..bf2f0b1 100644 --- a/src/Features/Confirm/StickerInteractConfirm.x +++ b/src/Features/Confirm/StickerInteractConfirm.x @@ -1,13 +1,24 @@ #import "../../Utils.h" +#import + +// Split by _analyticsModule: "highlight" substring → highlights toggle, else stories toggle. + +static BOOL sciTapIsHighlight(id target) { + Ivar iv = class_getInstanceVariable(object_getClass(target), "_analyticsModule"); + if (!iv) return NO; + id v = nil; + @try { v = object_getIvar(target, iv); } @catch (__unused id e) { return NO; } + if (![v isKindOfClass:[NSString class]]) return NO; + return [((NSString *)v).lowercaseString containsString:@"highlight"]; +} %hook IGStoryViewerTapTarget - (void)_didTap:(id)arg1 forEvent:(id)arg2 { - if ([SCIUtils getBoolPref:@"sticker_interact_confirm"]) { - NSLog(@"[SCInsta] Confirm sticker interact triggered"); - + NSString *key = sciTapIsHighlight(self) ? @"sticker_interact_confirm_highlights" : @"sticker_interact_confirm"; + if ([SCIUtils getBoolPref:key]) { [SCIUtils showConfirmation:^(void) { %orig; }]; } else { return %orig; } } -%end \ No newline at end of file +%end diff --git a/src/Features/ExpFlags/ExpFlagsHooks.xm_ b/src/Features/ExpFlags/ExpFlagsHooks.xm_ new file mode 100644 index 0000000..6a5c79d --- /dev/null +++ b/src/Features/ExpFlags/ExpFlagsHooks.xm_ @@ -0,0 +1,135 @@ +// Hooks installed iff sci_exp_flags_enabled. +// Override: MetaLocalExperiment group{,Peek}Name — substring-match _experimentName, return "test"/nil. +// View-only: IGMobileConfigContextManager get{Bool,Int64,Double,String}[:withDefault:] — record, no override. + +#import "../../Utils.h" +#import "SCIExpFlags.h" +#import +#import +#import + +// MetaLocalExperiment + +static NSString *experimentNameOf(id obj) { + if (!obj) return nil; + Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentName"); + if (!iv) return nil; + @try { + id v = object_getIvar(obj, iv); + if ([v isKindOfClass:[NSString class]]) return v; + } @catch (__unused id e) {} + return nil; +} + +static id overrideGroupFor(NSString *expName, id origGroup) { + if (!expName.length) return origGroup; + NSString *lower = expName.lowercaseString; + for (NSString *key in [SCIExpFlags allOverriddenNames]) { + if (![lower containsString:key.lowercaseString]) continue; + SCIExpFlagOverride o = [SCIExpFlags overrideForName:key]; + if (o == SCIExpFlagOverrideTrue) return @"test"; + if (o == SCIExpFlagOverrideFalse) return nil; + } + return origGroup; +} + +static id (*orig_groupName)(id, SEL); +static id new_groupName(id self, SEL _cmd) { + id orig = orig_groupName ? orig_groupName(self, _cmd) : nil; + NSString *name = experimentNameOf(self); + [SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil]; + return overrideGroupFor(name, orig); +} + +static id (*orig_peekGroupName)(id, SEL); +static id new_peekGroupName(id self, SEL _cmd) { + id orig = orig_peekGroupName ? orig_peekGroupName(self, _cmd) : nil; + NSString *name = experimentNameOf(self); + [SCIExpFlags recordExperimentName:name group:[orig isKindOfClass:[NSString class]] ? orig : nil]; + return overrideGroupFor(name, orig); +} + +// IGMobileConfigContextManager — view-only. +// param arg is {uint64} struct, ABI-identical to unsigned long long on arm64. + +static BOOL (*orig_mcBool)(id, SEL, unsigned long long); +static BOOL new_mcBool(id self, SEL _cmd, unsigned long long pid) { + BOOL v = orig_mcBool(self, _cmd, pid); + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:v ? @"YES" : @"NO"]; + return v; +} +static BOOL (*orig_mcBool_def)(id, SEL, unsigned long long, BOOL); +static BOOL new_mcBool_def(id self, SEL _cmd, unsigned long long pid, BOOL def) { + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeBool defaultValue:def ? @"YES" : @"NO"]; + return orig_mcBool_def(self, _cmd, pid, def); +} +static long long (*orig_mcInt)(id, SEL, unsigned long long); +static long long new_mcInt(id self, SEL _cmd, unsigned long long pid) { + long long v = orig_mcInt(self, _cmd, pid); + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", v]]; + return v; +} +static long long (*orig_mcInt_def)(id, SEL, unsigned long long, long long); +static long long new_mcInt_def(id self, SEL _cmd, unsigned long long pid, long long def) { + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeInt defaultValue:[NSString stringWithFormat:@"%lld", def]]; + return orig_mcInt_def(self, _cmd, pid, def); +} +static double (*orig_mcDouble)(id, SEL, unsigned long long); +static double new_mcDouble(id self, SEL _cmd, unsigned long long pid) { + double v = orig_mcDouble(self, _cmd, pid); + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", v]]; + return v; +} +static double (*orig_mcDouble_def)(id, SEL, unsigned long long, double); +static double new_mcDouble_def(id self, SEL _cmd, unsigned long long pid, double def) { + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeDouble defaultValue:[NSString stringWithFormat:@"%f", def]]; + return orig_mcDouble_def(self, _cmd, pid, def); +} +static id (*orig_mcString)(id, SEL, unsigned long long); +static id new_mcString(id self, SEL _cmd, unsigned long long pid) { + id v = orig_mcString(self, _cmd, pid); + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[v description] ?: @""]; + return v; +} +static id (*orig_mcString_def)(id, SEL, unsigned long long, id); +static id new_mcString_def(id self, SEL _cmd, unsigned long long pid, id def) { + [SCIExpFlags recordMCParamID:pid type:SCIExpMCTypeString defaultValue:[def description] ?: @""]; + return orig_mcString_def(self, _cmd, pid, def); +} + +// install + +static void install(Class cls, NSString *selName, IMP newImp, IMP *origOut) { + if (!cls) return; + SEL s = NSSelectorFromString(selName); + if (!class_getInstanceMethod(cls, s)) return; + MSHookMessageEx(cls, s, newImp, origOut); +} + +%ctor { + if (![SCIUtils getBoolPref:@"sci_exp_flags_enabled"]) return; + + if ([SCIExpFlags checkAndHandleCrashLoop]) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [SCIUtils showToastForDuration:4.0 title:@"Exp flags reset after repeated crashes"]; + }); + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ [SCIExpFlags markLaunchStable]; }); + + // Family inherits Meta — one install covers both + Class meta = NSClassFromString(@"MetaLocalExperiment"); + install(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName); + install(meta, @"peekGroupName", (IMP)new_peekGroupName, (IMP *)&orig_peekGroupName); + + Class mc = NSClassFromString(@"IGMobileConfigContextManager"); + install(mc, @"getBool:", (IMP)new_mcBool, (IMP *)&orig_mcBool); + install(mc, @"getBool:withDefault:", (IMP)new_mcBool_def, (IMP *)&orig_mcBool_def); + install(mc, @"getInt64:", (IMP)new_mcInt, (IMP *)&orig_mcInt); + install(mc, @"getInt64:withDefault:", (IMP)new_mcInt_def, (IMP *)&orig_mcInt_def); + install(mc, @"getDouble:", (IMP)new_mcDouble, (IMP *)&orig_mcDouble); + install(mc, @"getDouble:withDefault:", (IMP)new_mcDouble_def, (IMP *)&orig_mcDouble_def); + install(mc, @"getString:", (IMP)new_mcString, (IMP *)&orig_mcString); + install(mc, @"getString:withDefault:", (IMP)new_mcString_def, (IMP *)&orig_mcString_def); +} diff --git a/src/Features/ExpFlags/SCIExpFlags.h b/src/Features/ExpFlags/SCIExpFlags.h new file mode 100644 index 0000000..35db12d --- /dev/null +++ b/src/Features/ExpFlags/SCIExpFlags.h @@ -0,0 +1,56 @@ +// Exp flag override store + observation logs. +// Override works only for MetaLocalExperiment (name-substring match on _experimentName). +// MC reads + scanned names are view-only — no reliable name→ID mapping. + +#import + +typedef NS_ENUM(NSInteger, SCIExpFlagOverride) { + SCIExpFlagOverrideOff = 0, + SCIExpFlagOverrideTrue = 1, + SCIExpFlagOverrideFalse = 2, +}; + +typedef NS_ENUM(NSInteger, SCIExpMCType) { + SCIExpMCTypeBool, + SCIExpMCTypeInt, + SCIExpMCTypeDouble, + SCIExpMCTypeString, +}; + +@interface SCIExpObservation : NSObject +@property (nonatomic, copy) NSString *experimentName; +@property (nonatomic, copy) NSString *lastGroup; +@property (nonatomic, assign) NSUInteger hitCount; +@end + +@interface SCIExpMCObservation : NSObject +@property (nonatomic, assign) unsigned long long paramID; +@property (nonatomic, assign) SCIExpMCType type; +@property (nonatomic, copy) NSString *lastDefault; +@property (nonatomic, assign) NSUInteger hitCount; +@end + +@interface SCIExpFlags : NSObject + +// overrides (persisted) ++ (SCIExpFlagOverride)overrideForName:(NSString *)name; ++ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name; ++ (NSArray *)allOverriddenNames; ++ (void)resetAllOverrides; + +// meta observations (live) ++ (void)recordExperimentName:(NSString *)name group:(NSString *)group; ++ (NSArray *)allObservations; + +// MC id observations (live, view-only) ++ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def; ++ (NSArray *)allMCObservations; + +// binary-scanned names (bg, cb on main) ++ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray *names))completion; + +// crash-loop guard — 3 bad launches wipe overrides ++ (BOOL)checkAndHandleCrashLoop; ++ (void)markLaunchStable; + +@end diff --git a/src/Features/ExpFlags/SCIExpFlags.m_ b/src/Features/ExpFlags/SCIExpFlags.m_ new file mode 100644 index 0000000..add63b1 --- /dev/null +++ b/src/Features/ExpFlags/SCIExpFlags.m_ @@ -0,0 +1,187 @@ +#import "SCIExpFlags.h" +#import +#import +#import + +static NSString *const kOverridesKey = @"sci_exp_overrides_by_name"; +static NSString *const kCrashCounterKey = @"sci_exp_flags_unstable_launches"; +static const NSInteger kCrashThreshold = 3; + +@implementation SCIExpObservation +@end +@implementation SCIExpMCObservation +@end + +@implementation SCIExpFlags + +// overrides + ++ (NSMutableDictionary *)loadOverrides { + NSDictionary *d = [[NSUserDefaults standardUserDefaults] dictionaryForKey:kOverridesKey]; + return d ? [d mutableCopy] : [NSMutableDictionary dictionary]; +} + ++ (void)saveOverrides:(NSDictionary *)d { + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + if (d.count == 0) [ud removeObjectForKey:kOverridesKey]; + else [ud setObject:d forKey:kOverridesKey]; +} + ++ (SCIExpFlagOverride)overrideForName:(NSString *)name { + if (!name.length) return SCIExpFlagOverrideOff; + NSNumber *n = [self loadOverrides][name]; + return n ? (SCIExpFlagOverride)n.integerValue : SCIExpFlagOverrideOff; +} + ++ (void)setOverride:(SCIExpFlagOverride)o forName:(NSString *)name { + if (!name.length) return; + NSMutableDictionary *d = [self loadOverrides]; + if (o == SCIExpFlagOverrideOff) [d removeObjectForKey:name]; + else d[name] = @(o); + [self saveOverrides:d]; +} + ++ (NSArray *)allOverriddenNames { return [[self loadOverrides] allKeys]; } ++ (void)resetAllOverrides { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kOverridesKey]; } + +// meta observations + +static NSMutableDictionary *gMetaObs = nil; +static dispatch_queue_t metaQueue(void) { + static dispatch_queue_t q; + static dispatch_once_t once; + dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.meta", DISPATCH_QUEUE_CONCURRENT); }); + return q; +} + ++ (void)recordExperimentName:(NSString *)name group:(NSString *)group { + if (!name.length) return; + dispatch_barrier_async(metaQueue(), ^{ + if (!gMetaObs) gMetaObs = [NSMutableDictionary dictionary]; + SCIExpObservation *o = gMetaObs[name]; + if (!o) { o = [SCIExpObservation new]; o.experimentName = name; gMetaObs[name] = o; } + o.lastGroup = group; + o.hitCount++; + }); +} + ++ (NSArray *)allObservations { + __block NSArray *snap = @[]; + dispatch_sync(metaQueue(), ^{ snap = gMetaObs ? [gMetaObs.allValues copy] : @[]; }); + return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpObservation *a, SCIExpObservation *b) { + return [a.experimentName caseInsensitiveCompare:b.experimentName]; + }]; +} + +// MC observations (view-only) + +static NSMutableDictionary *gMCObs = nil; +static dispatch_queue_t mcQueue(void) { + static dispatch_queue_t q; + static dispatch_once_t once; + dispatch_once(&once, ^{ q = dispatch_queue_create("sci.expflags.mc", DISPATCH_QUEUE_CONCURRENT); }); + return q; +} + ++ (void)recordMCParamID:(unsigned long long)pid type:(SCIExpMCType)t defaultValue:(NSString *)def { + dispatch_barrier_async(mcQueue(), ^{ + if (!gMCObs) gMCObs = [NSMutableDictionary dictionary]; + NSNumber *k = @(pid); + SCIExpMCObservation *o = gMCObs[k]; + if (!o) { o = [SCIExpMCObservation new]; o.paramID = pid; o.type = t; gMCObs[k] = o; } + o.lastDefault = def ?: @""; + o.hitCount++; + }); +} + ++ (NSArray *)allMCObservations { + __block NSArray *snap = @[]; + dispatch_sync(mcQueue(), ^{ snap = gMCObs ? [gMCObs.allValues copy] : @[]; }); + // hot flags first + return [snap sortedArrayUsingComparator:^NSComparisonResult(SCIExpMCObservation *a, SCIExpMCObservation *b) { + if (a.hitCount != b.hitCount) return a.hitCount > b.hitCount ? NSOrderedAscending : NSOrderedDescending; + if (a.paramID < b.paramID) return NSOrderedAscending; + if (a.paramID > b.paramID) return NSOrderedDescending; + return NSOrderedSame; + }]; +} + +// crash-loop guard + ++ (BOOL)checkAndHandleCrashLoop { + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + NSInteger c = [ud integerForKey:kCrashCounterKey] + 1; + if (c >= kCrashThreshold && [self loadOverrides].count > 0) { + [self resetAllOverrides]; + [ud removeObjectForKey:kCrashCounterKey]; + return YES; + } + [ud setInteger:c forKey:kCrashCounterKey]; + return NO; +} + ++ (void)markLaunchStable { [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCrashCounterKey]; } + +// binary scan — mmap executable, grep for flag-prefix strings, dedupe/sort + ++ (void)scanExecutableNamesWithCompletion:(void (^)(NSArray *))completion { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + NSArray *names = [self scanExecutable]; + dispatch_async(dispatch_get_main_queue(), ^{ completion(names ?: @[]); }); + }); +} + ++ (NSArray *)scanExecutable { + NSString *path = [[NSBundle mainBundle] executablePath]; + if (!path) return @[]; + int fd = open(path.UTF8String, O_RDONLY); + if (fd < 0) return @[]; + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size <= 0) { close(fd); return @[]; } + size_t size = (size_t)st.st_size; + const char *base = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0); + close(fd); + if (base == MAP_FAILED) return @[]; + + // Meta flag/analytics name prefixes + static const char *prefixes[] = { + "ig_ios_", "ig_android_", "ig_direct_", "ig_feed_", "ig_reels_", + "ig_stories_", "ig_explore_", "ig_camera_", "ig_growth_", "ig_privacy_", + "fbios_", "fb_ios_" + }; + const size_t pc = sizeof(prefixes) / sizeof(prefixes[0]); + NSMutableSet *seen = [NSMutableSet set]; + + for (size_t i = 0; i < size; i++) { + char c = base[i]; + if (c != 'i' && c != 'f') continue; + if (i > 0) { + char prev = base[i - 1]; + if ((prev >= 'a' && prev <= 'z') || (prev >= '0' && prev <= '9') || prev == '_' || prev == '.') continue; + } + size_t matched = 0; + const char *rem = base + i; + size_t left = size - i; + for (size_t p = 0; p < pc; p++) { + size_t L = strlen(prefixes[p]); + if (left >= L && memcmp(rem, prefixes[p], L) == 0) { matched = L; break; } + } + if (!matched) continue; + size_t j = i + matched; + while (j < size) { + char ch = base[j]; + if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '_' || ch == '.')) break; + j++; + } + size_t nl = j - i; + if (nl >= 16 && nl <= 160) { + NSString *s = [[NSString alloc] initWithBytes:(base + i) length:nl encoding:NSASCIIStringEncoding]; + if (s) [seen addObject:s]; + } + i = j; + } + munmap((void *)base, size); + return [[seen allObjects] sortedArrayUsingSelector:@selector(compare:)]; +} + +@end diff --git a/src/Features/Experimental/DirectNotesCompat.xm b/src/Features/Experimental/DirectNotesCompat.xm new file mode 100644 index 0000000..c8b65f6 --- /dev/null +++ b/src/Features/Experimental/DirectNotesCompat.xm @@ -0,0 +1,60 @@ +// Direct Notes experimental reply types + friend map. Gates: igt_directnotes_*. + +#import "../../Utils.h" +#import +#import +#include "../../../modules/fishhook/fishhook.h" + +static inline BOOL prefFriendMap(void) { return [SCIUtils getBoolPref:@"igt_directnotes_friendmap"]; } +static inline BOOL prefAudio(void) { return [SCIUtils getBoolPref:@"igt_directnotes_audio_reply"]; } +static inline BOOL prefAvatar(void) { return [SCIUtils getBoolPref:@"igt_directnotes_avatar_reply"]; } +static inline BOOL prefGifs(void) { return [SCIUtils getBoolPref:@"igt_directnotes_gifs_reply"]; } +static inline BOOL prefPhoto(void) { return [SCIUtils getBoolPref:@"igt_directnotes_photo_reply"]; } + +static BOOL rep_friendmap(void) { return prefFriendMap(); } +static BOOL rep_audio(void) { return prefAudio(); } +static BOOL rep_avatar(void) { return prefAvatar(); } +static BOOL rep_gifs(void) { return prefGifs(); } +static BOOL rep_photo(void) { return prefPhoto(); } + +static inline BOOL containsAny(NSString *s, NSArray *needles) { + if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO; + NSString *lower = s.lowercaseString; + for (NSString *n in needles) if ([lower containsString:n]) return YES; + return NO; +} + +static BOOL matchesDirectNotes(NSString *name) { + if (prefFriendMap() && containsAny(name, @[@"friendmap", @"friends_map", + @"ig_ios_friendmap_", @"friendmapenabled"])) return YES; + if (prefAudio() && containsAny(name, @[@"audio"])) return YES; + if (prefAvatar() && containsAny(name, @[@"avatar"])) return YES; + if (prefGifs() && containsAny(name, @[@"gifs", @"sticker"])) return YES; + if (prefPhoto() && containsAny(name, @[@"photo"])) return YES; + return NO; +} + +static BOOL (*orig_isIn)(id, SEL, id) = NULL; +static BOOL new_isIn(id self, SEL _cmd, id name) { + if (matchesDirectNotes(name)) return YES; + return orig_isIn ? orig_isIn(self, _cmd, name) : NO; +} + +%ctor { + if (!(prefFriendMap() || prefAudio() || prefAvatar() || prefGifs() || prefPhoto())) return; + + struct rebinding binds[] = { + {"IGDirectNotesFriendMapEnabled", (void *)rep_friendmap, NULL}, + {"IGDirectNotesEnableAudioNoteReplyType", (void *)rep_audio, NULL}, + {"IGDirectNotesEnableAvatarReplyTypes", (void *)rep_avatar, NULL}, + {"IGDirectNotesEnableGifsStickersReplyTypes", (void *)rep_gifs, NULL}, + {"IGDirectNotesEnablePhotoNoteReplyType", (void *)rep_photo, NULL}, + }; + rebind_symbols(binds, sizeof(binds) / sizeof(binds[0])); + + Class helper = NSClassFromString(@"IGDirectNotesExperimentHelper"); + SEL sel = NSSelectorFromString(@"isInExperiment:"); + if (helper && class_getInstanceMethod(helper, sel)) { + MSHookMessageEx(helper, sel, (IMP)new_isIn, (IMP *)&orig_isIn); + } +} diff --git a/src/Features/Experimental/ExperimentalRolloutCompat.xm b/src/Features/Experimental/ExperimentalRolloutCompat.xm new file mode 100644 index 0000000..eaa73d7 --- /dev/null +++ b/src/Features/Experimental/ExperimentalRolloutCompat.xm @@ -0,0 +1,97 @@ +// Experiment-name substring override. Gates: igt_quicksnap, igt_directnotes_friendmap, igt_prism. + +#import "../../Utils.h" +#import +#import + +static inline BOOL containsAny(NSString *s, NSArray *needles) { + if (![s isKindOfClass:[NSString class]] || s.length == 0) return NO; + NSString *lower = s.lowercaseString; + for (NSString *n in needles) if ([lower containsString:n]) return YES; + return NO; +} + +static BOOL matchQuickSnap(NSString *name) { + if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return NO; + return containsAny(name, @[@"quicksnap", @"quick_snap", @"instants", @"xma_quicksnap", + @"_ig_ios_quicksnap_", @"_ig_ios_quick_snap_", @"_ig_ios_instants_"]); +} + +static BOOL matchFriendMap(NSString *name) { + if (![SCIUtils getBoolPref:@"igt_directnotes_friendmap"]) return NO; + return containsAny(name, @[@"friendmap", @"friends_map", @"direct_notes", + @"ig_direct_notes_ios", @"_ig_ios_friendmap_", @"_ig_ios_friends_map_"]); +} + +static BOOL matchPrism(NSString *name) { + if (![SCIUtils getBoolPref:@"igt_prism"]) return NO; + return containsAny(name, @[@"prism"]); +} + +static inline BOOL shouldForceOn(NSString *name) { + return matchQuickSnap(name) || matchFriendMap(name) || matchPrism(name); +} + +static NSString *expNameOf(id obj) { + if (!obj) return nil; + Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName"); + if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName"); + if (!iv) return nil; + @try { + id v = object_getIvar(obj, iv); + if ([v isKindOfClass:[NSString class]]) return v; + } @catch (__unused id e) {} + return nil; +} + +static BOOL (*orig_meta_isIn)(id, SEL) = NULL; +static BOOL new_meta_isIn(id self, SEL _cmd) { + if (shouldForceOn(expNameOf(self))) return YES; + return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO; +} + +static BOOL (*orig_family_isIn)(id, SEL) = NULL; +static BOOL new_family_isIn(id self, SEL _cmd) { + if (shouldForceOn(expNameOf(self))) return YES; + return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO; +} + +static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL; +static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) { + if (shouldForceOn(name)) return YES; + return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO; +} + +static id (*orig_groupName)(id, SEL) = NULL; +static id new_groupName(id self, SEL _cmd) { + if (shouldForceOn(expNameOf(self))) return @"test"; + return orig_groupName ? orig_groupName(self, _cmd) : nil; +} + +static id (*orig_peekGroup)(id, SEL) = NULL; +static id new_peekGroup(id self, SEL _cmd) { + if (shouldForceOn(expNameOf(self))) return @"test"; + return orig_peekGroup ? orig_peekGroup(self, _cmd) : nil; +} + +static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) { + if (!cls) return; + SEL s = NSSelectorFromString(selName); + if (!class_getInstanceMethod(cls, s)) return; + MSHookMessageEx(cls, s, newImp, origOut); +} + +%ctor { + if (!([SCIUtils getBoolPref:@"igt_quicksnap"] || + [SCIUtils getBoolPref:@"igt_directnotes_friendmap"] || + [SCIUtils getBoolPref:@"igt_prism"])) return; + + Class meta = NSClassFromString(@"MetaLocalExperiment"); + hook(meta, @"isInExperiment", (IMP)new_meta_isIn, (IMP *)&orig_meta_isIn); + hook(meta, @"groupName", (IMP)new_groupName, (IMP *)&orig_groupName); + hook(meta, @"peekGroupName", (IMP)new_peekGroup, (IMP *)&orig_peekGroup); + hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment", + (IMP)new_family_isIn, (IMP *)&orig_family_isIn); + hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:", + (IMP)new_lid_enabled, (IMP *)&orig_lid_enabled); +} diff --git a/src/Features/Experimental/HomecomingCompat.xm b/src/Features/Experimental/HomecomingCompat.xm new file mode 100644 index 0000000..4c64f62 --- /dev/null +++ b/src/Features/Experimental/HomecomingCompat.xm @@ -0,0 +1,63 @@ +// Force-enable Homecoming nav experiment. Gate: igt_homecoming. + +#import "../../Utils.h" +#import +#import + +static NSString *expNameOf(id obj) { + if (!obj) return nil; + Ivar iv = class_getInstanceVariable(object_getClass(obj), "_experimentGroupName"); + if (!iv) iv = class_getInstanceVariable(object_getClass(obj), "_experimentName"); + if (!iv) return nil; + @try { + id v = object_getIvar(obj, iv); + if ([v isKindOfClass:[NSString class]]) return v; + } @catch (__unused id e) {} + return nil; +} + +static inline BOOL matchesHomecoming(NSString *s) { + return [s isKindOfClass:[NSString class]] && s.length && + [s.lowercaseString containsString:@"homecoming"]; +} + +static BOOL (*orig_meta_isIn)(id, SEL) = NULL; +static BOOL new_meta_isIn(id self, SEL _cmd) { + if (matchesHomecoming(expNameOf(self))) return YES; + return orig_meta_isIn ? orig_meta_isIn(self, _cmd) : NO; +} + +static BOOL (*orig_family_isIn)(id, SEL) = NULL; +static BOOL new_family_isIn(id self, SEL _cmd) { + if (matchesHomecoming(expNameOf(self))) return YES; + return orig_family_isIn ? orig_family_isIn(self, _cmd) : NO; +} + +static BOOL (*orig_lid_enabled)(id, SEL, NSString *) = NULL; +static BOOL new_lid_enabled(id self, SEL _cmd, NSString *name) { + if (matchesHomecoming(name)) return YES; + return orig_lid_enabled ? orig_lid_enabled(self, _cmd, name) : NO; +} + +static BOOL (*orig_nav_isHC)(id, SEL) = NULL; +static BOOL new_nav_isHC(id self, SEL _cmd) { return YES; } + +static void hook(Class cls, NSString *selName, IMP newImp, IMP *origOut) { + if (!cls) return; + SEL s = NSSelectorFromString(selName); + if (!class_getInstanceMethod(cls, s)) return; + MSHookMessageEx(cls, s, newImp, origOut); +} + +%ctor { + if (![SCIUtils getBoolPref:@"igt_homecoming"]) return; + + hook(NSClassFromString(@"MetaLocalExperiment"), @"isInExperiment", + (IMP)new_meta_isIn, (IMP *)&orig_meta_isIn); + hook(NSClassFromString(@"FamilyLocalExperiment"), @"isInExperiment", + (IMP)new_family_isIn, (IMP *)&orig_family_isIn); + hook(NSClassFromString(@"LIDExperimentGenerator"), @"isExperimentEnabled:", + (IMP)new_lid_enabled, (IMP *)&orig_lid_enabled); + hook(NSClassFromString(@"_TtC18IGNavConfiguration18IGNavConfiguration"), + @"isHomecomingEnabled", (IMP)new_nav_isHC, (IMP *)&orig_nav_isHC); +} diff --git a/src/Features/Experimental/QuickSnapCompat.xm b/src/Features/Experimental/QuickSnapCompat.xm new file mode 100644 index 0000000..4efdd89 --- /dev/null +++ b/src/Features/Experimental/QuickSnapCompat.xm @@ -0,0 +1,108 @@ +// Force-enable QuickSnap (Instants) surfaces. Gate: igt_quicksnap. + +#import "../../Utils.h" +#import +#import + +#define QS_BOOL1_RETURN_YES(fnName) \ + static BOOL (*orig_##fnName)(id, SEL, id) = NULL; \ + static BOOL new_##fnName(id self, SEL _cmd, id arg) { return YES; } + +QS_BOOL1_RETURN_YES(qs_enabled) +QS_BOOL1_RETURN_YES(qs_enabled_feed) +QS_BOOL1_RETURN_YES(qs_enabled_inbox) +QS_BOOL1_RETURN_YES(qs_enabled_stories) +QS_BOOL1_RETURN_YES(qs_enabled_peek) +QS_BOOL1_RETURN_YES(qs_enabled_tray) +QS_BOOL1_RETURN_YES(qs_enabled_tray_peek) +QS_BOOL1_RETURN_YES(qs_enabled_tray_pog) +QS_BOOL1_RETURN_YES(qs_enabled_empty_pog) +QS_BOOL1_RETURN_YES(qs_isqp) + +static BOOL (*orig_qs_corner)(id, SEL) = NULL; +static BOOL new_qs_corner(id self, SEL _cmd) { return YES; } +static BOOL (*orig_qs_dialog)(id, SEL) = NULL; +static BOOL new_qs_dialog(id self, SEL _cmd) { return YES; } + +static BOOL (*orig_peek)(id, SEL) = NULL; +static BOOL new_peek(id self, SEL _cmd) { return YES; } +static BOOL (*orig_recap)(id, SEL) = NULL; +static BOOL new_recap(id self, SEL _cmd) { return YES; } +static BOOL (*orig__recap)(id, SEL) = NULL; +static BOOL new__recap(id self, SEL _cmd) { return YES; } +static BOOL (*orig_recap_media)(id, SEL) = NULL; +static BOOL new_recap_media(id self, SEL _cmd) { return YES; } +static BOOL (*orig_recap_video)(id, SEL) = NULL; +static BOOL new_recap_video(id self, SEL _cmd) { return YES; } +static BOOL (*orig_hidden)(id, SEL) = NULL; +static BOOL new_hidden(id self, SEL _cmd) { return NO; } +static BOOL (*orig__hidden)(id, SEL) = NULL; +static BOOL new__hidden(id self, SEL _cmd) { return NO; } + +static void hookClassMethod(NSString *cn, NSString *sn, IMP impl, IMP *orig) { + Class cls = NSClassFromString(cn); + if (!cls) return; + Class meta = object_getClass(cls); + if (!meta) return; + SEL sel = NSSelectorFromString(sn); + if (!class_getInstanceMethod(meta, sel)) return; + MSHookMessageEx(meta, sel, impl, orig); +} + +static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) { + Class cls = NSClassFromString(cn); + if (!cls) return; + SEL sel = NSSelectorFromString(sn); + if (!class_getInstanceMethod(cls, sel)) return; + MSHookMessageEx(cls, sel, impl, orig); +} + +static void hookZeroArgAcross(NSArray *classes, NSString *sn, IMP impl, IMP *orig) { + SEL sel = NSSelectorFromString(sn); + for (NSString *cn in classes) { + Class cls = NSClassFromString(cn); + if (!cls || !class_getInstanceMethod(cls, sel)) continue; + MSHookMessageEx(cls, sel, impl, orig); + } +} + +%ctor { + if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return; + + NSString *helper = @"_TtC26IGQuickSnapExperimentation32IGQuickSnapExperimentationHelper"; + hookClassMethod(helper, @"isQuicksnapEnabled:", (IMP)new_qs_enabled, (IMP *)&orig_qs_enabled); + hookClassMethod(helper, @"isQuicksnapEnabledInFeed:", (IMP)new_qs_enabled_feed, (IMP *)&orig_qs_enabled_feed); + hookClassMethod(helper, @"isQuicksnapEnabledInInbox:", (IMP)new_qs_enabled_inbox, (IMP *)&orig_qs_enabled_inbox); + hookClassMethod(helper, @"isQuicksnapEnabledInStories:", (IMP)new_qs_enabled_stories, (IMP *)&orig_qs_enabled_stories); + hookClassMethod(helper, @"isQuicksnapEnabledInNotesTray:", (IMP)new_qs_enabled_tray, (IMP *)&orig_qs_enabled_tray); + hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPeek:", (IMP)new_qs_enabled_tray_peek, (IMP *)&orig_qs_enabled_tray_peek); + hookClassMethod(helper, @"isQuicksnapEnabledInNotesTrayWithPog:", (IMP)new_qs_enabled_tray_pog, (IMP *)&orig_qs_enabled_tray_pog); + hookClassMethod(helper, @"isQuicksnapNotesTrayEmptyPogEnabled:", (IMP)new_qs_enabled_empty_pog, (IMP *)&orig_qs_enabled_empty_pog); + hookClassMethod(helper, @"isQuicksnapEnabledAsPeek:", (IMP)new_qs_enabled_peek, (IMP *)&orig_qs_enabled_peek); + + NSString *tray = @"_TtC21IGNotesTrayController21IGNotesTrayController"; + hookInstance(tray, @"_isEligibleForQuicksnapCornerStackTransitionDialog", (IMP)new_qs_corner, (IMP *)&orig_qs_corner); + hookInstance(tray, @"_isEligibleForQuicksnapDialog", (IMP)new_qs_dialog, (IMP *)&orig_qs_dialog); + hookInstance(tray, @"isQPEnabled:", (IMP)new_qs_isqp, (IMP *)&orig_qs_isqp); + + hookInstance(@"IGDirectNotesTrayRowSectionController", @"isQPEnabled:", (IMP)new_qs_isqp, NULL); + hookInstance(@"_TtC24IGDirectNotesTrayUISwift37IGDirectNotesTrayRowSectionController", + @"isQPEnabled:", (IMP)new_qs_isqp, NULL); + + NSArray *instantsClasses = @[ + @"IGInstantGestureRecognizer", + @"IGAPIQuickSnapData", + @"XDTQuickSnapData", + @"IGAPIQuicksnapRecapMediaInfo", + @"XDTQuicksnapRecapMediaInfo", + ]; + hookZeroArgAcross(instantsClasses, @"isEligibleForPeek", (IMP)new_peek, (IMP *)&orig_peek); + hookZeroArgAcross(instantsClasses, @"isQuicksnapRecap", (IMP)new_recap, (IMP *)&orig_recap); + hookZeroArgAcross(instantsClasses, @"_isQuicksnapRecap", (IMP)new__recap, (IMP *)&orig__recap); + hookZeroArgAcross(instantsClasses, @"hasQuicksnapRecapMedia",(IMP)new_recap_media, (IMP *)&orig_recap_media); + hookZeroArgAcross(instantsClasses, @"isInstantsRecapVideo", (IMP)new_recap_video, (IMP *)&orig_recap_video); + + NSString *svc = @"_TtC18IGQuickSnapService18IGQuickSnapService"; + hookInstance(svc, @"isHiddenByServer", (IMP)new_hidden, (IMP *)&orig_hidden); + hookInstance(svc, @"_isHiddenByServer", (IMP)new__hidden, (IMP *)&orig__hidden); +} diff --git a/src/Features/Experimental/QuickSnapMCCompat.xm b/src/Features/Experimental/QuickSnapMCCompat.xm new file mode 100644 index 0000000..0ddba99 --- /dev/null +++ b/src/Features/Experimental/QuickSnapMCCompat.xm @@ -0,0 +1,43 @@ +// MobileConfig override for any ig_boolForKey: naming QuickSnap. Gate: igt_quicksnap. + +#import "../../Utils.h" +#import +#import + +static inline BOOL keyMatchesQuickSnap(id key) { + if (![key isKindOfClass:[NSString class]]) return NO; + NSString *s = ((NSString *)key).lowercaseString; + return [s containsString:@"quicksnap"] || + [s containsString:@"quick_snap"] || + [s containsString:@"instants"] || + [s containsString:@"xma_quicksnap"]; +} + +static BOOL (*orig_bool_key)(id, SEL, id) = NULL; +static BOOL new_bool_key(id self, SEL _cmd, id key) { + if (keyMatchesQuickSnap(key)) return YES; + return orig_bool_key ? orig_bool_key(self, _cmd, key) : NO; +} + +static BOOL (*orig_bool_key_def)(id, SEL, id, BOOL) = NULL; +static BOOL new_bool_key_def(id self, SEL _cmd, id key, BOOL def) { + if (keyMatchesQuickSnap(key)) return YES; + return orig_bool_key_def ? orig_bool_key_def(self, _cmd, key, def) : def; +} + +static void hookInstance(NSString *cn, NSString *sn, IMP impl, IMP *orig) { + Class cls = NSClassFromString(cn); + if (!cls) return; + SEL sel = NSSelectorFromString(sn); + if (!class_getInstanceMethod(cls, sel)) return; + MSHookMessageEx(cls, sel, impl, orig); +} + +%ctor { + if (![SCIUtils getBoolPref:@"igt_quicksnap"]) return; + + hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key); + hookInstance(@"IGMobileConfigContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def); + hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:", (IMP)new_bool_key, (IMP *)&orig_bool_key); + hookInstance(@"IGMobileConfigUserSessionContextManager", @"ig_boolForKey:defaultValue:", (IMP)new_bool_key_def, (IMP *)&orig_bool_key_def); +} diff --git a/src/Features/Experimental/SCIExperimentalGuard.h b/src/Features/Experimental/SCIExperimentalGuard.h new file mode 100644 index 0000000..eea6038 --- /dev/null +++ b/src/Features/Experimental/SCIExperimentalGuard.h @@ -0,0 +1,12 @@ +// Crash-loop guard + pref registry for igt_* experimental flags. + +#import + +@interface SCIExperimentalGuard : NSObject + ++ (NSArray *)allPrefKeys; ++ (BOOL)anyEnabled; ++ (void)resetAll; ++ (BOOL)didResetThisLaunch; + +@end diff --git a/src/Features/Experimental/SCIExperimentalGuard.m b/src/Features/Experimental/SCIExperimentalGuard.m new file mode 100644 index 0000000..e46eb6a --- /dev/null +++ b/src/Features/Experimental/SCIExperimentalGuard.m @@ -0,0 +1,68 @@ +#import "SCIExperimentalGuard.h" +#import "../../Utils.h" + +static NSString *const kCounterKey = @"sci_exp_unstable_launches"; +static NSInteger const kThreshold = 3; +static BOOL gDidReset = NO; + +@implementation SCIExperimentalGuard + ++ (NSArray *)allPrefKeys { + static NSArray *keys; + static dispatch_once_t once; + dispatch_once(&once, ^{ + keys = @[ + @"igt_homecoming", + @"igt_quicksnap", + @"igt_prism", + @"igt_directnotes_friendmap", + @"igt_directnotes_audio_reply", + @"igt_directnotes_avatar_reply", + @"igt_directnotes_gifs_reply", + @"igt_directnotes_photo_reply", + ]; + }); + return keys; +} + ++ (BOOL)anyEnabled { + for (NSString *k in [self allPrefKeys]) { + if ([SCIUtils getBoolPref:k]) return YES; + } + return NO; +} + ++ (void)resetAll { + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + for (NSString *k in [self allPrefKeys]) [ud setBool:NO forKey:k]; +} + ++ (BOOL)didResetThisLaunch { return gDidReset; } + ++ (void)load { + if (![self anyEnabled]) return; + + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + NSInteger c = [ud integerForKey:kCounterKey] + 1; + + if (c >= kThreshold) { + [self resetAll]; + [ud removeObjectForKey:kCounterKey]; + gDidReset = YES; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(5 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [SCIUtils showToastForDuration:5.0 + title:SCILocalized(@"Experimental flags reset") + subtitle:SCILocalized(@"Disabled after repeated crashes.")]; + }); + return; + } + + [ud setInteger:c forKey:kCounterKey]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(30 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [[NSUserDefaults standardUserDefaults] removeObjectForKey:kCounterKey]; + }); +} + +@end diff --git a/src/Features/Feed/DisableFeedAutoplay.x b/src/Features/Feed/DisableFeedAutoplay.x index 9d539a3..70fd3a1 100644 --- a/src/Features/Feed/DisableFeedAutoplay.x +++ b/src/Features/Feed/DisableFeedAutoplay.x @@ -1,32 +1,61 @@ #import "../../Utils.h" #import +#import #import -// IGFeedPlayback.IGFeedPlaybackStrategy gained new init parameters in IG 423+. -// Both the 2-arg and 3-arg variants are hooked to force shouldDisableAutoplay=YES. -// Hooked via MSHookMessageEx in %ctor since the class has a Swift-mangled name. +// IGFeedPlayback.IGFeedPlaybackStrategy has a Swift-mangled name. Both init +// variants force shouldDisableAutoplay=YES when the pref is on. -static id (*orig_initStrategy2)(id, SEL, BOOL, BOOL); -static id new_initStrategy2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) { +static id (*orig_feedInit2)(id, SEL, BOOL, BOOL); +static id new_feedInit2(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale) { if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES; - return orig_initStrategy2(self, _cmd, shouldDisable, shouldClearStale); + return orig_feedInit2(self, _cmd, shouldDisable, shouldClearStale); } -static id (*orig_initStrategy3)(id, SEL, BOOL, BOOL, BOOL); -static id new_initStrategy3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) { +static id (*orig_feedInit3)(id, SEL, BOOL, BOOL, BOOL); +static id new_feedInit3(id self, SEL _cmd, BOOL shouldDisable, BOOL shouldClearStale, BOOL bypassForVoiceover) { if ([SCIUtils getBoolPref:@"disable_feed_autoplay"]) shouldDisable = YES; - return orig_initStrategy3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover); + return orig_feedInit3(self, _cmd, shouldDisable, shouldClearStale, bypassForVoiceover); +} + +// Carousel tap-to-play. The modern feed video cell receives single-taps via +// this delegate callback, but the Swift implementation skips resume when the +// cell sits inside a carousel. Force retryStartPlayback after orig. +static void (*orig_cellDidSingleTap)(id, SEL, id, id); +static void new_cellDidSingleTap(id self, SEL _cmd, id overlay, id gr) { + orig_cellDidSingleTap(self, _cmd, overlay, gr); + if (![SCIUtils getBoolPref:@"disable_feed_autoplay"]) return; + UIView *sv = [(UIView *)self superview]; + if (!sv || !strstr(class_getName([sv class]), "Carousel")) return; + if ([self respondsToSelector:@selector(retryStartPlayback)]) + ((void(*)(id, SEL))objc_msgSend)(self, @selector(retryStartPlayback)); +} + +static void sciHookFeedStrategy(void) { + Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy"); + if (!cls) return; + SEL s2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:); + if ([cls instancesRespondToSelector:s2]) + MSHookMessageEx(cls, s2, (IMP)new_feedInit2, (IMP *)&orig_feedInit2); + SEL s3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:); + if ([cls instancesRespondToSelector:s3]) + MSHookMessageEx(cls, s3, (IMP)new_feedInit3, (IMP *)&orig_feedInit3); +} + +static void sciHookVideoCell(void) { + static BOOL hooked = NO; + if (hooked) return; + Class cls = objc_getClass("IGModernFeedVideoCell.IGModernFeedVideoCell"); + if (!cls) return; + SEL s = @selector(videoPlayerOverlayControllerDidSingleTap:gestureRecognizer:); + if (![cls instancesRespondToSelector:s]) return; + MSHookMessageEx(cls, s, (IMP)new_cellDidSingleTap, (IMP *)&orig_cellDidSingleTap); + hooked = YES; } %ctor { - Class cls = objc_getClass("IGFeedPlayback.IGFeedPlaybackStrategy"); - if (!cls) return; - - SEL sel2 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:); - if ([cls instancesRespondToSelector:sel2]) - MSHookMessageEx(cls, sel2, (IMP)new_initStrategy2, (IMP *)&orig_initStrategy2); - - SEL sel3 = @selector(initWithShouldDisableAutoplay:shouldClearStaleReservation:shouldBypassDisabledAutoplayForVoiceover:); - if ([cls instancesRespondToSelector:sel3]) - MSHookMessageEx(cls, sel3, (IMP)new_initStrategy3, (IMP *)&orig_initStrategy3); + sciHookFeedStrategy(); + sciHookVideoCell(); + // Swift cell class can load after dylib init; retry on main runloop. + dispatch_async(dispatch_get_main_queue(), ^{ sciHookVideoCell(); }); } diff --git a/src/Features/General/FakeLocationMapButton.x b/src/Features/General/FakeLocationMapButton.x index 3989772..81a6b42 100644 --- a/src/Features/General/FakeLocationMapButton.x +++ b/src/Features/General/FakeLocationMapButton.x @@ -1,13 +1,15 @@ // Quick fake-location toggle injected into IG's Friends Map (DMs > Maps). #import "../../Utils.h" +#import "../../SCIChrome.h" #import "../../Settings/SCIFakeLocationSettingsVC.h" #import "../../Settings/SCIFakeLocationPickerVC.h" #import #import #import -static const NSInteger kSciMapBtnTag = 0x5C1F4B; +static const NSInteger kSciMapBtnTag = 0x5C1F4B; +static const NSInteger kSciMapHitBtnTag = 0x5C1F4C; static UIViewController *sciTopMost(void) { UIWindow *win = nil; @@ -179,6 +181,8 @@ static UIMenu *sciBuildMapMenu(void) { static void sciRemoveMapButton(UIView *mapView) { UIView *btn = [mapView viewWithTag:kSciMapBtnTag]; if (btn) [btn removeFromSuperview]; + UIView *hit = [mapView viewWithTag:kSciMapHitBtnTag]; + if (hit) [hit removeFromSuperview]; } static void sciAddMapButton(UIView *mapView) { @@ -186,40 +190,55 @@ static void sciAddMapButton(UIView *mapView) { if (![SCIUtils getBoolPref:@"show_fake_location_map_button"]) { sciRemoveMapButton(mapView); return; } if ([mapView viewWithTag:kSciMapBtnTag]) return; - UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; - btn.tag = kSciMapBtnTag; - btn.translatesAutoresizingMaskIntoConstraints = NO; - btn.backgroundColor = [UIColor secondarySystemBackgroundColor]; - btn.layer.cornerRadius = 24; - btn.layer.shadowColor = [UIColor blackColor].CGColor; - btn.layer.shadowOpacity = 0.18; - btn.layer.shadowRadius = 5; - btn.layer.shadowOffset = CGSizeMake(0, 2); - btn.showsMenuAsPrimaryAction = YES; - btn.menu = sciBuildMapMenu(); - - // Refresh menu on each press so toggle/preset state is current. - [btn addAction:[UIAction actionWithHandler:^(__unused UIAction *a) { - btn.menu = sciBuildMapMenu(); - }] forControlEvents:UIControlEventMenuActionTriggered]; - - [mapView addSubview:btn]; + // Visible chrome — static, never absorbed into the menu platter animation. + BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"]; + SCIChromeButton *chrome = [[SCIChromeButton alloc] initWithSymbol:on ? @"location.fill" : @"location.slash" + pointSize:18 + diameter:48]; + chrome.tag = kSciMapBtnTag; + chrome.bubbleColor = [UIColor secondarySystemBackgroundColor]; + chrome.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor]; + chrome.layer.shadowColor = [UIColor blackColor].CGColor; + chrome.layer.shadowOpacity = 0.18; + chrome.layer.shadowRadius = 5; + chrome.layer.shadowOffset = CGSizeMake(0, 2); + chrome.userInteractionEnabled = NO; + [mapView addSubview:chrome]; [NSLayoutConstraint activateConstraints:@[ - [btn.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16], - [btn.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78], - [btn.widthAnchor constraintEqualToConstant:48], - [btn.heightAnchor constraintEqualToConstant:48], + [chrome.leadingAnchor constraintEqualToAnchor:mapView.leadingAnchor constant:16], + [chrome.topAnchor constraintEqualToAnchor:mapView.safeAreaLayoutGuide.topAnchor constant:78], + [chrome.widthAnchor constraintEqualToConstant:48], + [chrome.heightAnchor constraintEqualToConstant:48], + ]]; + + // Invisible hit target owns the menu; visible chrome below stays put + // when UIKit absorbs the hit into the menu platter on dismiss. + UIButton *hit = [UIButton buttonWithType:UIButtonTypeCustom]; + hit.tag = kSciMapHitBtnTag; + hit.backgroundColor = [UIColor clearColor]; + hit.translatesAutoresizingMaskIntoConstraints = NO; + hit.showsMenuAsPrimaryAction = YES; + hit.menu = sciBuildMapMenu(); + [hit addAction:[UIAction actionWithHandler:^(__unused UIAction *a) { + hit.menu = sciBuildMapMenu(); + }] forControlEvents:UIControlEventMenuActionTriggered]; + [mapView addSubview:hit]; + [NSLayoutConstraint activateConstraints:@[ + [hit.leadingAnchor constraintEqualToAnchor:chrome.leadingAnchor], + [hit.trailingAnchor constraintEqualToAnchor:chrome.trailingAnchor], + [hit.topAnchor constraintEqualToAnchor:chrome.topAnchor], + [hit.bottomAnchor constraintEqualToAnchor:chrome.bottomAnchor], ]]; } static void sciRefreshMapButton(UIView *mapView) { - UIButton *btn = (UIButton *)[mapView viewWithTag:kSciMapBtnTag]; - if (!btn) return; + SCIChromeButton *btn = (SCIChromeButton *)[mapView viewWithTag:kSciMapBtnTag]; + if (![btn isKindOfClass:[SCIChromeButton class]]) return; BOOL on = [SCIUtils getBoolPref:@"fake_location_enabled"]; - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; - [btn setImage:[UIImage systemImageNamed:on ? @"location.fill" : @"location.slash" withConfiguration:cfg] forState:UIControlStateNormal]; - btn.tintColor = on ? [UIColor systemGreenColor] : [UIColor labelColor]; - btn.menu = sciBuildMapMenu(); + btn.symbolName = on ? @"location.fill" : @"location.slash"; + btn.iconTint = on ? [UIColor systemGreenColor] : [UIColor labelColor]; + // Don't touch btn.menu here — reassigning mid-dismiss flickers the button. + // UIControlEventMenuActionTriggered rebuilds on next open. } static void (*orig_mapLayout)(UIView *, SEL); diff --git a/src/Features/General/HideExploreGrid.xm b/src/Features/General/HideExploreGrid.xm index 732c587..b7c0ace 100644 --- a/src/Features/General/HideExploreGrid.xm +++ b/src/Features/General/HideExploreGrid.xm @@ -1,31 +1,172 @@ +// Explore tab hide toggles. +// hide_explore_grid → posts grid + shimmer loader +// hide_trending_searches → category chip bar + algo button on the right +// +// Grid revealing rules: tapping a chip or focusing the search bar counts as +// engagement and unhides the grid until the user leaves the Explore tab. + #import "../../Utils.h" #import "../../InstagramHeaders.h" +#import -%hook IGExploreGridViewController -- (void)viewDidLoad { - if ([SCIUtils getBoolPref:@"hide_explore_grid"]) { - NSLog(@"[SCInsta] Hiding explore grid"); +static BOOL sciHideGrid(void) { return [SCIUtils getBoolPref:@"hide_explore_grid"]; } +static BOOL sciHideSearch(void) { return [SCIUtils getBoolPref:@"hide_trending_searches"]; } - [[self view] removeFromSuperview]; +static __weak UIViewController *gActiveExploreVC = nil; +static BOOL gSearchFocused = NO; +static BOOL gUserEngaged = NO; - return; +// MARK: - Hide helpers + +// Alpha + userInteraction instead of .hidden keeps IG's data fetch and the +// shimmer animation alive, so toggling the pref back on shows fresh content +// instantly without a restart. +static void sciSetViewVisuallyHidden(UIView *v, BOOL hidden) { + if (!v) return; + v.alpha = hidden ? 0.0 : 1.0; + v.userInteractionEnabled = !hidden; +} + +static void sciSetIvarViewHidden(id host, const char *name, BOOL hidden) { + Ivar iv = class_getInstanceVariable([host class], name); + if (!iv) return; + @try { + UIView *v = object_getIvar(host, iv); + if ([v isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(v, hidden); + } @catch (__unused id e) {} +} + +static void sciApplyExploreHide(id vc) { + // Chips stay visible while search is focused (they act as filters then). + BOOL hideChips = sciHideSearch() && !gSearchFocused; + sciSetIvarViewHidden(vc, "_nidoChipBar", hideChips); + + // Force re-layout so pref flips reflect on re-entry. + Ivar stvIvar = class_getInstanceVariable([vc class], "_searchTitleView"); + if (stvIvar) { + @try { + UIView *tv = object_getIvar(vc, stvIvar); + if ([tv isKindOfClass:[UIView class]]) { + [tv setNeedsLayout]; + [tv layoutIfNeeded]; + } + } @catch (__unused id e) {} } - - return %orig; + + // Grid reveals on chip tap or search focus. + BOOL hideGrid = sciHideGrid() && !gUserEngaged && !gSearchFocused; + sciSetIvarViewHidden(vc, "_shimmeringGridView", hideGrid); + + Ivar gvcIvar = class_getInstanceVariable([vc class], "_gridViewController"); + if (!gvcIvar) return; + @try { + UIViewController *grid = object_getIvar(vc, gvcIvar); + if (![grid isKindOfClass:[UIViewController class]] || !grid.isViewLoaded) return; + sciSetViewVisuallyHidden(grid.view, hideGrid); + Ivar cvIvar = class_getInstanceVariable([grid class], "_collectionView"); + if (cvIvar) { + UIView *cv = object_getIvar(grid, cvIvar); + if ([cv isKindOfClass:[UIView class]]) sciSetViewVisuallyHidden(cv, hideGrid); + } + } @catch (__unused id e) {} +} + +// Algo button vs Cancel: both are IGTapButton siblings of the search bar. +// Cancel has a UIButtonLabel (the "Cancel" text); the algo button is square +// with just an icon child. +static BOOL sciIsAlgoButton(UIView *btn) { + if (btn.bounds.size.width != btn.bounds.size.height) return NO; + for (UIView *sub in btn.subviews) { + if ([sub isKindOfClass:[UILabel class]]) return NO; + } + return YES; +} + +// MARK: - VC hooks + +%group HideExploreGroup + +%hook IGExploreViewController +- (void)viewDidLayoutSubviews { + %orig; + gActiveExploreVC = self; + sciApplyExploreHide(self); +} +- (void)viewWillAppear:(BOOL)animated { + %orig; + gActiveExploreVC = self; + sciApplyExploreHide(self); +} +- (void)viewDidDisappear:(BOOL)animated { + %orig; + gUserEngaged = NO; + gSearchFocused = NO; +} +- (void)exploreChipBarView:(id)bar didSelectChipAtIndex:(NSInteger)idx { + %orig; + gUserEngaged = YES; + sciApplyExploreHide(self); + [self.view setNeedsLayout]; } %end -%hook IGExploreViewController -- (void)viewDidLoad { +%hook IGAnimatablePlaceholderTextField +- (BOOL)becomeFirstResponder { + BOOL r = %orig; + gSearchFocused = YES; + if (gActiveExploreVC) { + sciApplyExploreHide(gActiveExploreVC); + [gActiveExploreVC.view setNeedsLayout]; + } + return r; +} +- (BOOL)resignFirstResponder { + BOOL r = %orig; + gSearchFocused = NO; + if (gActiveExploreVC) { + sciApplyExploreHide(gActiveExploreVC); + [gActiveExploreVC.view setNeedsLayout]; + } + return r; +} +%end + +// Hook the search title view's own layout — catches every relayout at the +// source, so hiding the algo button + stretching the bar has no lagged frame. +%hook IGExploreSearchTitleView +- (void)layoutSubviews { %orig; + BOOL hide = sciHideSearch(); + Class tapBtnCls = NSClassFromString(@"IGTapButton"); + Class dotCls = NSClassFromString(@"IGDSDotView"); + Class searchCls = NSClassFromString(@"IGSearchBar"); - if ([SCIUtils getBoolPref:@"hide_explore_grid"]) { - NSLog(@"[SCInsta] Hiding explore grid"); - - IGShimmeringGridView *shimmeringGridView = MSHookIvar(self, "_shimmeringGridView"); - if (shimmeringGridView != nil) { - [shimmeringGridView removeFromSuperview]; + UIView *searchBar = nil; + for (UIView *sub in self.subviews) { + if (searchCls && [sub isKindOfClass:searchCls]) { + searchBar = sub; + } else if (tapBtnCls && [sub isKindOfClass:tapBtnCls] && sciIsAlgoButton(sub)) { + sub.hidden = hide; + } else if (dotCls && [sub isKindOfClass:dotCls]) { + sub.hidden = hide; + } + } + if (searchBar && hide) { + CGFloat target = self.bounds.size.width; + if (searchBar.frame.size.width != target) { + CGRect f = searchBar.frame; + f.size.width = target; + searchBar.frame = f; } } } -%end \ No newline at end of file +%end + +%end // HideExploreGroup + +%ctor { + if ([SCIUtils getBoolPref:@"hide_explore_grid"] || + [SCIUtils getBoolPref:@"hide_trending_searches"]) { + %init(HideExploreGroup); + } +} diff --git a/src/Features/General/HideSuggestedStories.x b/src/Features/General/HideSuggestedStories.x index a6b04a0..df8517d 100644 --- a/src/Features/General/HideSuggestedStories.x +++ b/src/Features/General/HideSuggestedStories.x @@ -1,5 +1,7 @@ -// Hide suggested stories from the tray. Only filters when suggested items -// are present — skips clean inputs to avoid IGListKit diff cascade. +// Hide suggested stories from the feed tray. The adapter hook is shared +// with profile highlights, so we key off diffIdentifier: only suggested +// items use a 32-char hex UUID (real users use numeric PKs, highlights use +// "highlight:"). Default-keep on anything ambiguous. #import "../../InstagramHeaders.h" #import "../../Utils.h" @@ -7,15 +9,27 @@ #import #import -static BOOL sciIsSuggestedTrayItem(id obj) { - if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO; +static BOOL sciIsHexUUIDString(NSString *s) { + if (s.length != 32) return NO; + static NSCharacterSet *nonHex; + static dispatch_once_t once; + dispatch_once(&once, ^{ + nonHex = [[NSCharacterSet characterSetWithCharactersInString:@"0123456789abcdef"] invertedSet]; + }); + return [s rangeOfCharacterFromSet:nonHex].location == NSNotFound; +} +static BOOL sciIsSuggestedTrayItem(id obj) { @try { + if (![NSStringFromClass([obj class]) isEqualToString:@"IGStoryTrayViewModel"]) return NO; if ([[obj valueForKey:@"isCurrentUserReel"] boolValue]) return NO; + NSString *diffId = nil; + @try { diffId = [[obj performSelector:@selector(diffIdentifier)] description]; } @catch (...) {} + if (!sciIsHexUUIDString(diffId)) return NO; + id owner = [obj valueForKey:@"reelOwner"]; if (!owner) return NO; - Ivar userIvar = class_getInstanceVariable([owner class], "_userReelOwner_user"); if (!userIvar) return NO; id igUser = object_getIvar(owner, userIvar); @@ -25,14 +39,11 @@ static BOOL sciIsSuggestedTrayItem(id obj) { for (Class c = [igUser class]; c && !fcIvar; c = class_getSuperclass(c)) fcIvar = class_getInstanceVariable(c, "_fieldCache"); if (!fcIvar) return NO; - id fc = object_getIvar(igUser, fcIvar); if (![fc isKindOfClass:[NSDictionary class]]) return NO; - if ([(NSDictionary *)fc count] == 0) return YES; id fs = [(NSDictionary *)fc objectForKey:@"friendship_status"]; if (!fs) return NO; - return ![[fs valueForKey:@"following"] boolValue]; } @catch (__unused NSException *e) { return NO; @@ -42,15 +53,13 @@ static BOOL sciIsSuggestedTrayItem(id obj) { static NSArray *(*orig_objectsForListAdapter)(id, SEL, id); static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) { NSArray *objects = orig_objectsForListAdapter(self, _cmd, adapter); - if (![SCIUtils getBoolPref:@"hide_suggested_stories"]) return objects; - // Pass through unchanged when input has no suggestions (avoids cascade). - BOOL hasSuggested = NO; + BOOL anySuggested = NO; for (id obj in objects) { - if (sciIsSuggestedTrayItem(obj)) { hasSuggested = YES; break; } + if (sciIsSuggestedTrayItem(obj)) { anySuggested = YES; break; } } - if (!hasSuggested) return objects; + if (!anySuggested) return objects; NSMutableArray *filtered = [NSMutableArray arrayWithCapacity:objects.count]; for (id obj in objects) { @@ -60,10 +69,9 @@ static NSArray *hook_objectsForListAdapter(id self, SEL _cmd, id adapter) { } %ctor { - Class dsCls = NSClassFromString(@"IGStoryTrayListAdapterDataSource"); - if (!dsCls) return; - + Class cls = NSClassFromString(@"IGStoryTrayListAdapterDataSource"); + if (!cls) return; SEL sel = NSSelectorFromString(@"objectsForListAdapter:"); - if (class_getInstanceMethod(dsCls, sel)) - MSHookMessageEx(dsCls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter); + if (!class_getInstanceMethod(cls, sel)) return; + MSHookMessageEx(cls, sel, (IMP)hook_objectsForListAdapter, (IMP *)&orig_objectsForListAdapter); } diff --git a/src/Features/General/HideTrendingSearches.x b/src/Features/General/HideTrendingSearches.x index c30de0c..d7f1790 100644 --- a/src/Features/General/HideTrendingSearches.x +++ b/src/Features/General/HideTrendingSearches.x @@ -1,16 +1,25 @@ +// Hide the trending-searches pill bar under the explore search bar. + #import "../../Utils.h" #import "../../InstagramHeaders.h" +%group HideTrendingSearchesGroup %hook IGDSSegmentedPillBarView -- (void)didMoveToWindow { +- (void)didMoveToSuperview { %orig; + if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return; + self.hidden = YES; +} +- (void)layoutSubviews { + %orig; + if (![[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) return; + self.hidden = YES; +} +%end +%end - if ([[self delegate] isKindOfClass:%c(IGSearchTypeaheadNavigationHeaderView)]) { - if ([SCIUtils getBoolPref:@"hide_trending_searches"]) { - NSLog(@"[SCInsta] Hiding trending searches"); - - [self removeFromSuperview]; - } +%ctor { + if ([SCIUtils getBoolPref:@"hide_trending_searches"]) { + %init(HideTrendingSearchesGroup); } } -%end \ No newline at end of file diff --git a/src/Features/General/MessagesOnly.x b/src/Features/General/MessagesOnly.x index 37e6a5d..b2f08e2 100644 --- a/src/Features/General/MessagesOnly.x +++ b/src/Features/General/MessagesOnly.x @@ -2,10 +2,14 @@ #import "../../Utils.h" #import "../../InstagramHeaders.h" +#import "../../SCIChrome.h" #import #import static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; } +static BOOL sciMsgOnlyHideTabBar(void) { + return sciMsgOnly() && [SCIUtils getBoolPref:@"messages_only_hide_tabbar"]; +} %hook IGTabBarController @@ -30,6 +34,21 @@ static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; } } } +- (void)viewDidLayoutSubviews { + %orig; + if (!sciMsgOnlyHideTabBar()) return; + Ivar tbIv = class_getInstanceVariable([self class], "_tabBar"); + UIView *tabBar = tbIv ? object_getIvar(self, tbIv) : nil; + if (tabBar) { + tabBar.hidden = YES; + tabBar.alpha = 0.0; + } + UIViewController *selected = [self valueForKey:@"selectedViewController"]; + if (selected.isViewLoaded) { + selected.view.frame = self.view.bounds; + } +} + // Surface enum no longer maps cleanly to the trimmed _buttons array, so flip // the selected state ourselves and nudge the liquid-glass indicator. %new - (void)sciSyncTabBarSelection:(NSString *)which { @@ -63,3 +82,43 @@ static BOOL sciMsgOnly(void) { return [SCIUtils getBoolPref:@"messages_only"]; } } %end + +// Floating settings button — long-press on tab bar is gone when it's hidden. +static const void *kSCIMsgOnlyBtnKey = &kSCIMsgOnlyBtnKey; + +static void sciMsgOnlyInjectSettingsButton(UIViewController *vc) { + if (!sciMsgOnlyHideTabBar() || !vc || !vc.isViewLoaded) return; + if (objc_getAssociatedObject(vc, kSCIMsgOnlyBtnKey)) return; + + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"gearshape" + pointSize:18 + diameter:36]; + btn.iconTint = [UIColor labelColor]; + btn.bubbleColor = [UIColor clearColor]; + btn.translatesAutoresizingMaskIntoConstraints = NO; + [btn addTarget:vc action:@selector(sciMsgOnlyOpenSettings) + forControlEvents:UIControlEventTouchUpInside]; + [vc.view addSubview:btn]; + + UILayoutGuide *sa = vc.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [btn.leadingAnchor constraintEqualToAnchor:sa.leadingAnchor constant:12], + [btn.topAnchor constraintEqualToAnchor:sa.topAnchor constant:6], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36], + ]]; + + objc_setAssociatedObject(vc, kSCIMsgOnlyBtnKey, btn, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +%hook IGDirectInboxViewController +- (void)viewWillAppear:(BOOL)animated { + %orig; + sciMsgOnlyInjectSettingsButton((UIViewController *)self); +} + +%new - (void)sciMsgOnlyOpenSettings { + UIViewController *vc = (UIViewController *)self; + [SCIUtils showSettingsVC:vc.view.window]; +} +%end diff --git a/src/Features/General/PasteLinkFromSearch.x b/src/Features/General/PasteLinkFromSearch.x new file mode 100644 index 0000000..e70a909 --- /dev/null +++ b/src/Features/General/PasteLinkFromSearch.x @@ -0,0 +1,118 @@ +// Long-press the Explore/search tab to open an IG link from the clipboard. + +#import "../../Utils.h" +#import "../../InstagramHeaders.h" +#import + +static const void *kPasteGestureKey = &kPasteGestureKey; + +// Parse the clipboard string into a URL IG will recognize. Accepts bare +// hostnames, canonical IG hosts, and fix-embed mirrors (any host with +// "instagram" in it — ddinstagram, eeinstagram, vxinstagram, etc.) which +// get rewritten to www.instagram.com. +static NSURL *sciNormalizeIGURL(NSString *raw) { + if (!raw.length) return nil; + raw = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceAndNewlineCharacterSet]]; + if (![raw containsString:@"://"]) raw = [@"https://" stringByAppendingString:raw]; + + NSURL *url = [NSURL URLWithString:raw]; + NSString *scheme = url.scheme.lowercaseString; + if ([scheme isEqualToString:@"instagram"]) return url; + if (![scheme isEqualToString:@"http"] && ![scheme isEqualToString:@"https"]) return nil; + + NSString *host = url.host.lowercaseString; + if (!host.length) return nil; + + if ([host isEqualToString:@"instagram.com"] + || [host hasSuffix:@".instagram.com"] + || [host isEqualToString:@"instagr.am"] + || [host isEqualToString:@"ig.me"]) { + return url; + } + + if ([host containsString:@"instagram"]) { + NSURLComponents *comps = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:NO]; + comps.scheme = @"https"; + comps.host = @"www.instagram.com"; + return comps.URL; + } + + return nil; +} + +@interface SCIPasteLinkHandler : NSObject ++ (instancetype)shared; +- (void)longPressed:(UILongPressGestureRecognizer *)g; +@end + +@implementation SCIPasteLinkHandler ++ (instancetype)shared { + static SCIPasteLinkHandler *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [SCIPasteLinkHandler new]; }); + return s; +} + +// Gate the gesture on the pref. When off, IG's default long-press falls through. +- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)g { + return [SCIUtils getBoolPref:@"paste_link_from_search"]; +} + +- (void)longPressed:(UILongPressGestureRecognizer *)g { + if (g.state != UIGestureRecognizerStateBegan) return; + + NSURL *url = sciNormalizeIGURL([[UIPasteboard generalPasteboard] string]); + if (!url) { + [SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Clipboard is not an Instagram URL")]; + return; + } + + // https URLs route through universal-link handling, not openURL:options:. + UIApplication *app = [UIApplication sharedApplication]; + id delegate = app.delegate; + + if ([url.scheme.lowercaseString isEqualToString:@"instagram"]) { + if ([delegate respondsToSelector:@selector(application:openURL:options:)]) { + [delegate application:app openURL:url options:@{}]; + } + return; + } + + NSUserActivity *activity = [[NSUserActivity alloc] initWithActivityType:NSUserActivityTypeBrowsingWeb]; + activity.webpageURL = url; + SEL contSel = @selector(application:continueUserActivity:restorationHandler:); + if ([delegate respondsToSelector:contSel]) { + BOOL handled = [delegate application:app + continueUserActivity:activity + restorationHandler:^(NSArray> *_Nullable _) {}]; + if (handled) return; + } + + if ([delegate respondsToSelector:@selector(application:openURL:options:)]) { + [delegate application:app openURL:url options:@{}]; + } +} +@end + +static void sciAttachPasteGesture(UIButton *btn) { + if (!btn || objc_getAssociatedObject(btn, kPasteGestureKey)) return; + SCIPasteLinkHandler *handler = [SCIPasteLinkHandler shared]; + UILongPressGestureRecognizer *g = [[UILongPressGestureRecognizer alloc] + initWithTarget:handler action:@selector(longPressed:)]; + g.minimumPressDuration = 0.5; + g.delegate = handler; + // Cancel the tap so IG's tab-tap doesn't fire after and clobber our nav. + g.cancelsTouchesInView = YES; + [btn addGestureRecognizer:g]; + objc_setAssociatedObject(btn, kPasteGestureKey, g, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} + +%hook IGTabBarController +- (void)viewDidLayoutSubviews { + %orig; + Ivar iv = class_getInstanceVariable([self class], "_exploreButton"); + if (!iv) return; + id btn = object_getIvar(self, iv); + if ([btn isKindOfClass:[UIButton class]]) sciAttachPasteGesture(btn); +} +%end diff --git a/src/Features/General/ProfileCopyButton.x b/src/Features/General/ProfileCopyButton.x index 74642e6..eb1ca6c 100644 --- a/src/Features/General/ProfileCopyButton.x +++ b/src/Features/General/ProfileCopyButton.x @@ -1,5 +1,6 @@ #import "../../Utils.h" #import "../../InstagramHeaders.h" +#import "../../SCIChrome.h" #import "../../../modules/JGProgressHUD/JGProgressHUD.h" #import #import @@ -103,9 +104,6 @@ static void sci_copyAndToast(NSString *value, NSString *label) { NSString *fullName = [sci_valueForAnyKey(user, @[@"fullName", @"fullname", @"name"]) description]; NSString *biography = [sci_valueForAnyKey(user, @[@"biography", @"bio", @"profileBiography"]) description]; - NSLog(@"[SCInsta] copy button user=%@ name=%@ bioLen=%lu", - username, fullName, (unsigned long)biography.length); - UIAlertController *menu = [UIAlertController alertControllerWithTitle:SCILocalized(@"Copy from profile") message:nil preferredStyle:UIAlertControllerStyleActionSheet]; @@ -154,15 +152,14 @@ static void sci_copyAndToast(NSString *value, NSString *label) { @end static UIView *sci_buildCopyButton(void) { - UIButton *btn = [UIButton buttonWithType:UIButtonTypeSystem]; + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"doc.on.doc" + pointSize:16 + diameter:24]; btn.accessibilityIdentifier = @"sci-profile-copy-button"; btn.accessibilityLabel = @"Copy profile info"; - UIImageSymbolConfiguration *cfg = - [UIImageSymbolConfiguration configurationWithPointSize:16 - weight:UIImageSymbolWeightRegular]; - UIImage *icon = [[UIImage systemImageNamed:@"doc.on.doc"] imageByApplyingSymbolConfiguration:cfg]; - [btn setImage:icon forState:UIControlStateNormal]; - btn.tintColor = [UIColor labelColor]; + btn.iconTint = [UIColor labelColor]; + btn.bubbleColor = [UIColor clearColor]; + btn.translatesAutoresizingMaskIntoConstraints = YES; btn.frame = CGRectMake(0, 0, 24, 44); [btn addTarget:[SCIProfileCopyTarget shared] action:@selector(handleTap:) diff --git a/src/Features/General/SCICacheManager.h b/src/Features/General/SCICacheManager.h new file mode 100644 index 0000000..2c0f4c0 --- /dev/null +++ b/src/Features/General/SCICacheManager.h @@ -0,0 +1,34 @@ +// Compute and clear Instagram's local caches (Library/Caches, Application +// Support, tmp, NSURLCache). + +#import + +// Posted on main after a non-transient scan completes. Object is NSNumber. +extern NSString *const SCICacheSizeDidUpdateNotification; + +@interface SCICacheManager : NSObject + +// Scan + update cachedSize + persist. Completion on main. ++ (void)getCacheSizeWithCompletion:(void(^)(uint64_t bytes))completion; + +// Scan without touching cachedSize / persistence / notification. ++ (void)getCacheSizeTransientWithCompletion:(void(^)(uint64_t bytes))completion; + +// Last computed value; lazy-loads from NSUserDefaults on first call. ++ (uint64_t)cachedSize; + ++ (void)refreshSizeInBackground; + +// No-op when `cache_auto_check_size` is off. ++ (void)refreshSizeInBackgroundIfEnabled; + +// Completion reports bytes reclaimed, on main. ++ (void)clearCacheWithCompletion:(void(^)(uint64_t bytesCleared))completion; + +// Fires a silent clear if the configured interval has elapsed. Called from +// applicationDidEnterBackground. ++ (void)runAutoClearIfDue; + ++ (NSString *)formattedSize:(uint64_t)bytes; + +@end diff --git a/src/Features/General/SCICacheManager.m b/src/Features/General/SCICacheManager.m new file mode 100644 index 0000000..fee4971 --- /dev/null +++ b/src/Features/General/SCICacheManager.m @@ -0,0 +1,202 @@ +#import "SCICacheManager.h" +#import +#import +#import +#import +#import + +static NSString *const kAutoClearModeKey = @"cache_auto_clear_mode"; +static NSString *const kLastAutoClearKey = @"cache_last_auto_clear_ts"; +static NSString *const kLastKnownSizeKey = @"cache_last_known_size"; + +NSString *const SCICacheSizeDidUpdateNotification = @"SCICacheSizeDidUpdateNotification"; + +static _Atomic uint64_t gCachedSize = 0; +static dispatch_once_t gLoadPersistedOnce; + +static void sciLoadPersistedSizeOnce(void) { + dispatch_once(&gLoadPersistedOnce, ^{ + uint64_t stored = (uint64_t)[[NSUserDefaults standardUserDefaults] doubleForKey:kLastKnownSizeKey]; + atomic_store(&gCachedSize, stored); + }); +} + +static NSArray *sciCacheDirs(void) { + NSMutableArray *dirs = [NSMutableArray array]; + NSArray *caches = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES); + if (caches.firstObject) [dirs addObject:caches.firstObject]; + NSArray *appSupport = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES); + if (appSupport.firstObject) [dirs addObject:appSupport.firstObject]; + NSString *tmp = NSTemporaryDirectory(); + if (tmp.length) [dirs addObject:tmp]; + return dirs; +} + +// Top-level entry names under any cache root that belong to RyukGram user +// data (analyzer snapshots, header cache, future persistent state) and must +// survive a cache wipe. +static BOOL sciIsProtectedEntryName(const char *name) { + return strcmp(name, "RyukGram") == 0; +} + +// POSIX fts — avoids the NSDirectoryEnumerator per-entry alloc overhead. +static uint64_t sciDirectorySize(NSString *path) { + const char *root = [path fileSystemRepresentation]; + if (!root) return 0; + char * const paths[] = { (char *)root, NULL }; + FTS *fts = fts_open(paths, FTS_PHYSICAL | FTS_NOCHDIR | FTS_XDEV, NULL); + if (!fts) return 0; + uint64_t total = 0; + FTSENT *ent; + while ((ent = fts_read(fts))) { + // Don't descend into RyukGram user-data subtrees. + if (ent->fts_info == FTS_D && ent->fts_level == 1 && + sciIsProtectedEntryName(ent->fts_name)) { + fts_set(fts, ent, FTS_SKIP); + continue; + } + if (ent->fts_info == FTS_F && ent->fts_statp) { + total += (uint64_t)ent->fts_statp->st_size; + } + } + fts_close(fts); + return total; +} + +// Recursive delete of directory contents — the top-level dir itself is +// preserved so IG's file handles stay valid, and RyukGram subtrees are +// skipped so our analyzer snapshots + header cache survive. +static void sciDeleteDirectoryContents(NSString *path) { + const char *root = [path fileSystemRepresentation]; + if (!root) return; + DIR *dp = opendir(root); + if (!dp) return; + struct dirent *de; + while ((de = readdir(dp))) { + if (de->d_name[0] == '.' && (de->d_name[1] == 0 || + (de->d_name[1] == '.' && de->d_name[2] == 0))) continue; + if (sciIsProtectedEntryName(de->d_name)) continue; + char full[PATH_MAX]; + snprintf(full, sizeof(full), "%s/%s", root, de->d_name); + removefile(full, NULL, REMOVEFILE_RECURSIVE); + } + closedir(dp); +} + +@implementation SCICacheManager + +// Transient mode reports the size to the caller but skips persisting it +// and firing the update notification — used by the "Show cache size" off +// tap path to scan on demand without leaking state. ++ (void)_scanWithQos:(qos_class_t)qos + transient:(BOOL)transient + completion:(void(^)(uint64_t))completion { + dispatch_queue_t q = dispatch_get_global_queue(qos, 0); + dispatch_async(q, ^{ + NSArray *dirs = sciCacheDirs(); + __block _Atomic uint64_t running = 0; + dispatch_group_t group = dispatch_group_create(); + for (NSString *d in dirs) { + dispatch_group_async(group, q, ^{ + atomic_fetch_add(&running, sciDirectorySize(d)); + }); + } + dispatch_group_async(group, q, ^{ + atomic_fetch_add(&running, (uint64_t)[[NSURLCache sharedURLCache] currentDiskUsage]); + }); + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + + uint64_t total = atomic_load(&running); + if (!transient) { + atomic_store(&gCachedSize, total); + [[NSUserDefaults standardUserDefaults] setDouble:(double)total forKey:kLastKnownSizeKey]; + } + dispatch_async(dispatch_get_main_queue(), ^{ + if (!transient) { + [[NSNotificationCenter defaultCenter] postNotificationName:SCICacheSizeDidUpdateNotification + object:@(total)]; + } + if (completion) completion(total); + }); + }); +} + ++ (void)getCacheSizeWithCompletion:(void(^)(uint64_t))completion { + [self _scanWithQos:QOS_CLASS_USER_INITIATED transient:NO completion:completion]; +} + ++ (void)getCacheSizeTransientWithCompletion:(void(^)(uint64_t))completion { + [self _scanWithQos:QOS_CLASS_USER_INITIATED transient:YES completion:completion]; +} + ++ (uint64_t)cachedSize { + sciLoadPersistedSizeOnce(); + return atomic_load(&gCachedSize); +} + ++ (void)refreshSizeInBackground { + [self _scanWithQos:QOS_CLASS_BACKGROUND transient:NO completion:nil]; +} + ++ (void)refreshSizeInBackgroundIfEnabled { + if (![[NSUserDefaults standardUserDefaults] boolForKey:@"cache_auto_check_size"]) return; + [self refreshSizeInBackground]; +} + ++ (void)clearCacheWithCompletion:(void(^)(uint64_t))completion { + dispatch_queue_t q = dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0); + dispatch_async(q, ^{ + // Snapshot the known size; only re-scan if we never measured. + uint64_t reclaimed = atomic_load(&gCachedSize); + if (reclaimed == 0) { + for (NSString *d in sciCacheDirs()) reclaimed += sciDirectorySize(d); + reclaimed += (uint64_t)[[NSURLCache sharedURLCache] currentDiskUsage]; + } + + NSArray *dirs = sciCacheDirs(); + dispatch_group_t group = dispatch_group_create(); + for (NSString *d in dirs) { + dispatch_group_async(group, q, ^{ sciDeleteDirectoryContents(d); }); + } + dispatch_group_async(group, q, ^{ + [[NSURLCache sharedURLCache] removeAllCachedResponses]; + }); + dispatch_group_wait(group, DISPATCH_TIME_FOREVER); + + atomic_store(&gCachedSize, 0); + [[NSUserDefaults standardUserDefaults] setDouble:0 forKey:kLastKnownSizeKey]; + [[NSUserDefaults standardUserDefaults] setDouble:[NSDate date].timeIntervalSince1970 + forKey:kLastAutoClearKey]; + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:SCICacheSizeDidUpdateNotification + object:@(0)]; + if (completion) completion(reclaimed); + }); + }); +} + ++ (void)runAutoClearIfDue { + NSString *mode = [[NSUserDefaults standardUserDefaults] stringForKey:kAutoClearModeKey]; + if (!mode.length || [mode isEqualToString:@"off"]) { [self refreshSizeInBackgroundIfEnabled]; return; } + + NSTimeInterval interval = 0; + if ([mode isEqualToString:@"daily"]) interval = 24 * 60 * 60; + else if ([mode isEqualToString:@"weekly"]) interval = 7 * 24 * 60 * 60; + else if ([mode isEqualToString:@"monthly"]) interval = 30 * 24 * 60 * 60; + else { [self refreshSizeInBackgroundIfEnabled]; return; } + + NSTimeInterval last = [[NSUserDefaults standardUserDefaults] doubleForKey:kLastAutoClearKey]; + NSTimeInterval now = [NSDate date].timeIntervalSince1970; + if (last > 0 && (now - last) < interval) { [self refreshSizeInBackgroundIfEnabled]; return; } + + [self clearCacheWithCompletion:^(uint64_t bytes) { + NSLog(@"[SCInsta] auto-clear cache mode=%@ reclaimed=%@", mode, [self formattedSize:bytes]); + }]; +} + ++ (NSString *)formattedSize:(uint64_t)bytes { + return [NSByteCountFormatter stringFromByteCount:(long long)bytes + countStyle:NSByteCountFormatterCountStyleFile]; +} + +@end diff --git a/src/Features/General/SCIChangelog.h b/src/Features/General/SCIChangelog.h new file mode 100644 index 0000000..a30ecb7 --- /dev/null +++ b/src/Features/General/SCIChangelog.h @@ -0,0 +1,16 @@ +// SCIChangelog — fetches RyukGram release notes from GitHub and presents +// them in a scrollable popup. Shows automatically on launch when the tweak +// version changes; also available from the About page. + +#import + +@interface SCIChangelog : NSObject + +/// Present the latest release notes when this is a version the user hasn't +/// seen yet. No-op otherwise. Safe to call on every launch. ++ (void)presentIfNewFromWindow:(UIWindow *)window; + +/// Present a browser of every release (tap a row → see its notes). ++ (void)presentAllFromViewController:(UIViewController *)host; + +@end diff --git a/src/Features/General/SCIChangelog.m b/src/Features/General/SCIChangelog.m new file mode 100644 index 0000000..1e1b61e --- /dev/null +++ b/src/Features/General/SCIChangelog.m @@ -0,0 +1,374 @@ +#import "SCIChangelog.h" +#import "../../Utils.h" +#import "../../Tweak.h" + +static NSString *const kRepo = @"faroukbmiled/RyukGram"; +// Stores the SCIVersionString of the last tweak build whose popup was shown. +// When the tweak updates, this mismatches and triggers a fresh check. +static NSString *const kLastSeenVersionKey = @"sci_changelog_last_seen_version"; +// Debug pref: when YES, the popup fires every launch regardless of version. +static NSString *const kForceShowKey = @"sci_changelog_force_show"; + +// MARK: - Cache + +static NSString *sciChangelogCacheDir(void) { + static NSString *dir = nil; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSString *base = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + dir = [base stringByAppendingPathComponent:@"RyukGramChangelog"]; + [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + }); + return dir; +} + +static NSString *sciCachedReleasePath(NSString *tag) { + NSString *safe = [tag stringByReplacingOccurrencesOfString:@"/" withString:@"_"]; + return [sciChangelogCacheDir() stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.json", safe]]; +} + +static NSDictionary *sciLoadCachedRelease(NSString *tag) { + NSData *data = [NSData dataWithContentsOfFile:sciCachedReleasePath(tag)]; + if (!data) return nil; + id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [obj isKindOfClass:[NSDictionary class]] ? obj : nil; +} + +static void sciSaveCachedRelease(NSString *tag, NSDictionary *json) { + NSData *data = [NSJSONSerialization dataWithJSONObject:json options:0 error:nil]; + if (data) [data writeToFile:sciCachedReleasePath(tag) atomically:YES]; +} + +// MARK: - Network + +static void sciFetchJSON(NSString *url, void (^completion)(NSDictionary *)) { + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; + [req setValue:@"application/vnd.github+json" forHTTPHeaderField:@"Accept"]; + [[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) { + NSDictionary *json = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + if (![json isKindOfClass:[NSDictionary class]] || !json[@"tag_name"]) { + dispatch_async(dispatch_get_main_queue(), ^{ completion(nil); }); + return; + } + dispatch_async(dispatch_get_main_queue(), ^{ completion(json); }); + }] resume]; +} + +// Fetch a specific tag, falling back to /releases/latest on 404 so the popup +// works in the window between a local version bump and the release being +// published on GitHub. +static void sciFetchRelease(NSString *tag, void (^completion)(NSDictionary *)) { + NSString *tagURL = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases/tags/%@", kRepo, tag]; + sciFetchJSON(tagURL, ^(NSDictionary *json) { + if (json) { + sciSaveCachedRelease(json[@"tag_name"], json); + completion(json); + return; + } + NSString *latestURL = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases/latest", kRepo]; + sciFetchJSON(latestURL, ^(NSDictionary *latest) { + if (latest) sciSaveCachedRelease(latest[@"tag_name"], latest); + completion(latest); + }); + }); +} + +static void sciFetchReleaseList(void (^completion)(NSArray *)) { + NSString *url = [NSString stringWithFormat:@"https://api.github.com/repos/%@/releases?per_page=50", kRepo]; + NSMutableURLRequest *req = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:url]]; + [req setValue:@"application/vnd.github+json" forHTTPHeaderField:@"Accept"]; + [[[NSURLSession sharedSession] dataTaskWithRequest:req completionHandler:^(NSData *data, NSURLResponse *resp, NSError *err) { + NSArray *arr = data ? [NSJSONSerialization JSONObjectWithData:data options:0 error:nil] : nil; + dispatch_async(dispatch_get_main_queue(), ^{ + completion([arr isKindOfClass:[NSArray class]] ? arr : nil); + }); + }] resume]; +} + +// MARK: - Markdown renderer + +static NSAttributedString *sciRenderMarkdown(NSString *md) { + NSMutableAttributedString *out = [[NSMutableAttributedString alloc] init]; + if (!md.length) return out; + + UIFont *body = [UIFont systemFontOfSize:15]; + UIFont *h2 = [UIFont systemFontOfSize:20 weight:UIFontWeightBold]; + UIFont *h3 = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + UIColor *fg = [UIColor labelColor]; + UIColor *muted = [UIColor secondaryLabelColor]; + + NSMutableParagraphStyle *bodyPS = [NSMutableParagraphStyle new]; + bodyPS.lineSpacing = 2; + bodyPS.paragraphSpacing = 3; + + NSMutableParagraphStyle *headingPS = [NSMutableParagraphStyle new]; + headingPS.lineSpacing = 2; + headingPS.paragraphSpacing = 4; + headingPS.paragraphSpacingBefore = 10; + + NSArray *lines = [md componentsSeparatedByString:@"\n"]; + BOOL firstEmitted = NO; + for (NSString *raw in lines) { + // Skip blank lines — paragraph spacing already handles breathing room. + if (raw.length == 0) continue; + + NSString *line = raw; + NSMutableDictionary *attrs = [@{ + NSFontAttributeName: body, + NSForegroundColorAttributeName: fg, + NSParagraphStyleAttributeName: bodyPS, + } mutableCopy]; + NSString *prefix = nil; + + if ([line hasPrefix:@"## "]) { + attrs[NSFontAttributeName] = h2; + attrs[NSParagraphStyleAttributeName] = firstEmitted ? headingPS : bodyPS; + line = [line substringFromIndex:3]; + } else if ([line hasPrefix:@"### "]) { + attrs[NSFontAttributeName] = h3; + attrs[NSParagraphStyleAttributeName] = firstEmitted ? headingPS : bodyPS; + line = [line substringFromIndex:4]; + } else if ([line hasPrefix:@"- "] || [line hasPrefix:@"* "]) { + prefix = @" • "; + line = [line substringFromIndex:2]; + } else if ([line hasPrefix:@"> "]) { + attrs[NSForegroundColorAttributeName] = muted; + line = [line substringFromIndex:2]; + } + + if (firstEmitted) { + [out appendAttributedString:[[NSAttributedString alloc] initWithString:@"\n" attributes:attrs]]; + } + if (prefix) { + [out appendAttributedString:[[NSAttributedString alloc] initWithString:prefix attributes:attrs]]; + } + + NSMutableAttributedString *seg = [[NSMutableAttributedString alloc] initWithString:line attributes:attrs]; + + // Inline **bold** + NSRegularExpression *boldRx = [NSRegularExpression regularExpressionWithPattern:@"\\*\\*(.+?)\\*\\*" options:0 error:nil]; + NSArray *boldMatches = [boldRx matchesInString:seg.string options:0 range:NSMakeRange(0, seg.string.length)]; + for (NSTextCheckingResult *m in [boldMatches reverseObjectEnumerator]) { + NSString *inner = [seg.string substringWithRange:[m rangeAtIndex:1]]; + UIFont *baseFont = attrs[NSFontAttributeName]; + UIFont *boldFont = [UIFont systemFontOfSize:baseFont.pointSize weight:UIFontWeightBold]; + NSMutableDictionary *boldAttrs = [attrs mutableCopy]; + boldAttrs[NSFontAttributeName] = boldFont; + NSAttributedString *replacement = [[NSAttributedString alloc] initWithString:inner attributes:boldAttrs]; + [seg replaceCharactersInRange:m.range withAttributedString:replacement]; + } + + // Inline [text](url) links + NSRegularExpression *linkRx = [NSRegularExpression regularExpressionWithPattern:@"\\[([^\\]]+)\\]\\(([^)]+)\\)" options:0 error:nil]; + NSArray *linkMatches = [linkRx matchesInString:seg.string options:0 range:NSMakeRange(0, seg.string.length)]; + for (NSTextCheckingResult *m in [linkMatches reverseObjectEnumerator]) { + NSString *text = [seg.string substringWithRange:[m rangeAtIndex:1]]; + NSString *url = [seg.string substringWithRange:[m rangeAtIndex:2]]; + NSMutableDictionary *linkAttrs = [attrs mutableCopy]; + linkAttrs[NSLinkAttributeName] = url; + NSAttributedString *replacement = [[NSAttributedString alloc] initWithString:text attributes:linkAttrs]; + [seg replaceCharactersInRange:m.range withAttributedString:replacement]; + } + + [out appendAttributedString:seg]; + firstEmitted = YES; + } + + return out; +} + +// MARK: - Detail view controller (renders one release) + +@interface _SCIChangelogDetailVC : UIViewController +@property (nonatomic, copy) NSDictionary *releaseJSON; +@property (nonatomic, copy) void (^onDismiss)(void); +@end + +@implementation _SCIChangelogDetailVC + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; + + NSString *name = self.releaseJSON[@"name"] ?: self.releaseJSON[@"tag_name"] ?: @"?"; + NSString *body = self.releaseJSON[@"body"] ?: @""; + NSString *htmlURL = self.releaseJSON[@"html_url"] ?: @""; + self.title = SCILocalized(@"What's new in RyukGram"); + + self.navigationItem.rightBarButtonItem = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(done)]; + + // Tap the release-name heading to open the GitHub page. + NSString *header = htmlURL.length + ? [NSString stringWithFormat:@"## [%@](%@)\n", name, htmlURL] + : [NSString stringWithFormat:@"## %@\n", name]; + NSAttributedString *attrBody = sciRenderMarkdown([header stringByAppendingString:body]); + + UITextView *tv = [UITextView new]; + tv.editable = NO; + tv.backgroundColor = [UIColor clearColor]; + tv.textContainerInset = UIEdgeInsetsMake(16, 16, 24, 16); + tv.translatesAutoresizingMaskIntoConstraints = NO; + tv.attributedText = attrBody; + tv.alwaysBounceVertical = YES; + [self.view addSubview:tv]; + + [NSLayoutConstraint activateConstraints:@[ + [tv.topAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.topAnchor], + [tv.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [tv.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [tv.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + ]]; +} + +- (void)done { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +@end + +// MARK: - Releases list view controller + +@interface _SCIReleaseListVC : UITableViewController +@property (nonatomic, copy) NSArray *releases; +@property (nonatomic, strong) UIActivityIndicatorView *spinner; +@end + +@implementation _SCIReleaseListVC + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = SCILocalized(@"Release notes"); + self.navigationItem.rightBarButtonItem = + [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemDone + target:self + action:@selector(done)]; + self.tableView.rowHeight = 60; + + UIActivityIndicatorView *spin = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge]; + spin.hidesWhenStopped = YES; + [spin startAnimating]; + self.tableView.backgroundView = spin; + self.spinner = spin; + + [self loadReleases]; +} + +- (void)loadReleases { + sciFetchReleaseList(^(NSArray *arr) { + self.releases = arr ?: @[]; + [self.spinner stopAnimating]; + self.tableView.backgroundView = nil; + if (self.releases.count == 0) { + UILabel *empty = [UILabel new]; + empty.text = SCILocalized(@"No releases"); + empty.textAlignment = NSTextAlignmentCenter; + empty.textColor = [UIColor secondaryLabelColor]; + empty.font = [UIFont systemFontOfSize:15]; + self.tableView.backgroundView = empty; + } + [self.tableView reloadData]; + }); +} + +- (void)done { + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { + return self.releases.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"r"]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:@"r"]; + NSDictionary *rel = self.releases[ip.row]; + NSString *tag = rel[@"tag_name"]; + NSString *title = rel[@"name"] ?: tag; + + NSMutableArray *tags = [NSMutableArray array]; + if (ip.row == 0) [tags addObject:SCILocalized(@"latest")]; + if ([tag isEqualToString:SCIVersionString]) [tags addObject:SCILocalized(@"installed")]; + if (tags.count) { + title = [NSString stringWithFormat:@"%@ (%@)", title, [tags componentsJoinedByString:@", "]]; + } + cell.textLabel.text = title; + NSString *published = rel[@"published_at"]; + cell.detailTextLabel.text = published ? [published substringToIndex:MIN((NSUInteger)10, published.length)] : @""; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip { + [tv deselectRowAtIndexPath:ip animated:YES]; + NSDictionary *rel = self.releases[ip.row]; + _SCIChangelogDetailVC *vc = [_SCIChangelogDetailVC new]; + vc.releaseJSON = rel; + [self.navigationController pushViewController:vc animated:YES]; +} + +@end + +// MARK: - Public API + +@implementation SCIChangelog + ++ (UIViewController *)topVCInWindow:(UIWindow *)window { + UIViewController *vc = window.rootViewController; + while (vc.presentedViewController) vc = vc.presentedViewController; + return vc; +} + ++ (void)presentReleaseJSON:(NSDictionary *)json onDismiss:(void(^)(void))onDismiss fromWindow:(UIWindow *)window { + _SCIChangelogDetailVC *vc = [_SCIChangelogDetailVC new]; + vc.releaseJSON = json; + vc.onDismiss = onDismiss; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + nav.modalPresentationStyle = UIModalPresentationPageSheet; + if (@available(iOS 15.0, *)) { + UISheetPresentationController *sheet = nav.sheetPresentationController; + sheet.detents = @[ + UISheetPresentationControllerDetent.mediumDetent, + UISheetPresentationControllerDetent.largeDetent, + ]; + sheet.prefersGrabberVisible = YES; + } + [[self topVCInWindow:window] presentViewController:nav animated:YES completion:nil]; +} + ++ (void)presentIfNewFromWindow:(UIWindow *)window { + if (!window) return; + NSUserDefaults *ud = [NSUserDefaults standardUserDefaults]; + BOOL force = [ud boolForKey:kForceShowKey]; + + // Fast-path: already shown for this tweak version — skip all I/O. + if (!force && [[ud stringForKey:kLastSeenVersionKey] isEqualToString:SCIVersionString]) return; + + void (^show)(NSDictionary *) = ^(NSDictionary *json) { + if (!json) return; + // Mark seen on show so any dismissal path (Done, swipe) is covered. + [[NSUserDefaults standardUserDefaults] setObject:SCIVersionString forKey:kLastSeenVersionKey]; + [self presentReleaseJSON:json onDismiss:nil fromWindow:window]; + }; + + NSDictionary *cached = sciLoadCachedRelease(SCIVersionString); + if (cached) { show(cached); return; } + sciFetchRelease(SCIVersionString, ^(NSDictionary *json) { show(json); }); +} + ++ (void)presentAllFromViewController:(UIViewController *)host { + if (!host) return; + _SCIReleaseListVC *list = [_SCIReleaseListVC new]; + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:list]; + nav.modalPresentationStyle = UIModalPresentationPageSheet; + if (@available(iOS 15.0, *)) { + UISheetPresentationController *sheet = nav.sheetPresentationController; + sheet.detents = @[UISheetPresentationControllerDetent.largeDetent]; + sheet.prefersGrabberVisible = YES; + } + [host presentViewController:nav animated:YES completion:nil]; +} + +@end diff --git a/src/Features/Live/LiveTweaks.xm b/src/Features/Live/LiveTweaks.xm new file mode 100644 index 0000000..e40d63a --- /dev/null +++ b/src/Features/Live/LiveTweaks.xm @@ -0,0 +1,136 @@ +// Live-stream tweaks — anonymous viewing + long-press heart to hide comments. + +#import "../../InstagramHeaders.h" +#import "../../Utils.h" +#import +#import + +// MARK: - Anonymous viewing + +static void sciDisableViewerCountPuller(id feedbackController) { + Ivar pullerIvar = class_getInstanceVariable([feedbackController class], "_viewCountPuller"); + if (!pullerIvar) return; + id puller = object_getIvar(feedbackController, pullerIvar); + if (!puller) return; + + // Ivars live on the IGLiveIntervalPuller superclass. + Ivar activeIvar = NULL; + Ivar timerIvar = NULL; + for (Class c = [puller class]; c && c != [NSObject class]; c = class_getSuperclass(c)) { + if (!activeIvar) activeIvar = class_getInstanceVariable(c, "_isActive"); + if (!timerIvar) timerIvar = class_getInstanceVariable(c, "_nextFetchTimer"); + if (activeIvar && timerIvar) break; + } + if (activeIvar) { + ptrdiff_t off = ivar_getOffset(activeIvar); + *(BOOL *)((char *)(__bridge void *)puller + off) = NO; + } + if (timerIvar) { + id timer = object_getIvar(puller, timerIvar); + if (timer && [timer respondsToSelector:@selector(invalidate)]) { + ((void(*)(id, SEL))objc_msgSend)(timer, @selector(invalidate)); + } + } +} + +%hook IGLiveFeedbackController +- (void)start { + %orig; + if ([SCIUtils getBoolPref:@"live_anonymous_view"]) { + sciDisableViewerCountPuller(self); + } +} +%end + +// MARK: - Hide comments (session-only) + +// Session-only — state resets on each new comments VC appearance. +static __weak UIViewController *gActiveLiveCommentsVC = nil; +static BOOL gCommentsHidden = NO; +static const void *kSCIHeartAttachedKey = &kSCIHeartAttachedKey; + +// Only hide the scrolling collection — keep input + like usable. +static void sciHideCommentCollections(UIView *root, BOOL hide, int depth) { + if (!root || depth > 8) return; + for (UIView *sub in root.subviews) { + if ([sub isKindOfClass:[UICollectionView class]]) { + sub.alpha = hide ? 0.0 : 1.0; + sub.userInteractionEnabled = !hide; + continue; + } + sciHideCommentCollections(sub, hide, depth + 1); + } +} + +static void sciApplyCommentsStateTo(UIViewController *vc) { + if (!vc || !vc.isViewLoaded) return; + sciHideCommentCollections(vc.view, gCommentsHidden, 0); +} + +extern "C" void sciRefreshLiveCommentsHidden(void) { + sciApplyCommentsStateTo(gActiveLiveCommentsVC); +} + +static void sciAttachLongPressToView(UIView *v); + +// Heart lives in the footer's _likeButton ivar. +%hook IGLiveFooterButtonsView +- (void)layoutSubviews { + %orig; + id obj = (id)self; + Ivar iv = class_getInstanceVariable([obj class], "_likeButton"); + if (!iv) return; + UIView *btn = object_getIvar(obj, iv); + if (btn) sciAttachLongPressToView(btn); +} +%end + +%hook IGLiveCommentsContainerViewController +- (void)viewDidAppear:(BOOL)animated { + %orig; + gActiveLiveCommentsVC = self; + gCommentsHidden = NO; + sciApplyCommentsStateTo(self); +} + +- (void)viewWillDisappear:(BOOL)animated { + %orig; + if (gActiveLiveCommentsVC == self) gActiveLiveCommentsVC = nil; +} +%end + +// MARK: - Long-press heart → toggle comments + +@interface SCILiveLikeLongPress : NSObject ++ (instancetype)shared; +- (void)fired:(UILongPressGestureRecognizer *)g; +@end + +@implementation SCILiveLikeLongPress ++ (instancetype)shared { + static SCILiveLikeLongPress *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [SCILiveLikeLongPress new]; }); + return s; +} +- (void)fired:(UILongPressGestureRecognizer *)g { + if (g.state != UIGestureRecognizerStateBegan) return; + if (![SCIUtils getBoolPref:@"live_hide_comments"]) return; + gCommentsHidden = !gCommentsHidden; + sciRefreshLiveCommentsHidden(); + [SCIUtils showToastForDuration:1.0 + title:gCommentsHidden ? SCILocalized(@"Comments hidden") + : SCILocalized(@"Comments shown")]; +} +@end + +static void sciAttachLongPressToView(UIView *v) { + if (!v || objc_getAssociatedObject(v, kSCIHeartAttachedKey)) return; + UILongPressGestureRecognizer *g = [[UILongPressGestureRecognizer alloc] + initWithTarget:[SCILiveLikeLongPress shared] action:@selector(fired:)]; + g.minimumPressDuration = 0.5; + // Swallow the tap so the reactions sheet doesn't open. + g.cancelsTouchesInView = YES; + [v addGestureRecognizer:g]; + objc_setAssociatedObject(v, kSCIHeartAttachedKey, @YES, OBJC_ASSOCIATION_RETAIN_NONATOMIC); +} diff --git a/src/Features/Profile/FakeProfileStats.x b/src/Features/Profile/FakeProfileStats.x new file mode 100644 index 0000000..5bb5dfd --- /dev/null +++ b/src/Features/Profile/FakeProfileStats.x @@ -0,0 +1,197 @@ +// Fake profile stats for own profile — follower/following/post counts +// and verified badge. Counts rewrite IGStatButton labels; verified flips +// is_verified at the JSON parse layer + swizzles IGUsernameModel to catch +// cached-model renders. + +#import "../../Utils.h" +#import +#import +#import + +static BOOL sciFakeOn(NSString *key) { return [SCIUtils getBoolPref:key]; } + +// IG format — 1,192 / 12.3K / 1.2M / 1.2B. Raw digits only; passthrough otherwise. +static NSString *sciFormatCount(NSString *raw) { + raw = [raw stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (!raw.length) return nil; + NSCharacterSet *digits = [NSCharacterSet decimalDigitCharacterSet]; + for (NSUInteger i = 0; i < raw.length; i++) { + if (![digits characterIsMember:[raw characterAtIndex:i]]) return raw; + } + long long n = raw.longLongValue; + if (n < 10000) { + NSNumberFormatter *f = [NSNumberFormatter new]; + f.numberStyle = NSNumberFormatterDecimalStyle; + return [f stringFromNumber:@(n)]; + } + double d; NSString *suf; + if (n >= 1000000000LL) { d = n / 1000000000.0; suf = @"B"; } + else if (n >= 1000000LL) { d = n / 1000000.0; suf = @"M"; } + else { d = n / 1000.0; suf = @"K"; } + NSString *s = [NSString stringWithFormat:@"%.1f", d]; + if ([s hasSuffix:@".0"]) s = [s substringToIndex:s.length - 2]; + return [s stringByAppendingString:suf]; +} + +static NSString *sciFakeValue(NSString *valueKey) { + return sciFormatCount([[NSUserDefaults standardUserDefaults] stringForKey:valueKey]); +} + +// ============ Fake counts — IGStatButton label rewrite ============ + +static BOOL sciButtonIsOnOwnProfile(UIView *btn) { + Class selfCellCls = NSClassFromString(@"IGProfileSimpleAvatarStatsCell"); + if (!selfCellCls) return NO; + UIView *cur = btn; + while (cur && ![cur isKindOfClass:selfCellCls]) cur = cur.superview; + if (!cur) return NO; + Ivar iv = class_getInstanceVariable([cur class], "_isCurrentUser"); + if (!iv) return NO; + return *(BOOL *)((char *)(__bridge void *)cur + ivar_getOffset(iv)); +} + +static NSString *sciFakeTextForName(NSString *name) { + if (!name) return nil; + NSString *low = name.lowercaseString; + if ([low containsString:@"follower"]) { + if (sciFakeOn(@"fake_follower_count")) return sciFakeValue(@"fake_follower_count_value"); + } else if ([low containsString:@"following"]) { + if (sciFakeOn(@"fake_following_count")) return sciFakeValue(@"fake_following_count_value"); + } else if ([low containsString:@"post"]) { + if (sciFakeOn(@"fake_post_count")) return sciFakeValue(@"fake_post_count_value"); + } + return nil; +} + +static void sciApplyFakeToButton(id btn) { + if (!sciFakeOn(@"fake_follower_count") + && !sciFakeOn(@"fake_following_count") + && !sciFakeOn(@"fake_post_count")) return; + Ivar nmIv = class_getInstanceVariable([btn class], "_name"); + NSString *name = nmIv ? object_getIvar(btn, nmIv) : nil; + NSString *fake = sciFakeTextForName(name); + if (!fake) return; + if (!sciButtonIsOnOwnProfile(btn)) return; + Ivar lblIv = class_getInstanceVariable([btn class], "_countLabel"); + UILabel *lbl = lblIv ? object_getIvar(btn, lblIv) : nil; + if ([lbl isKindOfClass:[UILabel class]]) lbl.text = fake; +} + +static void (*orig_setName)(id, SEL, id); +static void new_setName(id self, SEL _cmd, id name) { + orig_setName(self, _cmd, name); + sciApplyFakeToButton(self); +} + +static void (*orig_setCount)(id, SEL, id); +static void new_setCount(id self, SEL _cmd, id cfg) { + orig_setCount(self, _cmd, cfg); + sciApplyFakeToButton(self); +} + +static void (*orig_layout)(id, SEL); +static void new_layout(id self, SEL _cmd) { + orig_layout(self, _cmd); + sciApplyFakeToButton(self); +} + +// ============ Fake verified — JSON response rewrite ============ +// PK + pref cached — read on every JSON parse. +static NSString *gSelfPK = nil; +static BOOL gFakeVerifiedOn = NO; + +static BOOL sciPKMatchesSelf(id pk) { + if (!gSelfPK.length) return NO; + if ([pk isKindOfClass:[NSString class]]) return [pk isEqualToString:gSelfPK]; + if ([pk isKindOfClass:[NSNumber class]]) return [[(NSNumber *)pk stringValue] isEqualToString:gSelfPK]; + return NO; +} + +static void sciFlipVerifiedInJSON(id obj, int depth) { + if (depth > 16) return; + if ([obj isKindOfClass:[NSMutableDictionary class]]) { + NSMutableDictionary *d = obj; + id pk = d[@"pk"] ?: d[@"strong_id__"] ?: d[@"user_id"] ?: d[@"id"]; + if (sciPKMatchesSelf(pk)) d[@"is_verified"] = @YES; + for (id v in d.allValues) sciFlipVerifiedInJSON(v, depth + 1); + } else if ([obj isKindOfClass:[NSMutableArray class]]) { + for (id v in (NSMutableArray *)obj) sciFlipVerifiedInJSON(v, depth + 1); + } +} + +// Belt-and-suspenders — profile header reads isVerified from a cached +// IGUsernameModel without re-parsing JSON on every refresh. +typedef BOOL (*SciIsVerifiedFn)(id, SEL); +static SciIsVerifiedFn orig_UsernameModel_isVerified = NULL; +static NSString *gSelfUsername = nil; + +static BOOL new_UsernameModel_isVerified(id self, SEL _cmd) { + BOOL o = orig_UsernameModel_isVerified ? orig_UsernameModel_isVerified(self, _cmd) : NO; + if (o) return YES; + if (!gSelfUsername.length) return NO; + NSString *u = nil; + @try { u = [self valueForKey:@"username"]; } @catch (__unused id e) {} + if ([u isKindOfClass:[NSString class]] && [u isEqualToString:gSelfUsername]) return YES; + return NO; +} + +static id (*orig_JSONObjectWithData)(Class, SEL, NSData *, NSJSONReadingOptions, NSError **); +static id new_JSONObjectWithData(Class self, SEL _cmd, NSData *data, NSJSONReadingOptions opts, NSError **err) { + if (!gFakeVerifiedOn) return orig_JSONObjectWithData(self, _cmd, data, opts, err); + opts |= NSJSONReadingMutableContainers; + id r = orig_JSONObjectWithData(self, _cmd, data, opts, err); + if (r) sciFlipVerifiedInJSON(r, 0); + return r; +} + +__attribute__((constructor)) static void _sciFakeStatsInit(void) { + // Both feature sets gate install on launch pref + require restart — + // off means no hook at all. + BOOL anyCountOn = sciFakeOn(@"fake_follower_count") + || sciFakeOn(@"fake_following_count") + || sciFakeOn(@"fake_post_count"); + if (anyCountOn) { + Class sb = NSClassFromString(@"IGStatButton"); + if (sb) { + MSHookMessageEx(sb, @selector(setName:), (IMP)new_setName, (IMP *)&orig_setName); + MSHookMessageEx(sb, @selector(setCount:), (IMP)new_setCount, (IMP *)&orig_setCount); + MSHookMessageEx(sb, @selector(layoutSubviews), (IMP)new_layout, (IMP *)&orig_layout); + } + } + + if (!sciFakeOn(@"fake_verified")) return; + gFakeVerifiedOn = YES; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(2 * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + gSelfPK = [[SCIUtils currentUserPK] copy]; + id session = [SCIUtils activeUserSession]; + id user = nil; + @try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {} + @try { gSelfUsername = [[user valueForKey:@"username"] copy]; } @catch (__unused id e) {} + }); + [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidBecomeActiveNotification + object:nil queue:nil + usingBlock:^(__unused NSNotification *n) { + if (!gSelfPK.length) gSelfPK = [[SCIUtils currentUserPK] copy]; + if (!gSelfUsername.length) { + id session = [SCIUtils activeUserSession]; + id user = nil; + @try { user = [session valueForKey:@"user"]; } @catch (__unused id e) {} + @try { gSelfUsername = [[user valueForKey:@"username"] copy]; } @catch (__unused id e) {} + } + }]; + + Class jc = object_getClass([NSJSONSerialization class]); + MSHookMessageEx(jc, @selector(JSONObjectWithData:options:error:), + (IMP)new_JSONObjectWithData, (IMP *)&orig_JSONObjectWithData); + + Class um = NSClassFromString(@"IGUsernameModel"); + if (um) { + Method m = class_getInstanceMethod(um, @selector(isVerified)); + if (m) { + orig_UsernameModel_isVerified = (SciIsVerifiedFn)method_getImplementation(m); + method_setImplementation(m, (IMP)new_UsernameModel_isVerified); + } + } +} diff --git a/src/Features/Profile/FollowIndicator.x b/src/Features/Profile/FollowIndicator.x index c3a02d1..7964775 100644 --- a/src/Features/Profile/FollowIndicator.x +++ b/src/Features/Profile/FollowIndicator.x @@ -3,6 +3,7 @@ #import "../../InstagramHeaders.h" #import "../../Utils.h" +#import "../../SCIChrome.h" #import "../../Networking/SCIInstagramAPI.h" #import #import @@ -11,30 +12,6 @@ static const NSInteger kFollowBadgeTag = 99788; -static NSString *sciPKFromUser(id igUser) { - if (!igUser) return nil; - Ivar pkIvar = NULL; - for (Class c = [igUser class]; c && !pkIvar; c = class_getSuperclass(c)) - pkIvar = class_getInstanceVariable(c, "_pk"); - if (!pkIvar) return nil; - return [object_getIvar(igUser, pkIvar) description]; -} - -static NSString *sciCurrentUserPK(void) { - @try { - for (UIWindowScene *scene in [UIApplication sharedApplication].connectedScenes) { - if (![scene isKindOfClass:[UIWindowScene class]]) continue; - for (UIWindow *window in scene.windows) { - id session = [window valueForKey:@"userSession"]; - if (!session) continue; - id su = [session valueForKey:@"user"]; - if (su) return sciPKFromUser(su); - } - } - } @catch (NSException *e) {} - return nil; -} - // Cache follow status on the VC to avoid re-fetching static const char kFollowStatusKey; static NSNumber *sciGetFollowStatus(id vc) { @@ -64,26 +41,20 @@ static void sciRenderBadge(UIViewController *vc) { UIView *old = [statContainer viewWithTag:kFollowBadgeTag]; if (old) [old removeFromSuperview]; - UILabel *badge = [[UILabel alloc] init]; + NSString *text = followedBy ? SCILocalized(@"Follows you") : SCILocalized(@"Doesn't follow you"); + SCIChromeLabel *badge = [[SCIChromeLabel alloc] initWithText:text]; badge.tag = kFollowBadgeTag; - badge.text = followedBy ? SCILocalized(@"Follows you") : SCILocalized(@"Doesn't follow you"); badge.font = [UIFont systemFontOfSize:11 weight:UIFontWeightMedium]; badge.textColor = followedBy ? [UIColor colorWithRed:0.3 green:0.75 blue:0.4 alpha:1.0] : [UIColor colorWithRed:0.85 green:0.3 blue:0.3 alpha:1.0]; - [badge sizeToFit]; - - CGFloat x = 0; - for (UIView *sub in statContainer.subviews) { - if (!sub.isHidden && sub.frame.size.width > 0) { - x = sub.frame.origin.x; - break; - } - } - - badge.frame = CGRectMake(x, statContainer.bounds.size.height - badge.frame.size.height - 2, - badge.frame.size.width, badge.frame.size.height); [statContainer addSubview:badge]; + + // Pinned to the leading edge so it sits flush-left on any device + RTL. + [NSLayoutConstraint activateConstraints:@[ + [badge.leadingAnchor constraintEqualToAnchor:statContainer.leadingAnchor], + [badge.bottomAnchor constraintEqualToAnchor:statContainer.bottomAnchor constant:-8], + ]]; } %hook IGProfileViewController @@ -103,8 +74,8 @@ static void sciRenderBadge(UIViewController *vc) { @try { igUser = [self valueForKey:@"user"]; } @catch (NSException *e) {} if (!igUser) return; - NSString *profilePK = sciPKFromUser(igUser); - NSString *myPK = sciCurrentUserPK(); + NSString *profilePK = [SCIUtils pkFromIGUser:igUser]; + NSString *myPK = [SCIUtils currentUserPK]; if (!profilePK || !myPK || [profilePK isEqualToString:myPK]) return; __weak UIViewController *weakSelf = self; diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerListViewController.h b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerListViewController.h new file mode 100644 index 0000000..ab4611d --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerListViewController.h @@ -0,0 +1,21 @@ +#import +#import "SCIProfileAnalyzerModels.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, SCIPAListKind) { + SCIPAListKindPlain, // no action button + SCIPAListKindUnfollow, // show "Unfollow" button (you follow them) + SCIPAListKindFollow, // show "Follow" button (you don't follow them) + SCIPAListKindProfileUpdate, // displays previous → current change rows +}; + +@interface SCIProfileAnalyzerListViewController : UIViewController +- (instancetype)initWithTitle:(NSString *)title + users:(NSArray *)users + kind:(SCIPAListKind)kind; +- (instancetype)initWithTitle:(NSString *)title + profileUpdates:(NSArray *)updates; +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerListViewController.m b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerListViewController.m new file mode 100644 index 0000000..68d6a7a --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerListViewController.m @@ -0,0 +1,730 @@ +#import "SCIProfileAnalyzerListViewController.h" +#import "SCIProfileAnalyzerStorage.h" +#import "../../Networking/SCIInstagramAPI.h" +#import "../../Utils.h" +#import "../../SCIImageCache.h" +#import "../../Settings/SCISearchBarStyler.h" +#import "../../Localization/SCILocalization.h" + +// IG throttles /friendships/ aggressively — 50/session + a 1.5s cushion +// between calls keeps us well inside the soft limit. +static const NSInteger kSCIPABatchCap = 50; +static const NSTimeInterval kSCIPABatchDelay = 1.5; + +typedef NS_ENUM(NSInteger, SCIPASortMode) { + SCIPASortModeDefault, // original order from the snapshot + SCIPASortModeAZ, // username ascending + SCIPASortModeZA, // username descending +}; + +#pragma mark - Cell + +@interface SCIPAUserCell : UITableViewCell +@property (nonatomic, strong) UIImageView *avatar; +@property (nonatomic, strong) UILabel *usernameLabel; +@property (nonatomic, strong) UIImageView *verifiedBadge; +@property (nonatomic, strong) UILabel *subtitleLabel; +@property (nonatomic, strong) UIButton *actionButton; +@property (nonatomic, strong) NSLayoutConstraint *usernameTrailingToButton; +@property (nonatomic, strong) NSLayoutConstraint *usernameTrailingToEdge; +@property (nonatomic, copy) void(^onActionTap)(SCIPAUserCell *); +@end + +@implementation SCIPAUserCell +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { + self = [super initWithStyle:style reuseIdentifier:reuseIdentifier]; + if (!self) return self; + self.selectionStyle = UITableViewCellSelectionStyleDefault; + + _avatar = [UIImageView new]; + _avatar.translatesAutoresizingMaskIntoConstraints = NO; + _avatar.backgroundColor = [UIColor secondarySystemBackgroundColor]; + _avatar.layer.cornerRadius = 24; + _avatar.layer.masksToBounds = YES; + _avatar.contentMode = UIViewContentModeScaleAspectFill; + [self.contentView addSubview:_avatar]; + + _usernameLabel = [UILabel new]; + _usernameLabel.translatesAutoresizingMaskIntoConstraints = NO; + _usernameLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + _usernameLabel.textColor = [UIColor labelColor]; + [_usernameLabel setContentHuggingPriority:UILayoutPriorityDefaultHigh forAxis:UILayoutConstraintAxisHorizontal]; + [_usernameLabel setContentCompressionResistancePriority:UILayoutPriorityDefaultLow forAxis:UILayoutConstraintAxisHorizontal]; + [self.contentView addSubview:_usernameLabel]; + + _verifiedBadge = [[UIImageView alloc] initWithImage:[UIImage systemImageNamed:@"checkmark.seal.fill"]]; + _verifiedBadge.translatesAutoresizingMaskIntoConstraints = NO; + _verifiedBadge.tintColor = [UIColor systemBlueColor]; + _verifiedBadge.contentMode = UIViewContentModeScaleAspectFit; + _verifiedBadge.hidden = YES; + [_verifiedBadge setContentHuggingPriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; + [self.contentView addSubview:_verifiedBadge]; + + _subtitleLabel = [UILabel new]; + _subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + _subtitleLabel.font = [UIFont systemFontOfSize:13]; + _subtitleLabel.textColor = [UIColor secondaryLabelColor]; + _subtitleLabel.numberOfLines = 2; + [self.contentView addSubview:_subtitleLabel]; + + _actionButton = [UIButton buttonWithType:UIButtonTypeSystem]; + _actionButton.translatesAutoresizingMaskIntoConstraints = NO; + _actionButton.titleLabel.font = [UIFont systemFontOfSize:14 weight:UIFontWeightSemibold]; + _actionButton.layer.cornerRadius = 8; + _actionButton.contentEdgeInsets = UIEdgeInsetsMake(6, 14, 6, 14); + _actionButton.hidden = YES; + [_actionButton addTarget:self action:@selector(onAction) forControlEvents:UIControlEventTouchUpInside]; + [_actionButton setContentCompressionResistancePriority:UILayoutPriorityRequired forAxis:UILayoutConstraintAxisHorizontal]; + [self.contentView addSubview:_actionButton]; + + _usernameTrailingToButton = [_verifiedBadge.trailingAnchor constraintLessThanOrEqualToAnchor:_actionButton.leadingAnchor constant:-10]; + _usernameTrailingToEdge = [_verifiedBadge.trailingAnchor constraintLessThanOrEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor]; + + [NSLayoutConstraint activateConstraints:@[ + [_avatar.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor], + [_avatar.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + [_avatar.widthAnchor constraintEqualToConstant:48], + [_avatar.heightAnchor constraintEqualToConstant:48], + + [_usernameLabel.leadingAnchor constraintEqualToAnchor:_avatar.trailingAnchor constant:12], + [_usernameLabel.topAnchor constraintEqualToAnchor:_avatar.topAnchor constant:2], + + [_verifiedBadge.leadingAnchor constraintEqualToAnchor:_usernameLabel.trailingAnchor constant:4], + [_verifiedBadge.centerYAnchor constraintEqualToAnchor:_usernameLabel.centerYAnchor], + [_verifiedBadge.widthAnchor constraintEqualToConstant:14], + [_verifiedBadge.heightAnchor constraintEqualToConstant:14], + + [_subtitleLabel.leadingAnchor constraintEqualToAnchor:_usernameLabel.leadingAnchor], + [_subtitleLabel.topAnchor constraintEqualToAnchor:_usernameLabel.bottomAnchor constant:2], + [_subtitleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_actionButton.leadingAnchor constant:-10], + [_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-8], + + [_actionButton.trailingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor], + [_actionButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + + _usernameTrailingToButton, + ]]; + return self; +} + +- (void)setActionVisible:(BOOL)visible { + self.actionButton.hidden = !visible; + self.usernameTrailingToButton.active = visible; + self.usernameTrailingToEdge.active = !visible; +} + +- (void)onAction { if (self.onActionTap) self.onActionTap(self); } +- (void)prepareForReuse { + [super prepareForReuse]; + self.avatar.image = nil; + self.onActionTap = nil; + self.verifiedBadge.hidden = YES; +} +@end + +#pragma mark - VC + +@interface SCIProfileAnalyzerListViewController () +@property (nonatomic, copy) NSArray *allUsers; +@property (nonatomic, copy) NSArray *filteredUsers; +@property (nonatomic, copy) NSArray *allChanges; +@property (nonatomic, copy) NSArray *filteredChanges; +@property (nonatomic, assign) SCIPAListKind kind; +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UISearchController *searchController; +@property (nonatomic, strong) UILabel *emptyLabel; +@property (nonatomic, strong) NSMutableSet *pendingPKs; + +// Multi-select state +@property (nonatomic, assign) BOOL selectionMode; +@property (nonatomic, strong) NSMutableSet *selectedPKs; +@property (nonatomic, strong) UIView *batchBar; +@property (nonatomic, strong) UIButton *batchActionButton; + +// Filter / sort state +@property (nonatomic, assign) SCIPASortMode sortMode; +@property (nonatomic, assign) BOOL filterVerifiedOnly; +@property (nonatomic, assign) BOOL filterNotVerifiedOnly; +@property (nonatomic, assign) BOOL filterPrivateOnly; +@property (nonatomic, copy) NSString *currentQuery; +@end + +@implementation SCIProfileAnalyzerListViewController + +- (instancetype)initWithTitle:(NSString *)title + users:(NSArray *)users + kind:(SCIPAListKind)kind { + self = [super init]; + if (!self) return self; + self.title = title; + self.kind = kind; + self.allUsers = users ?: @[]; + self.filteredUsers = self.allUsers; + self.pendingPKs = [NSMutableSet set]; + self.selectedPKs = [NSMutableSet set]; + return self; +} + +- (instancetype)initWithTitle:(NSString *)title + profileUpdates:(NSArray *)updates { + self = [super init]; + if (!self) return self; + self.title = title; + self.kind = SCIPAListKindProfileUpdate; + self.allChanges = updates ?: @[]; + self.filteredChanges = self.allChanges; + self.pendingPKs = [NSMutableSet set]; + self.selectedPKs = [NSMutableSet set]; + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemBackgroundColor]; + [self setupTable]; + [self setupSearch]; + [self setupEmptyState]; + [self setupBatchBar]; + [self updateNavBar]; + [self refreshCounts]; +} + +- (void)setupTable { + self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStylePlain]; + self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.rowHeight = 72; + self.tableView.separatorInset = UIEdgeInsetsMake(0, 78, 0, 0); + self.tableView.allowsMultipleSelection = NO; + [self.tableView registerClass:[SCIPAUserCell class] forCellReuseIdentifier:@"cell"]; + [self.view addSubview:self.tableView]; +} + +- (void)setupSearch { + self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil]; + self.searchController.searchResultsUpdater = self; + self.searchController.delegate = self; + self.searchController.obscuresBackgroundDuringPresentation = NO; + self.searchController.searchBar.placeholder = SCILocalized(@"Search username or name"); + self.navigationItem.searchController = self.searchController; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.definesPresentationContext = YES; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self sciStyleSearchBar]; +} + +- (void)sciStyleSearchBar { + [SCISearchBarStyler styleSearchBar:self.searchController.searchBar]; +} + +- (void)willPresentSearchController:(UISearchController *)searchController { [self sciStyleSearchBar]; } +- (void)didPresentSearchController:(UISearchController *)searchController { + [self sciStyleSearchBar]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.05 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + [self sciStyleSearchBar]; + }); +} + +- (void)setupEmptyState { + self.emptyLabel = [UILabel new]; + self.emptyLabel.text = SCILocalized(@"No results"); + self.emptyLabel.textColor = [UIColor tertiaryLabelColor]; + self.emptyLabel.font = [UIFont systemFontOfSize:15]; + self.emptyLabel.textAlignment = NSTextAlignmentCenter; + self.emptyLabel.hidden = YES; + self.emptyLabel.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.emptyLabel]; + [NSLayoutConstraint activateConstraints:@[ + [self.emptyLabel.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.emptyLabel.centerYAnchor constraintEqualToAnchor:self.view.centerYAnchor constant:-40], + ]]; +} + +- (void)setupBatchBar { + // Floating capsule above the home indicator. + self.batchActionButton = [UIButton buttonWithType:UIButtonTypeSystem]; + self.batchActionButton.translatesAutoresizingMaskIntoConstraints = NO; + self.batchActionButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + [self.batchActionButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.batchActionButton.backgroundColor = [UIColor systemRedColor]; + self.batchActionButton.layer.cornerRadius = 26; + self.batchActionButton.contentEdgeInsets = UIEdgeInsetsMake(0, 28, 0, 28); + self.batchActionButton.layer.shadowColor = UIColor.blackColor.CGColor; + self.batchActionButton.layer.shadowOffset = CGSizeMake(0, 6); + self.batchActionButton.layer.shadowOpacity = 0.22; + self.batchActionButton.layer.shadowRadius = 12; + [self.batchActionButton addTarget:self action:@selector(batchActionTapped) forControlEvents:UIControlEventTouchUpInside]; + self.batchActionButton.hidden = YES; + [self.view addSubview:self.batchActionButton]; + + self.batchBar = self.batchActionButton; + + [NSLayoutConstraint activateConstraints:@[ + [self.batchActionButton.centerXAnchor constraintEqualToAnchor:self.view.centerXAnchor], + [self.batchActionButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-16], + [self.batchActionButton.heightAnchor constraintEqualToConstant:52], + [self.batchActionButton.widthAnchor constraintGreaterThanOrEqualToConstant:220], + [self.batchActionButton.widthAnchor constraintLessThanOrEqualToAnchor:self.view.widthAnchor constant:-40], + ]]; +} + +- (BOOL)supportsBatchAction { + return self.kind == SCIPAListKindUnfollow || self.kind == SCIPAListKindFollow; +} + +- (void)updateNavBar { + NSMutableArray *rights = [NSMutableArray array]; + if (self.supportsBatchAction) { + NSString *t = self.selectionMode ? SCILocalized(@"Done") : SCILocalized(@"Select"); + UIBarButtonItem *sel = [[UIBarButtonItem alloc] initWithTitle:t + style:UIBarButtonItemStylePlain + target:self action:@selector(toggleSelectionMode)]; + [rights addObject:sel]; + } + // Filled variant signals "filter/sort active". + NSString *symbol = [self hasActiveFilterOrSort] + ? @"line.3.horizontal.decrease.circle.fill" + : @"line.3.horizontal.decrease.circle"; + UIBarButtonItem *filter = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:symbol] + menu:[self buildFilterMenu]]; + [rights addObject:filter]; + self.navigationItem.rightBarButtonItems = rights; +} + +- (UIMenu *)buildFilterMenu { + __weak typeof(self) weakSelf = self; + UIAction *az = [UIAction actionWithTitle:SCILocalized(@"Username A → Z") + image:[UIImage systemImageNamed:@"arrow.up"] + identifier:nil + handler:^(__kindof UIAction *_) { + weakSelf.sortMode = weakSelf.sortMode == SCIPASortModeAZ ? SCIPASortModeDefault : SCIPASortModeAZ; + [weakSelf applyFiltersAndSort]; + }]; + az.state = (self.sortMode == SCIPASortModeAZ) ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIAction *za = [UIAction actionWithTitle:SCILocalized(@"Username Z → A") + image:[UIImage systemImageNamed:@"arrow.down"] + identifier:nil + handler:^(__kindof UIAction *_) { + weakSelf.sortMode = weakSelf.sortMode == SCIPASortModeZA ? SCIPASortModeDefault : SCIPASortModeZA; + [weakSelf applyFiltersAndSort]; + }]; + za.state = (self.sortMode == SCIPASortModeZA) ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIMenu *sortGroup = [UIMenu menuWithTitle:SCILocalized(@"Sort") + image:nil identifier:nil + options:UIMenuOptionsDisplayInline + children:@[az, za]]; + + UIAction *verified = [UIAction actionWithTitle:SCILocalized(@"Verified only") + image:[UIImage systemImageNamed:@"checkmark.seal.fill"] + identifier:nil + handler:^(__kindof UIAction *_) { + weakSelf.filterVerifiedOnly = !weakSelf.filterVerifiedOnly; + if (weakSelf.filterVerifiedOnly) weakSelf.filterNotVerifiedOnly = NO; + [weakSelf applyFiltersAndSort]; + }]; + verified.state = self.filterVerifiedOnly ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIAction *notVerified = [UIAction actionWithTitle:SCILocalized(@"Not verified only") + image:[UIImage systemImageNamed:@"seal"] + identifier:nil + handler:^(__kindof UIAction *_) { + weakSelf.filterNotVerifiedOnly = !weakSelf.filterNotVerifiedOnly; + if (weakSelf.filterNotVerifiedOnly) weakSelf.filterVerifiedOnly = NO; + [weakSelf applyFiltersAndSort]; + }]; + notVerified.state = self.filterNotVerifiedOnly ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIAction *priv = [UIAction actionWithTitle:SCILocalized(@"Private only") + image:[UIImage systemImageNamed:@"lock.fill"] + identifier:nil + handler:^(__kindof UIAction *_) { + weakSelf.filterPrivateOnly = !weakSelf.filterPrivateOnly; + [weakSelf applyFiltersAndSort]; + }]; + priv.state = self.filterPrivateOnly ? UIMenuElementStateOn : UIMenuElementStateOff; + + UIMenu *filterGroup = [UIMenu menuWithTitle:SCILocalized(@"Filter") + image:nil identifier:nil + options:UIMenuOptionsDisplayInline + children:@[verified, notVerified, priv]]; + + NSMutableArray *children = [NSMutableArray arrayWithObjects:sortGroup, filterGroup, nil]; + if ([self hasActiveFilterOrSort]) { + UIAction *clear = [UIAction actionWithTitle:SCILocalized(@"Clear") + image:[UIImage systemImageNamed:@"arrow.counterclockwise"] + identifier:nil + handler:^(__kindof UIAction *_) { + weakSelf.sortMode = SCIPASortModeDefault; + weakSelf.filterVerifiedOnly = NO; + weakSelf.filterNotVerifiedOnly = NO; + weakSelf.filterPrivateOnly = NO; + [weakSelf applyFiltersAndSort]; + }]; + clear.attributes = UIMenuElementAttributesDestructive; + [children addObject:[UIMenu menuWithTitle:@"" image:nil identifier:nil + options:UIMenuOptionsDisplayInline children:@[clear]]]; + } + return [UIMenu menuWithChildren:children]; +} + +- (void)refreshCounts { + NSUInteger total = self.kind == SCIPAListKindProfileUpdate ? self.allChanges.count : self.allUsers.count; + NSUInteger shown = self.kind == SCIPAListKindProfileUpdate ? self.filteredChanges.count : self.filteredUsers.count; + self.navigationItem.prompt = [NSString stringWithFormat:SCILocalized(@"%lu of %lu"), + (unsigned long)shown, (unsigned long)total]; + self.emptyLabel.hidden = shown > 0; +} + +#pragma mark - Search + +- (void)updateSearchResultsForSearchController:(UISearchController *)searchController { + self.currentQuery = [searchController.searchBar.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + [self applyFiltersAndSort]; +} + +// Pipeline: search → verified/private filter → sort. +- (void)applyFiltersAndSort { + NSString *q = self.currentQuery; + BOOL hasQuery = q.length > 0; + BOOL verified = self.filterVerifiedOnly; + BOOL notVerified = self.filterNotVerifiedOnly; + BOOL priv = self.filterPrivateOnly; + + NSArray *(^applyToUsers)(NSArray *) = ^NSArray *(NSArray *src) { + NSMutableArray *out = [NSMutableArray arrayWithCapacity:src.count]; + for (SCIProfileAnalyzerUser *u in src) { + if (hasQuery && ![u.username localizedCaseInsensitiveContainsString:q] + && ![u.fullName localizedCaseInsensitiveContainsString:q]) continue; + if (verified && !u.isVerified) continue; + if (notVerified && u.isVerified) continue; + if (priv && !u.isPrivate) continue; + [out addObject:u]; + } + return [self sortUsers:out]; + }; + + if (self.kind == SCIPAListKindProfileUpdate) { + NSMutableArray *out = [NSMutableArray arrayWithCapacity:self.allChanges.count]; + for (SCIProfileAnalyzerProfileChange *c in self.allChanges) { + SCIProfileAnalyzerUser *u = c.current; + if (hasQuery && ![u.username localizedCaseInsensitiveContainsString:q] + && ![u.fullName localizedCaseInsensitiveContainsString:q]) continue; + if (verified && !u.isVerified) continue; + if (notVerified && u.isVerified) continue; + if (priv && !u.isPrivate) continue; + [out addObject:c]; + } + self.filteredChanges = [self sortChanges:out]; + } else { + self.filteredUsers = applyToUsers(self.allUsers); + } + [self refreshCounts]; + [self updateNavBar]; // refresh filter-icon "active" state + [self.tableView reloadData]; +} + +- (NSArray *)sortUsers:(NSArray *)src { + if (self.sortMode == SCIPASortModeDefault) return src; + BOOL asc = (self.sortMode == SCIPASortModeAZ); + return [src sortedArrayUsingComparator:^NSComparisonResult(SCIProfileAnalyzerUser *a, SCIProfileAnalyzerUser *b) { + NSComparisonResult r = [a.username caseInsensitiveCompare:b.username ?: @""]; + return asc ? r : -r; + }]; +} + +- (NSArray *)sortChanges:(NSArray *)src { + if (self.sortMode == SCIPASortModeDefault) return src; + BOOL asc = (self.sortMode == SCIPASortModeAZ); + return [src sortedArrayUsingComparator:^NSComparisonResult(SCIProfileAnalyzerProfileChange *a, SCIProfileAnalyzerProfileChange *b) { + NSComparisonResult r = [a.current.username caseInsensitiveCompare:b.current.username ?: @""]; + return asc ? r : -r; + }]; +} + +- (BOOL)hasActiveFilterOrSort { + return self.filterVerifiedOnly || self.filterNotVerifiedOnly || self.filterPrivateOnly || self.sortMode != SCIPASortModeDefault; +} + +#pragma mark - Table + +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { + return self.kind == SCIPAListKindProfileUpdate ? self.filteredChanges.count : self.filteredUsers.count; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath { + SCIPAUserCell *cell = [tv dequeueReusableCellWithIdentifier:@"cell" forIndexPath:indexPath]; + SCIProfileAnalyzerUser *user; + SCIProfileAnalyzerProfileChange *change = nil; + if (self.kind == SCIPAListKindProfileUpdate) { + change = self.filteredChanges[indexPath.row]; + user = change.current; + } else { + user = self.filteredUsers[indexPath.row]; + } + + cell.usernameLabel.text = user.username.length ? [NSString stringWithFormat:@"@%@", user.username] : @"(unknown)"; + cell.verifiedBadge.hidden = !user.isVerified; + + if (self.kind == SCIPAListKindProfileUpdate) { + NSMutableArray *lines = [NSMutableArray array]; + if (change.usernameChanged) { + [lines addObject:[NSString stringWithFormat:SCILocalized(@"Username: @%@ → @%@"), + change.previous.username ?: @"", change.current.username ?: @""]]; + } + if (change.fullNameChanged) { + [lines addObject:[NSString stringWithFormat:SCILocalized(@"Name: %@ → %@"), + change.previous.fullName.length ? change.previous.fullName : @"—", + change.current.fullName.length ? change.current.fullName : @"—"]]; + } + if (change.profilePicChanged) [lines addObject:SCILocalized(@"Profile picture changed")]; + cell.subtitleLabel.text = [lines componentsJoinedByString:@"\n"]; + cell.subtitleLabel.numberOfLines = 3; + } else { + cell.subtitleLabel.text = user.fullName.length ? user.fullName : (user.isPrivate ? SCILocalized(@"Private account") : @""); + cell.subtitleLabel.numberOfLines = 1; + } + + [self configureActionForCell:cell user:user]; + + // Selection-mode checkmark affordance + if (self.selectionMode) { + BOOL on = [self.selectedPKs containsObject:user.pk]; + cell.accessoryType = on ? UITableViewCellAccessoryCheckmark : UITableViewCellAccessoryNone; + } else { + cell.accessoryType = UITableViewCellAccessoryNone; + } + + if (user.profilePicURL.length) { + NSURL *url = [NSURL URLWithString:user.profilePicURL]; + NSString *pkTag = user.pk; + cell.avatar.tag = pkTag.hash; + [SCIImageCache loadImageFromURL:url completion:^(UIImage *image) { + if (cell.avatar.tag == (NSInteger)pkTag.hash) cell.avatar.image = image; + }]; + } else { + cell.avatar.image = [UIImage systemImageNamed:@"person.circle.fill"]; + cell.avatar.tintColor = [UIColor systemGrayColor]; + } + return cell; +} + +- (void)configureActionForCell:(SCIPAUserCell *)cell user:(SCIProfileAnalyzerUser *)user { + BOOL hasButton = !self.selectionMode + && (self.kind == SCIPAListKindFollow || self.kind == SCIPAListKindUnfollow); + [cell setActionVisible:hasButton]; + if (!hasButton) return; + + BOOL pending = [self.pendingPKs containsObject:user.pk]; + if (self.kind == SCIPAListKindUnfollow) { + [cell.actionButton setTitle:SCILocalized(@"Unfollow") forState:UIControlStateNormal]; + cell.actionButton.backgroundColor = [[UIColor systemRedColor] colorWithAlphaComponent:0.12]; + [cell.actionButton setTitleColor:[UIColor systemRedColor] forState:UIControlStateNormal]; + } else { + [cell.actionButton setTitle:SCILocalized(@"Follow") forState:UIControlStateNormal]; + cell.actionButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]; + [cell.actionButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + } + cell.actionButton.enabled = !pending; + cell.actionButton.alpha = pending ? 0.5 : 1.0; + + __weak typeof(self) weakSelf = self; + cell.onActionTap = ^(SCIPAUserCell *c) { [weakSelf performActionForUser:user]; }; +} + +#pragma mark - Single-row action + +- (void)performActionForUser:(SCIProfileAnalyzerUser *)user { + if ([self.pendingPKs containsObject:user.pk]) return; + if (self.kind == SCIPAListKindUnfollow) { + NSString *msg = [NSString stringWithFormat:SCILocalized(@"Unfollow @%@?"), user.username ?: @""]; + UIAlertController *a = [UIAlertController alertControllerWithTitle:nil message:msg preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Unfollow") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { + [self sendFriendshipForUser:user follow:NO reload:YES]; + }]]; + [self presentViewController:a animated:YES completion:nil]; + } else { + [self sendFriendshipForUser:user follow:YES reload:YES]; + } +} + +- (void)sendFriendshipForUser:(SCIProfileAnalyzerUser *)user follow:(BOOL)follow reload:(BOOL)reload { + [self.pendingPKs addObject:user.pk]; + if (reload) [self.tableView reloadData]; + void(^done)(NSDictionary *, NSError *) = ^(NSDictionary *resp, NSError *err) { + [self.pendingPKs removeObject:user.pk]; + BOOL success = (err == nil) && ([resp[@"status"] isEqualToString:@"ok"] || resp[@"friendship_status"]); + if (success) { + [self persistFriendshipChangeForUser:user followed:follow]; + [self removeUserFromList:user]; + } else { + [SCIUtils showErrorHUDWithDescription:err.localizedDescription ?: SCILocalized(@"Request failed")]; + [self.tableView reloadData]; + } + }; + if (follow) [SCIInstagramAPI followUserPK:user.pk completion:done]; + else [SCIInstagramAPI unfollowUserPK:user.pk completion:done]; +} + +// Mirror in-app follow/unfollow into the cached snapshot so category counts +// + header stats update live without a rescan. +- (void)persistFriendshipChangeForUser:(SCIProfileAnalyzerUser *)user followed:(BOOL)followed { + NSString *pk = [SCIUtils currentUserPK]; + SCIProfileAnalyzerSnapshot *snap = [SCIProfileAnalyzerStorage currentSnapshotForUserPK:pk]; + if (!snap) return; + NSMutableArray *following = [snap.following mutableCopy] ?: [NSMutableArray array]; + BOOL alreadyIn = [following containsObject:user]; + if (followed && !alreadyIn) { + [following addObject:user]; + snap.followingCount = MAX(0, snap.followingCount + 1); + } else if (!followed && alreadyIn) { + [following removeObject:user]; + snap.followingCount = MAX(0, snap.followingCount - 1); + } else { + return; + } + snap.following = following; + [SCIProfileAnalyzerStorage updateCurrentSnapshot:snap forUserPK:pk]; +} + +- (void)removeUserFromList:(SCIProfileAnalyzerUser *)user { + NSMutableArray *all = [self.allUsers mutableCopy]; + [all removeObject:user]; + self.allUsers = all; + NSMutableArray *filt = [self.filteredUsers mutableCopy]; + [filt removeObject:user]; + self.filteredUsers = filt; + [self.selectedPKs removeObject:user.pk]; + [self refreshCounts]; + [self.tableView reloadData]; +} + +#pragma mark - Tap row + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tv deselectRowAtIndexPath:indexPath animated:YES]; + SCIProfileAnalyzerUser *user = self.kind == SCIPAListKindProfileUpdate + ? self.filteredChanges[indexPath.row].current + : self.filteredUsers[indexPath.row]; + + if (self.selectionMode) { + if ([self.selectedPKs containsObject:user.pk]) [self.selectedPKs removeObject:user.pk]; + else [self.selectedPKs addObject:user.pk]; + [self refreshBatchBar]; + [tv reloadRowsAtIndexPaths:@[indexPath] withRowAnimation:UITableViewRowAnimationNone]; + return; + } + + if (!user.username.length) return; + NSURL *url = [NSURL URLWithString:[NSString stringWithFormat:@"instagram://user?username=%@", user.username]]; + [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; +} + +#pragma mark - Multi-select + +- (void)toggleSelectionMode { + self.selectionMode = !self.selectionMode; + [self.selectedPKs removeAllObjects]; + self.batchActionButton.hidden = !self.selectionMode; + // Leave room for the capsule so last-row cells don't sit under it. + self.tableView.contentInset = UIEdgeInsetsMake(0, 0, self.selectionMode ? 96 : 0, 0); + [self updateNavBar]; + [self refreshBatchBar]; + [self.tableView reloadData]; +} + +- (void)refreshBatchBar { + NSUInteger n = self.selectedPKs.count; + BOOL follow = (self.kind == SCIPAListKindFollow); + NSString *t = follow + ? [NSString stringWithFormat:SCILocalized(@"Follow %lu"), (unsigned long)n] + : [NSString stringWithFormat:SCILocalized(@"Unfollow %lu"), (unsigned long)n]; + [self.batchActionButton setTitle:t forState:UIControlStateNormal]; + self.batchActionButton.backgroundColor = follow + ? ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]) + : [UIColor systemRedColor]; + self.batchActionButton.enabled = n > 0; + self.batchActionButton.alpha = n > 0 ? 1.0 : 0.5; +} + +- (void)batchActionTapped { + NSUInteger n = self.selectedPKs.count; + if (!n) return; + BOOL follow = (self.kind == SCIPAListKindFollow); + NSString *verb = follow ? SCILocalized(@"Follow") : SCILocalized(@"Unfollow"); + NSString *title = follow ? SCILocalized(@"Batch follow") : SCILocalized(@"Batch unfollow"); + NSString *msg; + if (n > kSCIPABatchCap) { + msg = [NSString stringWithFormat:SCILocalized(@"%@ %lu accounts? The first %ld will be processed to avoid rate limits."), + verb, (unsigned long)n, (long)kSCIPABatchCap]; + } else { + msg = [NSString stringWithFormat:SCILocalized(@"%@ %lu accounts? This runs sequentially with a short pause between each."), + verb, (unsigned long)n]; + } + UIAlertController *a = [UIAlertController alertControllerWithTitle:title + message:msg preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + UIAlertActionStyle style = follow ? UIAlertActionStyleDefault : UIAlertActionStyleDestructive; + [a addAction:[UIAlertAction actionWithTitle:verb style:style handler:^(UIAlertAction *_) { + [self runBatchAction]; + }]]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)runBatchAction { + NSMutableArray *queue = [NSMutableArray array]; + for (SCIProfileAnalyzerUser *u in self.allUsers) { + if ([self.selectedPKs containsObject:u.pk]) [queue addObject:u]; + if (queue.count >= kSCIPABatchCap) break; + } + [self.selectedPKs removeAllObjects]; + [self refreshBatchBar]; + [self batchStep:queue done:0 total:queue.count]; +} + +- (void)batchStep:(NSMutableArray *)queue + done:(NSUInteger)done + total:(NSUInteger)total { + BOOL follow = (self.kind == SCIPAListKindFollow); + if (!queue.count) { + NSString *finishedTitle = follow ? SCILocalized(@"Batch follow finished") : SCILocalized(@"Batch unfollow finished"); + NSString *finishedSub = follow + ? [NSString stringWithFormat:SCILocalized(@"%lu accounts followed"), (unsigned long)total] + : [NSString stringWithFormat:SCILocalized(@"%lu accounts unfollowed"), (unsigned long)total]; + [SCIUtils showToastForDuration:2.0 title:finishedTitle subtitle:finishedSub]; + self.navigationItem.prompt = nil; + [self toggleSelectionMode]; + [self refreshCounts]; + return; + } + SCIProfileAnalyzerUser *u = queue.firstObject; + [queue removeObjectAtIndex:0]; + __weak typeof(self) weakSelf = self; + void(^handler)(NSDictionary *, NSError *) = ^(NSDictionary *resp, NSError *err) { + typeof(self) strongSelf = weakSelf; + if (!strongSelf) return; + NSUInteger nextDone = done + 1; + BOOL ok = (err == nil) && ([resp[@"status"] isEqualToString:@"ok"] || resp[@"friendship_status"]); + if (ok) { + [strongSelf persistFriendshipChangeForUser:u followed:follow]; + [strongSelf removeUserFromList:u]; + } + NSString *progressFmt = follow ? SCILocalized(@"Following… %lu / %lu") : SCILocalized(@"Unfollowing… %lu / %lu"); + strongSelf.navigationItem.prompt = [NSString stringWithFormat:progressFmt, + (unsigned long)nextDone, (unsigned long)total]; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(kSCIPABatchDelay * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [weakSelf batchStep:queue done:nextDone total:total]; + }); + }; + if (follow) [SCIInstagramAPI followUserPK:u.pk completion:handler]; + else [SCIInstagramAPI unfollowUserPK:u.pk completion:handler]; +} + +@end diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerModels.h b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerModels.h new file mode 100644 index 0000000..4616685 --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerModels.h @@ -0,0 +1,74 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Lightweight user record — what we cache per follower/following entry. +@interface SCIProfileAnalyzerUser : NSObject + +@property (nonatomic, copy) NSString *pk; +@property (nonatomic, copy) NSString *username; +@property (nonatomic, copy, nullable) NSString *fullName; +@property (nonatomic, copy, nullable) NSString *profilePicURL; +// Stable IG-internal ID of the current profile picture — changes only when +// the user uploads a new one. Used for reliable change detection. +@property (nonatomic, copy, nullable) NSString *profilePicID; +@property (nonatomic, assign) BOOL isPrivate; +@property (nonatomic, assign) BOOL isVerified; + ++ (nullable instancetype)userFromAPIDict:(NSDictionary *)dict; ++ (nullable instancetype)userFromJSONDict:(NSDictionary *)dict; +- (NSDictionary *)toJSONDict; + +@end + +// One-point-in-time capture of an account's graph + self info. Persisted +// to disk as JSON; diffs between snapshots produce the report categories. +@interface SCIProfileAnalyzerSnapshot : NSObject + +@property (nonatomic, strong) NSDate *scanDate; +@property (nonatomic, copy) NSString *selfPK; +@property (nonatomic, copy, nullable) NSString *selfUsername; +@property (nonatomic, copy, nullable) NSString *selfFullName; +@property (nonatomic, copy, nullable) NSString *selfProfilePicURL; +@property (nonatomic, assign) NSInteger followerCount; +@property (nonatomic, assign) NSInteger followingCount; +@property (nonatomic, assign) NSInteger mediaCount; +@property (nonatomic, copy) NSArray *followers; +@property (nonatomic, copy) NSArray *following; + ++ (nullable instancetype)snapshotFromJSONDict:(NSDictionary *)dict; +- (NSDictionary *)toJSONDict; + +@end + +// Per-profile change entry (username/fullName/pic edited since last scan). +@interface SCIProfileAnalyzerProfileChange : NSObject +@property (nonatomic, strong) SCIProfileAnalyzerUser *previous; +@property (nonatomic, strong) SCIProfileAnalyzerUser *current; +@property (nonatomic, readonly) BOOL usernameChanged; +@property (nonatomic, readonly) BOOL fullNameChanged; +@property (nonatomic, readonly) BOOL profilePicChanged; +@end + +// Derived category arrays, computed from (current, previous) snapshots. +@interface SCIProfileAnalyzerReport : NSObject + +@property (nonatomic, strong, nullable) SCIProfileAnalyzerSnapshot *current; +@property (nonatomic, strong, nullable) SCIProfileAnalyzerSnapshot *previous; + +@property (nonatomic, copy) NSArray *mutualFollowers; +@property (nonatomic, copy) NSArray *notFollowingYouBack; +@property (nonatomic, copy) NSArray *youDontFollowBack; +// `new*` getters are reserved by ARC's Cocoa new-family rule, hence the name. +@property (nonatomic, copy) NSArray *recentFollowers; +@property (nonatomic, copy) NSArray *lostFollowers; +@property (nonatomic, copy) NSArray *youStartedFollowing; +@property (nonatomic, copy) NSArray *youUnfollowed; +@property (nonatomic, copy) NSArray *profileUpdates; + ++ (SCIProfileAnalyzerReport *)reportFromCurrent:(nullable SCIProfileAnalyzerSnapshot *)current + previous:(nullable SCIProfileAnalyzerSnapshot *)previous; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerModels.m b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerModels.m new file mode 100644 index 0000000..b1dcac8 --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerModels.m @@ -0,0 +1,212 @@ +#import "SCIProfileAnalyzerModels.h" + +#pragma mark - User + +@implementation SCIProfileAnalyzerUser + ++ (instancetype)userFromAPIDict:(NSDictionary *)d { + id pkRaw = d[@"pk"] ?: d[@"pk_id"] ?: d[@"id"]; + NSString *pk = [pkRaw isKindOfClass:[NSString class]] ? pkRaw + : [pkRaw respondsToSelector:@selector(stringValue)] ? [pkRaw stringValue] : nil; + if (!pk.length) return nil; + + SCIProfileAnalyzerUser *u = [self new]; + u.pk = pk; + u.username = [d[@"username"] isKindOfClass:[NSString class]] ? d[@"username"] : @""; + u.fullName = [d[@"full_name"] isKindOfClass:[NSString class]] ? d[@"full_name"] : nil; + u.profilePicURL = [d[@"profile_pic_url"] isKindOfClass:[NSString class]] ? d[@"profile_pic_url"] : nil; + id pid = d[@"profile_pic_id"]; + if ([pid isKindOfClass:[NSString class]]) u.profilePicID = pid; + else if ([pid respondsToSelector:@selector(stringValue)]) u.profilePicID = [pid stringValue]; + u.isPrivate = [d[@"is_private"] boolValue]; + u.isVerified = [d[@"is_verified"] boolValue]; + return u; +} + ++ (instancetype)userFromJSONDict:(NSDictionary *)d { + if (![d[@"pk"] isKindOfClass:[NSString class]]) return nil; + SCIProfileAnalyzerUser *u = [self new]; + u.pk = d[@"pk"]; + u.username = d[@"username"] ?: @""; + u.fullName = d[@"full_name"]; + u.profilePicURL = d[@"profile_pic_url"]; + u.profilePicID = d[@"profile_pic_id"]; + u.isPrivate = [d[@"is_private"] boolValue]; + u.isVerified = [d[@"is_verified"] boolValue]; + return u; +} + +- (NSDictionary *)toJSONDict { + NSMutableDictionary *d = [NSMutableDictionary dictionary]; + d[@"pk"] = self.pk ?: @""; + d[@"username"] = self.username ?: @""; + if (self.fullName) d[@"full_name"] = self.fullName; + if (self.profilePicURL) d[@"profile_pic_url"] = self.profilePicURL; + if (self.profilePicID) d[@"profile_pic_id"] = self.profilePicID; + d[@"is_private"] = @(self.isPrivate); + d[@"is_verified"] = @(self.isVerified); + return d; +} + +- (id)copyWithZone:(NSZone *)zone { + SCIProfileAnalyzerUser *u = [SCIProfileAnalyzerUser new]; + u.pk = self.pk; + u.username = self.username; + u.fullName = self.fullName; + u.profilePicURL = self.profilePicURL; + u.profilePicID = self.profilePicID; + u.isPrivate = self.isPrivate; + u.isVerified = self.isVerified; + return u; +} + +- (NSUInteger)hash { return self.pk.hash; } +- (BOOL)isEqual:(id)other { + if (![other isKindOfClass:[SCIProfileAnalyzerUser class]]) return NO; + return [self.pk isEqualToString:((SCIProfileAnalyzerUser *)other).pk]; +} + +@end + +#pragma mark - Snapshot + +@implementation SCIProfileAnalyzerSnapshot + ++ (instancetype)snapshotFromJSONDict:(NSDictionary *)d { + if (!d[@"self_pk"]) return nil; + SCIProfileAnalyzerSnapshot *s = [self new]; + s.scanDate = [NSDate dateWithTimeIntervalSince1970:[d[@"scan_date"] doubleValue]]; + s.selfPK = d[@"self_pk"]; + s.selfUsername = d[@"self_username"]; + s.selfFullName = d[@"self_full_name"]; + s.selfProfilePicURL = d[@"self_profile_pic_url"]; + s.followerCount = [d[@"follower_count"] integerValue]; + s.followingCount = [d[@"following_count"] integerValue]; + s.mediaCount = [d[@"media_count"] integerValue]; + + NSMutableArray *f = [NSMutableArray array]; + for (NSDictionary *u in d[@"followers"]) { + SCIProfileAnalyzerUser *user = [SCIProfileAnalyzerUser userFromJSONDict:u]; + if (user) [f addObject:user]; + } + s.followers = f; + + NSMutableArray *g = [NSMutableArray array]; + for (NSDictionary *u in d[@"following"]) { + SCIProfileAnalyzerUser *user = [SCIProfileAnalyzerUser userFromJSONDict:u]; + if (user) [g addObject:user]; + } + s.following = g; + return s; +} + +- (NSDictionary *)toJSONDict { + NSMutableArray *f = [NSMutableArray arrayWithCapacity:self.followers.count]; + for (SCIProfileAnalyzerUser *u in self.followers) [f addObject:[u toJSONDict]]; + NSMutableArray *g = [NSMutableArray arrayWithCapacity:self.following.count]; + for (SCIProfileAnalyzerUser *u in self.following) [g addObject:[u toJSONDict]]; + + return @{ + @"scan_date": @([self.scanDate timeIntervalSince1970]), + @"self_pk": self.selfPK ?: @"", + @"self_username": self.selfUsername ?: @"", + @"self_full_name": self.selfFullName ?: @"", + @"self_profile_pic_url": self.selfProfilePicURL ?: @"", + @"follower_count": @(self.followerCount), + @"following_count": @(self.followingCount), + @"media_count": @(self.mediaCount), + @"followers": f, + @"following": g, + }; +} + +@end + +#pragma mark - Profile change + +@implementation SCIProfileAnalyzerProfileChange +- (BOOL)usernameChanged { return ![self.previous.username isEqualToString:self.current.username]; } +- (BOOL)fullNameChanged { return ![(self.previous.fullName ?: @"") isEqualToString:(self.current.fullName ?: @"")]; } +// Compare profile_pic_id (stable per pic; changes only on upload). URL +// diffing was unusable — IG rotates the CDN host + path hash per request. +// Skip when either side is missing the id (old snapshots pre-feature). +- (BOOL)profilePicChanged { + NSString *a = self.previous.profilePicID; + NSString *b = self.current.profilePicID; + if (!a.length || !b.length) return NO; + return ![a isEqualToString:b]; +} +@end + +#pragma mark - Report + +@implementation SCIProfileAnalyzerReport + +static NSArray *sciSubtract(NSArray *a, NSSet *bSet) { + if (!a.count) return @[]; + NSMutableArray *out = [NSMutableArray arrayWithCapacity:a.count]; + for (SCIProfileAnalyzerUser *u in a) if (![bSet containsObject:u]) [out addObject:u]; + return out; +} + +static NSArray *sciIntersect(NSArray *a, NSSet *bSet) { + if (!a.count) return @[]; + NSMutableArray *out = [NSMutableArray arrayWithCapacity:a.count]; + for (SCIProfileAnalyzerUser *u in a) if ([bSet containsObject:u]) [out addObject:u]; + return out; +} + ++ (SCIProfileAnalyzerReport *)reportFromCurrent:(SCIProfileAnalyzerSnapshot *)current + previous:(SCIProfileAnalyzerSnapshot *)previous { + SCIProfileAnalyzerReport *r = [self new]; + r.current = current; + r.previous = previous; + r.mutualFollowers = @[]; + r.notFollowingYouBack = @[]; + r.youDontFollowBack = @[]; + r.recentFollowers = @[]; + r.lostFollowers = @[]; + r.youStartedFollowing = @[]; + r.youUnfollowed = @[]; + r.profileUpdates = @[]; + if (!current) return r; + + NSSet *followersSet = [NSSet setWithArray:current.followers]; + NSSet *followingSet = [NSSet setWithArray:current.following]; + + r.mutualFollowers = sciIntersect(current.followers, followingSet); + r.notFollowingYouBack = sciSubtract(current.following, followersSet); + r.youDontFollowBack = sciSubtract(current.followers, followingSet); + + if (previous) { + NSSet *prevFollowers = [NSSet setWithArray:previous.followers]; + NSSet *prevFollowing = [NSSet setWithArray:previous.following]; + r.recentFollowers = sciSubtract(current.followers, prevFollowers); + r.lostFollowers = sciSubtract(previous.followers, followersSet); + r.youStartedFollowing = sciSubtract(current.following, prevFollowing); + r.youUnfollowed = sciSubtract(previous.following, followingSet); + + // Profile updates: same pk in both snapshots, any field differs. + NSMutableDictionary *prevByPK = [NSMutableDictionary dictionary]; + for (SCIProfileAnalyzerUser *u in previous.followers) prevByPK[u.pk] = u; + for (SCIProfileAnalyzerUser *u in previous.following) prevByPK[u.pk] = u; + + NSMutableArray *updates = [NSMutableArray array]; + NSMutableSet *seen = [NSMutableSet set]; + NSArray *currentAll = [current.followers arrayByAddingObjectsFromArray:current.following]; + for (SCIProfileAnalyzerUser *u in currentAll) { + if ([seen containsObject:u.pk]) continue; + [seen addObject:u.pk]; + SCIProfileAnalyzerUser *prev = prevByPK[u.pk]; + if (!prev) continue; + SCIProfileAnalyzerProfileChange *ch = [SCIProfileAnalyzerProfileChange new]; + ch.previous = prev; + ch.current = u; + if (ch.usernameChanged || ch.fullNameChanged || ch.profilePicChanged) [updates addObject:ch]; + } + r.profileUpdates = updates; + } + return r; +} + +@end diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerService.h b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerService.h new file mode 100644 index 0000000..33904ce --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerService.h @@ -0,0 +1,36 @@ +#import +#import "SCIProfileAnalyzerModels.h" + +NS_ASSUME_NONNULL_BEGIN + +typedef NS_ENUM(NSInteger, SCIProfileAnalyzerError) { + SCIProfileAnalyzerErrorNoSession = 1, + SCIProfileAnalyzerErrorTooManyFollowers, + SCIProfileAnalyzerErrorNetwork, + SCIProfileAnalyzerErrorCancelled, +}; + +// Hard cap — beyond this follower count we refuse to run. Each followers +// page returns ~25-50 users so large accounts hit IG rate limits fast. +extern const NSInteger SCIProfileAnalyzerMaxFollowerCount; + +typedef void(^SCIPAProgress)(NSString *status, double fraction); +typedef void(^SCIPACompletion)(SCIProfileAnalyzerSnapshot * _Nullable snapshot, NSError * _Nullable error); +// Fires once, right after the self-user-info call returns. Lets the UI +// paint the header immediately instead of waiting for the full run to finish. +typedef void(^SCIPAHeaderInfo)(NSDictionary *userInfo); + +@interface SCIProfileAnalyzerService : NSObject + +@property (nonatomic, readonly) BOOL isRunning; + ++ (instancetype)sharedService; + +- (void)runForSelfWithHeaderInfo:(nullable SCIPAHeaderInfo)headerInfo + progress:(SCIPAProgress)progress + completion:(SCIPACompletion)completion; +- (void)cancel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerService.m b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerService.m new file mode 100644 index 0000000..ea1f737 --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerService.m @@ -0,0 +1,213 @@ +#import "SCIProfileAnalyzerService.h" +#import "../../Networking/SCIInstagramAPI.h" +#import "../../Utils.h" + +const NSInteger SCIProfileAnalyzerMaxFollowerCount = 13000; + +#define SCI_PA_PAGE_DELAY_S 0.25 // small pause between pages — lightweight rate cushion + +@interface SCIProfileAnalyzerService () { +@public + NSInteger _expectedFollowers; + NSInteger _expectedFollowing; +} +@property (nonatomic, assign) BOOL cancelled; +@property (nonatomic, assign) BOOL isRunning; +@end + +@implementation SCIProfileAnalyzerService + ++ (instancetype)sharedService { + static SCIProfileAnalyzerService *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [self new]; }); + return s; +} + +- (void)cancel { self.cancelled = YES; } + +- (void)finishWithSnapshot:(SCIProfileAnalyzerSnapshot *)s error:(NSError *)e completion:(SCIPACompletion)completion { + self.isRunning = NO; + self.cancelled = NO; + if (completion) dispatch_async(dispatch_get_main_queue(), ^{ completion(s, e); }); +} + +- (NSError *)errorWithCode:(SCIProfileAnalyzerError)code message:(NSString *)msg { + return [NSError errorWithDomain:@"SCIProfileAnalyzer" code:code + userInfo:@{ NSLocalizedDescriptionKey: msg ?: @"" }]; +} + +- (void)runForSelfWithHeaderInfo:(SCIPAHeaderInfo)headerInfo + progress:(SCIPAProgress)progress + completion:(SCIPACompletion)completion { + if (self.isRunning) { + if (completion) completion(nil, [self errorWithCode:SCIProfileAnalyzerErrorCancelled + message:SCILocalized(@"Another analysis is already running")]); + return; + } + self.isRunning = YES; + self.cancelled = NO; + + NSString *selfPK = [SCIUtils currentUserPK]; + if (!selfPK.length) { + [self finishWithSnapshot:nil + error:[self errorWithCode:SCIProfileAnalyzerErrorNoSession message:SCILocalized(@"No active Instagram session found")] + completion:completion]; + return; + } + + __weak typeof(self) weakSelf = self; + [self reportProgress:progress status:SCILocalized(@"Fetching profile info…") fraction:0.02]; + + [SCIInstagramAPI sendRequestWithMethod:@"GET" + path:[NSString stringWithFormat:@"users/%@/info/", selfPK] + body:nil + completion:^(NSDictionary *resp, NSError *error) { + typeof(self) strongSelf = weakSelf; + if (!strongSelf) return; + if (strongSelf.cancelled) { + [strongSelf finishWithSnapshot:nil error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")] + completion:completion]; + return; + } + NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil; + if (!user) { + [strongSelf finishWithSnapshot:nil + error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorNetwork message:SCILocalized(@"Couldn't fetch profile information")] + completion:completion]; + return; + } + NSInteger followerCount = [user[@"follower_count"] integerValue]; + if (followerCount > SCIProfileAnalyzerMaxFollowerCount) { + [strongSelf finishWithSnapshot:nil + error:[strongSelf errorWithCode:SCIProfileAnalyzerErrorTooManyFollowers + message:SCILocalized(@"Too many followers to analyze")] + completion:completion]; + return; + } + + SCIProfileAnalyzerSnapshot *snap = [SCIProfileAnalyzerSnapshot new]; + snap.selfPK = selfPK; + snap.selfUsername = user[@"username"]; + snap.selfFullName = user[@"full_name"]; + snap.selfProfilePicURL = user[@"profile_pic_url"]; + snap.followerCount = followerCount; + snap.followingCount = [user[@"following_count"] integerValue]; + snap.mediaCount = [user[@"media_count"] integerValue]; + snap.scanDate = [NSDate date]; + + strongSelf->_expectedFollowers = followerCount; + strongSelf->_expectedFollowing = snap.followingCount; + + if (headerInfo) dispatch_async(dispatch_get_main_queue(), ^{ headerInfo(user); }); + [strongSelf fetchFollowersForPK:selfPK snapshot:snap progress:progress completion:completion]; + }]; +} + +- (void)reportProgress:(SCIPAProgress)p status:(NSString *)s fraction:(double)f { + if (!p) return; + dispatch_async(dispatch_get_main_queue(), ^{ p(s, f); }); +} + +#pragma mark - Paginated fetchers + +- (void)fetchFollowersForPK:(NSString *)pk + snapshot:(SCIProfileAnalyzerSnapshot *)snap + progress:(SCIPAProgress)progress + completion:(SCIPACompletion)completion { + NSMutableArray *acc = [NSMutableArray array]; + [self pagePath:[NSString stringWithFormat:@"friendships/%@/followers/", pk] + acc:acc + maxId:nil + total:snap.followerCount + stage:@"followers" + progress:progress + completion:^(NSArray *users, NSError *error) { + if (error || self.cancelled) { + [self finishWithSnapshot:nil error:error ?: [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")] + completion:completion]; + return; + } + snap.followers = users; + [self fetchFollowingForPK:pk snapshot:snap progress:progress completion:completion]; + }]; +} + +- (void)fetchFollowingForPK:(NSString *)pk + snapshot:(SCIProfileAnalyzerSnapshot *)snap + progress:(SCIPAProgress)progress + completion:(SCIPACompletion)completion { + NSMutableArray *acc = [NSMutableArray array]; + [self pagePath:[NSString stringWithFormat:@"friendships/%@/following/", pk] + acc:acc + maxId:nil + total:snap.followingCount + stage:@"following" + progress:progress + completion:^(NSArray *users, NSError *error) { + if (error || self.cancelled) { + [self finishWithSnapshot:nil error:error ?: [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")] + completion:completion]; + return; + } + snap.following = users; + [self finishWithSnapshot:snap error:nil completion:completion]; + }]; +} + +- (void)pagePath:(NSString *)basePath + acc:(NSMutableArray *)acc + maxId:(NSString *)maxId + total:(NSInteger)total + stage:(NSString *)stage + progress:(SCIPAProgress)progress + completion:(void(^)(NSArray *users, NSError *error))completion { + if (self.cancelled) { + completion(nil, [self errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")]); + return; + } + NSString *path = maxId.length ? [NSString stringWithFormat:@"%@?max_id=%@", basePath, maxId] : basePath; + + __weak typeof(self) weakSelf = self; + [SCIInstagramAPI sendRequestWithMethod:@"GET" path:path body:nil completion:^(NSDictionary *resp, NSError *error) { + typeof(self) strongSelf = weakSelf; + if (!strongSelf) return; + if (error) { completion(nil, [strongSelf errorWithCode:SCIProfileAnalyzerErrorNetwork message:error.localizedDescription]); return; } + + NSArray *users = resp[@"users"]; + if ([users isKindOfClass:[NSArray class]]) { + for (NSDictionary *d in users) { + SCIProfileAnalyzerUser *u = [SCIProfileAnalyzerUser userFromAPIDict:d]; + if (u) [acc addObject:u]; + } + } + // Weight each stage by its share of expected work so the ring moves + // proportionally regardless of follower/following ratio. 3% reserved + // up front for the initial user-info call. + NSInteger followerTarget = strongSelf->_expectedFollowers; + NSInteger followingTarget = strongSelf->_expectedFollowing; + double total0 = MAX(1, followerTarget + followingTarget); + double stageWeight = ([stage isEqualToString:@"followers"] ? followerTarget : followingTarget) / total0; + double stageOffset = ([stage isEqualToString:@"followers"] ? 0.0 : (double)followerTarget / total0); + double stageLocal = total > 0 ? MIN(1.0, (double)acc.count / (double)total) : 0; + double frac = 0.03 + (stageOffset + stageLocal * stageWeight) * 0.97; + NSString *fmt = [stage isEqualToString:@"followers"] + ? SCILocalized(@"Fetching followers (%lu/%ld)…") + : SCILocalized(@"Fetching following (%lu/%ld)…"); + NSString *label = [NSString stringWithFormat:fmt, (unsigned long)acc.count, (long)total]; + [strongSelf reportProgress:progress status:label fraction:frac]; + + id next = resp[@"next_max_id"]; + NSString *nextMax = [next isKindOfClass:[NSString class]] ? next : ([next respondsToSelector:@selector(stringValue)] ? [next stringValue] : nil); + if (!nextMax.length || strongSelf.cancelled) { + completion(acc, strongSelf.cancelled ? [strongSelf errorWithCode:SCIProfileAnalyzerErrorCancelled message:SCILocalized(@"Cancelled")] : nil); + return; + } + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(SCI_PA_PAGE_DELAY_S * NSEC_PER_SEC)), + dispatch_get_main_queue(), ^{ + [strongSelf pagePath:basePath acc:acc maxId:nextMax total:total stage:stage progress:progress completion:completion]; + }); + }]; +} + +@end diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.h b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.h new file mode 100644 index 0000000..af188f5 --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.h @@ -0,0 +1,41 @@ +#import +#import "SCIProfileAnalyzerModels.h" + +NS_ASSUME_NONNULL_BEGIN + +// Posted on every save/update/reset. userInfo carries @"user_pk". +extern NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification; + +// Per-account on-disk store: current + previous snapshots (for since-last-scan +// diffs), an optional baseline for cumulative tracking, and a lightweight +// header cache keyed by PK. +@interface SCIProfileAnalyzerStorage : NSObject + ++ (nullable SCIProfileAnalyzerSnapshot *)currentSnapshotForUserPK:(NSString *)userPK; ++ (nullable SCIProfileAnalyzerSnapshot *)previousSnapshotForUserPK:(NSString *)userPK; ++ (nullable SCIProfileAnalyzerSnapshot *)baselineSnapshotForUserPK:(NSString *)userPK; ++ (BOOL)saveBaselineSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK; ++ (void)clearBaselineForUserPK:(NSString *)userPK; + +// Rotates current → previous, then writes the new current. ++ (BOOL)saveSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK; + +// Overwrites current without touching previous — keeps the diff baseline +// intact across in-app follow/unfollow mutations. ++ (BOOL)updateCurrentSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK; + ++ (void)resetForUserPK:(NSString *)userPK; ++ (void)resetAll; + +// Self-profile summary (username, name, counts, pic) cached so the header +// paints on cold launch without a /users/{pk}/info/ call. ++ (nullable NSDictionary *)headerInfoForUserPK:(NSString *)userPK; ++ (void)saveHeaderInfo:(NSDictionary *)info forUserPK:(NSString *)userPK; + +// Backup/Restore hooks — opaque pk-keyed JSON blob. ++ (NSDictionary *)exportedDict; ++ (BOOL)importFromDict:(NSDictionary *)dict; + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.m b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.m new file mode 100644 index 0000000..eff607c --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.m @@ -0,0 +1,136 @@ +#import "SCIProfileAnalyzerStorage.h" + +NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification = @"SCIProfileAnalyzerDataDidChangeNotification"; + +@implementation SCIProfileAnalyzerStorage + +static NSString *const kSCIPAStorageDir = @"RyukGram/ProfileAnalyzer"; + +static void sciPostDataChanged(NSString *userPK) { + dispatch_async(dispatch_get_main_queue(), ^{ + [[NSNotificationCenter defaultCenter] postNotificationName:SCIProfileAnalyzerDataDidChangeNotification + object:nil + userInfo:userPK.length ? @{ @"user_pk": userPK } : @{}]; + }); +} + +static NSString *sciStorageDir(void) { + NSArray *roots = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES); + NSString *dir = [roots.firstObject stringByAppendingPathComponent:kSCIPAStorageDir]; + [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + return dir; +} + +static NSString *sciPath(NSString *userPK, NSString *slot) { + NSString *safePK = userPK.length ? userPK : @"anon"; + return [sciStorageDir() stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.%@.json", safePK, slot]]; +} + +static NSDictionary *sciReadJSON(NSString *path) { + NSData *data = [NSData dataWithContentsOfFile:path]; + if (!data.length) return nil; + id obj = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil]; + return [obj isKindOfClass:[NSDictionary class]] ? obj : nil; +} + +static BOOL sciWriteJSON(NSString *path, NSDictionary *dict) { + NSError *err = nil; + NSData *data = [NSJSONSerialization dataWithJSONObject:dict options:0 error:&err]; + if (!data) return NO; + return [data writeToFile:path atomically:YES]; +} + ++ (SCIProfileAnalyzerSnapshot *)currentSnapshotForUserPK:(NSString *)userPK { + return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"current"))]; +} + ++ (SCIProfileAnalyzerSnapshot *)previousSnapshotForUserPK:(NSString *)userPK { + return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"previous"))]; +} + ++ (SCIProfileAnalyzerSnapshot *)baselineSnapshotForUserPK:(NSString *)userPK { + return [SCIProfileAnalyzerSnapshot snapshotFromJSONDict:sciReadJSON(sciPath(userPK, @"baseline"))]; +} + ++ (BOOL)saveBaselineSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK { + if (!snapshot) return NO; + BOOL ok = sciWriteJSON(sciPath(userPK, @"baseline"), [snapshot toJSONDict]); + if (ok) sciPostDataChanged(userPK); + return ok; +} + ++ (void)clearBaselineForUserPK:(NSString *)userPK { + [[NSFileManager defaultManager] removeItemAtPath:sciPath(userPK, @"baseline") error:nil]; + sciPostDataChanged(userPK); +} + ++ (BOOL)saveSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK { + if (!snapshot) return NO; + NSString *cur = sciPath(userPK, @"current"); + NSString *prev = sciPath(userPK, @"previous"); + NSFileManager *fm = [NSFileManager defaultManager]; + if ([fm fileExistsAtPath:cur]) { + [fm removeItemAtPath:prev error:nil]; + [fm moveItemAtPath:cur toPath:prev error:nil]; + } + BOOL ok = sciWriteJSON(cur, [snapshot toJSONDict]); + if (ok) sciPostDataChanged(userPK); + return ok; +} + ++ (BOOL)updateCurrentSnapshot:(SCIProfileAnalyzerSnapshot *)snapshot forUserPK:(NSString *)userPK { + if (!snapshot) return NO; + BOOL ok = sciWriteJSON(sciPath(userPK, @"current"), [snapshot toJSONDict]); + if (ok) sciPostDataChanged(userPK); + return ok; +} + ++ (void)resetForUserPK:(NSString *)userPK { + NSFileManager *fm = [NSFileManager defaultManager]; + [fm removeItemAtPath:sciPath(userPK, @"current") error:nil]; + [fm removeItemAtPath:sciPath(userPK, @"previous") error:nil]; + [fm removeItemAtPath:sciPath(userPK, @"baseline") error:nil]; + sciPostDataChanged(userPK); +} + ++ (void)resetAll { + [[NSFileManager defaultManager] removeItemAtPath:sciStorageDir() error:nil]; + sciPostDataChanged(nil); +} + ++ (NSDictionary *)headerInfoForUserPK:(NSString *)userPK { + return sciReadJSON(sciPath(userPK, @"header")); +} + ++ (void)saveHeaderInfo:(NSDictionary *)info forUserPK:(NSString *)userPK { + if (!info.count) return; + NSMutableDictionary *stored = [info mutableCopy]; + stored[@"cached_at"] = @([[NSDate date] timeIntervalSince1970]); + sciWriteJSON(sciPath(userPK, @"header"), stored); +} + ++ (NSDictionary *)exportedDict { + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + NSFileManager *fm = [NSFileManager defaultManager]; + for (NSString *name in [fm contentsOfDirectoryAtPath:sciStorageDir() error:nil]) { + NSDictionary *d = sciReadJSON([sciStorageDir() stringByAppendingPathComponent:name]); + if (d) out[name] = d; + } + return out; +} + ++ (BOOL)importFromDict:(NSDictionary *)dict { + if (![dict isKindOfClass:[NSDictionary class]] || !dict.count) return NO; + [self resetAll]; + NSString *dir = sciStorageDir(); + for (NSString *name in dict) { + if (![name hasSuffix:@".json"]) continue; + NSDictionary *d = dict[name]; + if (![d isKindOfClass:[NSDictionary class]]) continue; + sciWriteJSON([dir stringByAppendingPathComponent:name], d); + } + return YES; +} + +@end diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerViewController.h b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerViewController.h new file mode 100644 index 0000000..842219f --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerViewController.h @@ -0,0 +1,4 @@ +#import + +@interface SCIProfileAnalyzerViewController : UIViewController +@end diff --git a/src/Features/ProfileAnalyzer/SCIProfileAnalyzerViewController.m b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerViewController.m new file mode 100644 index 0000000..d529aba --- /dev/null +++ b/src/Features/ProfileAnalyzer/SCIProfileAnalyzerViewController.m @@ -0,0 +1,990 @@ +#import "SCIProfileAnalyzerViewController.h" +#import "SCIProfileAnalyzerModels.h" +#import "SCIProfileAnalyzerStorage.h" +#import "SCIProfileAnalyzerService.h" +#import "SCIProfileAnalyzerListViewController.h" +#import "../../Utils.h" +#import "../../SCIImageCache.h" +#import "../../Networking/SCIInstagramAPI.h" +#import "../../Localization/SCILocalization.h" +#import + +extern NSNotificationName const SCIProfileAnalyzerDataDidChangeNotification; + +#pragma mark - Category descriptor + +typedef NS_ENUM(NSInteger, SCIPACategory) { + SCIPACategoryMutual, + SCIPACategoryNotFollowingBack, + SCIPACategoryDontFollowBack, + SCIPACategoryNewFollowers, + SCIPACategoryLostFollowers, + SCIPACategoryYouStartedFollowing, + SCIPACategoryYouUnfollowed, + SCIPACategoryProfileUpdates, +}; + +@interface SCIPACategoryDescriptor : NSObject +@property (nonatomic, assign) SCIPACategory category; +@property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSString *subtitle; +@property (nonatomic, copy) NSString *symbol; +@property (nonatomic, strong) UIColor *color; +@property (nonatomic, assign) NSInteger count; +@property (nonatomic, assign) BOOL requiresPrevious; +@end +@implementation SCIPACategoryDescriptor @end + +#pragma mark - Avatar with progress ring + +@interface SCIPAAvatarRingView : UIView +@property (nonatomic, strong) UIImageView *imageView; +@property (nonatomic, strong) CAShapeLayer *trackLayer; +@property (nonatomic, strong) CAShapeLayer *progressLayer; +@property (nonatomic, assign) double progress; // 0..1 +@property (nonatomic, assign) BOOL showProgress; +@end + +@implementation SCIPAAvatarRingView +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (!self) return self; + + _trackLayer = [CAShapeLayer layer]; + _trackLayer.fillColor = UIColor.clearColor.CGColor; + _trackLayer.strokeColor = [UIColor systemGray5Color].CGColor; + _trackLayer.lineWidth = 3.5; + _trackLayer.hidden = YES; + [self.layer addSublayer:_trackLayer]; + + _progressLayer = [CAShapeLayer layer]; + _progressLayer.fillColor = UIColor.clearColor.CGColor; + _progressLayer.strokeColor = ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]).CGColor; + _progressLayer.lineWidth = 3.5; + _progressLayer.lineCap = kCALineCapRound; + _progressLayer.strokeEnd = 0; + _progressLayer.hidden = YES; + [self.layer addSublayer:_progressLayer]; + + _imageView = [UIImageView new]; + _imageView.contentMode = UIViewContentModeScaleAspectFill; + _imageView.backgroundColor = [UIColor secondarySystemBackgroundColor]; + _imageView.layer.masksToBounds = YES; + _imageView.image = [UIImage systemImageNamed:@"person.circle.fill"]; + _imageView.tintColor = [UIColor systemGrayColor]; + [self addSubview:_imageView]; + return self; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + CGFloat size = MIN(self.bounds.size.width, self.bounds.size.height); + if (size < 16) return; // transient tiny bounds during transitions + CGFloat inset = 7; + CGRect imgFrame = CGRectInset(CGRectMake(0, 0, size, size), inset, inset); + self.imageView.frame = imgFrame; + self.imageView.layer.cornerRadius = imgFrame.size.width / 2.0; + + UIBezierPath *path = [UIBezierPath bezierPathWithArcCenter:CGPointMake(size / 2.0, size / 2.0) + radius:size / 2.0 - 2 + startAngle:-M_PI_2 + endAngle:-M_PI_2 + 2 * M_PI + clockwise:YES]; + self.trackLayer.frame = self.bounds; + self.progressLayer.frame = self.bounds; + self.trackLayer.path = path.CGPath; + self.progressLayer.path = path.CGPath; +} + +- (void)setProgress:(double)progress { + _progress = MAX(0, MIN(1, progress)); + [CATransaction begin]; + [CATransaction setAnimationDuration:0.25]; + self.progressLayer.strokeEnd = _progress; + [CATransaction commit]; +} + +- (void)setShowProgress:(BOOL)show { + _showProgress = show; + self.trackLayer.hidden = !show; + self.progressLayer.hidden = !show; + if (show) self.progressLayer.strokeEnd = _progress; +} +@end + +#pragma mark - Header + +@interface SCIPAHeaderView : UIView +@property (nonatomic, strong) SCIPAAvatarRingView *avatar; +@property (nonatomic, strong) UILabel *fullNameLabel; +@property (nonatomic, strong) UILabel *usernameLabel; +@property (nonatomic, strong) UIStackView *statsRow; +@property (nonatomic, strong) UILabel *scanDateLabel; +@property (nonatomic, strong) UILabel *warningLabel; +@property (nonatomic, strong) UIButton *scanButton; +@property (nonatomic, strong) UILabel *progressLabel; +@end + +@implementation SCIPAHeaderView +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (!self) return self; + self.backgroundColor = [UIColor secondarySystemBackgroundColor]; + self.layer.cornerRadius = 18; + + _avatar = [[SCIPAAvatarRingView alloc] init]; + _avatar.translatesAutoresizingMaskIntoConstraints = NO; + [self addSubview:_avatar]; + + _fullNameLabel = [UILabel new]; + _fullNameLabel.translatesAutoresizingMaskIntoConstraints = NO; + _fullNameLabel.font = [UIFont systemFontOfSize:20 weight:UIFontWeightSemibold]; + _fullNameLabel.textColor = [UIColor labelColor]; + _fullNameLabel.textAlignment = NSTextAlignmentCenter; + [self addSubview:_fullNameLabel]; + + _usernameLabel = [UILabel new]; + _usernameLabel.translatesAutoresizingMaskIntoConstraints = NO; + _usernameLabel.font = [UIFont systemFontOfSize:14]; + _usernameLabel.textColor = [UIColor secondaryLabelColor]; + _usernameLabel.textAlignment = NSTextAlignmentCenter; + [self addSubview:_usernameLabel]; + + _statsRow = [[UIStackView alloc] init]; + _statsRow.translatesAutoresizingMaskIntoConstraints = NO; + _statsRow.axis = UILayoutConstraintAxisHorizontal; + _statsRow.distribution = UIStackViewDistributionFillEqually; + _statsRow.spacing = 0; + [self addSubview:_statsRow]; + + _scanDateLabel = [UILabel new]; + _scanDateLabel.translatesAutoresizingMaskIntoConstraints = NO; + _scanDateLabel.font = [UIFont systemFontOfSize:12]; + _scanDateLabel.textColor = [UIColor tertiaryLabelColor]; + _scanDateLabel.textAlignment = NSTextAlignmentCenter; + [self addSubview:_scanDateLabel]; + + _warningLabel = [UILabel new]; + _warningLabel.translatesAutoresizingMaskIntoConstraints = NO; + _warningLabel.font = [UIFont systemFontOfSize:12]; + _warningLabel.textColor = [UIColor systemOrangeColor]; + _warningLabel.numberOfLines = 0; + _warningLabel.textAlignment = NSTextAlignmentCenter; + _warningLabel.hidden = YES; + [self addSubview:_warningLabel]; + + _scanButton = [UIButton buttonWithType:UIButtonTypeSystem]; + _scanButton.translatesAutoresizingMaskIntoConstraints = NO; + _scanButton.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + _scanButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]; + [_scanButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + _scanButton.layer.cornerRadius = 18; + _scanButton.contentEdgeInsets = UIEdgeInsetsMake(0, 22, 0, 22); + [_scanButton setTitle:SCILocalized(@"Run analysis") forState:UIControlStateNormal]; + [self addSubview:_scanButton]; + + _progressLabel = [UILabel new]; + _progressLabel.translatesAutoresizingMaskIntoConstraints = NO; + _progressLabel.font = [UIFont systemFontOfSize:12]; + _progressLabel.textColor = [UIColor secondaryLabelColor]; + _progressLabel.textAlignment = NSTextAlignmentCenter; + _progressLabel.hidden = YES; + [self addSubview:_progressLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [_avatar.centerXAnchor constraintEqualToAnchor:self.centerXAnchor], + [_avatar.topAnchor constraintEqualToAnchor:self.topAnchor constant:18], + [_avatar.widthAnchor constraintEqualToConstant:96], + [_avatar.heightAnchor constraintEqualToConstant:96], + + [_fullNameLabel.topAnchor constraintEqualToAnchor:_avatar.bottomAnchor constant:10], + [_fullNameLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16], + [_fullNameLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16], + + [_usernameLabel.topAnchor constraintEqualToAnchor:_fullNameLabel.bottomAnchor constant:2], + [_usernameLabel.leadingAnchor constraintEqualToAnchor:_fullNameLabel.leadingAnchor], + [_usernameLabel.trailingAnchor constraintEqualToAnchor:_fullNameLabel.trailingAnchor], + + [_statsRow.topAnchor constraintEqualToAnchor:_usernameLabel.bottomAnchor constant:14], + [_statsRow.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12], + [_statsRow.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], + [_statsRow.heightAnchor constraintEqualToConstant:44], + + [_scanDateLabel.topAnchor constraintEqualToAnchor:_statsRow.bottomAnchor constant:10], + [_scanDateLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16], + [_scanDateLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16], + + [_warningLabel.topAnchor constraintEqualToAnchor:_scanDateLabel.bottomAnchor constant:6], + [_warningLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:20], + [_warningLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-20], + + [_scanButton.topAnchor constraintEqualToAnchor:_warningLabel.bottomAnchor constant:12], + [_scanButton.centerXAnchor constraintEqualToAnchor:self.centerXAnchor], + [_scanButton.heightAnchor constraintEqualToConstant:36], + [_scanButton.widthAnchor constraintGreaterThanOrEqualToConstant:160], + + [_progressLabel.topAnchor constraintEqualToAnchor:_scanButton.bottomAnchor constant:6], + [_progressLabel.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:16], + [_progressLabel.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-16], + [_progressLabel.bottomAnchor constraintEqualToAnchor:self.bottomAnchor constant:-16], + ]]; + return self; +} + +- (void)setStatsLabelsPosts:(NSString *)posts followers:(NSString *)followers following:(NSString *)following { + for (UIView *v in self.statsRow.arrangedSubviews) [self.statsRow removeArrangedSubview:v], [v removeFromSuperview]; + [self.statsRow addArrangedSubview:[self statColumn:posts caption:SCILocalized(@"Posts")]]; + [self.statsRow addArrangedSubview:[self statColumn:followers caption:SCILocalized(@"Followers")]]; + [self.statsRow addArrangedSubview:[self statColumn:following caption:SCILocalized(@"Following")]]; +} + +- (UIView *)statColumn:(NSString *)value caption:(NSString *)caption { + UIView *w = [UIView new]; + UILabel *v = [UILabel new]; + v.translatesAutoresizingMaskIntoConstraints = NO; + v.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + v.textColor = [UIColor labelColor]; + v.textAlignment = NSTextAlignmentCenter; + v.text = value; + [w addSubview:v]; + + UILabel *c = [UILabel new]; + c.translatesAutoresizingMaskIntoConstraints = NO; + c.font = [UIFont systemFontOfSize:12]; + c.textColor = [UIColor secondaryLabelColor]; + c.textAlignment = NSTextAlignmentCenter; + c.text = caption; + [w addSubview:c]; + + [NSLayoutConstraint activateConstraints:@[ + [v.topAnchor constraintEqualToAnchor:w.topAnchor], + [v.leadingAnchor constraintEqualToAnchor:w.leadingAnchor], + [v.trailingAnchor constraintEqualToAnchor:w.trailingAnchor], + [c.topAnchor constraintEqualToAnchor:v.bottomAnchor constant:1], + [c.leadingAnchor constraintEqualToAnchor:w.leadingAnchor], + [c.trailingAnchor constraintEqualToAnchor:w.trailingAnchor], + [c.bottomAnchor constraintEqualToAnchor:w.bottomAnchor], + ]]; + return w; +} +@end + +#pragma mark - Category cell + +@interface SCIPACategoryCell : UITableViewCell +@property (nonatomic, strong) UIView *iconBadge; +@property (nonatomic, strong) UIImageView *iconView; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *subtitleLabel; +@property (nonatomic, strong) UILabel *countLabel; +@end + +@implementation SCIPACategoryCell +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)rid { + self = [super initWithStyle:style reuseIdentifier:rid]; + if (!self) return self; + self.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + + _iconBadge = [UIView new]; + _iconBadge.translatesAutoresizingMaskIntoConstraints = NO; + _iconBadge.layer.cornerRadius = 8; + [self.contentView addSubview:_iconBadge]; + + _iconView = [UIImageView new]; + _iconView.translatesAutoresizingMaskIntoConstraints = NO; + _iconView.contentMode = UIViewContentModeScaleAspectFit; + _iconView.tintColor = [UIColor whiteColor]; + [_iconBadge addSubview:_iconView]; + + _titleLabel = [UILabel new]; + _titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + _titleLabel.font = [UIFont systemFontOfSize:16]; + [self.contentView addSubview:_titleLabel]; + + _subtitleLabel = [UILabel new]; + _subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + _subtitleLabel.font = [UIFont systemFontOfSize:12]; + _subtitleLabel.textColor = [UIColor tertiaryLabelColor]; + [self.contentView addSubview:_subtitleLabel]; + + _countLabel = [UILabel new]; + _countLabel.translatesAutoresizingMaskIntoConstraints = NO; + _countLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + _countLabel.textColor = [UIColor secondaryLabelColor]; + _countLabel.textAlignment = NSTextAlignmentRight; + [self.contentView addSubview:_countLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [_iconBadge.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor], + [_iconBadge.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + [_iconBadge.widthAnchor constraintEqualToConstant:32], + [_iconBadge.heightAnchor constraintEqualToConstant:32], + + [_iconView.centerXAnchor constraintEqualToAnchor:_iconBadge.centerXAnchor], + [_iconView.centerYAnchor constraintEqualToAnchor:_iconBadge.centerYAnchor], + [_iconView.widthAnchor constraintEqualToConstant:18], + [_iconView.heightAnchor constraintEqualToConstant:18], + + [_titleLabel.leadingAnchor constraintEqualToAnchor:_iconBadge.trailingAnchor constant:12], + [_titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10], + [_titleLabel.trailingAnchor constraintLessThanOrEqualToAnchor:_countLabel.leadingAnchor constant:-8], + + [_subtitleLabel.leadingAnchor constraintEqualToAnchor:_titleLabel.leadingAnchor], + [_subtitleLabel.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:1], + [_subtitleLabel.trailingAnchor constraintEqualToAnchor:_titleLabel.trailingAnchor], + [_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-10], + + [_countLabel.trailingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.trailingAnchor], + [_countLabel.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + [_countLabel.widthAnchor constraintGreaterThanOrEqualToConstant:40], + ]]; + return self; +} +@end + +#pragma mark - Main VC + +@interface SCIProfileAnalyzerViewController () +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UIView *headerContainer; +@property (nonatomic, strong) SCIPAHeaderView *headerView; + +@property (nonatomic, strong) SCIProfileAnalyzerReport *report; +@property (nonatomic, strong) NSArray *categories; +@property (nonatomic, assign) BOOL running; +@property (nonatomic, copy) NSString *lastHeaderPK; +@property (nonatomic, assign) BOOL pendingHeaderFetch; +@end + +@implementation SCIProfileAnalyzerViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; + self.title = SCILocalized(@"Profile Analyzer"); + self.navigationItem.titleView = [self buildTitleViewWithBeta]; + + [self setupTable]; + + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(analyzerDataChanged:) + name:SCIProfileAnalyzerDataDidChangeNotification + object:nil]; +} + +- (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } + +- (void)analyzerDataChanged:(NSNotification *)note { + if (!self.isViewLoaded || !self.view.window) return; + NSString *pk = note.userInfo[@"user_pk"]; + NSString *current = [SCIUtils currentUserPK]; + if (pk.length && current.length && ![pk isEqualToString:current]) return; + @try { + [self loadCachedReport]; + SCIProfileAnalyzerSnapshot *cur = self.report.current; + if (cur) { + [self.headerView setStatsLabelsPosts:[self compactNumber:cur.mediaCount] + followers:[self compactNumber:cur.followerCount] + following:[self compactNumber:cur.followingCount]]; + } + } @catch (__unused NSException *e) {} +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + // Cheap disk + set math; safe during push. + @try { [self loadCachedReport]; } @catch (__unused NSException *e) {} +} + +- (void)viewDidAppear:(BOOL)animated { + [super viewDidAppear:animated]; + // Header merge + any network fetch wait until the transition settles. + @try { [self loadHeaderLayered]; } @catch (__unused NSException *e) {} + if (self.pendingHeaderFetch) { + self.pendingHeaderFetch = NO; + @try { [self fetchAndCacheHeader]; } @catch (__unused NSException *e) {} + } +} + +- (UIView *)buildTitleViewWithBeta { + UILabel *title = [UILabel new]; + title.text = SCILocalized(@"Profile Analyzer"); + title.font = [UIFont systemFontOfSize:17 weight:UIFontWeightSemibold]; + title.textColor = [UIColor labelColor]; + + UILabel *beta = [UILabel new]; + beta.text = @" BETA "; + beta.font = [UIFont systemFontOfSize:10 weight:UIFontWeightHeavy]; + beta.textColor = [UIColor whiteColor]; + beta.backgroundColor = [UIColor systemOrangeColor]; + beta.layer.cornerRadius = 5; + beta.layer.masksToBounds = YES; + beta.textAlignment = NSTextAlignmentCenter; + + UIStackView *row = [[UIStackView alloc] initWithArrangedSubviews:@[title, beta]]; + row.axis = UILayoutConstraintAxisHorizontal; + row.alignment = UIStackViewAlignmentCenter; + row.spacing = 6; + return row; +} + +- (void)setupTable { + self.tableView = [[UITableView alloc] initWithFrame:self.view.bounds style:UITableViewStyleInsetGrouped]; + self.tableView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.sectionHeaderTopPadding = 0; + self.tableView.rowHeight = UITableViewAutomaticDimension; + self.tableView.estimatedRowHeight = 60; + [self.tableView registerClass:[SCIPACategoryCell class] forCellReuseIdentifier:@"cat"]; + + UIRefreshControl *rc = [UIRefreshControl new]; + [rc addTarget:self action:@selector(pullToRefreshProfile:) forControlEvents:UIControlEventValueChanged]; + self.tableView.refreshControl = rc; + + [self.view addSubview:self.tableView]; + [self buildTableHeader]; +} + +// Pull-to-refresh: re-fetch just the self-profile (/users/{pk}/info/) so the +// header reflects IG's truth on demand. No rescan, no data reset. +- (void)pullToRefreshProfile:(UIRefreshControl *)sender { + NSString *pk = [SCIUtils currentUserPK]; + if (!pk.length) { [sender endRefreshing]; return; } + __weak typeof(self) weakSelf = self; + [SCIInstagramAPI sendRequestWithMethod:@"GET" + path:[NSString stringWithFormat:@"users/%@/info/", pk] + body:nil + completion:^(NSDictionary *resp, NSError *error) { + NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil; + if (user.count) { + [SCIProfileAnalyzerStorage saveHeaderInfo:user forUserPK:pk]; + typeof(self) strongSelf = weakSelf; + if (strongSelf.isViewLoaded && strongSelf.view.window) { + [strongSelf paintHeaderFromUserInfo:user]; + [strongSelf applyFollowerLimitGateFor:[user[@"follower_count"] integerValue]]; + } + } + [sender endRefreshing]; + }]; +} + +- (void)buildTableHeader { + // tableHeaderView is frame-driven; let viewWillLayoutSubviews set width. + self.headerContainer = [UIView new]; + self.headerContainer.autoresizingMask = UIViewAutoresizingFlexibleWidth; + + self.headerView = [[SCIPAHeaderView alloc] init]; + self.headerView.translatesAutoresizingMaskIntoConstraints = NO; + [self.headerContainer addSubview:self.headerView]; + + [self.headerView.scanButton addTarget:self action:@selector(analyzeTapped) forControlEvents:UIControlEventTouchUpInside]; + + [NSLayoutConstraint activateConstraints:@[ + [self.headerView.topAnchor constraintEqualToAnchor:self.headerContainer.topAnchor constant:12], + [self.headerView.leadingAnchor constraintEqualToAnchor:self.headerContainer.leadingAnchor constant:16], + [self.headerView.trailingAnchor constraintEqualToAnchor:self.headerContainer.trailingAnchor constant:-16], + [self.headerView.bottomAnchor constraintEqualToAnchor:self.headerContainer.bottomAnchor constant:-4], + ]]; +} + +- (void)viewWillLayoutSubviews { + [super viewWillLayoutSubviews]; + if (!self.headerContainer) return; + CGFloat w = self.tableView.bounds.size.width; + if (w < 1) return; + + // Resolve internal height against the tableView's width. + self.headerContainer.frame = CGRectMake(0, 0, w, 1); + [self.headerContainer setNeedsLayout]; + [self.headerContainer layoutIfNeeded]; + CGFloat h = [self.headerContainer systemLayoutSizeFittingSize:CGSizeMake(w, UILayoutFittingCompressedSize.height) + withHorizontalFittingPriority:UILayoutPriorityRequired + verticalFittingPriority:UILayoutPriorityFittingSizeLevel].height; + CGRect target = CGRectMake(0, 0, w, h); + if (!CGRectEqualToRect(self.headerContainer.frame, target)) { + self.headerContainer.frame = target; + self.tableView.tableHeaderView = self.headerContainer; + } else if (self.tableView.tableHeaderView != self.headerContainer) { + self.tableView.tableHeaderView = self.headerContainer; + } +} + +#pragma mark - Header resolution (IG memory → our cache → network) + +// Layered header lookup: IG fieldCache → on-disk cache → network (only when +// neither source has usable counts). Results get persisted so cold relaunch +// is offline. +- (void)loadHeaderLayered { + NSString *pk = [SCIUtils currentUserPK]; + self.lastHeaderPK = pk; + + NSDictionary *live = [self liveSelfInfoFromSession]; + NSMutableDictionary *cached = [[SCIProfileAnalyzerStorage headerInfoForUserPK:pk] mutableCopy] + ?: [NSMutableDictionary dictionary]; + SCIProfileAnalyzerSnapshot *snap = self.report.current; + + // Hybrid reconciliation for following_count: + // * snapshot.followingCount captures in-app follow/unfollow mutations. + // * IG's fieldCache only refreshes when the user visits own profile. + // We store the last fieldCache value we saw; when it moves, IG refreshed + // and is authoritative — we align the snapshot to match. Otherwise the + // snapshot (possibly just mutated) wins so unfollows show up live. + NSNumber *liveFollowing = live[@"following_count"]; + NSNumber *lastSeenFollowing = cached[@"last_synced_following_count"]; + BOOL fieldCacheRefreshed = liveFollowing && (!lastSeenFollowing || ![liveFollowing isEqual:lastSeenFollowing]); + if (fieldCacheRefreshed) { + cached[@"following_count"] = liveFollowing; + cached[@"last_synced_following_count"] = liveFollowing; + if (snap && snap.followingCount != liveFollowing.integerValue) { + snap.followingCount = liveFollowing.integerValue; + [SCIProfileAnalyzerStorage updateCurrentSnapshot:snap forUserPK:pk]; + } + } else if (snap && snap.followingCount > 0) { + cached[@"following_count"] = @(snap.followingCount); + } else if (liveFollowing) { + cached[@"following_count"] = liveFollowing; + } + + // Non-mutable-in-app fields: fieldCache wins when present. + for (NSString *k in @[@"username", @"full_name", @"profile_pic_url", + @"profile_pic_id", @"follower_count", @"media_count"]) { + if (live[k]) cached[k] = live[k]; + } + // Fallbacks from snapshot if fieldCache lacks them entirely. + if (snap && !cached[@"follower_count"] && snap.followerCount > 0) cached[@"follower_count"] = @(snap.followerCount); + if (snap && !cached[@"media_count"] && snap.mediaCount > 0) cached[@"media_count"] = @(snap.mediaCount); + + if (cached[@"username"] || [cached[@"follower_count"] integerValue] > 0) { + [self paintHeaderFromUserInfo:cached]; + [self applyFollowerLimitGateFor:[cached[@"follower_count"] integerValue]]; + } else if (!snap) { + self.headerView.fullNameLabel.text = SCILocalized(@"No scan yet"); + self.headerView.usernameLabel.text = @""; + [self.headerView setStatsLabelsPosts:@"—" followers:@"—" following:@"—"]; + } + + BOOL haveCounts = [cached[@"follower_count"] integerValue] > 0 + || [cached[@"following_count"] integerValue] > 0 + || [cached[@"media_count"] integerValue] > 0; + if (haveCounts) { + [SCIProfileAnalyzerStorage saveHeaderInfo:cached forUserPK:pk]; + } else { + // Defer to next runloop so the push transition can complete before + // any completion-block layout mutations. + __weak typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + if (weakSelf.isViewLoaded && weakSelf.view.window) { + [weakSelf fetchAndCacheHeader]; + } else { + weakSelf.pendingHeaderFetch = YES; + } + }); + } +} + +- (NSDictionary *)liveSelfInfoFromSession { + id session = [SCIUtils activeUserSession]; + id igUser = nil; + @try { if ([session respondsToSelector:@selector(user)]) igUser = [session valueForKey:@"user"]; } @catch (__unused id e) {} + NSDictionary *fc = [self fieldCacheForUser:igUser]; + NSMutableDictionary *out = [NSMutableDictionary dictionary]; + for (NSString *k in @[@"username", @"full_name", @"profile_pic_url", + @"follower_count", @"following_count", @"media_count"]) { + if (fc[k]) out[k] = fc[k]; + } + return out; +} + +- (void)fetchAndCacheHeader { + NSString *pk = self.lastHeaderPK; + if (!pk.length) return; + __weak typeof(self) weakSelf = self; + [SCIInstagramAPI sendRequestWithMethod:@"GET" + path:[NSString stringWithFormat:@"users/%@/info/", pk] + body:nil + completion:^(NSDictionary *resp, NSError *error) { + NSDictionary *user = [resp[@"user"] isKindOfClass:[NSDictionary class]] ? resp[@"user"] : nil; + if (!user.count) return; + [SCIProfileAnalyzerStorage saveHeaderInfo:user forUserPK:pk]; + typeof(self) strongSelf = weakSelf; + // Drop UI updates if the VC left the window between send + callback. + if (!strongSelf.isViewLoaded || !strongSelf.view.window) return; + [strongSelf paintHeaderFromUserInfo:user]; + [strongSelf applyFollowerLimitGateFor:[user[@"follower_count"] integerValue]]; + }]; +} + +- (void)applyFollowerLimitGateFor:(NSInteger)followers { + if (followers > SCIProfileAnalyzerMaxFollowerCount) { + self.headerView.warningLabel.hidden = NO; + self.headerView.warningLabel.text = [NSString stringWithFormat: + SCILocalized(@"Follower count exceeds %ld — analysis disabled to avoid rate limits."), + (long)SCIProfileAnalyzerMaxFollowerCount]; + self.headerView.scanButton.enabled = NO; + self.headerView.scanButton.alpha = 0.5; + } +} + +- (NSDictionary *)fieldCacheForUser:(id)user { + if (!user) return @{}; + Ivar iv = NULL; + for (Class c = [user class]; c && !iv; c = class_getSuperclass(c)) + iv = class_getInstanceVariable(c, "_fieldCache"); + if (!iv) return @{}; + id d = object_getIvar(user, iv); + return [d isKindOfClass:[NSDictionary class]] ? d : @{}; +} + +- (NSString *)compactNumber:(NSInteger)n { + if (n < 1000) return [NSString stringWithFormat:@"%ld", (long)n]; + if (n < 10000) return [NSString stringWithFormat:@"%.1fK", n / 1000.0]; + if (n < 1000000) return [NSString stringWithFormat:@"%ldK", (long)(n / 1000)]; + return [NSString stringWithFormat:@"%.1fM", n / 1000000.0]; +} + +#pragma mark - Data + +- (void)loadCachedReport { + NSString *pk = [SCIUtils currentUserPK]; + SCIProfileAnalyzerSnapshot *cur = [SCIProfileAnalyzerStorage currentSnapshotForUserPK:pk]; + SCIProfileAnalyzerSnapshot *prev = [SCIProfileAnalyzerStorage previousSnapshotForUserPK:pk]; + SCIProfileAnalyzerSnapshot *base = [SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk]; + // Baseline wins when present; the toggle only drives its lifecycle. + SCIProfileAnalyzerSnapshot *diffAgainst = base ?: prev; + self.report = [SCIProfileAnalyzerReport reportFromCurrent:cur previous:diffAgainst]; + [self rebuildCategories]; + [self refreshHeader]; + [self.tableView reloadData]; +} + +- (void)rebuildCategories { + SCIProfileAnalyzerReport *r = self.report; + NSArray *(^build)(void) = ^NSArray *{ + SCIPACategoryDescriptor *(^make)(SCIPACategory, NSString *, NSString *, NSString *, UIColor *, NSInteger, BOOL) = + ^SCIPACategoryDescriptor *(SCIPACategory c, NSString *t, NSString *s, NSString *sym, UIColor *col, NSInteger count, BOOL needsPrev) { + SCIPACategoryDescriptor *d = [SCIPACategoryDescriptor new]; + d.category = c; d.title = t; d.subtitle = s; d.symbol = sym; d.color = col; + d.count = count; d.requiresPrevious = needsPrev; + return d; + }; + return @[ + make(SCIPACategoryMutual, SCILocalized(@"Mutual followers"), + SCILocalized(@"You both follow each other"), + @"person.2.fill", [UIColor systemBlueColor], r.mutualFollowers.count, NO), + make(SCIPACategoryNotFollowingBack, SCILocalized(@"Not following you back"), + SCILocalized(@"You follow them, they don't follow back"), + @"person.fill.xmark", [UIColor systemOrangeColor], r.notFollowingYouBack.count, NO), + make(SCIPACategoryDontFollowBack, SCILocalized(@"You don't follow back"), + SCILocalized(@"They follow you, you don't follow back"), + @"person.fill.questionmark", [UIColor systemTealColor], r.youDontFollowBack.count, NO), + make(SCIPACategoryNewFollowers, SCILocalized(@"New followers"), + SCILocalized(@"Gained since last scan"), + @"person.fill.badge.plus", [UIColor systemGreenColor], r.recentFollowers.count, YES), + make(SCIPACategoryLostFollowers, SCILocalized(@"Lost followers"), + SCILocalized(@"Unfollowed you since last scan"), + @"person.fill.badge.minus", [UIColor systemRedColor], r.lostFollowers.count, YES), + make(SCIPACategoryYouStartedFollowing, SCILocalized(@"You started following"), + SCILocalized(@"Since last scan"), + @"arrow.up.forward.circle.fill", [UIColor systemIndigoColor], r.youStartedFollowing.count, YES), + make(SCIPACategoryYouUnfollowed, SCILocalized(@"You unfollowed"), + SCILocalized(@"Since last scan"), + @"arrow.down.backward.circle.fill", [UIColor systemPurpleColor], r.youUnfollowed.count, YES), + make(SCIPACategoryProfileUpdates, SCILocalized(@"Profile updates"), + SCILocalized(@"Username, name or picture changes"), + @"person.crop.circle.badge.exclamationmark", [UIColor systemPinkColor], r.profileUpdates.count, YES), + ]; + }; + self.categories = build(); +} + +// Snapshot-backed paint: only scan-date + warning. Identity + stats + avatar +// are owned by loadHeaderLayered so fieldCache always wins. +- (void)refreshHeader { + self.headerView.scanDateLabel.text = self.report.current + ? [self scanDateText] + : SCILocalized(@"Run your first analysis"); + [self refreshWarning]; +} + +- (NSString *)scanDateText { + if (!self.report.current.scanDate) return @""; + NSDateFormatter *f = [NSDateFormatter new]; + f.dateStyle = NSDateFormatterMediumStyle; + f.timeStyle = NSDateFormatterShortStyle; + NSString *when = [f stringFromDate:self.report.current.scanDate]; + if (self.report.previous) return [NSString stringWithFormat:SCILocalized(@"Last scan: %@"), when]; + return [NSString stringWithFormat:SCILocalized(@"First scan: %@"), when]; +} + +- (void)refreshWarning { + SCIProfileAnalyzerSnapshot *cur = self.report.current; + NSInteger followers = cur ? cur.followerCount + : [[self fieldCacheForUser:[[SCIUtils activeUserSession] valueForKey:@"user"]][@"follower_count"] integerValue]; + if (followers > SCIProfileAnalyzerMaxFollowerCount) { + self.headerView.warningLabel.hidden = NO; + self.headerView.warningLabel.text = [NSString stringWithFormat: + SCILocalized(@"Follower count exceeds %ld — analysis disabled to avoid rate limits."), + (long)SCIProfileAnalyzerMaxFollowerCount]; + self.headerView.scanButton.enabled = NO; + self.headerView.scanButton.alpha = 0.5; + } else { + self.headerView.warningLabel.hidden = YES; + self.headerView.scanButton.enabled = !self.running; + self.headerView.scanButton.alpha = self.running ? 0.5 : 1.0; + } + [self.view setNeedsLayout]; +} + +#pragma mark - Actions + +- (void)analyzeTapped { + if (self.running) { [[SCIProfileAnalyzerService sharedService] cancel]; return; } + self.running = YES; + self.headerView.progressLabel.hidden = NO; + self.headerView.progressLabel.text = SCILocalized(@"Starting…"); + [self.headerView.avatar setShowProgress:YES]; + self.headerView.avatar.progress = 0; + [self.headerView.scanButton setTitle:SCILocalized(@"Cancel") forState:UIControlStateNormal]; + self.headerView.scanButton.backgroundColor = [UIColor systemRedColor]; + [self.view setNeedsLayout]; + + __weak typeof(self) weakSelf = self; + [[SCIProfileAnalyzerService sharedService] runForSelfWithHeaderInfo:^(NSDictionary *userInfo) { + // Paint the header the moment user-info returns — before follower fetch. + [weakSelf paintHeaderFromUserInfo:userInfo]; + } progress:^(NSString *status, double fraction) { + weakSelf.headerView.progressLabel.text = status; + weakSelf.headerView.avatar.progress = fraction; + } completion:^(SCIProfileAnalyzerSnapshot *snapshot, NSError *error) { + [weakSelf onAnalysisFinished:snapshot error:error]; + }]; +} + +- (void)paintHeaderFromUserInfo:(NSDictionary *)user { + NSString *username = user[@"username"]; + NSString *fullName = user[@"full_name"]; + NSString *picURL = user[@"profile_pic_url"]; + NSInteger followers = [user[@"follower_count"] integerValue]; + NSInteger following = [user[@"following_count"] integerValue]; + NSInteger posts = [user[@"media_count"] integerValue]; + self.headerView.fullNameLabel.text = fullName.length ? fullName : (username.length ? username : SCILocalized(@"No scan yet")); + self.headerView.usernameLabel.text = username.length ? [NSString stringWithFormat:@"@%@", username] : @""; + [self.headerView setStatsLabelsPosts:[self compactNumber:posts] + followers:[self compactNumber:followers] + following:[self compactNumber:following]]; + if (picURL.length) { + __weak UIImageView *iv = self.headerView.avatar.imageView; + [SCIImageCache loadImageFromURL:[NSURL URLWithString:picURL] completion:^(UIImage *img) { + if (img) iv.image = img; + }]; + } +} + +- (void)onAnalysisFinished:(SCIProfileAnalyzerSnapshot *)snapshot error:(NSError *)error { + self.running = NO; + self.headerView.progressLabel.hidden = YES; + [self.headerView.avatar setShowProgress:NO]; + [self.headerView.scanButton setTitle:SCILocalized(@"Run analysis") forState:UIControlStateNormal]; + self.headerView.scanButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]; + [self.view setNeedsLayout]; + + if (error && error.code == SCIProfileAnalyzerErrorTooManyFollowers) { + [self alertTitle:SCILocalized(@"Too many followers") + message:[NSString stringWithFormat:SCILocalized(@"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits."), + (long)SCIProfileAnalyzerMaxFollowerCount]]; + return; + } + if (error && error.code != SCIProfileAnalyzerErrorCancelled) { + [self alertTitle:SCILocalized(@"Analysis failed") message:error.localizedDescription ?: @""]; + return; + } + if (!snapshot) { [self loadCachedReport]; return; } + + NSString *pk = [SCIUtils currentUserPK]; + [SCIProfileAnalyzerStorage saveSnapshot:snapshot forUserPK:pk]; + // Baseline lifecycle lives at scan boundaries so flipping the toggle + // mid-session doesn't wipe what's on screen. + BOOL accumulate = [SCIUtils getBoolPref:@"profile_analyzer_accumulate"]; + BOOL baselineExists = [SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk] != nil; + if (accumulate && !baselineExists) { + [SCIProfileAnalyzerStorage saveBaselineSnapshot:snapshot forUserPK:pk]; + } else if (!accumulate && baselineExists) { + [SCIProfileAnalyzerStorage clearBaselineForUserPK:pk]; + } + [SCIProfileAnalyzerStorage saveHeaderInfo:@{ + @"username": snapshot.selfUsername ?: @"", + @"full_name": snapshot.selfFullName ?: @"", + @"profile_pic_url": snapshot.selfProfilePicURL ?: @"", + @"follower_count": @(snapshot.followerCount), + @"following_count": @(snapshot.followingCount), + @"media_count": @(snapshot.mediaCount), + } forUserPK:pk]; + [self loadCachedReport]; + [SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Analysis complete") + subtitle:[NSString stringWithFormat:SCILocalized(@"%lu followers · %lu following"), + (unsigned long)snapshot.followers.count, (unsigned long)snapshot.following.count]]; +} + +- (void)resetTapped { + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Reset analyzer data?") + message:SCILocalized(@"Removes cached snapshots for this account. You'll lose since-last-scan diffs.") + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Reset") style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { + [SCIProfileAnalyzerStorage resetForUserPK:[SCIUtils currentUserPK]]; + [self loadCachedReport]; + }]]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)infoTapped { + NSString *body = [@[ + SCILocalized(@"First scan: we collect your followers and following lists and save them locally."), + SCILocalized(@"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates."), + SCILocalized(@"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon."), + SCILocalized(@"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app."), + SCILocalized(@"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk."), + ] componentsJoinedByString:@"\n\n"]; + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"About Profile Analyzer") message:body preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)alertTitle:(NSString *)title message:(NSString *)msg { + UIAlertController *a = [UIAlertController alertControllerWithTitle:title message:msg preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleDefault handler:nil]]; + [self presentViewController:a animated:YES completion:nil]; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 3; } +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { + if (section == 0) return (NSInteger)self.categories.count; + if (section == 1) return 1; // Preferences: keep-changes toggle + return 2; // Actions: About + Reset +} +- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section { + if (section == 0) return SCILocalized(@"Categories"); + if (section == 1) return SCILocalized(@"Preferences"); + return @""; +} +- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)section { + if (section == 1) return SCILocalized(@"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans."); + return nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 1) return [self preferencesCellForRow:indexPath.row tableView:tv]; + if (indexPath.section == 2) return [self actionCellForRow:indexPath.row tableView:tv]; + SCIPACategoryCell *cell = [tv dequeueReusableCellWithIdentifier:@"cat" forIndexPath:indexPath]; + SCIPACategoryDescriptor *d = self.categories[indexPath.row]; + BOOL waitingForPrev = d.requiresPrevious && !self.report.previous; + BOOL hasReport = self.report.current != nil; + BOOL disabled = waitingForPrev || !hasReport || d.count == 0; + + cell.titleLabel.text = d.title; + if (waitingForPrev) { + cell.subtitleLabel.text = SCILocalized(@"Available after your next scan"); + } else if (!hasReport) { + cell.subtitleLabel.text = d.subtitle; + } else { + cell.subtitleLabel.text = d.subtitle; + } + cell.countLabel.text = (waitingForPrev || !hasReport) ? @"—" : [NSString stringWithFormat:@"%ld", (long)d.count]; + cell.iconBadge.backgroundColor = disabled ? [UIColor systemGray3Color] : d.color; + cell.iconView.image = [UIImage systemImageNamed:d.symbol]; + cell.contentView.alpha = disabled ? 0.5 : 1.0; + cell.selectionStyle = disabled ? UITableViewCellSelectionStyleNone : UITableViewCellSelectionStyleDefault; + return cell; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tv deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section == 1) return; // toggle row handles its own tap + if (indexPath.section == 2) { + if (indexPath.row == 0) [self infoTapped]; + else [self resetTapped]; + return; + } + SCIPACategoryDescriptor *d = self.categories[indexPath.row]; + if (d.requiresPrevious && !self.report.previous) return; + if (!self.report.current) return; + if (d.count == 0) return; + [self.navigationController pushViewController:[self listVCForCategory:d] animated:YES]; +} + +- (UITableViewCell *)preferencesCellForRow:(NSInteger)row tableView:(UITableView *)tv { + static NSString *rid = @"pref"; + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid]; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + cell.textLabel.text = SCILocalized(@"Keep scan history"); + cell.imageView.image = [UIImage systemImageNamed:@"clock.arrow.circlepath"]; + cell.imageView.tintColor = [UIColor systemIndigoColor]; + + UISwitch *sw = [UISwitch new]; + sw.on = [SCIUtils getBoolPref:@"profile_analyzer_accumulate"]; + sw.onTintColor = [SCIUtils SCIColor_Primary]; + [sw addTarget:self action:@selector(accumulateToggled:) forControlEvents:UIControlEventValueChanged]; + cell.accessoryView = sw; + return cell; +} + +- (void)accumulateToggled:(UISwitch *)sw { + [[NSUserDefaults standardUserDefaults] setBool:sw.isOn forKey:@"profile_analyzer_accumulate"]; + NSString *pk = [SCIUtils currentUserPK]; + if (sw.isOn) { + // Promote the current snapshot to baseline immediately. + if (![SCIProfileAnalyzerStorage baselineSnapshotForUserPK:pk] && self.report.current) { + [SCIProfileAnalyzerStorage saveBaselineSnapshot:self.report.current forUserPK:pk]; + [self loadCachedReport]; + } + } + // Flipping off is deferred — the baseline is dropped on the next scan. +} + +- (UITableViewCell *)actionCellForRow:(NSInteger)row tableView:(UITableView *)tv { + static NSString *rid = @"action"; + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleDefault reuseIdentifier:rid]; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.imageView.contentMode = UIViewContentModeCenter; + if (row == 0) { + cell.textLabel.text = SCILocalized(@"About Profile Analyzer"); + cell.textLabel.textColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]; + cell.imageView.image = [UIImage systemImageNamed:@"info.circle"]; + cell.imageView.tintColor = cell.textLabel.textColor; + } else { + cell.textLabel.text = SCILocalized(@"Reset analyzer data"); + cell.textLabel.textColor = [UIColor systemRedColor]; + cell.imageView.image = [UIImage systemImageNamed:@"trash"]; + cell.imageView.tintColor = [UIColor systemRedColor]; + } + return cell; +} + +- (UIViewController *)listVCForCategory:(SCIPACategoryDescriptor *)d { + SCIProfileAnalyzerReport *r = self.report; + switch (d.category) { + case SCIPACategoryMutual: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.mutualFollowers kind:SCIPAListKindPlain]; + case SCIPACategoryNotFollowingBack: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.notFollowingYouBack kind:SCIPAListKindUnfollow]; + case SCIPACategoryDontFollowBack: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youDontFollowBack kind:SCIPAListKindFollow]; + case SCIPACategoryNewFollowers: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.recentFollowers kind:SCIPAListKindPlain]; + case SCIPACategoryLostFollowers: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.lostFollowers kind:SCIPAListKindPlain]; + case SCIPACategoryYouStartedFollowing: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youStartedFollowing kind:SCIPAListKindUnfollow]; + case SCIPACategoryYouUnfollowed: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title users:r.youUnfollowed kind:SCIPAListKindFollow]; + case SCIPACategoryProfileUpdates: + return [[SCIProfileAnalyzerListViewController alloc] initWithTitle:d.title profileUpdates:r.profileUpdates]; + } +} + +@end diff --git a/src/Features/Reels/EnhancedPlayback.xm b/src/Features/Reels/EnhancedPlayback.xm index 0ff8900..048b483 100644 --- a/src/Features/Reels/EnhancedPlayback.xm +++ b/src/Features/Reels/EnhancedPlayback.xm @@ -160,6 +160,8 @@ static void sciForceUnmuteCell(id videoCell) { } } +%group ReelsPauseModeGroup + %hook IGSundialViewerVideoCell // hidden=YES on play; IG resets it on the next pause. - (void)sundialVideoPlaybackViewDidStartPlaying:(id)view { @@ -196,6 +198,57 @@ static void sciForceUnmuteCell(id videoCell) { } %end +// ============ PHOTO REELS: TAP-TO-MUTE ============ +// Skip IG's single-tap delegate on photo cells and drive the mute via the +// same hardware-switch notification StoryAudioToggle uses. + +extern "C" void sciToggleStoryAudio(void); + +static BOOL sciIsPhotoMuteEnabled(void) { + return sciIsPausePlayMode() && [SCIUtils getBoolPref:@"reels_photo_tap_mute"]; +} + +%hook IGSundialViewerPhotoCell +- (void)gestureController:(id)gc didObserveSingleTap:(id)tap { + if (sciIsPhotoMuteEnabled()) { sciToggleStoryAudio(); return; } + %orig; +} +%end + +%hook IGSundialViewerCarouselPhotoCell +- (void)gestureController:(id)gc didObserveSingleTap:(id)tap { + if (sciIsPhotoMuteEnabled()) { sciToggleStoryAudio(); return; } + %orig; +} +%end + +// Carousels route the tap through the outer cell, so hijack there too — +// but only when the visible page is a photo. Video pages keep %orig. +%hook IGSundialViewerCarouselCell +- (void)gestureController:(id)gc didObserveSingleTap:(id)tap { + if (!sciIsPhotoMuteEnabled()) { %orig; return; } + BOOL hasVideo = NO, hasPhoto = NO; + NSMutableArray *stack = [NSMutableArray arrayWithObject:self]; + for (int d = 0; d < 6 && stack.count && !hasVideo; d++) { + NSMutableArray *next = [NSMutableArray array]; + for (UIView *sub in stack) { + NSString *cls = NSStringFromClass([sub class]); + if ([cls isEqualToString:@"IGSundialViewerCarouselVideoCell"]) { + if (!CGRectIsEmpty(CGRectIntersection(sub.bounds, self.bounds)) && + sub.window) hasVideo = YES; + } else if ([cls isEqualToString:@"IGSundialViewerCarouselPhotoCell"]) { + if (!CGRectIsEmpty(CGRectIntersection(sub.bounds, self.bounds)) && + sub.window) hasPhoto = YES; + } + for (UIView *s in sub.subviews) [next addObject:s]; + } + stack = next; + } + if (hasPhoto && !hasVideo) { sciToggleStoryAudio(); return; } + %orig; +} +%end + // ============ UFI: SYNC DOWNLOAD BUTTON + SETUP KVO ============ %hook IGSundialViewerVerticalUFI @@ -309,9 +362,15 @@ static void new_playbackToggle_layoutSubviews(id self, SEL _cmd) { } %end +%end // ReelsPauseModeGroup + // ============ RUNTIME HOOKS ============ %ctor { + if (![[SCIUtils getStringPref:@"reels_tap_control"] isEqualToString:@"pause"]) return; + + %init(ReelsPauseModeGroup); + Class toggleClass = objc_getClass("IGSundialPlaybackToggle.IGSundialPlaybackToggleView"); if (toggleClass) { MSHookMessageEx(toggleClass, @selector(didMoveToSuperview), diff --git a/src/Features/Stickers/RevealStickers.xm b/src/Features/Stickers/RevealStickers.xm new file mode 100644 index 0000000..81598b0 --- /dev/null +++ b/src/Features/Stickers/RevealStickers.xm @@ -0,0 +1,601 @@ +// Reveal poll/slider vote counts and quiz correct answers on story/reel +// stickers, plus force the legacy Quiz sticker back into the composer tray. +// +// Prefs: +// stories_show_poll_votes_count / stories_show_quiz_answer +// reels_show_poll_votes_count / reels_show_quiz_answer +// force_enable_quiz_sticker + +#import "../../Utils.h" +#import "../../InstagramHeaders.h" +#import "../StoriesAndMessages/StoryHelpers.h" +#import +#import + +extern "C" __weak UIViewController *sciActiveStoryViewerVC; + +// ============ Runtime helpers ============ + +static id sciCallMaybe(id obj, NSString *selName) { + SEL sel = NSSelectorFromString(selName); + if (!obj || ![obj respondsToSelector:sel]) return nil; + @try { return ((id(*)(id,SEL))objc_msgSend)(obj, sel); } + @catch (__unused id e) { return nil; } +} + +static NSArray *sciArrayIvar(id obj, const char *name) { + if (!obj || !name) return nil; + Class cls = [obj class]; + while (cls && cls != [NSObject class]) { + Ivar iv = class_getInstanceVariable(cls, name); + if (iv) { + id v = object_getIvar(obj, iv); + return [v isKindOfClass:[NSArray class]] ? (NSArray *)v : nil; + } + cls = class_getSuperclass(cls); + } + return nil; +} + +// ============ Context detection (stories vs reels) ============ + +// Reels surface via IGSundialFeedViewController and also via contextual +// feeds (profile reels) that host Sundial-prefixed cells. +static BOOL sciIsInReelsContext(UIView *anchor) { + Class reelCls = NSClassFromString(@"IGSundialFeedViewController"); + for (UIResponder *r = anchor; r; r = r.nextResponder) { + if (reelCls && [r isKindOfClass:reelCls]) return YES; + if ([NSStringFromClass([r class]) hasPrefix:@"IGSundial"]) return YES; + } + return NO; +} + +static BOOL sciPrefShowPollCounts(UIView *anchor) { + return [SCIUtils getBoolPref: + sciIsInReelsContext(anchor) + ? @"reels_show_poll_votes_count" + : @"stories_show_poll_votes_count"]; +} +static BOOL sciPrefShowQuizAnswer(UIView *anchor) { + return [SCIUtils getBoolPref: + sciIsInReelsContext(anchor) + ? @"reels_show_quiz_answer" + : @"stories_show_quiz_answer"]; +} + +// ============ Media lookup ============ + +static UIViewController *sciFindAnyStoryViewerVC(UIView *start) { + Class target = NSClassFromString(@"IGStoryViewerViewController"); + if (!target) return nil; + for (UIResponder *r = start; r; r = r.nextResponder) { + if ([r isKindOfClass:target]) return (UIViewController *)r; + } + if (sciActiveStoryViewerVC) return sciActiveStoryViewerVC; + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) continue; + for (UIWindow *w in ((UIWindowScene *)scene).windows) { + NSMutableArray *stack = [NSMutableArray array]; + if (w.rootViewController) [stack addObject:w.rootViewController]; + while (stack.count) { + UIViewController *cur = stack.lastObject; + [stack removeLastObject]; + if ([cur isKindOfClass:target]) return cur; + for (UIViewController *child in cur.childViewControllers) [stack addObject:child]; + if (cur.presentedViewController) [stack addObject:cur.presentedViewController]; + } + } + } + return nil; +} + +static IGMedia *sciCurrentStoryMedia(UIView *anchor) { + UIViewController *vc = sciFindAnyStoryViewerVC(anchor); + if (!vc) return nil; + IGMedia *media = nil; + @try { + id vm = sciCall(vc, @selector(currentViewModel)); + id item = sciCall1(vc, @selector(currentStoryItemForViewModel:), vm); + if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) media = (IGMedia *)item; + else media = sciExtractMediaFromItem(item); + } @catch (__unused id e) {} + return media; +} + +// Walks the responder chain probing common getters for an IGMedia — covers +// reel cells where no story viewer VC is in the chain. +static IGMedia *sciFindMediaFromAnchor(UIView *anchor) { + IGMedia *m = sciCurrentStoryMedia(anchor); + if (m) return m; + Class mediaCls = NSClassFromString(@"IGMedia"); + if (!mediaCls) return nil; + NSArray *probes = @[@"media", @"post", @"feedItem", @"igMedia", @"storyItem", + @"item", @"model", @"backingModel", @"storyMedia", + @"currentMedia", @"currentMediaItem", @"currentStoryItem", + @"mediaModel", @"mediaItem"]; + for (UIResponder *r = anchor; r; r = r.nextResponder) { + for (NSString *sel in probes) { + id v = sciCallMaybe(r, sel); + if ([v isKindOfClass:mediaCls]) return (IGMedia *)v; + IGMedia *nested = sciExtractMediaFromItem(v); + if (nested) return nested; + } + } + return nil; +} + +// View-local sticker models zero their tallies for unvoted viewers; the real +// counts live on IGMedia.{storyPolls,storyQuizs,storySliders} — match by pk. +static id sciAuthoritativeSticker(UIView *anchor, NSString *arrayKey, NSString *innerKey, id viewModel, NSString *idKey) { + IGMedia *media = sciFindMediaFromAnchor(anchor); + if (!media) return nil; + NSArray *arr = sciCallMaybe(media, arrayKey); + if (![arr isKindOfClass:[NSArray class]]) return nil; + NSString *viewId = idKey ? [sciCallMaybe(viewModel, idKey) description] : nil; + for (id entry in arr) { + id sticker = sciCallMaybe(entry, innerKey); + if (!sticker) continue; + if (viewId.length) { + NSString *stickerId = [sciCallMaybe(sticker, idKey) description]; + if ([stickerId isEqualToString:viewId]) return sticker; + } + } + if (arr.count > 0) { + id sticker = sciCallMaybe(arr[0], innerKey); + if (sticker) return sticker; + } + return nil; +} + +static NSInteger sciHighestTallyIndex(NSArray *tallies) { + NSInteger best = -1, bestCount = 0; + for (NSUInteger i = 0; i < tallies.count; i++) { + NSInteger c = [(NSNumber *)sciCallMaybe(tallies[i], @"totalCount") integerValue]; + if (c > bestCount) { best = (NSInteger)i; bestCount = c; } + } + return best; +} + +// ============ Editing/composer detection ============ + +static BOOL sciIsStickerEditing(UIView *v) { + Class cls = [v class]; + while (cls && cls != [NSObject class]) { + const char *names[] = { "_isEditing", "_editing" }; + for (size_t k = 0; k < sizeof(names)/sizeof(names[0]); k++) { + Ivar iv = class_getInstanceVariable(cls, names[k]); + if (!iv) continue; + ptrdiff_t off = ivar_getOffset(iv); + BOOL val = NO; + memcpy(&val, (uint8_t *)(__bridge void *)v + off, sizeof(val)); + if (val) return YES; + } + cls = class_getSuperclass(cls); + } + NSArray *composers = @[@"IGStoryStickerTrayViewController", + @"IGStoryPostCaptureEditingViewController", + @"IGStoryMediaCompositionEditingViewController"]; + for (UIResponder *r = v; r; r = r.nextResponder) { + NSString *cn = NSStringFromClass([r class]); + for (NSString *c in composers) if ([cn isEqualToString:c]) return YES; + } + return NO; +} + +// Keeps overlays in sync with the current item on story/reel nav. +static void sciForceRelayoutStickers(UIView *root) { + if (!root) return; + NSMutableArray *stack = [NSMutableArray arrayWithObject:root]; + Class pollV2 = NSClassFromString(@"IGPollStickerV2View"); + Class pollV1 = NSClassFromString(@"IGPollStickerView"); + Class slider = NSClassFromString(@"IGSliderStickerView"); + Class quiz = NSClassFromString(@"IGQuizStickerView"); + while (stack.count) { + UIView *v = stack.lastObject; + [stack removeLastObject]; + if ((pollV2 && [v isKindOfClass:pollV2]) || + (pollV1 && [v isKindOfClass:pollV1]) || + (slider && [v isKindOfClass:slider]) || + (quiz && [v isKindOfClass:quiz])) { + [v setNeedsLayout]; + [v layoutIfNeeded]; + } + for (UIView *sub in v.subviews) [stack addObject:sub]; + } +} + +// Sticker views often lay out once with zero bounds / no cells; retries +// catch the settled state without relying on a second layoutSubviews. +static void sciScheduleRetries(UIView *view, SEL action) { + __weak UIView *weak = view; + NSArray *delays = @[@0.1, @0.3, @0.7]; + for (NSNumber *d in delays) { + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(d.doubleValue * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + UIView *s = weak; + if (s && s.window) ((void(*)(id,SEL))objc_msgSend)(s, action); + }); + } +} + +// ============ Overlay badges / highlight ============ + +static const char kSciPollBadgeKey = 0; +static const char kSciSliderBadgeKey = 0; +static const char kSciQuizHighlightKey = 0; + +static UILabel *sciMakeBadge(void) { + UILabel *b = [[UILabel alloc] init]; + b.font = [UIFont systemFontOfSize:13 weight:UIFontWeightBold]; + b.textColor = [UIColor whiteColor]; + b.backgroundColor = [UIColor colorWithRed:0.0 green:0.45 blue:0.95 alpha:0.92]; + b.textAlignment = NSTextAlignmentCenter; + b.layer.cornerRadius = 10; + b.clipsToBounds = YES; + b.userInteractionEnabled = NO; + return b; +} + +static void sciAttachPollCountBadge(UIView *optionView, NSInteger count, double total) { + UILabel *badge = objc_getAssociatedObject(optionView, &kSciPollBadgeKey); + if (!badge) { + badge = sciMakeBadge(); + [optionView addSubview:badge]; + objc_setAssociatedObject(optionView, &kSciPollBadgeKey, badge, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + badge.text = total > 0 + ? [NSString stringWithFormat:@" %ld · %.0f%% ", (long)count, 100.0 * (double)count / total] + : [NSString stringWithFormat:@" %ld ", (long)count]; + [badge sizeToFit]; + CGSize sz = badge.bounds.size; + sz.width += 10; + sz.height = MAX(sz.height + 4, 22); + CGRect b = optionView.bounds; + badge.frame = CGRectMake(b.size.width - sz.width - 4, -sz.height * 0.35, sz.width, sz.height); + badge.layer.zPosition = 1000; + [optionView bringSubviewToFront:badge]; + optionView.clipsToBounds = NO; +} + +static void sciAttachSliderBadge(UIView *sliderView, NSUInteger count, double avg) { + UILabel *badge = objc_getAssociatedObject(sliderView, &kSciSliderBadgeKey); + if (!badge) { + badge = sciMakeBadge(); + [sliderView addSubview:badge]; + objc_setAssociatedObject(sliderView, &kSciSliderBadgeKey, badge, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + badge.text = [NSString stringWithFormat:@" %lu votes · avg %.0f%% ", + (unsigned long)count, avg * 100.0]; + [badge sizeToFit]; + CGSize sz = badge.bounds.size; + sz.height = MAX(sz.height, 18); + CGRect b = sliderView.bounds; + badge.frame = CGRectMake((b.size.width - sz.width) * 0.5, -sz.height - 4, sz.width, sz.height); + [sliderView bringSubviewToFront:badge]; +} + +static void sciAttachQuizHighlight(UIView *optionView, CGFloat cornerRadius) { + CAShapeLayer *hl = objc_getAssociatedObject(optionView, &kSciQuizHighlightKey); + if (!hl) { + hl = [CAShapeLayer layer]; + UIColor *green = [UIColor colorWithRed:0.24 green:0.76 blue:0.38 alpha:1.0]; + hl.fillColor = [green colorWithAlphaComponent:0.35].CGColor; + hl.strokeColor = green.CGColor; + hl.lineWidth = 2.0; + hl.zPosition = 50; + [optionView.layer addSublayer:hl]; + objc_setAssociatedObject(optionView, &kSciQuizHighlightKey, hl, OBJC_ASSOCIATION_RETAIN_NONATOMIC); + } + CGRect b = CGRectInset(optionView.bounds, 1.0, 1.0); + hl.frame = optionView.bounds; + hl.path = cornerRadius > 0 + ? [UIBezierPath bezierPathWithRoundedRect:b cornerRadius:cornerRadius].CGPath + : [UIBezierPath bezierPathWithRect:b].CGPath; +} + +static void sciRemovePollCountBadge(UIView *v) { + UILabel *b = objc_getAssociatedObject(v, &kSciPollBadgeKey); + if (b) { [b removeFromSuperview]; objc_setAssociatedObject(v, &kSciPollBadgeKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } +} +static void sciRemoveSliderBadge(UIView *v) { + UILabel *b = objc_getAssociatedObject(v, &kSciSliderBadgeKey); + if (b) { [b removeFromSuperview]; objc_setAssociatedObject(v, &kSciSliderBadgeKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } +} +static void sciRemoveQuizHighlight(UIView *v) { + CAShapeLayer *l = objc_getAssociatedObject(v, &kSciQuizHighlightKey); + if (l) { [l removeFromSuperlayer]; objc_setAssociatedObject(v, &kSciQuizHighlightKey, nil, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } +} + +// ============ Poll reveal (V2 + legacy) ============ + +static void sciApplyPollReveal(UIView *pollView, NSArray *opts) { + BOOL showCounts = sciPrefShowPollCounts(pollView); + BOOL showWinner = sciPrefShowQuizAnswer(pollView); + BOOL editing = sciIsStickerEditing(pollView); + + if ((!showCounts && !showWinner) || editing) { + for (UIView *opt in opts) { + if (![opt isKindOfClass:[UIView class]]) continue; + sciRemovePollCountBadge(opt); + sciRemoveQuizHighlight(opt); + } + return; + } + + id viewModel = sciCallMaybe(pollView, @"igapiStickerModel") ?: sciCallMaybe(pollView, @"exportModel"); + id model = sciAuthoritativeSticker(pollView, @"storyPolls", @"pollSticker", viewModel, @"pollId") ?: viewModel; + NSArray *tallies = sciCallMaybe(model, @"tallies"); + if (![tallies isKindOfClass:[NSArray class]]) tallies = nil; + double total = [(NSNumber *)sciCallMaybe(model, @"totalVotes") doubleValue]; + + NSNumber *correctAnswer = sciCallMaybe(model, @"correctAnswer"); + NSInteger winnerIdx = correctAnswer ? correctAnswer.integerValue : sciHighestTallyIndex(tallies ?: @[]); + + // V2 poll preallocates up to 4 option views; only render on real slots. + NSUInteger realOptCount = tallies ? tallies.count : 0; + for (NSUInteger i = 0; i < opts.count; i++) { + UIView *opt = opts[i]; + if (![opt isKindOfClass:[UIView class]]) continue; + if (i >= realOptCount) { + sciRemovePollCountBadge(opt); + sciRemoveQuizHighlight(opt); + continue; + } + if (showCounts) { + NSInteger c = [(NSNumber *)sciCallMaybe(tallies[i], @"totalCount") integerValue]; + sciAttachPollCountBadge(opt, c, total); + } else { + sciRemovePollCountBadge(opt); + } + if (showWinner && winnerIdx >= 0 && (NSInteger)i == winnerIdx) { + sciAttachQuizHighlight(opt, 0.0); + } else { + sciRemoveQuizHighlight(opt); + } + } +} + +/////////////////////////////////////////////////////////////////////////////// +// STORIES // +/////////////////////////////////////////////////////////////////////////////// + +%hook IGStoryViewerViewController + +- (void)viewDidLayoutSubviews { + %orig; + sciForceRelayoutStickers(((UIViewController *)self).view); +} + +- (void)viewDidAppear:(BOOL)animated { + %orig; + dispatch_async(dispatch_get_main_queue(), ^{ + sciForceRelayoutStickers(((UIViewController *)self).view); + }); +} + +%end + +// Force-inject IGQuizStickerTrayModel into the composer tray (IG keeps the +// class + handler wired but filtered it out of the picker). + +static IGQuizStickerTrayModel *sciMakeQuizTrayModel(id neighborModel) { + Class cls = NSClassFromString(@"IGQuizStickerTrayModel"); + if (!cls) return nil; + id quiz = [[cls alloc] init]; + if (!quiz) return nil; + @try { + id section = sciCallMaybe(neighborModel, @"stickerSection"); + if (section && [quiz respondsToSelector:@selector(setStickerSection:)]) { + [(IGQuizStickerTrayModel *)quiz setStickerSection:section]; + } + } @catch (__unused id e) {} + if ([quiz respondsToSelector:@selector(setPrompts:)]) { + [(IGQuizStickerTrayModel *)quiz setPrompts:@[]]; + } + return quiz; +} + +%hook IGStoryStickerDataSourceImpl + +- (NSArray *)items { + NSArray *orig = %orig; + if (!orig || ![SCIUtils getBoolPref:@"force_enable_quiz_sticker"]) return orig; + for (id m in orig) { + if ([NSStringFromClass([m class]) rangeOfString:@"Quiz" options:NSCaseInsensitiveSearch].location != NSNotFound) { + return orig; + } + } + + // Slot quiz next to the poll tray model so it lands in the interactive row. + NSUInteger insertIdx = NSNotFound; + id neighbor = nil; + for (NSUInteger i = 0; i < orig.count; i++) { + NSString *cn = NSStringFromClass([orig[i] class]); + if ([cn isEqualToString:@"IGPollStickerV2TrayModel"] || + [cn isEqualToString:@"IGPollStickerTrayModel"]) { + insertIdx = i + 1; + neighbor = orig[i]; + break; + } + } + if (insertIdx == NSNotFound) { + for (NSUInteger i = 0; i < orig.count; i++) { + if ([NSStringFromClass([orig[i] class]) isEqualToString:@"IGQuestionAnswerStickerModel"]) { + insertIdx = i + 1; + neighbor = orig[i]; + break; + } + } + } + IGQuizStickerTrayModel *quiz = sciMakeQuizTrayModel(neighbor); + if (!quiz) return orig; + NSMutableArray *mutated = [orig mutableCopy]; + if (insertIdx == NSNotFound) insertIdx = mutated.count; + [mutated insertObject:quiz atIndex:insertIdx]; + return mutated; +} + +%end + +/////////////////////////////////////////////////////////////////////////////// +// REELS // +/////////////////////////////////////////////////////////////////////////////// + +%hook IGSundialFeedViewController + +- (void)viewDidLayoutSubviews { + %orig; + sciForceRelayoutStickers(((UIViewController *)self).view); +} + +%end + +/////////////////////////////////////////////////////////////////////////////// +// STICKER VIEW HOOKS — shared by stories + reels // +/////////////////////////////////////////////////////////////////////////////// + +// IGPollStickerV2View + +%hook IGPollStickerV2View + +%new +- (void)sci_applyPollReveal { + sciApplyPollReveal(self, sciArrayIvar(self, "_optionViews") ?: @[]); +} + +- (void)layoutSubviews { + %orig; + ((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyPollReveal)); +} + +- (void)didMoveToWindow { + %orig; + if (self.window) sciScheduleRetries(self, @selector(sci_applyPollReveal)); +} + +%end + +// IGPollStickerView (legacy) + +%hook IGPollStickerView + +%new +- (void)sci_applyPollReveal { + NSArray *opts = sciArrayIvar(self, "_optionViews") + ?: sciArrayIvar(self, "_voteOptionViews") + ?: sciArrayIvar(self, "_options"); + sciApplyPollReveal(self, opts ?: @[]); +} + +- (void)layoutSubviews { + %orig; + ((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyPollReveal)); +} + +- (void)didMoveToWindow { + %orig; + if (self.window) sciScheduleRetries(self, @selector(sci_applyPollReveal)); +} + +%end + +// IGSliderStickerView + +%hook IGSliderStickerView + +%new +- (void)sci_applySliderReveal { + if (!sciPrefShowPollCounts(self) || sciIsStickerEditing(self)) { + sciRemoveSliderBadge(self); + return; + } + NSUInteger count = 0; + double avg = 0.0; + id model = sciCallMaybe(self, @"igapiStickerModel") ?: sciCallMaybe(self, @"exportModel"); + if (model) { + count = [(NSNumber *)sciCallMaybe(model, @"sliderVoteCount") unsignedIntegerValue]; + avg = [(NSNumber *)sciCallMaybe(model, @"sliderVoteAverage") doubleValue]; + } + if (count == 0 && avg == 0.0) { + Ivar vc = class_getInstanceVariable([self class], "_voteCount"); + if (vc) memcpy(&count, (uint8_t *)(__bridge void *)self + ivar_getOffset(vc), sizeof(count)); + Ivar va = class_getInstanceVariable([self class], "_averageVote"); + if (va) avg = [(NSNumber *)object_getIvar(self, va) doubleValue]; + } + sciAttachSliderBadge(self, count, avg); +} + +- (void)didMoveToWindow { + %orig; + if (self.window) sciScheduleRetries(self, @selector(sci_applySliderReveal)); +} + +- (void)layoutSubviews { + %orig; + ((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applySliderReveal)); +} + +// Refresh after the vote posts — count/average land on the ivars async. +- (void)emojiSliderDidEndSliding:(id)arg { + %orig; + sciScheduleRetries(self, @selector(sci_applySliderReveal)); +} + +%end + +// IGQuizStickerView + +%hook IGQuizStickerView + +%new +- (void)sci_applyQuizReveal { + BOOL showWinner = sciPrefShowQuizAnswer(self); + BOOL editing = sciIsStickerEditing(self); + + UICollectionView *cv = nil; + Ivar cvIvar = class_getInstanceVariable([self class], "_optionsCollectionView"); + if (cvIvar) { + id v = object_getIvar(self, cvIvar); + if ([v isKindOfClass:[UICollectionView class]]) cv = (UICollectionView *)v; + } + // Populate visibleCells before we walk them; IG also ships quiz + // interaction off on the consumption path, so restore it. + if (cv) { [cv setNeedsLayout]; [cv layoutIfNeeded]; cv.userInteractionEnabled = YES; } + self.userInteractionEnabled = YES; + NSArray *cells = cv ? cv.visibleCells : @[]; + + if (!showWinner || editing) { + for (UIView *cell in cells) sciRemoveQuizHighlight(cell); + return; + } + + id viewModel = sciCallMaybe(self, @"igapiStickerModel") ?: sciCallMaybe(self, @"exportModel"); + id model = sciAuthoritativeSticker(self, @"storyQuizs", @"quizSticker", viewModel, @"quizId") ?: viewModel; + NSNumber *correct = sciCallMaybe(model, @"correctAnswer"); + NSInteger winnerIdx = correct ? correct.integerValue : -1; + + // Quiz cell corner radius lives on a sublayer; hardcode to match. + for (UICollectionViewCell *cell in cells) { + if (![cell isKindOfClass:[UICollectionViewCell class]]) continue; + NSIndexPath *ip = cv ? [cv indexPathForCell:cell] : nil; + NSInteger i = ip ? ip.row : -1; + if (i < 0) continue; + if (winnerIdx >= 0 && i == winnerIdx) { + sciAttachQuizHighlight(cell, 18.0); + } else { + sciRemoveQuizHighlight(cell); + } + } +} + +- (void)layoutSubviews { + %orig; + ((void(*)(id,SEL))objc_msgSend)(self, @selector(sci_applyQuizReveal)); + sciScheduleRetries(self, @selector(sci_applyQuizReveal)); +} + +- (void)didMoveToWindow { + %orig; + if (self.window) sciScheduleRetries(self, @selector(sci_applyQuizReveal)); +} + +%end diff --git a/src/Features/StoriesAndMessages/DMOverlayButtons.xm b/src/Features/StoriesAndMessages/DMOverlayButtons.xm new file mode 100644 index 0000000..0ec0232 --- /dev/null +++ b/src/Features/StoriesAndMessages/DMOverlayButtons.xm @@ -0,0 +1,269 @@ +// DM disappearing-media overlay buttons — action / eye / audio (tags 1342–1344). +// Hooks IGDirectVisualMessageViewerController directly; reads only dm_visual_* prefs. + +#import "OverlayHelpers.h" +#import "../../SCIChrome.h" +#import "../../ActionButton/SCIMediaViewer.h" + +// Per-button weak ref to the owning DM VC so handlers skip the responder walk. +static const void *kSCIDMOwnerVCKey = &kSCIDMOwnerVCKey; + +// MARK: - Menu item builders + +static NSArray *sciDMActionMenuItems(UIViewController *dmVC, UIView *sourceView) { + __weak UIView *weakSource = sourceView; + return @[ + [UIAction actionWithTitle:SCILocalized(@"Expand") + image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"] + identifier:nil + handler:^(UIAction *a) { sciDMExpandMedia(dmVC); }], + [UIAction actionWithTitle:SCILocalized(@"Messages settings") + image:[UIImage systemImageNamed:@"gearshape"] + identifier:nil + handler:^(UIAction *a) { sciOpenMessagesSettings(weakSource); }], + [UIAction actionWithTitle:SCILocalized(@"Download and share") + image:[UIImage systemImageNamed:@"square.and.arrow.up"] + identifier:nil + handler:^(UIAction *a) { sciDMShareMedia(dmVC); }], + [UIAction actionWithTitle:SCILocalized(@"Download to Photos") + image:[UIImage systemImageNamed:@"square.and.arrow.down"] + identifier:nil + handler:^(UIAction *a) { sciDMDownloadMedia(dmVC); }], + ]; +} + +static NSArray *sciDMEyeMenuItems(UIViewController *dmVC, UIView *sourceView) { + __weak UIView *weakSource = sourceView; + return @[ + [UIAction actionWithTitle:SCILocalized(@"Mark as viewed") + image:[UIImage systemImageNamed:@"eye"] + identifier:nil + handler:^(UIAction *a) { sciDMMarkCurrentAsViewed(dmVC); }], + [UIAction actionWithTitle:SCILocalized(@"Messages settings") + image:[UIImage systemImageNamed:@"gearshape"] + identifier:nil + handler:^(UIAction *a) { sciOpenMessagesSettings(weakSource); }], + ]; +} + +static void sciDMApplyTapMenu(UIButton *btn, __weak UIViewController *weakDMVC) { + __weak UIButton *weakBtn = btn; + UIDeferredMenuElement *deferred = [UIDeferredMenuElement elementWithUncachedProvider: + ^(void (^completion)(NSArray * _Nonnull)) { + UIViewController *dmVC = weakDMVC; + UIButton *strongBtn = weakBtn; + if (!dmVC || !strongBtn) { completion(@[]); return; } + completion(sciDMActionMenuItems(dmVC, strongBtn)); + }]; + btn.menu = [UIMenu menuWithChildren:@[deferred]]; + btn.showsMenuAsPrimaryAction = YES; +} + +// MARK: - Button delegate (tap handlers) + +@interface SCIDMButtonDelegate : NSObject ++ (instancetype)shared; +- (void)actionTapped:(UIButton *)sender; +- (void)eyeTapped:(UIButton *)sender; +- (void)audioTapped:(UIButton *)sender; +@end + +@implementation SCIDMButtonDelegate + ++ (instancetype)shared { + static SCIDMButtonDelegate *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ s = [SCIDMButtonDelegate new]; }); + return s; +} + +- (UIViewController *)ownerForButton:(UIView *)btn { + return objc_getAssociatedObject(btn, kSCIDMOwnerVCKey); +} + +// Default-tap path (pref != menu). +- (void)actionTapped:(UIButton *)sender { + UIViewController *dmVC = [self ownerForButton:sender]; + if (!dmVC) return; + NSString *tap = [SCIUtils getStringPref:@"dm_visual_action_default"]; + if ([tap isEqualToString:@"expand"]) sciDMExpandMedia(dmVC); + else if ([tap isEqualToString:@"download_share"]) sciDMShareMedia(dmVC); + else if ([tap isEqualToString:@"download_photos"]) sciDMDownloadMedia(dmVC); +} + +- (void)eyeTapped:(UIButton *)sender { + UIViewController *dmVC = [self ownerForButton:sender]; + if (!dmVC) return; + UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; + [haptic impactOccurred]; + [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; } + completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }]; + sciDMMarkCurrentAsViewed(dmVC); +} + +- (void)audioTapped:(SCIChromeButton *)sender { + UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; + [haptic impactOccurred]; + sciToggleStoryAudio(); + sender.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; +} + +@end + +// MARK: - Long-press menu builder + +// UIButton.menu + showsMenuAsPrimaryAction=NO is iOS's native pattern for +// "tap fires action, long-press shows menu". Compose a UIDeferredMenuElement +// so the menu rebuilds per presentation — owner lookup stays fresh. +static void sciDMAttachLongPressMenu(SCIChromeButton *btn, NSInteger tag) { + __weak SCIChromeButton *weakBtn = btn; + UIDeferredMenuElement *deferred = [UIDeferredMenuElement elementWithUncachedProvider: + ^(void (^completion)(NSArray * _Nonnull)) { + SCIChromeButton *strongBtn = weakBtn; + UIViewController *dmVC = strongBtn ? objc_getAssociatedObject(strongBtn, kSCIDMOwnerVCKey) : nil; + if (!dmVC) { completion(@[]); return; } + NSArray *items = (tag == SCI_DM_ACTION_TAG) + ? sciDMActionMenuItems(dmVC, strongBtn) + : sciDMEyeMenuItems(dmVC, strongBtn); + completion(items); + }]; + btn.menu = [UIMenu menuWithChildren:@[deferred]]; + btn.showsMenuAsPrimaryAction = NO; +} + +// MARK: - Overlay injection + +static void sciDMInstallButtons(UIViewController *dmVC) { + if (!dmVC || !dmVC.isViewLoaded) return; + UIView *overlay = sciFindOverlayInView(dmVC.view); + if (!overlay) return; + + // Kill any story-tag injections from the shared overlay hook. + UIView *sA = [overlay viewWithTag:SCI_STORY_ACTION_TAG]; if (sA) [sA removeFromSuperview]; + UIView *sE = [overlay viewWithTag:SCI_STORY_EYE_TAG]; if (sE) [sE removeFromSuperview]; + UIView *sU = [overlay viewWithTag:SCI_STORY_AUDIO_TAG]; if (sU) [sU removeFromSuperview]; + + SCIDMButtonDelegate *dg = [SCIDMButtonDelegate shared]; + + // --- Action button (tag 1342) --- + UIView *staleAction = [overlay viewWithTag:SCI_DM_ACTION_TAG]; + if (staleAction) [staleAction removeFromSuperview]; + if ([SCIUtils getBoolPref:@"dm_visual_action_button"]) { + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:18 diameter:36]; + btn.tag = SCI_DM_ACTION_TAG; + objc_setAssociatedObject(btn, kSCIDMOwnerVCKey, dmVC, OBJC_ASSOCIATION_ASSIGN); + [overlay addSubview:btn]; + [NSLayoutConstraint activateConstraints:@[ + [btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100], + [btn.trailingAnchor constraintEqualToAnchor:overlay.trailingAnchor constant:-12], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; + + NSString *defaultTap = [SCIUtils getStringPref:@"dm_visual_action_default"]; + if (!defaultTap.length || [defaultTap isEqualToString:@"menu"]) { + sciDMApplyTapMenu(btn, dmVC); + } else { + // Tap = default action, long-press = full menu. + [btn addTarget:dg action:@selector(actionTapped:) forControlEvents:UIControlEventTouchUpInside]; + sciDMAttachLongPressMenu(btn, SCI_DM_ACTION_TAG); + } + } + + // --- Eye / mark-as-viewed (tag 1343) --- + UIView *staleEye = [overlay viewWithTag:SCI_DM_EYE_TAG]; + if (staleEye) [staleEye removeFromSuperview]; + if ([SCIUtils getBoolPref:@"dm_visual_seen_button"]) { + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"eye" pointSize:18 diameter:36]; + btn.tag = SCI_DM_EYE_TAG; + objc_setAssociatedObject(btn, kSCIDMOwnerVCKey, dmVC, OBJC_ASSOCIATION_ASSIGN); + [btn addTarget:dg action:@selector(eyeTapped:) forControlEvents:UIControlEventTouchUpInside]; + sciDMAttachLongPressMenu(btn, SCI_DM_EYE_TAG); + [overlay addSubview:btn]; + + UIView *anchor = [overlay viewWithTag:SCI_DM_ACTION_TAG]; + if (anchor) { + [NSLayoutConstraint activateConstraints:@[ + [btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor], + [btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; + } else { + [NSLayoutConstraint activateConstraints:@[ + [btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100], + [btn.trailingAnchor constraintEqualToAnchor:overlay.trailingAnchor constant:-12], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; + } + } + + // --- Audio toggle (tag 1344) --- + UIView *staleAudio = [overlay viewWithTag:SCI_DM_AUDIO_TAG]; + if (staleAudio) [staleAudio removeFromSuperview]; + sciInitStoryAudioState(); + if ([SCIUtils getBoolPref:@"dm_visual_audio_toggle"]) { + NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:icon pointSize:14 diameter:28]; + btn.tag = SCI_DM_AUDIO_TAG; + [btn addTarget:dg action:@selector(audioTapped:) forControlEvents:UIControlEventTouchUpInside]; + [overlay addSubview:btn]; + [NSLayoutConstraint activateConstraints:@[ + [btn.bottomAnchor constraintEqualToAnchor:overlay.safeAreaLayoutGuide.bottomAnchor constant:-100], + [btn.leadingAnchor constraintEqualToAnchor:overlay.leadingAnchor constant:12], + [btn.widthAnchor constraintEqualToConstant:28], + [btn.heightAnchor constraintEqualToConstant:28] + ]]; + } +} + +// Rebuild only when an enabled button is missing — handles overlay recycling. +static void sciDMEnsureButtons(UIViewController *dmVC) { + if (!dmVC || !dmVC.isViewLoaded) return; + UIView *overlay = sciFindOverlayInView(dmVC.view); + if (!overlay) return; + + BOOL needAction = [SCIUtils getBoolPref:@"dm_visual_action_button"] && ![overlay viewWithTag:SCI_DM_ACTION_TAG]; + BOOL needEye = [SCIUtils getBoolPref:@"dm_visual_seen_button"] && ![overlay viewWithTag:SCI_DM_EYE_TAG]; + BOOL needAudio = [SCIUtils getBoolPref:@"dm_visual_audio_toggle"] && ![overlay viewWithTag:SCI_DM_AUDIO_TAG]; + if (needAction || needEye || needAudio) sciDMInstallButtons(dmVC); +} + +// MARK: - VC hook + +%group DMOverlayGroup + +%hook IGDirectVisualMessageViewerController + +- (void)viewDidAppear:(BOOL)animated { + %orig; + sciDMInstallButtons(self); +} + +- (void)viewDidLayoutSubviews { + %orig; + sciDMEnsureButtons(self); +} + +- (void)viewWillDisappear:(BOOL)animated { + %orig; + if (!self.isViewLoaded) return; + UIView *overlay = sciFindOverlayInView(self.view); + if (!overlay) return; + UIView *a = [overlay viewWithTag:SCI_DM_ACTION_TAG]; if (a) [a removeFromSuperview]; + UIView *e = [overlay viewWithTag:SCI_DM_EYE_TAG]; if (e) [e removeFromSuperview]; + UIView *u = [overlay viewWithTag:SCI_DM_AUDIO_TAG]; if (u) [u removeFromSuperview]; +} + +%end + +%end // DMOverlayGroup + +%ctor { + if ([SCIUtils getBoolPref:@"dm_visual_action_button"] || + [SCIUtils getBoolPref:@"dm_visual_seen_button"] || + [SCIUtils getBoolPref:@"dm_visual_audio_toggle"]) { + %init(DMOverlayGroup); + } +} diff --git a/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x b/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x index 54b1f4d..b7efcb3 100644 --- a/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x +++ b/src/Features/StoriesAndMessages/ExcludeFromStorySeen.x @@ -157,7 +157,7 @@ void sciTriggerStoryMarkSeen(UIViewController *storyVC) { Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); if (!overlayCls) overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayMetalLayerView"); if (!overlayCls) return; - SEL markSel = @selector(sciMarkSeenTapped:); + SEL markSel = @selector(sciStoryMarkSeenTapped:); NSMutableArray *stack = [NSMutableArray arrayWithObject:storyVC.view]; while (stack.count) { UIView *v = stack.lastObject; [stack removeLastObject]; diff --git a/src/Features/StoriesAndMessages/HideCallButtons.x b/src/Features/StoriesAndMessages/HideCallButtons.x index 77a6e6b..2b06431 100644 --- a/src/Features/StoriesAndMessages/HideCallButtons.x +++ b/src/Features/StoriesAndMessages/HideCallButtons.x @@ -24,6 +24,8 @@ static BOOL sciPlatterContainsHiddenButton(UIView *platter) { return NO; } +%group HideCallButtonsGroup + // Block taps in case a hidden button still receives hit-test events during transitions. %hook IGDirectThreadCallButtonsCoordinator - (void)_didTapAudioButton:(id)arg1 { @@ -88,3 +90,12 @@ static void sciRepackPlatters(UIView *container) { } } %end + +%end // HideCallButtonsGroup + +%ctor { + if ([SCIUtils getBoolPref:@"hide_voice_call_button"] || + [SCIUtils getBoolPref:@"hide_video_call_button"]) { + %init(HideCallButtonsGroup); + } +} diff --git a/src/Features/StoriesAndMessages/NotesActions.x b/src/Features/StoriesAndMessages/NotesActions.x index 80410b9..ff4572d 100644 --- a/src/Features/StoriesAndMessages/NotesActions.x +++ b/src/Features/StoriesAndMessages/NotesActions.x @@ -97,8 +97,8 @@ static UIImage *sciGIFImageFromCell(UIView *cell) { return nil; } -// Get audio URL from the cell's view model -static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) { +// Audio track from the note cell's view model. 426 added launcherSet. +static id sciAudioTrackFromCell(UIView *cell) { if (!cell) return nil; Ivar vmIvar = class_getInstanceVariable([cell class], "viewModel"); if (!vmIvar) vmIvar = class_getInstanceVariable([cell class], "_viewModel"); @@ -106,41 +106,48 @@ static NSURL *sciAudioURLFromCell(UIView *cell, id targetNote) { id vm = object_getIvar(cell, vmIvar); if (!vm) return nil; - SEL audioSel = NSSelectorFromString(@"audioTrackWithUserMap:"); - if (![vm respondsToSelector:audioSel]) return nil; - + SEL audioSel2 = NSSelectorFromString(@"audioTrackWithUserMap:launcherSet:"); + SEL audioSel1 = NSSelectorFromString(@"audioTrackWithUserMap:"); @try { - id track = ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel, nil); - if (!track) return nil; - - // audioFileURL is an IGAsyncTask — try to resolve it - if ([track respondsToSelector:NSSelectorFromString(@"audioFileURL")]) { - id urlOrTask = [track valueForKey:@"audioFileURL"]; - if ([urlOrTask isKindOfClass:[NSURL class]]) return urlOrTask; - - // IGAsyncTask — try .result, .value, .get - for (NSString *prop in @[@"result", @"value", @"get", @"cachedResult"]) { - if ([urlOrTask respondsToSelector:NSSelectorFromString(prop)]) { - @try { - id resolved = [urlOrTask valueForKey:prop]; - if ([resolved isKindOfClass:[NSURL class]]) return resolved; - } @catch (__unused id e) {} - } - } - - SEL awaitSel = NSSelectorFromString(@"await"); - if ([urlOrTask respondsToSelector:awaitSel]) { - @try { - id resolved = ((id(*)(id,SEL))objc_msgSend)(urlOrTask, awaitSel); - if ([resolved isKindOfClass:[NSURL class]]) return resolved; - } @catch (__unused id e) {} - } + if ([vm respondsToSelector:audioSel2]) { + id session = [SCIUtils activeUserSession]; + id launcher = nil; + @try { launcher = session ? [session valueForKey:@"launcherSet"] : nil; } @catch (__unused id e) {} + return ((id(*)(id,SEL,id,id))objc_msgSend)(vm, audioSel2, nil, launcher); + } + if ([vm respondsToSelector:audioSel1]) { + return ((id(*)(id,SEL,id))objc_msgSend)(vm, audioSel1, nil); } - } @catch (__unused id e) {} return nil; } +// Pull URL from the track's IGAsyncTask — sync if cached, else async. +static void sciResolveAudioURL(id track, void (^completion)(NSURL *)) { + if (!track || !completion) { if (completion) completion(nil); return; } + id task = nil; + @try { + if ([track respondsToSelector:@selector(audioFileURLTask)]) + task = ((id(*)(id,SEL))objc_msgSend)(track, @selector(audioFileURLTask)); + } @catch (__unused id e) {} + if (!task) { completion(nil); return; } + + @try { + id res = [task valueForKey:@"result"]; + if ([res isKindOfClass:[NSURL class]]) { completion(res); return; } + } @catch (__unused id e) {} + + SEL onSuccess = NSSelectorFromString(@"onSuccess:"); + if (![task respondsToSelector:onSuccess]) { completion(nil); return; } + void (^cb)(id) = ^(id resolved) { + NSURL *u = [resolved isKindOfClass:[NSURL class]] ? resolved : nil; + dispatch_async(dispatch_get_main_queue(), ^{ completion(u); }); + }; + @try { + ((void(*)(id,SEL,id))objc_msgSend)(task, onSuccess, cb); + } @catch (__unused id e) { completion(nil); } +} + static SCIDownloadDelegate *sciNoteDl = nil; static void (*orig_present)(UIViewController *, SEL, UIViewController *, BOOL, id); @@ -254,13 +261,18 @@ static void hook_present(UIViewController *self, SEL _cmd, UIViewController *vc, }]]; } - // Audio (style=1): download from audioFileURL - NSURL *audioURL = sciAudioURLFromCell(cell, note); - if (audioURL) { + id audioTrack = sciAudioTrackFromCell(cell); + if (audioTrack) { [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Download audio") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { - sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:NO]; - [sciNoteDl downloadFileWithURL:audioURL fileExtension:@"m4a" hudLabel:nil]; + sciResolveAudioURL(audioTrack, ^(NSURL *audioURL) { + if (!audioURL) { + [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Audio URL not available")]; + return; + } + sciNoteDl = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:NO]; + [sciNoteDl downloadFileWithURL:audioURL fileExtension:@"m4a" hudLabel:nil]; + }); }]]; } diff --git a/src/Features/StoriesAndMessages/OverlayButtons.xm b/src/Features/StoriesAndMessages/OverlayButtons.xm deleted file mode 100644 index bc6ae99..0000000 --- a/src/Features/StoriesAndMessages/OverlayButtons.xm +++ /dev/null @@ -1,649 +0,0 @@ -// Action + mark-seen buttons on story/DM visual message overlay -// Tags: [1339] eye [1340] action [1341] audio - -#import "StoryHelpers.h" -#import "SCIExcludedThreads.h" -#import "SCIExcludedStoryUsers.h" -#import "../../ActionButton/SCIActionButton.h" -#import "../../ActionButton/SCIMediaActions.h" -#import "../../ActionButton/SCIActionMenu.h" -#import "../../ActionButton/SCIMediaViewer.h" -#import "../../Downloader/Download.h" - -extern "C" BOOL sciSeenBypassActive; -extern "C" BOOL sciAdvanceBypassActive; -extern "C" NSMutableSet *sciAllowedSeenPKs; -extern "C" void sciAllowSeenForPK(id); -extern "C" BOOL sciIsCurrentStoryOwnerExcluded(void); -extern "C" NSDictionary *sciCurrentStoryOwnerInfo(void); -extern "C" NSDictionary *sciOwnerInfoForView(UIView *view); -extern "C" BOOL sciStorySeenToggleEnabled; -extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC); -extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC); -extern "C" __weak UIViewController *sciActiveStoryViewerVC; -extern "C" void sciToggleStoryAudio(void); -extern "C" BOOL sciIsStoryAudioEnabled(void); -extern "C" void sciInitStoryAudioState(void); -extern "C" void sciResetStoryAudioState(void); -extern "C" void sciShowStoryMentions(UIViewController *, UIView *); - -// ── Disappearing DM media ── -static NSURL *sciDisappearingMediaURL(UIViewController *dmVC, BOOL *outIsVideo) { - Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource"); - id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil; - Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil; - id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil; - if (!msg) return nil; - - Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo"); - id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil; - Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil; - id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil; - if (!visMedia) return nil; - - // Video - @try { - id rawVideo = [msg valueForKey:@"rawVideo"]; - if (rawVideo) { - NSURL *url = [SCIUtils getVideoUrl:rawVideo]; - if (url) { if (outIsVideo) *outIsVideo = YES; return url; } - } - } @catch (NSException *e) {} - - // Photo - Ivar pi = class_getInstanceVariable([visMedia class], "_photo_photo"); - id photo = pi ? object_getIvar(visMedia, pi) : nil; - if (photo) { - if (outIsVideo) *outIsVideo = NO; - return [SCIUtils getPhotoUrl:photo]; - } - return nil; -} - -static SCIDownloadDelegate *sciDMDownloadDelegate = nil; -static void sciDownloadDisappearingMedia(UIViewController *dmVC) { - BOOL isVideo = NO; - NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo); - if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; } - - sciDMDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:YES]; - [sciDMDownloadDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil]; -} - -static SCIDownloadDelegate *sciDMShareDelegate = nil; -static void sciShareDisappearingMedia(UIViewController *dmVC) { - BOOL isVideo = NO; - NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo); - if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; } - - sciDMShareDelegate = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:YES]; - [sciDMShareDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil]; -} - -static void sciExpandDisappearingMedia(UIViewController *dmVC) { - BOOL isVideo = NO; - NSURL *url = sciDisappearingMediaURL(dmVC, &isVideo); - if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; } - - if (isVideo) { - [SCIMediaViewer showWithVideoURL:url photoURL:nil caption:nil]; - } else { - [SCIMediaViewer showWithVideoURL:nil photoURL:url caption:nil]; - } -} - -// ── Story playback control ── - -static void sciPauseStoryPlayback(UIView *sourceView) { - UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController"); - if (!storyVC) return; - id sc = sciFindSectionController(storyVC); - - SEL pauseSel = NSSelectorFromString(@"pauseWithReason:"); - if (sc && [sc respondsToSelector:pauseSel]) { - ((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, pauseSel, 10); - return; - } - if ([storyVC respondsToSelector:pauseSel]) { - ((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, pauseSel, 10); - return; - } -} - -static void sciResumeStoryPlayback(UIView *sourceView) { - UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController"); - if (!storyVC) return; - id sc = sciFindSectionController(storyVC); - - SEL resumeSel1 = NSSelectorFromString(@"tryResumePlaybackWithReason:"); - SEL resumeSel2 = NSSelectorFromString(@"tryResumePlayback"); - if (sc && [sc respondsToSelector:resumeSel1]) { - ((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, resumeSel1, 0); - return; - } - if ([storyVC respondsToSelector:resumeSel2]) { - ((void(*)(id, SEL))objc_msgSend)(storyVC, resumeSel2); - return; - } - if ([storyVC respondsToSelector:resumeSel1]) { - ((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, resumeSel1, 0); - return; - } -} - -%hook IGStoryFullscreenOverlayView - -// ============ Button injection ============ - -- (void)didMoveToSuperview { - %orig; - if (!self.superview) return; - - // Action button - if ([SCIUtils getBoolPref:@"stories_action_button"] && ![self viewWithTag:1340]) { - UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; - btn.tag = 1340; - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; - [btn setImage:[UIImage systemImageNamed:@"ellipsis.circle" withConfiguration:cfg] forState:UIControlStateNormal]; - btn.tintColor = [UIColor whiteColor]; - btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4]; - btn.layer.cornerRadius = 18; - btn.clipsToBounds = YES; - btn.translatesAutoresizingMaskIntoConstraints = NO; - [self addSubview:btn]; - [NSLayoutConstraint activateConstraints:@[ - [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], - [btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], - [btn.widthAnchor constraintEqualToConstant:36], - [btn.heightAnchor constraintEqualToConstant:36] - ]]; - - SCIActionMediaProvider storyProvider = ^id (UIView *sourceView) { - // DM disappearing message — handle directly for tap actions - UIViewController *dmVC = sciFindVC(sourceView, @"IGDirectVisualMessageViewerController"); - if (dmVC) { - sciDownloadDisappearingMedia(dmVC); - return (id)kCFNull; - } - - // Story path - sciPauseStoryPlayback(sourceView); - id item = sciGetCurrentStoryItem(sourceView); - if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) return item; - return sciExtractMediaFromItem(item); - }; - - [SCIActionButton configureButton:btn - context:SCIActionContextStories - prefKey:@"stories_action_default" - mediaProvider:storyProvider]; - - // When configureButton chose "menu" mode, override with our custom - // deferred menu that handles both DM and story contexts. - if (btn.showsMenuAsPrimaryAction) { - btn.menu = [UIMenu menuWithChildren:@[ - [UIDeferredMenuElement elementWithUncachedProvider:^(void (^completion)(NSArray *)) { - UIViewController *dmVC = sciFindVC(btn, @"IGDirectVisualMessageViewerController"); - if (dmVC) { - completion(@[ - [UIAction actionWithTitle:SCILocalized(@"Expand") image:[UIImage systemImageNamed:@"arrow.up.left.and.arrow.down.right"] - identifier:nil handler:^(UIAction *a) { sciExpandDisappearingMedia(dmVC); }], - [UIAction actionWithTitle:SCILocalized(@"Share") image:[UIImage systemImageNamed:@"square.and.arrow.up"] - identifier:nil handler:^(UIAction *a) { sciShareDisappearingMedia(dmVC); }], - [UIAction actionWithTitle:SCILocalized(@"Save to Photos") image:[UIImage systemImageNamed:@"square.and.arrow.down"] - identifier:nil handler:^(UIAction *a) { sciDownloadDisappearingMedia(dmVC); }], - ]); - } else { - id media = nil; - sciPauseStoryPlayback(btn); - id item = sciGetCurrentStoryItem(btn); - media = [item isKindOfClass:NSClassFromString(@"IGMedia")] ? item : sciExtractMediaFromItem(item); - NSArray *actions = [SCIMediaActions actionsForContext:SCIActionContextStories media:media fromView:btn]; - UIMenu *built = [SCIActionMenu buildMenuWithActions:actions]; - completion(built.children); - } - }] - ]]; - } - - // KVO highlighted → resume playback when menu dismisses. - [btn addObserver:self forKeyPath:@"highlighted" - options:NSKeyValueObservingOptionNew context:NULL]; - - - // Story reel items provider for "download all" detection. - static const void *kStoryReelItemsProvider = &kStoryReelItemsProvider; - objc_setAssociatedObject(btn, kStoryReelItemsProvider, ^NSArray *(UIView *src) { - UIViewController *storyVC = sciFindVC(src, @"IGStoryViewerViewController"); - if (!storyVC) return nil; - id vm = sciCall(storyVC, @selector(currentViewModel)); - if (!vm) return nil; - - // Try known selectors - for (NSString *sel in @[@"items", @"storyItems", @"reelItems", @"mediaItems", @"allItems"]) { - if ([vm respondsToSelector:NSSelectorFromString(sel)]) { - @try { - id val = ((id(*)(id,SEL))objc_msgSend)(vm, NSSelectorFromString(sel)); - if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) { - return val; - } - } @catch (__unused id e) {} - } - } - - // Scan vm ivars for arrays of IGMedia - Class mc = NSClassFromString(@"IGMedia"); - unsigned int cnt = 0; - Ivar *ivs = class_copyIvarList(object_getClass(vm), &cnt); - for (unsigned int i = 0; i < cnt; i++) { - const char *type = ivar_getTypeEncoding(ivs[i]); - if (!type || type[0] != '@') continue; - @try { - id val = object_getIvar(vm, ivs[i]); - if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) { - id first = [(NSArray *)val firstObject]; - if (mc && [first isKindOfClass:mc]) { - free(ivs); - return val; - } - // Items might be wrapped — try extracting media from first - IGMedia *extracted = sciExtractMediaFromItem(first); - if (extracted) { - free(ivs); - return val; - } - } - } @catch (__unused id e) {} - } - if (ivs) free(ivs); - - return nil; - }, OBJC_ASSOCIATION_COPY_NONATOMIC); - } - - // Audio toggle button - sciInitStoryAudioState(); - if ([SCIUtils getBoolPref:@"story_audio_toggle"] && ![self viewWithTag:1341]) { - UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; - btn.tag = 1341; - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold]; - NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; - [btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal]; - btn.tintColor = [UIColor whiteColor]; - btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4]; - btn.layer.cornerRadius = 14; - btn.clipsToBounds = YES; - btn.translatesAutoresizingMaskIntoConstraints = NO; - [btn addTarget:self action:@selector(sciAudioToggleTapped:) forControlEvents:UIControlEventTouchUpInside]; - [self addSubview:btn]; - [NSLayoutConstraint activateConstraints:@[ - [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], - [btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12], - [btn.widthAnchor constraintEqualToConstant:28], - [btn.heightAnchor constraintEqualToConstant:28] - ]]; - } - - // Seen button — deferred so the responder chain is wired up - __weak UIView *weakSelf = self; - dispatch_async(dispatch_get_main_queue(), ^{ - UIView *s = weakSelf; - if (s && s.superview) ((void(*)(id, SEL))objc_msgSend)(s, @selector(sciRefreshSeenButton)); - }); -} - -// ============ Seen button lifecycle ============ - -// KVO: action button highlighted → NO means UIMenu dismissed → resume. -%new - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object - change:(NSDictionary *)change context:(void *)context { - if ([keyPath isEqualToString:@"highlighted"]) { - BOOL highlighted = [change[NSKeyValueChangeNewKey] boolValue]; - if (!highlighted) { - sciResumeStoryPlayback(self); - } - } -} - -// Refresh the audio toggle icon (tag 1341) to match current state. -%new - (void)sciRefreshAudioButton { - UIButton *btn = (UIButton *)[self viewWithTag:1341]; - if (!btn) return; - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold]; - NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; - [btn setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal]; -} - -// Rebuilds the eye button (tag 1339). Visible only when the story is -// actively blocked for this owner. List management lives in the hold menu -// and the ellipsis action menu. -%new - (void)sciRefreshSeenButton { - BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"]; - if (!seenBlockingOn) return; - - NSDictionary *ownerInfo = sciOwnerInfoForView(self); - NSString *ownerPK = ownerInfo[@"pk"] ?: @""; - BOOL excluded = ownerPK.length && [SCIExcludedStoryUsers isUserPKExcluded:ownerPK]; - UIButton *existing = (UIButton *)[self viewWithTag:1339]; - - // Not blocked → no eye button. - if (excluded) { [existing removeFromSuperview]; return; } - - BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]; - NSString *symName; - UIColor *tint; - if (toggleMode) { - symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye"; - tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor]; - } else { - symName = @"eye"; tint = [UIColor whiteColor]; - } - - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; - - if (existing) { - [existing setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal]; - existing.tintColor = tint; - return; - } - - UIButton *btn = [UIButton buttonWithType:UIButtonTypeCustom]; - btn.tag = 1339; - [btn setImage:[UIImage systemImageNamed:symName withConfiguration:cfg] forState:UIControlStateNormal]; - btn.tintColor = tint; - btn.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.4]; - btn.layer.cornerRadius = 18; - btn.clipsToBounds = YES; - btn.translatesAutoresizingMaskIntoConstraints = NO; - [btn addTarget:self action:@selector(sciSeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; - UILongPressGestureRecognizer *lp = [[UILongPressGestureRecognizer alloc] - initWithTarget:self action:@selector(sciSeenButtonLongPressed:)]; - lp.minimumPressDuration = 0.4; - [btn addGestureRecognizer:lp]; - [self addSubview:btn]; - UIView *anchor = [self viewWithTag:1340]; - if (anchor) { - [NSLayoutConstraint activateConstraints:@[ - [btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor], - [btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10], - [btn.widthAnchor constraintEqualToConstant:36], - [btn.heightAnchor constraintEqualToConstant:36] - ]]; - } else { - [NSLayoutConstraint activateConstraints:@[ - [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], - [btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], - [btn.widthAnchor constraintEqualToConstant:36], - [btn.heightAnchor constraintEqualToConstant:36] - ]]; - } -} - -// Refresh when story owner changes or audio state changes -- (void)layoutSubviews { - %orig; - static char kLastPKKey; - static char kLastExclKey; - static char kLastAudioKey; - - // Audio button: check if state changed - UIButton *audioBtn = (UIButton *)[self viewWithTag:1341]; - if (audioBtn) { - BOOL audioOn = sciIsStoryAudioEnabled(); - NSNumber *prevAudio = objc_getAssociatedObject(self, &kLastAudioKey); - if (!prevAudio || [prevAudio boolValue] != audioOn) { - objc_setAssociatedObject(self, &kLastAudioKey, @(audioOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC); - ((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshAudioButton)); - } - } - - // Seen button: check if owner/exclusion changed - if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return; - NSDictionary *info = sciOwnerInfoForView(self); - NSString *pk = info[@"pk"] ?: @""; - BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk]; - NSString *prev = objc_getAssociatedObject(self, &kLastPKKey); - NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey); - BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded); - if (!changed) return; - objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC); - objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC); - ((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton)); -} - -// ============ Audio toggle handler ============ - -%new - (void)sciAudioToggleTapped:(UIButton *)sender { - UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; - [haptic impactOccurred]; - sciToggleStoryAudio(); - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:14 weight:UIImageSymbolWeightSemibold]; - NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; - [sender setImage:[UIImage systemImageNamed:icon withConfiguration:cfg] forState:UIControlStateNormal]; -} - -// ============ Seen button tap ============ - -%new - (void)sciSeenButtonTapped:(UIButton *)sender { - // Toggle mode - if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) { - sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled; - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightSemibold]; - [sender setImage:[UIImage systemImageNamed:(sciStorySeenToggleEnabled ? @"eye.fill" : @"eye") withConfiguration:cfg] forState:UIControlStateNormal]; - sender.tintColor = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor]; - [SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")]; - return; - } - - // Button mode: mark seen once - ((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), sender); -} - -// ============ Seen button long-press menu ============ - -%new - (void)sciSeenButtonLongPressed:(UILongPressGestureRecognizer *)gr { - if (gr.state != UIGestureRecognizerStateBegan) return; - UIView *btn = gr.view; - UIViewController *host = [SCIUtils nearestViewControllerForView:self]; - if (!host) return; - - // Pause story while the sheet is open - sciPauseStoryPlayback(self); - UIWindow *capturedWin = btn.window ?: self.window; - if (!capturedWin) { - for (UIWindow *w in [UIApplication sharedApplication].windows) { if (w.isKeyWindow) { capturedWin = w; break; } } - } - NSDictionary *ownerInfo = sciOwnerInfoForView(self); - NSString *pk = ownerInfo[@"pk"]; - NSString *username = ownerInfo[@"username"] ?: @""; - NSString *fullName = ownerInfo[@"fullName"] ?: @""; - BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk]; - BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode]; - - __weak UIView *weakSelf = self; - void (^resume)(void) = ^{ if (weakSelf) sciResumeStoryPlayback(weakSelf); }; - - UIAlertController *sheet = [UIAlertController alertControllerWithTitle:nil message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Mark seen") style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { - ((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciMarkSeenTapped:), btn); - resume(); - }]]; - if (pk) { - NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen"); - NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen"); - NSString *t = inList ? removeLabel : addLabel; - [sheet addAction:[UIAlertAction actionWithTitle:t style:inList ? UIAlertActionStyleDestructive : UIAlertActionStyleDefault handler:^(UIAlertAction *_) { - if (inList) { - [SCIExcludedStoryUsers removePK:pk]; - [SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")]; - if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC); - } else { - [SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }]; - [SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")]; - if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC); - } - sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC); - resume(); - }]]; - } - [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:^(UIAlertAction *_) { - resume(); - }]]; - sheet.popoverPresentationController.sourceView = btn; - sheet.popoverPresentationController.sourceRect = btn.bounds; - [host presentViewController:sheet animated:YES completion:nil]; -} - -// ============ Mark seen handler ============ - -%new - (void)sciMarkSeenTapped:(UIButton *)sender { - UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; - [haptic impactOccurred]; - if (sender) { - [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; } - completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }]; - } - - @try { - // Story path - UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController"); - if (storyVC) { - id sectionCtrl = sciFindSectionController(storyVC); - id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil; - if (!storyItem) storyItem = sciGetCurrentStoryItem(self); - IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem); - - if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; } - - sciAllowSeenForPK(media); - sciSeenBypassActive = YES; - - SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:); - if ([storyVC respondsToSelector:delegateSel]) { - typedef void (*Func)(id, SEL, id, id); - ((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media); - } - if (sectionCtrl) { - SEL markSel = NSSelectorFromString(@"markItemAsSeen:"); - if ([sectionCtrl respondsToSelector:markSel]) - ((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media); - } - id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager)); - id vm = sciCall(storyVC, @selector(currentViewModel)); - if (seenManager && vm) { - SEL setSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:"); - if ([seenManager respondsToSelector:setSel]) { - id mediaPK = sciCall(media, @selector(pk)); - id reelPK = sciCall(vm, NSSelectorFromString(@"reelPK")); - if (!reelPK) reelPK = sciCall(vm, @selector(pk)); - if (mediaPK && reelPK) { - typedef void (*SetFunc)(id, SEL, id, id); - ((SetFunc)objc_msgSend)(seenManager, setSel, mediaPK, reelPK); - } - } - } - sciSeenBypassActive = NO; - [SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked as seen") subtitle:SCILocalized(@"Will sync when leaving stories")]; - - // Advance to next story if enabled (skip when triggered programmatically via exclude) - if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) { - __block id secCtrl = sectionCtrl; - __weak __typeof(self) weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - sciAdvanceBypassActive = YES; - SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:"); - if ([secCtrl respondsToSelector:advSel]) - ((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1); - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - __strong __typeof(weakSelf) strongSelf = weakSelf; - UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil; - id sc2 = vc2 ? sciFindSectionController(vc2) : nil; - if (sc2) { - SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:"); - if ([sc2 respondsToSelector:resumeSel]) - ((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0); - } - sciAdvanceBypassActive = NO; - }); - }); - } - return; - } - - // DM visual message path - UIViewController *dmVC = sciFindVC(self, @"IGDirectVisualMessageViewerController"); - if (dmVC) { - extern BOOL dmVisualMsgsViewedButtonEnabled; - BOOL wasEnabled = dmVisualMsgsViewedButtonEnabled; - dmVisualMsgsViewedButtonEnabled = YES; - - Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource"); - id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil; - Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil; - id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil; - Ivar erIvar = class_getInstanceVariable([dmVC class], "_eventResponders"); - NSArray *responders = erIvar ? object_getIvar(dmVC, erIvar) : nil; - - if (responders && msg) { - for (id resp in responders) { - SEL beginSel = @selector(visualMessageViewerController:didBeginPlaybackForVisualMessage:atIndex:); - if ([resp respondsToSelector:beginSel]) { - typedef void (*Fn)(id, SEL, id, id, NSInteger); - ((Fn)objc_msgSend)(resp, beginSel, dmVC, msg, 0); - } - SEL endSel = @selector(visualMessageViewerController:didEndPlaybackForVisualMessage:atIndex:mediaCurrentTime:forNavType:); - if ([resp respondsToSelector:endSel]) { - typedef void (*Fn)(id, SEL, id, id, NSInteger, CGFloat, NSInteger); - ((Fn)objc_msgSend)(resp, endSel, dmVC, msg, 0, 0.0, 0); - } - } - } - - SEL dismissSel = NSSelectorFromString(@"_didTapHeaderViewDismissButton:"); - if ([dmVC respondsToSelector:dismissSel]) - ((void(*)(id,SEL,id))objc_msgSend)(dmVC, dismissSel, nil); - - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ - dmVisualMsgsViewedButtonEnabled = wasEnabled; - }); - - [SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")]; - return; - } - - [SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")]; - } @catch (NSException *e) { - [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]]; - } -} - -%end - -// ============ Chrome alpha sync ============ - -static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) { - Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); - if (!overlayCls) return; - UIView *cur = self_; - while (cur) { - for (UIView *sib in cur.superview.subviews) { - if (![sib isKindOfClass:overlayCls]) continue; - UIView *seen = [sib viewWithTag:1339]; - UIView *dl = [sib viewWithTag:1340]; - UIView *audio = [sib viewWithTag:1341]; - if (seen) seen.alpha = alpha; - if (dl) dl.alpha = alpha; - if (audio) audio.alpha = alpha; - return; - } - cur = cur.superview; - } -} - -%hook IGStoryFullscreenHeaderView -- (void)setAlpha:(CGFloat)alpha { - %orig; - sciSyncStoryButtonsAlpha((UIView *)self, alpha); -} -%end diff --git a/src/Features/StoriesAndMessages/OverlayHelpers.h b/src/Features/StoriesAndMessages/OverlayHelpers.h new file mode 100644 index 0000000..639c798 --- /dev/null +++ b/src/Features/StoriesAndMessages/OverlayHelpers.h @@ -0,0 +1,46 @@ +// Shared helpers for StoryOverlayButtons.xm and DMOverlayButtons.xm. + +#import "StoryHelpers.h" + +// Disjoint tag spaces so viewWithTag: can't cross-hit between surfaces. +#define SCI_STORY_EYE_TAG 1339 +#define SCI_STORY_ACTION_TAG 1340 +#define SCI_STORY_AUDIO_TAG 1341 +#define SCI_DM_ACTION_TAG 1342 +#define SCI_DM_EYE_TAG 1343 +#define SCI_DM_AUDIO_TAG 1344 + +#ifdef __cplusplus +extern "C" { +#endif + +// From StoryAudioToggle.xm. +void sciToggleStoryAudio(void); +BOOL sciIsStoryAudioEnabled(void); +void sciInitStoryAudioState(void); + +#ifdef __cplusplus +} +#endif +extern BOOL dmVisualMsgsViewedButtonEnabled; +#ifdef __cplusplus +extern "C" { +#endif + +// Context detection / view lookup. +BOOL sciOverlayIsInDMContext(UIView *overlay); +UIView * _Nullable sciFindOverlayInView(UIView *root); + +// DM disappearing-media actions. +NSURL * _Nullable sciDMMediaURL(UIViewController *dmVC, BOOL *outIsVideo); +void sciDMExpandMedia(UIViewController *dmVC); +void sciDMShareMedia(UIViewController *dmVC); +void sciDMDownloadMedia(UIViewController *dmVC); +void sciDMMarkCurrentAsViewed(UIViewController *dmVC); + +// Opens RyukGram settings on the Messages tab. +void sciOpenMessagesSettings(UIView *source); + +#ifdef __cplusplus +} +#endif diff --git a/src/Features/StoriesAndMessages/OverlayHelpers.m b/src/Features/StoriesAndMessages/OverlayHelpers.m new file mode 100644 index 0000000..03aaf4b --- /dev/null +++ b/src/Features/StoriesAndMessages/OverlayHelpers.m @@ -0,0 +1,163 @@ +#import "OverlayHelpers.h" +#import "../../ActionButton/SCIMediaViewer.h" +#import "../../Downloader/Download.h" + +// MARK: - Context detection + +BOOL sciOverlayIsInDMContext(UIView *overlay) { + Class dmCls = NSClassFromString(@"IGDirectVisualMessageViewerController"); + if (!dmCls) return NO; + + UIResponder *r = overlay.nextResponder; + while (r) { + if ([r isKindOfClass:dmCls]) return YES; + r = r.nextResponder; + } + + // Fallback: _gestureDelegate ivar is the DM VC in DM contexts. + static Ivar gdIvar = NULL; + static dispatch_once_t once; + dispatch_once(&once, ^{ + Class c = NSClassFromString(@"IGStoryFullscreenOverlayView"); + if (c) gdIvar = class_getInstanceVariable(c, "_gestureDelegate"); + }); + if (gdIvar) { + id d = object_getIvar(overlay, gdIvar); + if (d && [d isKindOfClass:dmCls]) return YES; + } + return NO; +} + +UIView *sciFindOverlayInView(UIView *root) { + Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); + if (!overlayCls || !root) return nil; + if ([root isKindOfClass:overlayCls]) return root; + for (UIView *sub in root.subviews) { + UIView *found = sciFindOverlayInView(sub); + if (found) return found; + } + return nil; +} + +// MARK: - DM media URL + +NSURL *sciDMMediaURL(UIViewController *dmVC, BOOL *outIsVideo) { + if (!dmVC) return nil; + + Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource"); + id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil; + Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil; + id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil; + if (!msg) return nil; + + Ivar vmiIvar = class_getInstanceVariable([msg class], "_visualMediaInfo"); + id vmi = vmiIvar ? object_getIvar(msg, vmiIvar) : nil; + Ivar mIvar = vmi ? class_getInstanceVariable([vmi class], "_media") : nil; + id visMedia = mIvar ? object_getIvar(vmi, mIvar) : nil; + if (!visMedia) return nil; + + @try { + id rawVideo = [msg valueForKey:@"rawVideo"]; + if (rawVideo) { + NSURL *url = [SCIUtils getVideoUrl:rawVideo]; + if (url) { if (outIsVideo) *outIsVideo = YES; return url; } + } + } @catch (__unused NSException *e) {} + + Ivar pi = class_getInstanceVariable([visMedia class], "_photo_photo"); + id photo = pi ? object_getIvar(visMedia, pi) : nil; + if (photo) { + if (outIsVideo) *outIsVideo = NO; + return [SCIUtils getPhotoUrl:photo]; + } + return nil; +} + +// MARK: - DM actions + +// Strong refs — SCIDownloadDelegate needs to outlive the download. +static SCIDownloadDelegate *sciDMShareDelegate = nil; +static SCIDownloadDelegate *sciDMDownloadDelegate = nil; + +void sciDMExpandMedia(UIViewController *dmVC) { + BOOL isVideo = NO; + NSURL *url = sciDMMediaURL(dmVC, &isVideo); + if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; } + if (isVideo) [SCIMediaViewer showWithVideoURL:url photoURL:nil caption:nil]; + else [SCIMediaViewer showWithVideoURL:nil photoURL:url caption:nil]; +} + +void sciDMShareMedia(UIViewController *dmVC) { + BOOL isVideo = NO; + NSURL *url = sciDMMediaURL(dmVC, &isVideo); + if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; } + sciDMShareDelegate = [[SCIDownloadDelegate alloc] initWithAction:share showProgress:YES]; + [sciDMShareDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil]; +} + +void sciDMDownloadMedia(UIViewController *dmVC) { + BOOL isVideo = NO; + NSURL *url = sciDMMediaURL(dmVC, &isVideo); + if (!url) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find media")]; return; } + sciDMDownloadDelegate = [[SCIDownloadDelegate alloc] initWithAction:saveToPhotos showProgress:YES]; + [sciDMDownloadDelegate downloadFileWithURL:url fileExtension:(isVideo ? @"mp4" : @"jpg") hudLabel:nil]; +} + +// Flips dmVisualMsgsViewedButtonEnabled for ~1s so VisualMsgModifier lets the +// begin/end playback callbacks through, then restores. +void sciDMMarkCurrentAsViewed(UIViewController *dmVC) { + if (!dmVC) return; + + BOOL wasEnabled = dmVisualMsgsViewedButtonEnabled; + dmVisualMsgsViewedButtonEnabled = YES; + + Ivar dsIvar = class_getInstanceVariable([dmVC class], "_dataSource"); + id ds = dsIvar ? object_getIvar(dmVC, dsIvar) : nil; + Ivar msgIvar = ds ? class_getInstanceVariable([ds class], "_currentMessage") : nil; + id msg = msgIvar ? object_getIvar(ds, msgIvar) : nil; + Ivar erIvar = class_getInstanceVariable([dmVC class], "_eventResponders"); + NSArray *responders = erIvar ? object_getIvar(dmVC, erIvar) : nil; + + if (responders && msg) { + for (id resp in responders) { + SEL beginSel = @selector(visualMessageViewerController:didBeginPlaybackForVisualMessage:atIndex:); + if ([resp respondsToSelector:beginSel]) { + typedef void (*Fn)(id, SEL, id, id, NSInteger); + ((Fn)objc_msgSend)(resp, beginSel, dmVC, msg, 0); + } + SEL endSel = @selector(visualMessageViewerController:didEndPlaybackForVisualMessage:atIndex:mediaCurrentTime:forNavType:); + if ([resp respondsToSelector:endSel]) { + typedef void (*Fn)(id, SEL, id, id, NSInteger, CGFloat, NSInteger); + ((Fn)objc_msgSend)(resp, endSel, dmVC, msg, 0, 0.0, 0); + } + } + } + + SEL dismissSel = NSSelectorFromString(@"_didTapHeaderViewDismissButton:"); + if ([dmVC respondsToSelector:dismissSel]) { + ((void(*)(id,SEL,id))objc_msgSend)(dmVC, dismissSel, nil); + } + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.0 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + dmVisualMsgsViewedButtonEnabled = wasEnabled; + }); + + [SCIUtils showToastForDuration:1.5 title:SCILocalized(@"Marked as viewed")]; +} + +// MARK: - Settings shortcut + +void sciOpenMessagesSettings(UIView *source) { + UIWindow *win = source.window; + if (!win) { + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) continue; + for (UIWindow *w in ((UIWindowScene *)scene).windows) { + if (w.isKeyWindow) { win = w; break; } + } + if (win) break; + } + } + if (!win) return; + [SCIUtils showSettingsVC:win atTopLevelEntry:SCILocalized(@"Messages")]; +} diff --git a/src/Features/StoriesAndMessages/SCIStoryInteractionPipeline.m b/src/Features/StoriesAndMessages/SCIStoryInteractionPipeline.m index 6691de6..ba2ef96 100644 --- a/src/Features/StoriesAndMessages/SCIStoryInteractionPipeline.m +++ b/src/Features/StoriesAndMessages/SCIStoryInteractionPipeline.m @@ -63,7 +63,7 @@ static void sciMarkSeen(NSString *prefKey) { if (!prefKey || ![SCIUtils getBoolPref:prefKey]) return; UIView *overlay = sciFindOverlay(sciActiveStoryVC); if (!overlay) return; - SEL sel = NSSelectorFromString(@"sciMarkSeenTapped:"); + SEL sel = NSSelectorFromString(@"sciStoryMarkSeenTapped:"); if ([overlay respondsToSelector:sel]) ((void(*)(id, SEL, id))objc_msgSend)(overlay, sel, nil); } diff --git a/src/Features/StoriesAndMessages/SeenButtons.x b/src/Features/StoriesAndMessages/SeenButtons.x index cfdd06b..d694221 100644 --- a/src/Features/StoriesAndMessages/SeenButtons.x +++ b/src/Features/StoriesAndMessages/SeenButtons.x @@ -1,6 +1,7 @@ #import "../../InstagramHeaders.h" #import "../../Tweak.h" #import "../../Utils.h" +#import "../../SCIChrome.h" #import "SCIExcludedThreads.h" #import #import @@ -309,11 +310,13 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) { BOOL navInList = navThreadId && [SCIExcludedThreads isInList:navThreadId]; if ([SCIUtils getBoolPref:@"remove_lastseen"] && !navExcluded) { - UIBarButtonItem *seenButton = [[UIBarButtonItem alloc] initWithImage:[UIImage systemImageNamed:@"eye"] style:UIBarButtonItemStylePlain target:self action:@selector(seenButtonHandler:)]; + SCIChromeButton *inner = nil; + UIBarButtonItem *seenButton = SCIChromeBarButtonItem(@"eye", 18, self, @selector(seenButtonHandler:), &inner); seenButton.accessibilityIdentifier = @"sci-seen-btn"; - if (sciIsSeenToggleMode()) - [seenButton setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor]; - seenButton.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window); + UIColor *tint = UIColor.labelColor; + if (sciIsSeenToggleMode()) tint = dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor; + inner.iconTint = tint; + inner.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window); [new_items addObject:seenButton]; } @@ -328,25 +331,23 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) { BOOL showAddBtn = blockSelected && !navInList; if (showListButton && (showRemoveBtn || showAddBtn)) { SEL action = showRemoveBtn ? @selector(sciUnexcludeButtonHandler:) : @selector(sciAddToListHandler:); - UIBarButtonItem *listBtn = [[UIBarButtonItem alloc] - initWithImage:[UIImage systemImageNamed:showRemoveBtn ? @"eye.slash.fill" : @"eye.slash"] - style:UIBarButtonItemStylePlain - target:self - action:action]; + NSString *sym = showRemoveBtn ? @"eye.slash.fill" : @"eye.slash"; + SCIChromeButton *inner = nil; + UIBarButtonItem *listBtn = SCIChromeBarButtonItem(sym, 18, self, action, &inner); listBtn.accessibilityIdentifier = @"sci-unex-btn"; - listBtn.tintColor = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor; - listBtn.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window); + inner.iconTint = showRemoveBtn ? SCIUtils.SCIColor_Primary : UIColor.labelColor; + inner.menu = sciBuildThreadActionsMenu(self, navThreadId, self.window); [new_items addObject:listBtn]; } // Replay toggle: in eye menu when eye button exists, standalone button otherwise BOOL eyeButtonOn = [SCIUtils getBoolPref:@"remove_lastseen"]; if ([SCIUtils getBoolPref:@"unlimited_replay"] && !navExcluded && !eyeButtonOn) { - UIBarButtonItem *replayBtn = [[UIBarButtonItem alloc] - initWithImage:[UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"] - style:UIBarButtonItemStylePlain target:self action:@selector(sciReplayToggleHandler:)]; + NSString *sym = dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"; + SCIChromeButton *inner = nil; + UIBarButtonItem *replayBtn = SCIChromeBarButtonItem(sym, 18, self, @selector(sciReplayToggleHandler:), &inner); replayBtn.accessibilityIdentifier = @"sci-visual-btn"; - replayBtn.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary; + inner.iconTint = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary; [new_items addObject:replayBtn]; } @@ -355,10 +356,14 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) { // ============ MESSAGES SEEN BUTTON ============ -%new - (void)seenButtonHandler:(UIBarButtonItem *)sender { +%new - (void)seenButtonHandler:(id)sender { + UIBarButtonItem *barItem = [sender isKindOfClass:[UIBarButtonItem class]] ? (UIBarButtonItem *)sender : nil; + SCIChromeButton *inner = [sender isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)sender : SCIChromeButtonForBarItem(barItem); if (sciIsSeenToggleMode()) { dmSeenToggleEnabled = !dmSeenToggleEnabled; - [sender setTintColor:dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor]; + UIColor *tint = dmSeenToggleEnabled ? SCIUtils.SCIColor_Primary : UIColor.labelColor; + if (inner) inner.iconTint = tint; + else [barItem setTintColor:tint]; if (dmSeenToggleEnabled) { UIViewController *nearestVC = [SCIUtils nearestViewControllerForView:self]; if ([nearestVC isKindOfClass:%c(IGDirectThreadViewController)]) @@ -377,13 +382,19 @@ static NSDictionary *sciEntryFromThreadVC(UIViewController *vc) { // Rebuild menu so toggle text updates UIViewController *navNearestVC = [SCIUtils nearestViewControllerForView:self]; NSString *tid = sciThreadIdForVC(navNearestVC); - sender.menu = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window); + UIMenu *rebuilt = sciBuildThreadActionsMenu(self, tid, ((UIView *)self).window); + if (inner) inner.menu = rebuilt; + else if (barItem) barItem.menu = rebuilt; } -%new - (void)sciReplayToggleHandler:(UIBarButtonItem *)sender { +%new - (void)sciReplayToggleHandler:(id)sender { + UIBarButtonItem *barItem = [sender isKindOfClass:[UIBarButtonItem class]] ? (UIBarButtonItem *)sender : nil; + SCIChromeButton *inner = [sender isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)sender : SCIChromeButtonForBarItem(barItem); dmVisualMsgsViewedButtonEnabled = !dmVisualMsgsViewedButtonEnabled; - sender.image = [UIImage systemImageNamed:dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"]; - sender.tintColor = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary; + NSString *sym = dmVisualMsgsViewedButtonEnabled ? @"photo.badge.checkmark" : @"photo.badge.checkmark.fill"; + UIColor *tint = dmVisualMsgsViewedButtonEnabled ? UIColor.labelColor : SCIUtils.SCIColor_Primary; + if (inner) { inner.symbolName = sym; inner.iconTint = tint; } + else if (barItem) { barItem.image = [UIImage systemImageNamed:sym]; barItem.tintColor = tint; } [SCIUtils showToastForDuration:2.0 title:dmVisualMsgsViewedButtonEnabled ? SCILocalized(@"Visual messages will expire") : SCILocalized(@"Unlimited replay enabled")]; } diff --git a/src/Features/StoriesAndMessages/StoryAudioToggle.xm b/src/Features/StoriesAndMessages/StoryAudioToggle.xm index e480db6..2c62474 100644 --- a/src/Features/StoriesAndMessages/StoryAudioToggle.xm +++ b/src/Features/StoriesAndMessages/StoryAudioToggle.xm @@ -1,5 +1,7 @@ -// Story audio mute/unmute toggle. Posts mute-switch-state-changed to toggle -// IG's audio. Reads _audioEnabled on IGAudioStatusAnnouncer for icon state. +// Story audio mute/unmute toggle. +// Flips IGAudioStatusAnnouncer private state then fans out to listeners +// via the two IGUltralightAnnouncer sub-forwarders (426 dropped the old +// mute-switch notification). #import #import "StoryHelpers.h" @@ -12,14 +14,34 @@ extern "C" void sciRefreshAllVisibleOverlays(UIViewController *); static id sciAudioAnnouncer = nil; +static id sciReadIvar(id obj, const char *name) { + if (!obj) return nil; + Ivar iv = class_getInstanceVariable([obj class], name); + if (!iv) return nil; + return object_getIvar(obj, iv); +} + static BOOL sciIGAudioEnabled(void) { if (!sciAudioAnnouncer) return NO; + SEL s = NSSelectorFromString(@"isAudioEnabledForSoundBehavior:"); + if ([sciAudioAnnouncer respondsToSelector:s]) { + typedef BOOL (*Fn)(id, SEL, NSInteger); + return ((Fn)objc_msgSend)(sciAudioAnnouncer, s, 1); + } Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled"); if (!ivar) return NO; ptrdiff_t offset = ivar_getOffset(ivar); return *(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset); } +static void sciWriteAudioEnabled(BOOL value) { + if (!sciAudioAnnouncer) return; + Ivar ivar = class_getInstanceVariable([sciAudioAnnouncer class], "_audioEnabled"); + if (!ivar) return; + ptrdiff_t offset = ivar_getOffset(ivar); + *(BOOL *)((char *)(__bridge void *)sciAudioAnnouncer + offset) = value; +} + // ============ Volume KVO ============ @interface _SciVolumeObserver : NSObject @@ -41,12 +63,29 @@ extern "C" { BOOL sciStoryAudioBypass = NO; void sciToggleStoryAudio(void) { + if (!sciAudioAnnouncer) return; + BOOL on = sciIGAudioEnabled(); + BOOL wanted = !on; sciStoryAudioBypass = YES; - [[NSNotificationCenter defaultCenter] - postNotificationName:@"mute-switch-state-changed" - object:nil - userInfo:@{@"mute-state": @(on ? 0 : 1)}]; + + sciWriteAudioEnabled(wanted); + + // 2 = user-enabled, 1 = user-disabled. + Ivar stickIv = class_getInstanceVariable([sciAudioAnnouncer class], "_stickySoundState"); + if (stickIv) { + ptrdiff_t off = ivar_getOffset(stickIv); + NSInteger *p = (NSInteger *)((char *)(__bridge void *)sciAudioAnnouncer + off); + *p = wanted ? 2 : 1; + } + + SEL notify = NSSelectorFromString(@"audioStatusDidChangeIsAudioEnabled:forReason:"); + typedef void (*NotifyFn)(id, SEL, BOOL, NSInteger); + id subA = sciReadIvar(sciAudioAnnouncer, "_announcerForDefaultBehaviors"); + id subB = sciReadIvar(sciAudioAnnouncer, "_announcerForIgnoreUserPreferenceAndMatchDeviceState"); + if (subA) ((NotifyFn)objc_msgSend)(subA, notify, wanted, 0); + if (subB) ((NotifyFn)objc_msgSend)(subB, notify, wanted, 0); + sciStoryAudioBypass = NO; if (sciActiveStoryViewerVC) sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC); } diff --git a/src/Features/StoriesAndMessages/StoryLikeHook.x b/src/Features/StoriesAndMessages/StoryLikeHook.x index 9145529..b612748 100644 --- a/src/Features/StoriesAndMessages/StoryLikeHook.x +++ b/src/Features/StoriesAndMessages/StoryLikeHook.x @@ -31,9 +31,11 @@ static void new_sciStoryLikeTap(id self, SEL _cmd, id button) { } %ctor { - Class cls = NSClassFromString(@"IGStoryLikesInteractionControllingImpl"); + Class cls = NSClassFromString(@"_TtC22IGStoryLikesController38IGStoryLikesInteractionControllingImpl"); + if (!cls) cls = NSClassFromString(@"IGStoryLikesInteractionControllingImpl"); if (!cls) return; - SEL sel = NSSelectorFromString(@"handleStoryLikeTapWithButton:"); + SEL sel = NSSelectorFromString(@"handleStoryLikeTapWith:"); + if (!class_getInstanceMethod(cls, sel)) sel = NSSelectorFromString(@"handleStoryLikeTapWithButton:"); if (!class_getInstanceMethod(cls, sel)) return; MSHookMessageEx(cls, sel, (IMP)new_sciStoryLikeTap, (IMP *)&orig_sciStoryLikeTap); } diff --git a/src/Features/StoriesAndMessages/StoryMentions.x b/src/Features/StoriesAndMessages/StoryMentions.x index 68fda3a..1a9fdc6 100644 --- a/src/Features/StoriesAndMessages/StoryMentions.x +++ b/src/Features/StoriesAndMessages/StoryMentions.x @@ -3,6 +3,7 @@ #import "../../Utils.h" #import "../../InstagramHeaders.h" +#import "../../SCIImageCache.h" #import "../../Networking/SCIInstagramAPI.h" #import "StoryHelpers.h" #import @@ -358,21 +359,15 @@ static NSDictionary *sciMentionUserInfo(id mention) { avatar.tintColor = [UIColor tertiaryLabelColor]; if (picURL) { - NSURL *url = [picURL copy]; NSInteger row = indexPath.row; - dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ - NSData *data = [NSData dataWithContentsOfURL:url]; - if (!data) return; - UIImage *img = [UIImage imageWithData:data]; + [SCIImageCache loadImageFromURL:picURL completion:^(UIImage *img) { if (!img) return; - dispatch_async(dispatch_get_main_queue(), ^{ - UITableViewCell *c = [tableView cellForRowAtIndexPath: - [NSIndexPath indexPathForRow:row inSection:0]]; - if (!c) return; - UIImageView *av = [c.contentView viewWithTag:kAvTag]; - if (av) { av.image = img; av.tintColor = nil; } - }); - }); + UITableViewCell *c = [tableView cellForRowAtIndexPath: + [NSIndexPath indexPathForRow:row inSection:0]]; + if (!c) return; + UIImageView *av = [c.contentView viewWithTag:kAvTag]; + if (av) { av.image = img; av.tintColor = nil; } + }]; } [followBtn removeTarget:nil action:NULL forControlEvents:UIControlEventTouchUpInside]; diff --git a/src/Features/StoriesAndMessages/StoryOverlayButtons.xm b/src/Features/StoriesAndMessages/StoryOverlayButtons.xm new file mode 100644 index 0000000..1fa3d60 --- /dev/null +++ b/src/Features/StoriesAndMessages/StoryOverlayButtons.xm @@ -0,0 +1,495 @@ +// Story overlay buttons — action / audio / eye (tags 1339–1341). +// Early-exits in DM context; DMOverlayButtons.xm handles that surface. + +#import "OverlayHelpers.h" +#import "SCIExcludedStoryUsers.h" +#import "../../SCIChrome.h" +#import "../../ActionButton/SCIActionButton.h" +#import "../../ActionButton/SCIMediaActions.h" +#import "../../ActionButton/SCIActionMenu.h" +#import "../../Downloader/Download.h" + +extern "C" BOOL sciSeenBypassActive; +extern "C" BOOL sciAdvanceBypassActive; +extern "C" void sciAllowSeenForPK(id); +extern "C" BOOL sciStorySeenToggleEnabled; +extern "C" void sciRefreshAllVisibleOverlays(UIViewController *storyVC); +extern "C" void sciTriggerStoryMarkSeen(UIViewController *storyVC); +extern "C" __weak UIViewController *sciActiveStoryViewerVC; +extern "C" NSDictionary *sciOwnerInfoForView(UIView *view); +extern "C" void sciShowStoryMentions(UIViewController *, UIView *); + +// MARK: - Playback control + +static void sciPauseStoryPlayback(UIView *sourceView) { + UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController"); + if (!storyVC) return; + id sc = sciFindSectionController(storyVC); + + SEL pauseSel = NSSelectorFromString(@"pauseWithReason:"); + if (sc && [sc respondsToSelector:pauseSel]) { + ((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, pauseSel, 10); + return; + } + if ([storyVC respondsToSelector:pauseSel]) { + ((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, pauseSel, 10); + } +} + +static void sciResumeStoryPlayback(UIView *sourceView) { + UIViewController *storyVC = sciFindVC(sourceView, @"IGStoryViewerViewController"); + if (!storyVC) return; + id sc = sciFindSectionController(storyVC); + + SEL resumeSel1 = NSSelectorFromString(@"tryResumePlaybackWithReason:"); + SEL resumeSel2 = NSSelectorFromString(@"tryResumePlayback"); + if (sc && [sc respondsToSelector:resumeSel1]) { + ((void(*)(id, SEL, NSInteger))objc_msgSend)(sc, resumeSel1, 0); + return; + } + if ([storyVC respondsToSelector:resumeSel2]) { + ((void(*)(id, SEL))objc_msgSend)(storyVC, resumeSel2); + return; + } + if ([storyVC respondsToSelector:resumeSel1]) { + ((void(*)(id, SEL, NSInteger))objc_msgSend)(storyVC, resumeSel1, 0); + } +} + +// MARK: - Overlay hook + +%group StoryOverlayGroup + +%hook IGStoryFullscreenOverlayView + +- (void)didMoveToSuperview { + %orig; + if (!self.superview) return; + + // Strip stale tags up-front so nothing flashes when this overlay + // turns out to belong to a DM viewer. + UIView *sA = [self viewWithTag:SCI_STORY_ACTION_TAG]; if (sA) [sA removeFromSuperview]; + UIView *sE = [self viewWithTag:SCI_STORY_EYE_TAG]; if (sE) [sE removeFromSuperview]; + UIView *sU = [self viewWithTag:SCI_STORY_AUDIO_TAG]; if (sU) [sU removeFromSuperview]; + + // Defer one tick — responder chain isn't complete yet, so the DM + // context check needs to run after the current runloop iteration. + __weak __typeof(self) weakSelf = self; + dispatch_async(dispatch_get_main_queue(), ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf || !strongSelf.superview) return; + if (sciOverlayIsInDMContext(strongSelf)) return; + ((void(*)(id, SEL))objc_msgSend)(strongSelf, @selector(sciInstallStoryOverlayButtons)); + }); +} + +%new - (void)sciInstallStoryOverlayButtons { + if (!self.superview) return; + + // --- Action button (tag 1340) --- + UIView *staleAction = [self viewWithTag:SCI_STORY_ACTION_TAG]; + if (staleAction) { + @try { [staleAction removeObserver:self forKeyPath:@"highlighted"]; } @catch (__unused id e) {} + [staleAction removeFromSuperview]; + } + if ([SCIUtils getBoolPref:@"stories_action_button"]) { + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:@"ellipsis.circle" pointSize:18 diameter:36]; + btn.tag = SCI_STORY_ACTION_TAG; + [self addSubview:btn]; + [NSLayoutConstraint activateConstraints:@[ + [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], + [btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; + + SCIActionMediaProvider provider = ^id (UIView *sourceView) { + sciPauseStoryPlayback(sourceView); + id item = sciGetCurrentStoryItem(sourceView); + if ([item isKindOfClass:NSClassFromString(@"IGMedia")]) return item; + id extracted = sciExtractMediaFromItem(item); + return extracted ?: (id)kCFNull; + }; + + [SCIActionButton configureButton:btn + context:SCIActionContextStories + prefKey:@"stories_action_default" + mediaProvider:provider]; + + // Resume playback when the native UIMenu dismisses. + [btn addObserver:self forKeyPath:@"highlighted" + options:NSKeyValueObservingOptionNew context:NULL]; + + // Reel items provider — used by SCIMediaActions for "download all". + static const void *kStoryReelItemsProvider = &kStoryReelItemsProvider; + objc_setAssociatedObject(btn, kStoryReelItemsProvider, ^NSArray *(UIView *src) { + UIViewController *storyVC = sciFindVC(src, @"IGStoryViewerViewController"); + if (!storyVC) return nil; + id vm = sciCall(storyVC, @selector(currentViewModel)); + if (!vm) return nil; + + for (NSString *sel in @[@"items", @"storyItems", @"reelItems", @"mediaItems", @"allItems"]) { + if ([vm respondsToSelector:NSSelectorFromString(sel)]) { + @try { + id val = ((id(*)(id,SEL))objc_msgSend)(vm, NSSelectorFromString(sel)); + if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) return val; + } @catch (__unused id e) {} + } + } + + Class mc = NSClassFromString(@"IGMedia"); + unsigned int cnt = 0; + Ivar *ivs = class_copyIvarList(object_getClass(vm), &cnt); + for (unsigned int i = 0; i < cnt; i++) { + const char *type = ivar_getTypeEncoding(ivs[i]); + if (!type || type[0] != '@') continue; + @try { + id val = object_getIvar(vm, ivs[i]); + if ([val isKindOfClass:[NSArray class]] && [(NSArray *)val count] > 1) { + id first = [(NSArray *)val firstObject]; + if (mc && [first isKindOfClass:mc]) { free(ivs); return val; } + IGMedia *extracted = sciExtractMediaFromItem(first); + if (extracted) { free(ivs); return val; } + } + } @catch (__unused id e) {} + } + if (ivs) free(ivs); + return nil; + }, OBJC_ASSOCIATION_COPY_NONATOMIC); + } + + // --- Audio toggle (tag 1341) --- + UIView *staleAudio = [self viewWithTag:SCI_STORY_AUDIO_TAG]; + if (staleAudio) [staleAudio removeFromSuperview]; + sciInitStoryAudioState(); + if ([SCIUtils getBoolPref:@"story_audio_toggle"]) { + NSString *icon = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:icon pointSize:14 diameter:28]; + btn.tag = SCI_STORY_AUDIO_TAG; + [btn addTarget:self action:@selector(sciStoryAudioToggleTapped:) forControlEvents:UIControlEventTouchUpInside]; + [self addSubview:btn]; + [NSLayoutConstraint activateConstraints:@[ + [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], + [btn.leadingAnchor constraintEqualToAnchor:self.leadingAnchor constant:12], + [btn.widthAnchor constraintEqualToConstant:28], + [btn.heightAnchor constraintEqualToConstant:28] + ]]; + } + + // --- Eye / mark-seen (tag 1339) --- + // layoutSubviews can fire between the tick-0 strip and now, creating + // the eye with fallback constraints before the action exists. Drop it + // so the refresh rebuilds it anchored to the action button. + UIView *staleEye = [self viewWithTag:SCI_STORY_EYE_TAG]; + if (staleEye) [staleEye removeFromSuperview]; + ((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton)); +} + +// MARK: - Action button menu-dismiss resume + +%new - (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object + change:(NSDictionary *)change context:(void *)context { + if ([keyPath isEqualToString:@"highlighted"]) { + BOOL highlighted = [change[NSKeyValueChangeNewKey] boolValue]; + if (!highlighted) sciResumeStoryPlayback(self); + } +} + +// MARK: - Audio toggle + +%new - (void)sciStoryAudioToggleTapped:(SCIChromeButton *)sender { + UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleLight]; + [haptic impactOccurred]; + sciToggleStoryAudio(); + sender.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; +} + +%new - (void)sciRefreshStoryAudioButton { + SCIChromeButton *btn = (SCIChromeButton *)[self viewWithTag:SCI_STORY_AUDIO_TAG]; + if (![btn isKindOfClass:[SCIChromeButton class]]) return; + btn.symbolName = sciIsStoryAudioEnabled() ? @"speaker.wave.2" : @"speaker.slash"; +} + +// MARK: - Seen eye button + +// Visible only when no_seen_receipt is on and the owner isn't excluded. +%new - (void)sciRefreshSeenButton { + BOOL seenBlockingOn = [SCIUtils getBoolPref:@"no_seen_receipt"]; + if (!seenBlockingOn) { UIView *old = [self viewWithTag:SCI_STORY_EYE_TAG]; if (old) [old removeFromSuperview]; return; } + + NSDictionary *ownerInfo = sciOwnerInfoForView(self); + NSString *ownerPK = ownerInfo[@"pk"] ?: @""; + BOOL excluded = ownerPK.length && [SCIExcludedStoryUsers isUserPKExcluded:ownerPK]; + SCIChromeButton *existing = (SCIChromeButton *)[self viewWithTag:SCI_STORY_EYE_TAG]; + if (![existing isKindOfClass:[SCIChromeButton class]]) existing = nil; + + if (excluded) { if (existing) [existing removeFromSuperview]; return; } + + BOOL toggleMode = [[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]; + NSString *symName; + UIColor *tint; + if (toggleMode) { + symName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye"; + tint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor]; + } else { + symName = @"eye"; tint = [UIColor whiteColor]; + } + + if (existing) { + existing.symbolName = symName; + existing.iconTint = tint; + return; + } + + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:symName pointSize:18 diameter:36]; + btn.tag = SCI_STORY_EYE_TAG; + btn.iconTint = tint; + [btn addTarget:self action:@selector(sciStorySeenButtonTapped:) forControlEvents:UIControlEventTouchUpInside]; + // Long-press → context menu (positions itself next to the button). + UIContextMenuInteraction *ix = [[UIContextMenuInteraction alloc] initWithDelegate:(id)self]; + [btn addInteraction:ix]; + [self addSubview:btn]; + + UIView *anchor = [self viewWithTag:SCI_STORY_ACTION_TAG]; + if (anchor) { + [NSLayoutConstraint activateConstraints:@[ + [btn.centerYAnchor constraintEqualToAnchor:anchor.centerYAnchor], + [btn.trailingAnchor constraintEqualToAnchor:anchor.leadingAnchor constant:-10], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; + } else { + [NSLayoutConstraint activateConstraints:@[ + [btn.bottomAnchor constraintEqualToAnchor:self.safeAreaLayoutGuide.bottomAnchor constant:-100], + [btn.trailingAnchor constraintEqualToAnchor:self.trailingAnchor constant:-12], + [btn.widthAnchor constraintEqualToConstant:36], + [btn.heightAnchor constraintEqualToConstant:36] + ]]; + } +} + +// MARK: - Owner / audio refresh on layout + +- (void)layoutSubviews { + %orig; + static char kLastPKKey; + static char kLastExclKey; + static char kLastAudioKey; + + UIButton *audioBtn = (UIButton *)[self viewWithTag:SCI_STORY_AUDIO_TAG]; + if (audioBtn) { + BOOL audioOn = sciIsStoryAudioEnabled(); + NSNumber *prevAudio = objc_getAssociatedObject(self, &kLastAudioKey); + if (!prevAudio || [prevAudio boolValue] != audioOn) { + objc_setAssociatedObject(self, &kLastAudioKey, @(audioOn), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + ((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshStoryAudioButton)); + } + } + + if (![SCIUtils getBoolPref:@"no_seen_receipt"]) return; + NSDictionary *info = sciOwnerInfoForView(self); + NSString *pk = info[@"pk"] ?: @""; + BOOL excluded = pk.length && [SCIExcludedStoryUsers isUserPKExcluded:pk]; + NSString *prev = objc_getAssociatedObject(self, &kLastPKKey); + NSNumber *prevExcl = objc_getAssociatedObject(self, &kLastExclKey); + BOOL changed = ![pk isEqualToString:prev ?: @""] || (prevExcl && [prevExcl boolValue] != excluded); + if (!changed) return; + objc_setAssociatedObject(self, &kLastPKKey, pk, OBJC_ASSOCIATION_COPY_NONATOMIC); + objc_setAssociatedObject(self, &kLastExclKey, @(excluded), OBJC_ASSOCIATION_RETAIN_NONATOMIC); + ((void(*)(id, SEL))objc_msgSend)(self, @selector(sciRefreshSeenButton)); +} + +// MARK: - Seen button tap handlers + +%new - (void)sciStorySeenButtonTapped:(SCIChromeButton *)sender { + if ([[SCIUtils getStringPref:@"story_seen_mode"] isEqualToString:@"toggle"]) { + sciStorySeenToggleEnabled = !sciStorySeenToggleEnabled; + sender.symbolName = sciStorySeenToggleEnabled ? @"eye.fill" : @"eye"; + sender.iconTint = sciStorySeenToggleEnabled ? SCIUtils.SCIColor_Primary : [UIColor whiteColor]; + [SCIUtils showToastForDuration:2.0 title:sciStorySeenToggleEnabled ? SCILocalized(@"Story read receipts enabled") : SCILocalized(@"Story read receipts disabled")]; + return; + } + ((void(*)(id, SEL, id))objc_msgSend)(self, @selector(sciStoryMarkSeenTapped:), sender); +} + +// Long-press menu — rebuilt per display so owner/exclusion is always fresh. +%new - (UIContextMenuConfiguration *)contextMenuInteraction:(UIContextMenuInteraction *)interaction + configurationForMenuAtLocation:(CGPoint)location { + __weak __typeof(self) weakSelf = self; + return [UIContextMenuConfiguration + configurationWithIdentifier:nil + previewProvider:nil + actionProvider:^UIMenu * _Nullable(NSArray * _Nonnull suggested) { + __strong __typeof(weakSelf) strongSelf = weakSelf; + if (!strongSelf) return nil; + + NSDictionary *ownerInfo = sciOwnerInfoForView(strongSelf); + NSString *pk = ownerInfo[@"pk"]; + NSString *username = ownerInfo[@"username"] ?: @""; + NSString *fullName = ownerInfo[@"fullName"] ?: @""; + BOOL inList = pk && [SCIExcludedStoryUsers isInList:pk]; + BOOL blockSelected = [SCIExcludedStoryUsers isBlockSelectedMode]; + + NSMutableArray *items = [NSMutableArray array]; + [items addObject:[UIAction actionWithTitle:SCILocalized(@"Mark seen") + image:[UIImage systemImageNamed:@"eye"] + identifier:nil + handler:^(UIAction *a) { + ((void(*)(id, SEL, id))objc_msgSend)(strongSelf, @selector(sciStoryMarkSeenTapped:), nil); + }]]; + if (pk) { + NSString *addLabel = blockSelected ? SCILocalized(@"Add to block list") : SCILocalized(@"Exclude story seen"); + NSString *removeLabel = blockSelected ? SCILocalized(@"Remove from block list") : SCILocalized(@"Un-exclude story seen"); + NSString *t = inList ? removeLabel : addLabel; + NSString *img = inList ? @"minus.circle" : @"eye.slash"; + UIAction *excl = [UIAction actionWithTitle:t + image:[UIImage systemImageNamed:img] + identifier:nil + handler:^(UIAction *a) { + if (inList) { + [SCIExcludedStoryUsers removePK:pk]; + [SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Unblocked") : SCILocalized(@"Un-excluded")]; + if (blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC); + } else { + [SCIExcludedStoryUsers addOrUpdateEntry:@{ @"pk": pk, @"username": username, @"fullName": fullName }]; + [SCIUtils showToastForDuration:2.0 title:blockSelected ? SCILocalized(@"Blocked") : SCILocalized(@"Excluded")]; + if (!blockSelected) sciTriggerStoryMarkSeen(sciActiveStoryViewerVC); + } + sciRefreshAllVisibleOverlays(sciActiveStoryViewerVC); + }]; + if (inList) excl.attributes = UIMenuElementAttributesDestructive; + [items addObject:excl]; + } + return [UIMenu menuWithTitle:@"" children:items]; + }]; +} + +%new - (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction + willDisplayMenuForConfiguration:(UIContextMenuConfiguration *)configuration + animator:(id)animator { + sciPauseStoryPlayback(self); +} + +%new - (void)contextMenuInteraction:(UIContextMenuInteraction *)interaction + willEndForConfiguration:(UIContextMenuConfiguration *)configuration + animator:(id)animator { + __weak __typeof(self) weakSelf = self; + void (^resume)(void) = ^{ if (weakSelf) sciResumeStoryPlayback(weakSelf); }; + if (animator) [animator addCompletion:resume]; + else resume(); +} + +%new - (void)sciStoryMarkSeenTapped:(UIButton *)sender { + UIImpactFeedbackGenerator *haptic = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; + [haptic impactOccurred]; + if (sender) { + [UIView animateWithDuration:0.1 animations:^{ sender.transform = CGAffineTransformMakeScale(0.8, 0.8); sender.alpha = 0.6; } + completion:^(BOOL f) { [UIView animateWithDuration:0.15 animations:^{ sender.transform = CGAffineTransformIdentity; sender.alpha = 1.0; }]; }]; + } + + @try { + UIViewController *storyVC = sciFindVC(self, @"IGStoryViewerViewController"); + if (!storyVC) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"VC not found")]; return; } + + id sectionCtrl = sciFindSectionController(storyVC); + id storyItem = sectionCtrl ? sciCall(sectionCtrl, NSSelectorFromString(@"currentStoryItem")) : nil; + if (!storyItem) storyItem = sciGetCurrentStoryItem(self); + IGMedia *media = (storyItem && [storyItem isKindOfClass:NSClassFromString(@"IGMedia")]) ? storyItem : sciExtractMediaFromItem(storyItem); + if (!media) { [SCIUtils showErrorHUDWithDescription:SCILocalized(@"Could not find story media")]; return; } + + sciAllowSeenForPK(media); + sciSeenBypassActive = YES; + + SEL delegateSel = @selector(fullscreenSectionController:didMarkItemAsSeen:); + if ([storyVC respondsToSelector:delegateSel]) { + typedef void (*Func)(id, SEL, id, id); + ((Func)objc_msgSend)(storyVC, delegateSel, sectionCtrl, media); + } + if (sectionCtrl) { + SEL markSel = NSSelectorFromString(@"markItemAsSeen:"); + if ([sectionCtrl respondsToSelector:markSel]) + ((SCIMsgSend1)objc_msgSend)(sectionCtrl, markSel, media); + } + id seenManager = sciCall(storyVC, @selector(viewingSessionSeenStateManager)); + id vm = sciCall(storyVC, @selector(currentViewModel)); + if (seenManager && vm) { + SEL setSel = NSSelectorFromString(@"setSeenMediaId:forReelPK:"); + if ([seenManager respondsToSelector:setSel]) { + id mediaPK = sciCall(media, @selector(pk)); + id reelPK = sciCall(vm, NSSelectorFromString(@"reelPK")); + if (!reelPK) reelPK = sciCall(vm, @selector(pk)); + if (mediaPK && reelPK) { + typedef void (*SetFunc)(id, SEL, id, id); + ((SetFunc)objc_msgSend)(seenManager, setSel, mediaPK, reelPK); + } + } + } + sciSeenBypassActive = NO; + [SCIUtils showToastForDuration:2.0 title:SCILocalized(@"Marked as seen") subtitle:SCILocalized(@"Will sync when leaving stories")]; + + if (sender && [SCIUtils getBoolPref:@"advance_on_mark_seen"] && sectionCtrl) { + __block id secCtrl = sectionCtrl; + __weak __typeof(self) weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + sciAdvanceBypassActive = YES; + SEL advSel = NSSelectorFromString(@"advanceToNextItemWithNavigationAction:"); + if ([secCtrl respondsToSelector:advSel]) + ((void(*)(id, SEL, NSInteger))objc_msgSend)(secCtrl, advSel, 1); + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.2 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ + __strong __typeof(weakSelf) strongSelf = weakSelf; + UIViewController *vc2 = strongSelf ? sciFindVC(strongSelf, @"IGStoryViewerViewController") : nil; + id sc2 = vc2 ? sciFindSectionController(vc2) : nil; + if (sc2) { + SEL resumeSel = NSSelectorFromString(@"tryResumePlaybackWithReason:"); + if ([sc2 respondsToSelector:resumeSel]) + ((void(*)(id, SEL, NSInteger))objc_msgSend)(sc2, resumeSel, 0); + } + sciAdvanceBypassActive = NO; + }); + }); + } + } @catch (NSException *e) { + [SCIUtils showErrorHUDWithDescription:[NSString stringWithFormat:SCILocalized(@"Error: %@"), e.reason]]; + } +} + +%end + +// MARK: - Chrome alpha sync (story only) + +static void sciSyncStoryButtonsAlpha(UIView *self_, CGFloat alpha) { + Class overlayCls = NSClassFromString(@"IGStoryFullscreenOverlayView"); + if (!overlayCls) return; + UIView *cur = self_; + while (cur) { + for (UIView *sib in cur.superview.subviews) { + if (![sib isKindOfClass:overlayCls]) continue; + UIView *seen = [sib viewWithTag:SCI_STORY_EYE_TAG]; + UIView *act = [sib viewWithTag:SCI_STORY_ACTION_TAG]; + UIView *audio = [sib viewWithTag:SCI_STORY_AUDIO_TAG]; + if (seen) seen.alpha = alpha; + if (act) act.alpha = alpha; + if (audio) audio.alpha = alpha; + return; + } + cur = cur.superview; + } +} + +%hook IGStoryFullscreenHeaderView +- (void)setAlpha:(CGFloat)alpha { + %orig; + sciSyncStoryButtonsAlpha((UIView *)self, alpha); +} +%end + +%end // StoryOverlayGroup + +%ctor { + if ([SCIUtils getBoolPref:@"stories_action_button"] || + [SCIUtils getBoolPref:@"story_audio_toggle"] || + [SCIUtils getBoolPref:@"no_seen_receipt"]) { + %init(StoryOverlayGroup); + } +} diff --git a/src/Features/Theme/ForceDarkMode.x b/src/Features/Theme/ForceDarkMode.x new file mode 100644 index 0000000..fe705f3 --- /dev/null +++ b/src/Features/Theme/ForceDarkMode.x @@ -0,0 +1,24 @@ +// Force IG into dark appearance regardless of iOS setting. + +#import "../../Utils.h" + +%group ForceDarkModeGroup + +%hook UIWindow +- (void)makeKeyAndVisible { + %orig; + self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark; +} +- (void)becomeKeyWindow { + %orig; + self.overrideUserInterfaceStyle = UIUserInterfaceStyleDark; +} +%end + +%end + +%ctor { + if ([SCIUtils getBoolPref:@"theme_force_dark"]) { + %init(ForceDarkModeGroup); + } +} diff --git a/src/Features/Theme/FullOLED.xm b/src/Features/Theme/FullOLED.xm new file mode 100644 index 0000000..4693762 --- /dev/null +++ b/src/Features/Theme/FullOLED.xm @@ -0,0 +1,62 @@ +// Replace IG's dark-gray surfaces with pure black. +// +// Swaps any near-black fill (RGB all < 0.13, alpha >= 0.9) for #000000. +// RyukGram's own surfaces opt out by painting above the threshold or with +// alpha < 0.9 — see SCIOLEDSurface.xm. + +#import "../../Utils.h" + +static inline BOOL sciOLEDShouldReplace(UIColor *color) { + if (!color) return NO; + CGFloat r = 0, g = 0, b = 0, a = 0; + if (![color getRed:&r green:&g blue:&b alpha:&a]) { + CGFloat w = 0; + if ([color getWhite:&w alpha:&a]) { + return (a >= 0.9 && w < 0.13); + } + return NO; + } + return (a >= 0.9 && r < 0.13 && g < 0.13 && b < 0.13); +} + +%group FullOLEDGroup + +%hook UIView +- (void)setBackgroundColor:(UIColor *)color { + if (sciOLEDShouldReplace(color)) { + %orig([UIColor blackColor]); + return; + } + %orig; +} +%end + +%hook CAGradientLayer +- (void)setColors:(NSArray *)colors { + if (colors.count >= 1) { + BOOL allDark = YES; + for (id raw in colors) { + CGColorRef cg = (__bridge CGColorRef)raw; + if (!cg) { allDark = NO; break; } + UIColor *c = [UIColor colorWithCGColor:cg]; + if (!sciOLEDShouldReplace(c)) { allDark = NO; break; } + } + if (allDark) { + id black = (id)[UIColor blackColor].CGColor; + NSMutableArray *flat = [NSMutableArray arrayWithCapacity:colors.count]; + for (NSUInteger i = 0; i < colors.count; i++) [flat addObject:black]; + %orig(flat); + return; + } + } + %orig; +} +%end + +%end + +%ctor { + if ([SCIUtils getBoolPref:@"theme_full_oled"]) { + %init(FullOLEDGroup); + } +} diff --git a/src/Features/Theme/KeyboardTheme.x b/src/Features/Theme/KeyboardTheme.x new file mode 100644 index 0000000..41b19cb --- /dev/null +++ b/src/Features/Theme/KeyboardTheme.x @@ -0,0 +1,56 @@ +// Keyboard appearance override for IG's text inputs. +// Modes: "off" / "dark" / "oled". + +#import "../../Utils.h" +#import "../../InstagramHeaders.h" + +static inline BOOL sciKeyboardOLED(void) { + return [[[NSUserDefaults standardUserDefaults] stringForKey:@"theme_keyboard"] isEqualToString:@"oled"]; +} + +%group KeyboardThemeDarkGroup + +%hook UITextField +- (BOOL)becomeFirstResponder { + self.keyboardAppearance = UIKeyboardAppearanceDark; + return %orig; +} +%end + +%hook UITextView +- (BOOL)becomeFirstResponder { + self.keyboardAppearance = UIKeyboardAppearanceDark; + return %orig; +} +%end + +%end + +%group KeyboardThemeOLEDGroup + +%hook UIKBBackdropView +- (void)layoutSubviews { + %orig; + self.backgroundColor = [UIColor blackColor]; + for (UIView *sub in self.subviews) sub.backgroundColor = [UIColor blackColor]; +} +%end + +%hook UIKBKeyplaneChargedView +- (void)layoutSubviews { + %orig; + self.backgroundColor = [UIColor blackColor]; +} +%end + +%end + +%ctor { + NSString *mode = [[NSUserDefaults standardUserDefaults] stringForKey:@"theme_keyboard"]; + if ([mode isEqualToString:@"dark"] || [mode isEqualToString:@"oled"]) { + %init(KeyboardThemeDarkGroup); + if (sciKeyboardOLED()) { + %init(KeyboardThemeOLEDGroup); + } + } +} diff --git a/src/Features/Theme/OLEDChatTheme.x b/src/Features/Theme/OLEDChatTheme.x new file mode 100644 index 0000000..d9ac534 --- /dev/null +++ b/src/Features/Theme/OLEDChatTheme.x @@ -0,0 +1,43 @@ +// Pure-black DM thread background + incoming message bubbles. +// IGDirectThreadBackgroundImageView / IGDirectMessageBubbleView declared in InstagramHeaders.h. + +#import "../../Utils.h" +#import "../../InstagramHeaders.h" + +%group OLEDChatThemeGroup + +%hook IGDirectThreadBackgroundImageView +- (void)layoutSubviews { + %orig; + self.image = nil; + self.backgroundColor = [UIColor blackColor]; +} +- (void)setImage:(UIImage *)image { + %orig(nil); + self.backgroundColor = [UIColor blackColor]; +} +- (void)setBackgroundColor:(UIColor *)color { + %orig([UIColor blackColor]); +} +%end + +%hook IGDirectMessageBubbleView +- (void)layoutSubviews { + %orig; + CGFloat r = 0, g = 0, b = 0, a = 0; + if ([self.backgroundColor getRed:&r green:&g blue:&b alpha:&a]) { + // Leave tinted outgoing bubbles (blue/purple) alone. + if (a >= 0.9 && r < 0.2 && g < 0.2 && b < 0.2) { + self.backgroundColor = [UIColor blackColor]; + } + } +} +%end + +%end + +%ctor { + if ([SCIUtils getBoolPref:@"theme_oled_chat"]) { + %init(OLEDChatThemeGroup); + } +} diff --git a/src/Features/Theme/SCIOLEDSurface.xm b/src/Features/Theme/SCIOLEDSurface.xm new file mode 100644 index 0000000..217829d --- /dev/null +++ b/src/Features/Theme/SCIOLEDSurface.xm @@ -0,0 +1,58 @@ +// Keep RyukGram's table-view surfaces visible under Full OLED. +// +// Grouped-inset cells default to #1C1C1E which Full OLED blackens. Repaint +// SCI*-owned cells at ~#121212 (alpha 0.89 passes the hook's a >= 0.9 gate) +// on attach, so settings + Profile Analyzer stay readable on black. + +#import "../../Utils.h" +#import + +static inline BOOL sciOLEDSurfaceInRyukGram(UIView *view) { + UIResponder *r = view; + while (r) { + const char *name = class_getName([r class]); + if (name && name[0] == 'S' && name[1] == 'C' && name[2] == 'I') return YES; + r = r.nextResponder; + } + return NO; +} + +static UIColor *sciOLEDSurfaceTone(void) { + static UIColor *tone; + static dispatch_once_t once; + dispatch_once(&once, ^{ tone = [UIColor colorWithWhite:0.08 alpha:0.89]; }); + return tone; +} + +%group OLEDSurfaceGroup + +%hook UITableViewCell +- (void)didMoveToSuperview { + %orig; + if (!self.superview) return; + if (!sciOLEDSurfaceInRyukGram((UIView *)self)) return; + UIColor *tone = sciOLEDSurfaceTone(); + UIBackgroundConfiguration *bg = [UIBackgroundConfiguration listGroupedCellConfiguration]; + bg.backgroundColor = tone; + self.backgroundConfiguration = bg; + self.backgroundColor = tone; + self.contentView.backgroundColor = tone; +} +%end + +%hook UITableViewHeaderFooterView +- (void)didMoveToSuperview { + %orig; + if (!self.superview) return; + if (!sciOLEDSurfaceInRyukGram((UIView *)self)) return; + self.backgroundConfiguration = [UIBackgroundConfiguration clearConfiguration]; +} +%end + +%end + +%ctor { + if ([SCIUtils getBoolPref:@"theme_full_oled"]) { + %init(OLEDSurfaceGroup); + } +} diff --git a/src/InstagramHeaders.h b/src/InstagramHeaders.h index 5fce058..3e5c674 100644 --- a/src/InstagramHeaders.h +++ b/src/InstagramHeaders.h @@ -43,6 +43,12 @@ @interface IGExploreGridViewController : IGViewController @end +@interface IGExploreViewController : IGViewController +@end + +@interface IGExploreSearchTitleView : UIView +@end + @interface UIImage () - (NSString *)ig_imageName; @end @@ -164,6 +170,15 @@ - (void)addLongPressGestureRecognizer; // new @end +@interface IGSundialViewerPhotoCell : UIView +@end + +@interface IGSundialViewerCarouselPhotoCell : UIView +@end + +@interface IGSundialViewerCarouselCell : UIView +@end + @interface IGImageProgressView : UIView @property(retain, nonatomic) IGImageSpecifier *imageSpecifier; @end @@ -538,6 +553,14 @@ // IG's UINavigationBar subclass — hosts the iOS 26 liquid-glass platter layout. @interface IGNavigationBar : UINavigationBar @end +// DM thread background + message bubble views — OLED chat theme. +@interface IGDirectThreadBackgroundImageView : UIImageView @end +@interface IGDirectMessageBubbleView : UIView @end + +// UIKit-private keyboard classes — OLED keyboard theme. +@interface UIKBBackdropView : UIView @end +@interface UIKBKeyplaneChargedView : UIView @end + // Story tray list adapter — drives data source updates for the home feed tray. @interface IGListAdapter : NSObject - (void)performUpdatesAnimated:(BOOL)animated completion:(void (^)(BOOL))completion; @@ -674,4 +697,39 @@ typedef FLEXAlertAction * _Nonnull (^FLEXAlertActionHandler)(void(^handler)(NSAr - (void)showExplorer; - (void)hideExplorer; - (void)toggleExplorer; +@end + +// IGLive classes — discovered via runtime ivar/method dump. +@interface IGLiveFeedbackController : NSObject +- (void)start; +- (void)stop; +@end + +@interface IGLiveCommentsContainerViewController : UIViewController +- (void)setIsHidden:(BOOL)hidden; +- (void)setDisabled:(BOOL)disabled; +@end + +// Story/reel sticker views — data accessors resolved at runtime. +@interface IGQuizStickerView : UIView +@end + +@interface IGPollStickerView : UIView +@end + +@interface IGPollStickerV2View : UIView +@end + +@interface IGSliderStickerView : UIView +@end + +// Composer sticker tray data source — hooked to inject the quiz model. +@interface IGStoryStickerDataSourceImpl : NSObject +- (NSArray *)items; +@end + +@interface IGQuizStickerTrayModel : NSObject +@property (nonatomic) BOOL isBoostEligible; +@property (nonatomic, copy) id stickerSection; +@property (nonatomic, copy) NSArray *prompts; @end \ No newline at end of file diff --git a/src/Localization/Resources/ar.lproj/Localizable.strings b/src/Localization/Resources/ar.lproj/Localizable.strings index ad8a478..2493ca9 100644 --- a/src/Localization/Resources/ar.lproj/Localizable.strings +++ b/src/Localization/Resources/ar.lproj/Localizable.strings @@ -57,6 +57,7 @@ * Translation made by @bruuhim (التعريب بواسطة @bruuhim) */ + ////////////////////////////////////////////////////////////////////////////// // CHROME — TOP BAR, LANGUAGE PICKER, FIRST-RUN // // Shown on the root Settings screen: title, search bar, the globe language // @@ -67,11 +68,11 @@ "settings.firstrun.message" = "‏مستقبلاً: اضغط مطولاً على الخطوط الثلاثة أعلى صفحة ملفك الشخصي لفتح إعدادات ريوك غرام.‏"; "settings.firstrun.ok" = "‏مفهوم!‏"; "settings.firstrun.title" = "‏معلومات إعدادات ريوك غرام‏"; +"settings.language.english_only" = "‏يتوفر ريوك غرام حاليًا باللغة الإنجليزية فقط. اللغات الأخرى جاهزة بانتظار الترجمة — ساهم في الترجمة إلى لغتك باتباع الدليل القصير في ملف ريدمي (README).‏"; +"settings.language.help_translate" = "‏المساعدة في الترجمة‏"; +"settings.language.ok" = "‏موافق‏"; "settings.language.system" = "‏الافتراضي للنظام‏"; "settings.language.title" = "‏اللغة‏"; -"settings.language.english_only" = "‏يتوفر ريوك غرام حاليًا باللغة الإنجليزية فقط. اللغات الأخرى جاهزة بانتظار الترجمة — ساهم في الترجمة إلى لغتك باتباع الدليل القصير في ملف ريدمي (README).‏"; -"settings.language.ok" = "‏موافق‏"; -"settings.language.help_translate" = "‏المساعدة في الترجمة‏"; "settings.results.many" = "‏%lu نتائج‏"; "settings.results.none" = "‏لا توجد نتائج‏"; "settings.results.one" = "‏%lu نتيجة‏"; @@ -85,6 +86,8 @@ "Adds a copy option to the comment long-press menu" = "‏إضافة خيار النسخ إلى قائمة الضغط المطول للتعليقات‏"; "Adds a download option for GIF comments" = "‏إضافة خيار تنزيل لتعليقات صور جيف (GIF)‏"; +"Anonymous live viewing" = "‏مشاهدة البث المباشر مجهول الهوية‏"; +"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "‏يمنع نبض عدّاد المشاهدين فلا يراك المذيع — ولن ترى عدّاد المشاهدين أيضاً‏"; "Browser" = "‏المتصفح‏"; "Comments" = "‏التعليقات‏"; "Copy comment text" = "‏نسخ نص التعليق‏"; @@ -105,13 +108,14 @@ "Experimental features" = "‏ميزات تجريبية‏"; "Focus/distractions" = "‏التركيز والمشتتات‏"; "General" = "‏عام‏"; -"Hide Meta AI" = "‏إخفاء ذكاء ميتا الاصطناعي‏"; "Hide ads" = "‏إخفاء الإعلانات‏"; "Hide explore posts grid" = "‏إخفاء شبكة منشورات الإكسبلور‏"; "Hide friends map" = "‏إخفاء خريطة الأصدقاء‏"; +"Hide Meta AI" = "‏إخفاء ذكاء ميتا الاصطناعي‏"; "Hide metrics" = "‏إخفاء الإحصائيات‏"; "Hide notes tray" = "‏إخفاء شريط الملاحظات‏"; "Hide trending searches" = "‏إخفاء عمليات البحث الرائجة‏"; +"Hide UI on capture" = "‏إخفاء الواجهة عند الالتقاط‏"; "Hides all suggested users for you to follow, outside your feed" = "‏إخفاء جميع المستخدمين المقترحين لمتابعتهم، خارج يومياتك‏"; "Hides like/comment/share counts on posts and reels" = "‏إخفاء عدد الإعجابات والتعليقات والمشاركات على المنشورات ومقاطع ريلز‏"; "Hides the friends map icon in the notes tray" = "‏إخفاء أيقونة خريطة الأصدقاء في شريط الملاحظات‏"; @@ -121,23 +125,30 @@ "Hides the suggested broadcast channels in direct messages" = "‏إخفاء قنوات البث المقترحة في الرسائل الخاصة‏"; "Hides the trending searches under the explore search bar" = "‏إخفاء عمليات البحث الرائجة أسفل شريط البحث في الإكسبلور‏"; "Hold down on the Instagram logo to change the app icon" = "‏اضغط مطولاً على شعار إنستغرام لتغيير أيقونة التطبيق‏"; +"Live" = "‏البث المباشر‏"; "Long press on the eyedropper tool in stories to customize the text color more precisely" = "‏اضغط مطولاً على أداة القطارة في القصص لتخصيص لون النص بدقة أكبر‏"; +"Long-press the heart button in a live to hide or show the comments" = "‏اضغط مطولاً على زر القلب في البث المباشر لإخفاء التعليقات أو إظهارها‏"; +"Long-press the search tab to open a copied Instagram link" = "اضغط مطولاً على تبويب البحث لفتح رابط إنستغرام المنسوخ"; "No suggested chats" = "‏لا توجد دردشات مقترحة‏"; "No suggested users" = "‏لا يوجد مستخدمون مقترحون‏"; "Notes" = "‏الملاحظات‏"; +"Open app icon picker" = "‏فتح منتقي أيقونات التطبيق‏"; +"Open link from clipboard" = "فتح الرابط من الحافظة"; "Open links in external 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" = "‏إزالة روابط تتبع إنستغرام ومعلمات التتبع من الروابط‏"; +"Privacy" = "‏الخصوصية‏"; +"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "‏يُخفي أزرار RyukGram من لقطات الشاشة وتسجيل الشاشة والعرض المنعكس‏"; "Removes all ads from the Instagram app" = "‏إزالة جميع الإعلانات من تطبيق إنستغرام‏"; "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." = "‏استبدال الطوابع الزمنية النسبية لإنستغرام (\"منذ 3 أيام\") بتنسيق مخصص. قم بتبديل الأماكن التي يتم تطبيقها عليها من الداخل.‏"; +"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "‏إزالة روابط تتبع إنستغرام ومعلمات التتبع من الروابط‏"; "Replace domain in shared links" = "‏استبدال النطاق في الروابط المشتركة‏"; +"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "‏استبدال الطوابع الزمنية النسبية لإنستغرام (\"منذ 3 أيام\") بتنسيق مخصص. قم بتبديل الأماكن التي يتم تطبيقها عليها من الداخل.‏"; "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" = "‏لن تقوم أشرطة البحث بحفظ عمليات بحثك الأخيرة بعد الآن‏"; "Sharing" = "‏المشاركة‏"; "Strip tracking from links" = "‏تجريد الروابط من التتبع‏"; "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)." = "‏تعتمد هذه الميزات على إعدادات إنستغرام المخفية وقد لا تعمل على جميع الحسابات أو الإصدارات.\nأبحاث الميزات التجريبية بواسطة @euoradan (رادان).‏"; +"Toggle live comments" = "‏تبديل التعليقات المباشرة‏"; "Use detailed color picker" = "‏استخدام منتقي الألوان التفصيلي‏"; ////////////////////////////////////////////////////////////////////////////// @@ -232,9 +243,6 @@ "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" = "‏إظهار شريط التقدم دائمًا‏"; "Auto-scroll reels" = "‏التمرير التلقائي لمقاطع ريلز‏"; -"IG default: native behavior. RyukGram: re-advances after swiping back." = "‏الافتراضي لإنستغرام: السلوك الأصلي. ريوك غرام: يعيد التقدم بعد التمرير للخلف.‏"; -"IG default" = "‏الافتراضي لإنستغرام‏"; -"RyukGram" = "‏ريوك غرام‏"; "Change what happens when you tap on a reel" = "‏تغيير ما يحدث عند النقر على مقطع ريلز‏"; "Confirm reel refresh" = "‏تأكيد تحديث ريلز‏"; "Disable auto-unmuting reels" = "‏تعطيل إلغاء الكتم التلقائي لمقاطع ريلز‏"; @@ -246,6 +254,8 @@ "Hides the repost button on the reels sidebar" = "‏إخفاء زر إعادة النشر على الشريط الجانبي لمقاطع ريلز‏"; "Hides the top navigation bar when watching reels" = "‏إخفاء شريط التنقل العلوي عند مشاهدة ريلز‏"; "Hiding" = "‏إخفاء‏"; +"IG default" = "‏الافتراضي لإنستغرام‏"; +"IG default: native behavior. RyukGram: re-advances after swiping back." = "‏الافتراضي لإنستغرام: السلوك الأصلي. ريوك غرام: يعيد التقدم بعد التمرير للخلف.‏"; "Limits" = "‏الحدود‏"; "Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "‏يحد من عدد مقاطع ريلز المتاحة للتمرير في أي وقت، ويمنع التحديث‏"; "Only loads %@ %@" = "‏تحميل %@ %@ فقط‏"; @@ -253,11 +263,14 @@ "Prevent doom scrolling" = "‏منع التمرير المفرط‏"; "Prevents reels from being scrolled to the next video" = "‏يمنع التمرير إلى الفيديو التالي في ريلز‏"; "Prevents reels from unmuting when the volume/silent button is pressed" = "‏يمنع إلغاء كتم صوت ريلز عند الضغط على أزرار الصوت‏"; +"RyukGram" = "‏ريوك غرام‏"; "Shows an alert when you trigger a reels refresh" = "‏يعرض تنبيهًا عند بدء تحديث ريلز‏"; "Shows buttons to reveal and auto-fill the password on locked reels" = "‏يظهر أزرارًا للكشف عن كلمة المرور وتعبئتها تلقائيًا في مقاطع ريلز المقفلة‏"; "Tap Controls" = "‏عناصر تحكم النقر‏"; +"Tap to mute on photo reels" = "‏اضغط للكتم في ريلز الصور‏"; "Tapping the Reels tab while on reels does nothing" = "‏النقر على تبويب ريلز أثناء التواجد فيه لا يفعل شيئًا‏"; "Unlock password-locked reels" = "‏فتح مقاطع ريلز المقفلة بكلمة مرور‏"; +"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "‏عند تفعيل وضع الإيقاف المؤقت، يؤدي النقر على ريلز الصور إلى تبديل الصوت بدلاً من إيماءة الإيقاف الأصلية‏"; ////////////////////////////////////////////////////////////////////////////// // PROFILE // @@ -267,14 +280,25 @@ "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" = "‏إضافة خيار عرض إلى قائمة الضغط المطول على الهايلايت لفتح الغلاف بملء الشاشة‏"; "Copy note on long press" = "‏نسخ الملاحظة عند الضغط المطول‏"; +"Fake follower count" = "‏عدد متابعين وهمي‏"; +"Fake following count" = "‏عدد المتابَعين وهمي‏"; +"Fake post count" = "‏عدد منشورات وهمي‏"; +"Fake profile stats" = "‏إحصائيات ملف شخصي وهمية‏"; +"Fake verified badge" = "‏شارة توثيق وهمية‏"; "Follow indicator" = "‏مؤشر المتابعة‏"; +"Follower count" = "‏عدد المتابعين‏"; +"Following count" = "‏عدد المتابَعين‏"; "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 to download directly (ignored when zoom is on)" = "‏اضغط مطولاً للتنزيل مباشرة (يتم تجاهله عند تشغيل التكبير)‏"; "Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "‏إيماءات الضغط المطول على عناصر الملف الشخصي — منفصلة عن أزرار الإجراءات لكل ميزة.‏"; +"Only affects your own profile header. Other users see the real numbers." = "‏يؤثر فقط على رأس ملفك الشخصي. يرى المستخدمون الآخرون الأرقام الحقيقية.‏"; +"Post count" = "‏عدد المنشورات‏"; "Profile copy button" = "‏زر نسخ الملف الشخصي‏"; "Save profile picture" = "‏حفظ صورة الملف الشخصي‏"; +"Show a checkmark next to your name on your own profile" = "‏إظهار علامة توثيق بجوار اسمك في ملفك الشخصي‏"; "Shows whether the profile user follows you" = "‏يُظهر ما إذا كان صاحب الملف الشخصي يتابعك‏"; +"Tap to set" = "‏اضغط للتعيين‏"; "View highlight cover" = "‏عرض غلاف الهايلايت‏"; "Zoom profile photo" = "‏تكبير صورة الملف الشخصي‏"; @@ -330,16 +354,25 @@ "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 when you send a reply or emoji reaction, even with seen blocking on" = "‏يحدد القصة كمقروءة عند إرسال رد أو تفاعل، حتى مع تفعيل حظر المشاهدة‏"; +"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "‏يحدد القصص كمقروءة محليًا (حلقة رمادية) بينما لا يزال يحظر إرسال مؤشر المشاهدة للخادم‏"; "Master toggle. When off, the list is ignored" = "‏المفتاح الرئيسي. عند إيقاف التشغيل، يتم تجاهل القائمة‏"; "Other" = "‏أخرى‏"; "Playback" = "‏التشغيل‏"; -"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "‏يحدد القصص كمقروءة محليًا (حلقة رمادية) بينما لا يزال يحظر إرسال مؤشر المشاهدة للخادم‏"; "Quick list button in stories" = "‏زر القائمة السريعة في القصص‏"; "Search, sort, swipe to remove" = "‏بحث، فرز، التمرير للإزالة‏"; "Seen receipts" = "‏مؤشرات القراءة‏"; "Sending a reply or emoji reaction automatically advances to the next story" = "‏إرسال رد أو تفاعل ينقلك تلقائيًا للقصة التالية‏"; "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" = "‏يعرض زر عين في القصص لإضافة وإزالة المستخدمين من القائمة. إيقاف = استخدم قائمة النقاط الثلاث أو الضغط المطول فقط‏"; +"Stickers" = "‏ملصقات‏"; +"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "‏اطلع على نتائج استطلاعات الرأي/الاختبارات/شريط التمرير قبل التفاعل — لا يزال بإمكانك النقر للتصويت بشكل طبيعي. 'إجبار ظهور ملصق الاختبار' يعيد ملصق الاختبار القديم إلى لوحة إنشاء القصة.‏"; +"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "‏اطلع على نتائج استطلاعات الرأي/الاختبارات/شريط التمرير في الريلز قبل التفاعل — لا يزال بإمكانك النقر للتصويت بشكل طبيعي.‏"; +"Force Quiz sticker in tray" = "‏إجبار ظهور ملصق الاختبار‏"; +"Adds Quiz back to the story sticker picker" = "‏إعادة ملصق الاختبار إلى منتقي ملصقات القصة‏"; +"Show quiz answer" = "‏إظهار إجابة الاختبار‏"; +"Circle the correct option on quiz stickers, or the leading option on polls" = "‏تمييز الخيار الصحيح في ملصقات الاختبار أو الخيار الأكثر تصويتًا في الاستطلاعات‏"; +"Show poll vote counts" = "‏إظهار عدد أصوات الاستطلاع‏"; +"Show vote tallies on poll options and slider count/average before you vote" = "‏إظهار عدد الأصوات على خيارات الاستطلاع ومتوسط/عدد شريط التمرير قبل التصويت‏"; "Stop story auto-advance" = "‏إيقاف التقدم التلقائي للقصص‏"; "Stories" = "‏القصص‏"; "Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "‏لن تنتقل القصص تلقائيًا للتالية عند انتهاء المؤقت. انقر للتقدم يدويًا‏"; @@ -384,7 +417,7 @@ "Copy text on hold" = "‏نسخ النص عند التوقف‏"; "Custom emojis and background/text colors" = "‏إيموجي مخصصة وألوان للخلفية والنص‏"; "Custom note themes" = "‏سمات ملاحظات مخصصة‏"; -"Disable disappearing mode swipe" = "‏تعطيل سحب وضع الاختفاء‏"; +"Disable vanish mode swipe" = "‏تعطيل سحب وضع الاختفاء‏"; "Disable screenshot detection" = "‏تعطيل الكشف عن لقطات الشاشة‏"; "Disable typing status" = "‏تعطيل حالة الكتابة (يكتب...)‏"; "Disable view-once limitations" = "‏تعطيل قيود العرض لمرة واحدة‏"; @@ -405,7 +438,7 @@ "Note actions" = "‏إجراءات الملاحظات‏"; "Preserve messages that others unsend" = "‏الاحتفاظ بالرسائل التي يلغي الآخرون إرسالها‏"; "Preserves messages that others unsend" = "‏يحتفظ بالرسائل التي يلغي الآخرون إرسالها‏"; -"Prevents accidental swipe-up activation of disappearing mode" = "‏يمنع التنشيط العشوائي لوضع الاختفاء عند السحب لأعلى‏"; +"Prevents accidental swipe-up activation of vanish mode" = "‏يمنع التنشيط العشوائي لوضع الاختفاء عند السحب لأعلى‏"; "Quick list button in chats" = "‏زر القائمة السريعة في الدردشات‏"; "Removes the audio call button from DM thread header" = "‏يزيل زر المكالمة الصوتية من أعلى المحادثة‏"; "Removes the screenshot-prevention features for visual messages in DMs" = "‏يزيل ميزات منع لقطات الشاشة للرسائل المرئية في الرسائل الخاصة‏"; @@ -420,7 +453,6 @@ "Shows an \"Unsent\" label on preserved messages" = "‏يعرض علامة \"أُلغي الإرسال\" على الرسائل المحفوظة‏"; "Unlimited replay of visual messages" = "‏إعادة تشغيل غير محدودة للرسائل المرئية‏"; "Unsent message notification" = "‏إشعار الرسائل الملغاة‏"; -"Visual messages" = "‏الرسائل المرئية‏"; "Voice messages" = "‏الرسائل الصوتية‏"; "Warn before clearing on refresh" = "‏تحذير قبل المسح عند التحديث‏"; "Which chats get read-receipt blocking" = "‏الدردشات التي يُحظر فيها مؤشر القراءة‏"; @@ -439,11 +471,13 @@ // Settings → Navigation tab // ////////////////////////////////////////////////////////////////////////////// +"Also hide the bottom tab bar — only the inbox is visible" = "‏إخفاء شريط التبويبات السفلي أيضًا — يظهر صندوق الوارد فقط‏"; "Hide create tab" = "‏إخفاء تبويب الإنشاء‏"; "Hide explore tab" = "‏إخفاء تبويب الإكسبلور‏"; "Hide feed tab" = "‏إخفاء تبويب اليوميات‏"; "Hide messages tab" = "‏إخفاء تبويب الرسائل‏"; "Hide reels tab" = "‏إخفاء تبويب ريلز‏"; +"Hide tab bar" = "‏إخفاء شريط التبويبات‏"; "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 direct messages tab on the bottom navigation bar" = "‏يخفي تبويب الرسائل المباشرة من شريط التنقل السفلي‏"; @@ -468,7 +502,8 @@ ////////////////////////////////////////////////////////////////////////////// "Confirm actions" = "‏تأكيد الإجراءات‏"; -"Confirm call" = "‏تأكيد المكالمة‏"; +"Confirm video call" = "‏تأكيد مكالمة الفيديو‏"; +"Confirm voice call" = "‏تأكيد المكالمة الصوتية‏"; "Confirm changing theme" = "‏تأكيد تغيير السمة‏"; "Confirm follow" = "‏تأكيد المتابعة‏"; "Confirm follow requests" = "‏تأكيد طلبات المتابعة‏"; @@ -476,24 +511,26 @@ "Confirm like: Reels" = "‏تأكيد الإعجاب: ريلز‏"; "Confirm posting comment" = "‏تأكيد نشر التعليق‏"; "Confirm repost" = "‏تأكيد إعادة النشر‏"; -"Confirm shh mode" = "‏تأكيد وضع الصمت (Shh)‏"; -"Confirm sticker interaction" = "‏تأكيد التفاعل مع الملصقات‏"; +"Confirm vanish mode" = "‏تأكيد وضع الاختفاء‏"; +"Confirm sticker interaction (stories)" = "‏تأكيد التفاعل مع الملصقات (القصص)‏"; +"Confirm sticker interaction (highlights)" = "‏تأكيد التفاعل مع الملصقات (الهايلايت)‏"; "Confirm story emoji reaction" = "‏تأكيد تفاعل الإيموجي في القصة‏"; "Confirm story like" = "‏تأكيد الإعجاب بالقصة‏"; "Confirm unfollow" = "‏تأكيد إلغاء المتابعة‏"; -"Shows an alert before sending an emoji reaction on a story" = "‏يُظهر تنبيهًا قبل إرسال تفاعل إيموجي على قصة‏"; -"Shows an alert when you click the like button on posts to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في المنشورات للتأكيد‏"; -"Shows an alert when you click the like button on stories to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في القصص للتأكيد‏"; "Confirm voice messages" = "‏تأكيد الرسائل الصوتية‏"; +"Shows an alert before sending an emoji reaction on a story" = "‏يُظهر تنبيهًا قبل إرسال تفاعل إيموجي على قصة‏"; "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 vanish mode" = "‏يُظهر تنبيهًا للتأكيد قبل تفعيل وضع الاختفاء‏"; "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 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 tap a sticker on someone's story" = "‏يُظهر تنبيهًا عند النقر على ملصق في قصة شخص ما‏"; +"Shows an alert when you tap a sticker inside a highlight" = "‏يُظهر تنبيهًا عند النقر على ملصق داخل هايلايت‏"; +"Shows an alert when you click the video call button to confirm before calling" = "‏يُظهر تنبيهًا عند النقر على زر مكالمة الفيديو للتأكيد قبل الاتصال‏"; +"Shows an alert when you click the voice 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 like button on posts or stories to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في المنشورات أو القصص للتأكيد‏"; +"Shows an alert when you click the like button on posts 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 stories to confirm the like" = "‏يُظهر تنبيهًا عند النقر على زر الإعجاب في القصص للتأكيد‏"; "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 unfollow button to confirm" = "‏يُظهر تنبيهًا عند النقر على زر إلغاء المتابعة للتأكيد‏"; @@ -504,22 +541,6 @@ ////////////////////////////////////////////////////////////////////////////// "Backup & Restore" = "‏النسخ الاحتياطي والاستعادة‏"; -"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." = "‏قم بتصدير إعدادات ريوك غرام الخاصة بك إلى ملف جيسون (JSON) واستيرادها لاحقًا. الاستيراد يعيد تعيين جميع الإعدادات للافتراضي قبل التطبيق، ويظهر معاينة مسبقة.‏"; -"Import settings" = "‏استيراد الإعدادات‏"; -"Load settings from a JSON file" = "‏تحميل الإعدادات من ملف جيسون (JSON)‏"; -"Reset to defaults" = "‏إعادة التعيين للافتراضي‏"; -"Revert every RyukGram preference" = "‏إرجاع كل تفضيلات ريوك غرام‏"; -"Save settings as a JSON file" = "‏حفظ الإعدادات كملف جيسون (JSON)‏"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL // -// Settings → Experimental tab // -////////////////////////////////////////////////////////////////////////////// - -"Experimental" = "‏تجريبي‏"; -"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "‏هذه الميزات غير مستقرة وقد تسبب انهيار تطبيق إنستغرام بشكل مفاجئ.\n\nاستخدمها على مسؤوليتك الخاصة!‏"; -"Warning" = "‏تحذير‏"; ////////////////////////////////////////////////////////////////////////////// // ADVANCED // @@ -527,17 +548,76 @@ ////////////////////////////////////////////////////////////////////////////// "Advanced" = "‏متقدم‏"; +"Auto-clear cache" = "‏مسح تلقائي للذاكرة المؤقتة‏"; "Automatically opens settings when the app launches" = "‏يفتح الإعدادات تلقائيًا عند تشغيل التطبيق‏"; +"Cache" = "‏ذاكرة التخزين المؤقت‏"; +"Cache cleared" = "‏تم مسح ذاكرة التخزين المؤقت‏"; +"Calculating cache size…" = "‏جاري حساب حجم ذاكرة التخزين المؤقت…‏"; +"Clear" = "‏مسح‏"; +"Clear cache" = "‏مسح ذاكرة التخزين المؤقت‏"; +"Clear cache (%@)" = "‏مسح ذاكرة التخزين المؤقت (%@)‏"; +"Clear cache?" = "‏مسح ذاكرة التخزين المؤقت؟‏"; +"Clearing cache…" = "‏جاري المسح…‏"; +"Clearing still scans on demand." = "‏سيظل المسح يُجري الفحص عند الطلب.‏"; +"Daily" = "‏يومياً‏"; "Disable safe mode" = "‏تعطيل الوضع الآمن‏"; "Enable tweak settings quick-access" = "‏تفعيل الوصول السريع لإعدادات الأداة‏"; +"Free %@ of Instagram cache. A restart is recommended." = "‏تحرير %@ من ذاكرة إنستغرام. يُنصح بإعادة التشغيل.‏"; +"Freed %@. Restart to apply." = "‏تم تحرير %@. أعد التشغيل للتطبيق.‏"; "Hold on the home tab to open RyukGram settings" = "‏اضغط مطولاً على تبويب الصفحة الرئيسية لفتح إعدادات ريوك غرام‏"; "Instagram" = "‏إنستغرام‏"; +"Monthly" = "‏شهرياً‏"; +"Nothing to clear" = "‏لا شيء للمسح‏"; +"Off skips the size scan when Advanced opens." = "‏عند الإيقاف يتم تخطي فحص الحجم عند فتح «متقدم».‏"; "Pause playback when opening settings" = "‏إيقاف التشغيل مؤقتًا عند فتح الإعدادات‏"; "Pauses any playing video/audio when settings opens" = "‏يوقف أي فيديو أو صوت قيد التشغيل مؤقتًا عند فتح الإعدادات‏"; "Prevents Instagram from resetting settings after crashes (at your own risk)" = "‏يمنع إنستغرام من إعادة ضبط الإعدادات بعد الانهيارات (على مسؤوليتك)‏"; +"Remove Instagram's cached images, videos, and temporary files." = "‏يُزيل صور وفيديوهات وملفات إنستغرام المؤقتة.‏"; "Reset onboarding state" = "‏إعادة ضبط حالة التهيئة التمهيدية‏"; -"Settings" = "‏الإعدادات‏"; +"Run a silent cache clear on launch when the interval has elapsed." = "‏يُنفّذ مسحاً صامتاً للذاكرة المؤقتة عند التشغيل إذا انقضت المدة المحددة.‏"; +"Show cache size" = "‏إظهار حجم ذاكرة التخزين المؤقت‏"; "Show tweak settings on app launch" = "‏إظهار إعدادات الأداة عند تشغيل التطبيق‏"; +"Weekly" = "‏أسبوعياً‏"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED EXPERIMENTAL // +// Settings → Advanced → Advanced experimental features // +////////////////////////////////////////////////////////////////////////////// + +"Actions" = "‏الإجراءات‏"; +"Advanced experimental features" = "‏الميزات التجريبية المتقدمة‏"; +"All experimental toggles will be turned off. Instagram will restart." = "‏سيتم إيقاف جميع المفاتيح التجريبية. سيُعاد تشغيل Instagram.‏"; +"Direct Notes — Audio reply" = "‏ملاحظات Direct — الرد الصوتي‏"; +"Direct Notes — Avatar reply" = "‏ملاحظات Direct — الرد بالصورة الرمزية‏"; +"Direct Notes — Friend Map" = "‏ملاحظات Direct — خريطة الأصدقاء‏"; +"Direct Notes — GIFs & stickers reply" = "‏ملاحظات Direct — الرد بصور GIF والملصقات‏"; +"Direct Notes — Photo reply" = "‏ملاحظات Direct — الرد بصورة‏"; +"Disabled after repeated crashes." = "‏تم الإيقاف بعد تعطل متكرر.‏"; +"Enables GIF/sticker replies" = "‏يُفعّل الرد بصور GIF والملصقات‏"; +"Enables photo replies" = "‏يُفعّل الرد بالصور‏"; +"Enables the audio-note reply type" = "‏يُفعّل الرد بملاحظة صوتية‏"; +"Enables the avatar reply type" = "‏يُفعّل الرد بالصورة الرمزية‏"; +"Experimental flags reset" = "‏تمت إعادة تعيين الميزات التجريبية‏"; +"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "‏فعّل ما تريد ثم اضغط «تطبيق» لإعادة التشغيل. قد لا تعمل بعض الميزات على جميع الحسابات أو إصدارات IG. تُعاد تعيينها تلقائيًا إذا تعطل IG عند بدء التشغيل 3 مرات.‏"; +"Forces Prism-gated experiments on" = "‏يُجبر تفعيل التجارب المُقيّدة بواسطة Prism‏"; +"Forces the Homecoming home surface / nav on" = "‏يُجبر تفعيل واجهة وتنقل Homecoming‏"; +"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "‏يُجبر ظهور QuickSnap / Instants في الخلاصة والرسائل والقصص وشريط الملاحظات‏"; +"Got it" = "‏فهمت‏"; +"Heads up" = "‏تنبيه‏"; +"Hidden Instagram experiments" = "‏تجارب Instagram المخفية‏"; +"Hidden Instagram experiments (in Advanced)" = "‏تجارب Instagram المخفية (في الإعدادات المتقدمة)‏"; +"Homecoming" = "Homecoming"; +"Notes & QuickSnap" = "‏الملاحظات وQuickSnap‏"; +"Prism design system" = "‏نظام تصميم Prism‏"; +"QuickSnap (Instants)" = "QuickSnap (Instants)"; +"Reset all experimental flags" = "‏إعادة تعيين جميع المفاتيح التجريبية‏"; +"Reset experimental flags?" = "‏إعادة تعيين المفاتيح التجريبية؟‏"; +"Restart Instagram to apply changes" = "‏أعِد تشغيل Instagram لتطبيق التغييرات‏"; +"Shows the friend map entry in Direct Notes" = "‏يُظهر خريطة الأصدقاء في ملاحظات Direct‏"; +"Surfaces" = "‏الواجهات‏"; +"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "‏تُفعّل هذه المفاتيح تجارب Instagram المخفية. قد لا تعمل بعض الميزات على جميع الحسابات أو إصدارات IG. إذا استمر IG بالتعطل عند التشغيل، تُعاد تعيين المفاتيح بعد 3 محاولات فاشلة.‏"; +"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "‏فعّل تجارب Instagram المخفية. قد لا يعمل بعضها على جميع الحسابات أو إصدارات IG.‏"; +"Turn every experimental toggle off" = "‏إيقاف جميع المفاتيح التجريبية‏"; ////////////////////////////////////////////////////////////////////////////// // DEBUG // @@ -546,24 +626,38 @@ "Button Cell" = "‏خلية زر‏"; "Change the value on the right" = "‏غيّر القيمة الموجودة على اليسار‏"; +"Could not delete: %@" = "‏تعذّر الحذف: %@‏"; "Debug" = "‏تصحيح الأخطاء‏"; +"Delete an imported override and fall back to the shipped strings" = "‏احذف تجاوزاً مستورداً وارجع إلى النصوص الأصلية‏"; +"Deleted %@ override. Restart to apply." = "‏تم حذف تجاوز %@. أعد التشغيل للتطبيق.‏"; "Enable FLEX gesture" = "‏تفعيل إيماءة أداة فليكس (FLEX)‏"; +"Export English strings" = "‏تصدير نصوص الإنجليزية‏"; "Hold 5 fingers on the screen to open FLEX" = "‏ضع 5 أصابع على الشاشة لفتح فليكس (FLEX)‏"; "I have %@%@" = "‏لدي %@%@‏"; +"Import a .strings file for a language" = "‏استيراد ملف .strings للغة‏"; +"Import a .strings file to update a translation. Pick a language, select the file, restart." = "‏استورد ملف .strings لتحديث الترجمة. اختر لغة، حدد الملف، وأعد التشغيل.‏"; "Link Cell" = "‏خلية رابط‏"; +"Localization" = "‏التعريب‏"; "Menu Cell" = "‏خلية قائمة‏"; +"Navigation Cell" = "‏خلية التنقل‏"; +"No imported localization files to reset." = "‏لا توجد ملفات ترجمة مستوردة للإعادة.‏"; +"No overrides" = "‏لا توجد تجاوزات‏"; "Open FLEX on app focus" = "‏فتح فليكس عند التركيز على التطبيق‏"; "Open FLEX on app launch" = "‏فتح فليكس عند إطلاق التطبيق‏"; "Opens FLEX when the app is focused" = "‏يفتح فليكس عندما يكون التطبيق قيد التركيز‏"; "Opens FLEX when the app launches" = "‏يفتح فليكس عند إطلاق التطبيق‏"; +"Pick a language to delete the imported file" = "‏اختر لغة لحذف الملف المستورد‏"; +"Reset localization" = "‏إعادة تعيين الترجمة‏"; +"Share the base English .strings file for translating" = "‏مشاركة ملف .strings الأساسي بالإنجليزية للترجمة‏"; "Static Cell" = "‏خلية ثابتة‏"; "Stepper cell" = "‏خلية متدرج‏"; "Switch Cell" = "‏خلية مفتاح تبديل‏"; "Switch Cell (Restart)" = "‏خلية مفتاح تبديل (إعادة تشغيل)‏"; "Tap the switch" = "‏انقر فوق مفتاح التبديل‏"; +"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "‏تعتمد هذه الميزات على علامات Instagram مخفية وقد لا تعمل على جميع الحسابات أو الإصدارات.‏"; +"Update localization file" = "‏تحديث ملف التعريب‏"; "Using icon" = "‏استخدام أيقونة‏"; "Using image" = "‏استخدام صورة‏"; -"_ Example" = "‏_ مثال‏"; ////////////////////////////////////////////////////////////////////////////// // DOWNLOADS & MEDIA ACTIONS // @@ -595,16 +689,16 @@ "Failed to save" = "‏فشل الحفظ‏"; "HD download complete" = "‏اكتمل التنزيل بدقة عالية (HD)‏"; "Mute audio" = "‏كتم الصوت‏"; -"No URLs" = "‏لا توجد روابط‏"; -"No URLs found" = "‏لم يتم العثور على روابط‏"; "No caption on this post" = "‏لا يوجد وصف في هذا المنشور‏"; "No carousel children" = "‏لا توجد وسائط متعددة في هذا المنشور‏"; "No cover image" = "‏لا توجد صورة غلاف‏"; "No files downloaded" = "‏لم يتم تنزيل أي ملفات‏"; "No media" = "‏لا توجد وسائط‏"; -"No media URL" = "‏لا يوجد رابط للوسائط‏"; "No media to expand" = "‏لا توجد وسائط لتوسيعها‏"; "No media to show" = "‏لا توجد وسائط لعرضها‏"; +"No media URL" = "‏لا يوجد رابط للوسائط‏"; +"No URLs" = "‏لا توجد روابط‏"; +"No URLs found" = "‏لم يتم العثور على روابط‏"; "No video URL" = "‏لا يوجد رابط للفيديو‏"; "Not a carousel" = "‏ليس منشورًا متعدد الوسائط‏"; "Nothing to save" = "‏لا يوجد شيء لحفظه‏"; @@ -637,6 +731,7 @@ "Add to block list" = "‏إضافة لقائمة الحظر‏"; "Add to block list?" = "‏إضافة لقائمة الحظر؟‏"; "Added to block list" = "‏تمت الإضافة لقائمة الحظر‏"; +"Added to exclude list" = "‏أُضيف إلى قائمة الاستبعاد‏"; "Audio not loaded yet. Play the message first and try again." = "‏لم يتم تحميل الصوت بعد. قم بتشغيل الرسالة أولاً وحاول مجددًا.‏"; "Audio sent" = "‏تم إرسال الصوت‏"; "Audio/Video from Files" = "‏صوت أو فيديو من الملفات‏"; @@ -650,12 +745,14 @@ "Could not get audio data. Try again after refreshing the chat." = "‏تعذر الحصول على بيانات الصوت. حاول مجددًا بعد تحديث الدردشة.‏"; "Could not get video URL" = "‏تعذر الحصول على رابط الفيديو‏"; "Disable read receipts" = "‏تعطيل مؤشرات القراءة‏"; +"Disappearing media" = "‏وسائط مختفية‏"; "Done!" = "‏تم!‏"; "Download audio" = "‏تنزيل الصوت‏"; "Downloading audio..." = "‏جارِ تنزيل الصوت...‏"; "Enable read receipts" = "‏تفعيل مؤشرات القراءة‏"; "Error: %@" = "‏خطأ: %@‏"; "Exclude chat" = "‏استبعاد الدردشة‏"; +"Exclude from seen" = "‏استبعاد من المُشاهد‏"; "Exclude story seen" = "‏استبعاد مشاهدة القصة‏"; "Excluded" = "‏مستبعد‏"; "Extracting audio..." = "‏جارِ استخراج الصوت...‏"; @@ -663,6 +760,10 @@ "File sending not supported" = "‏إرسال الملفات غير مدعوم‏"; "Follow" = "‏متابعة‏"; "Following" = "‏تُتابع‏"; +"Inserts a button on disappearing media overlays" = "‏يُضيف زراً على طبقة الوسائط المختفية‏"; +"Inserts a speaker button to mute/unmute disappearing media" = "‏يُضيف زر مكبر صوت لكتم/إلغاء كتم الوسائط المختفية‏"; +"Inserts an eye button to mark the current disappearing media as viewed" = "‏يُضيف زر عين لتعليم الوسائط المختفية الحالية كمُشاهدة‏"; +"Mark as viewed" = "‏تعليم كمُشاهدة‏"; "Mark messages as seen" = "‏تحديد الرسائل كمقروءة‏"; "Mark seen" = "‏تحديد كمقروءة‏"; "Marked as seen" = "‏تم التحديد كمقروءة‏"; @@ -671,6 +772,7 @@ "Mentions" = "‏الإشارات‏"; "Message sender not found" = "‏لم يتم العثور على مُرسل الرسالة‏"; "Messages settings" = "‏إعدادات الرسائل‏"; +"Audio URL not available" = "‏رابط الصوت غير متاح‏"; "Mute story audio" = "‏كتم صوت القصة‏"; "No audio URL found. Try again after refreshing the chat." = "‏لم يتم العثور على رابط للصوت. حاول مجددًا بعد تحديث الدردشة.‏"; "No mentions in this story" = "‏لا توجد إشارات في هذه القصة‏"; @@ -686,26 +788,25 @@ "Remove" = "‏إزالة‏"; "Remove from block list" = "‏إزالة من قائمة الحظر‏"; "Remove from block list?" = "‏إزالة من قائمة الحظر؟‏"; +"Remove from exclude list" = "‏إزالة من قائمة الاستبعاد‏"; "Removed" = "‏تمت الإزالة‏"; +"Removed from list" = "‏تمت الإزالة من القائمة‏"; "Save GIF" = "‏حفظ صورة جيف (GIF)‏"; "Selection too short (min 0.5s)" = "‏التحديد قصير جدًا (الحد الأدنى 0.5 ثانية)‏"; -"Send Audio" = "‏إرسال صوت‏"; "Send anyway" = "‏إرسال على أي حال‏"; +"Send Audio" = "‏إرسال صوت‏"; "Send failed: %@" = "‏فشل الإرسال: %@‏"; "Send service not found" = "‏لم يتم العثور على خدمة الإرسال‏"; -"Share" = "‏مشاركة‏"; +"Show audio toggle" = "‏إظهار زر الصوت‏"; +"Show mark-as-viewed button" = "‏إظهار زر علامة كمُشاهدة‏"; "Story read receipts disabled" = "‏تم تعطيل مؤشرات قراءة القصص‏"; "Story read receipts enabled" = "‏تم تفعيل مؤشرات قراءة القصص‏"; -"Story seen receipts will be blocked for @%@." = "‏سيتم حظر مؤشرات مشاهدة القصة لـ @%@.‏"; "This chat will resume normal read-receipt behavior." = "‏ستستأنف هذه الدردشة السلوك الطبيعي لمؤشر القراءة.‏"; "Total: %@" = "‏الإجمالي: %@‏"; -"Un-exclude" = "‏إلغاء الاستبعاد‏"; "Un-exclude chat" = "‏إلغاء استبعاد الدردشة‏"; "Un-exclude chat?" = "‏إلغاء استبعاد الدردشة؟‏"; "Un-exclude story seen" = "‏إلغاء استبعاد مشاهدة القصة‏"; -"Un-exclude story seen?" = "‏إلغاء استبعاد مشاهدة القصة؟‏"; "Un-excluded" = "‏تم إلغاء الاستبعاد‏"; -"Unblock" = "‏إلغاء الحظر‏"; "Unblocked" = "‏تم إلغاء الحظر‏"; "Unlimited replay enabled" = "‏تم تفعيل الإعادة غير المحدودة‏"; "Unmute story audio" = "‏إلغاء كتم صوت القصة‏"; @@ -728,6 +829,9 @@ "Add preset" = "‏إضافة إعداد مسبق‏"; "Change location" = "‏تغيير الموقع‏"; "Click the Apply button after this to see the emoji" = "‏انقر على زر تطبيق بعد هذا لرؤية الإيموجي‏"; +"Clipboard is not an Instagram URL" = "الحافظة لا تحتوي على رابط إنستغرام"; +"Comments hidden" = "‏تم إخفاء التعليقات‏"; +"Comments shown" = "‏تم إظهار التعليقات‏"; "Copied text to clipboard" = "‏تم نسخ النص إلى الحافظة‏"; "Copy" = "‏نسخ‏"; "Copy all" = "‏نسخ الكل‏"; @@ -738,19 +842,168 @@ "Current: %@" = "‏الحالي: %@‏"; "Disable" = "‏تعطيل‏"; "Download GIF" = "‏تنزيل صورة جيف (GIF)‏"; +"Dropped pin" = "‏دبوس الموقع‏"; "Enable" = "‏تفعيل‏"; +"Enable Location Services for Instagram in Settings to use your current location." = "‏فعّل خدمات الموقع لإنستغرام من الإعدادات لاستخدام موقعك الحالي.‏"; "Enter Emoji Text" = "‏أدخل نص الإيموجي‏"; "Fake location" = "‏موقع وهمي‏"; +"Location access denied" = "‏تم رفض الوصول إلى الموقع‏"; +"Location Services off" = "‏خدمات الموقع متوقفة‏"; "Name" = "‏الاسم‏"; "Nothing to copy" = "‏لا يوجد شيء لنسخه‏"; +"Open Settings" = "‏فتح الإعدادات‏"; +"Pick location" = "‏اختر الموقع‏"; "Save" = "‏حفظ‏"; "Save preset" = "‏حفظ الإعداد المسبق‏"; "Saved locations" = "‏المواقع المحفوظة‏"; "Select color" = "‏اختيار اللون‏"; "Set location" = "‏تعيين الموقع‏"; "Settings…" = "‏الإعدادات…‏"; +"Turn Location Services on in Settings → Privacy to use your current location." = "‏فعّل خدمات الموقع من الإعدادات ← الخصوصية لاستخدام موقعك الحالي.‏"; "Type emoji..." = "‏اكتب إيموجي...‏"; +"Theme" = "‏السمة‏"; +"Appearance" = "‏المظهر‏"; +"Keyboard" = "‏لوحة المفاتيح‏"; +"Force dark mode" = "‏فرض الوضع الداكن‏"; +"Keep Instagram in dark appearance regardless of iOS system setting" = "‏إبقاء انستغرام في الوضع الداكن بغض النظر عن إعدادات النظام‏"; +"Full OLED" = "‏OLED كامل‏"; +"Replace Instagram's dark grays with pure black across the entire app" = "‏استبدال الرمادي الداكن في انستغرام بالأسود النقي في جميع أنحاء التطبيق‏"; +"OLED chat theme" = "‏سمة OLED للمحادثات‏"; +"Pure black DM thread background and incoming message bubbles" = "‏خلفية سوداء نقية لمحادثات الرسائل وفقاعات الرسائل الواردة‏"; +"Keyboard theme" = "‏سمة لوحة المفاتيح‏"; +"Override the keyboard appearance when typing inside Instagram" = "‏تجاوز مظهر لوحة المفاتيح عند الكتابة داخل انستغرام‏"; +"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "‏الداكن يستخدم لوحة المفاتيح الداكنة للنظام. OLED يفرض خلفية لوحة المفاتيح إلى الأسود النقي.‏"; +"Dark" = "‏داكن‏"; +"OLED" = "OLED"; +"Apply & restart" = "‏تطبيق وإعادة التشغيل‏"; +"Restart Instagram to apply your theme changes" = "‏أعد تشغيل انستغرام لتطبيق تغييرات السمة‏"; +"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "‏لا تُطبَّق تغييرات السمة إلا بعد إعادة تشغيل التطبيق. اضغط تطبيق بالأسفل عند الانتهاء من الاختيار.‏"; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE ANALYZER // +// Settings → General → Profile Analyzer // +////////////////////////////////////////////////////////////////////////////// + +"%lu followers · %lu following" = "‏%lu متابع · %lu يتابع‏"; +"%lu of %lu" = "‏%lu من %lu‏"; +"Analysis complete" = "‏اكتمل التحليل‏"; +"Analysis failed" = "‏فشل التحليل‏"; +"Another analysis is already running" = "‏هناك تحليل آخر قيد التشغيل بالفعل‏"; +"Available after your next scan" = "‏متاحة بعد التحليل التالي‏"; +"Cancelled" = "‏تم الإلغاء‏"; +"Couldn't fetch profile information" = "‏تعذّر جلب معلومات الملف الشخصي‏"; +"Fetching followers (%lu/%ld)…" = "‏جاري جلب المتابعين (%lu/%ld)…‏"; +"Fetching following (%lu/%ld)…" = "‏جاري جلب المتابَعين (%lu/%ld)…‏"; +"Fetching profile info…" = "‏جاري جلب معلومات الملف الشخصي…‏"; +"Categories" = "‏الفئات‏"; +"First scan: %@" = "‏أول تحليل: %@‏"; +"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "‏عدد المتابعين يتجاوز %ld — التحليل معطّل لتجنب حدود المعدل.‏"; +"Gained since last scan" = "‏اكتسبتهم منذ آخر تحليل‏"; +"Last scan: %@" = "‏آخر تحليل: %@‏"; +"Lost followers" = "‏متابعون مفقودون‏"; +"Mutual followers" = "‏متابعون متبادلون‏"; +"Name: %@ → %@" = "‏الاسم: %@ ← %@‏"; +"New followers" = "‏متابعون جدد‏"; +"No results" = "‏لا توجد نتائج‏"; +"No active Instagram session found" = "‏لا توجد جلسة Instagram نشطة‏"; +"No scan yet" = "‏لا يوجد تحليل بعد‏"; +"Not following you back" = "‏لا يتابعونك بالمقابل‏"; +"OK" = "‏حسنًا‏"; +"Private account" = "‏حساب خاص‏"; +"Profile Analyzer" = "‏محلل الملف الشخصي‏"; +"Profile picture changed" = "‏تم تغيير صورة الملف الشخصي‏"; +"Profile updates" = "‏تحديثات الملف الشخصي‏"; +"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "‏يزيل اللقطات المحفوظة لهذا الحساب. ستفقد الفروق منذ آخر تحليل.‏"; +"Request failed" = "‏فشل الطلب‏"; +"Reset analyzer data?" = "‏إعادة تعيين بيانات المحلل؟‏"; +"Run analysis" = "‏تشغيل التحليل‏"; +"Run your first analysis" = "‏شغّل أول تحليل لك‏"; +"Search username or name" = "‏ابحث باسم المستخدم أو الاسم‏"; +"Since last scan" = "‏منذ آخر تحليل‏"; +"Starting…" = "‏يبدأ…‏"; +"They follow you, you don't follow back" = "‏يتابعونك، لكنك لا تتابعهم‏"; +"Too many followers" = "‏عدد متابعين كبير جدًا‏"; +"Too many followers to analyze" = "‏عدد المتابعين أكبر من أن يُحلَّل‏"; +"Unfollow" = "‏إلغاء المتابعة‏"; +"Unfollow @%@?" = "‏إلغاء متابعة @%@؟‏"; +"Unfollowed you since last scan" = "‏ألغوا متابعتك منذ آخر تحليل‏"; +"Username, name or picture changes" = "‏تغييرات اسم المستخدم أو الاسم أو الصورة‏"; +"Username: @%@ → @%@" = "‏اسم المستخدم: @%@ ← @%@‏"; +"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "‏لا نشغّل التحليل عندما يتجاوز عدد المتابعين %ld لتجنب حدود Instagram.‏"; +"You both follow each other" = "‏تتابعان بعضكما البعض‏"; +"You don't follow back" = "‏لا تتابعهم بالمقابل‏"; +"You follow them, they don't follow back" = "‏تتابعهم، لكنهم لا يتابعونك‏"; +"You started following" = "‏بدأت تتابعهم‏"; +"You unfollowed" = "‏ألغيت متابعتهم‏"; + +"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "‏%@ %lu حسابًا؟ ستتم معالجة أول %ld لتجنّب حدود المعدل.‏"; +"%@ %lu accounts? This runs sequentially with a short pause between each." = "‏%@ %lu حسابًا؟ ستتم المعالجة بالتتابع مع وقفة قصيرة بين كل طلب.‏"; +"%lu account(s) · %lu snapshot(s) · tap to inspect" = "‏%lu حساب · %lu لقطة · انقر للفحص‏"; +"%lu accounts followed" = "‏تمت متابعة %lu حسابًا‏"; +"%lu accounts unfollowed" = "‏تم إلغاء متابعة %lu حسابًا‏"; +"%lu entries across %lu lists · tap to inspect" = "‏%lu إدخال عبر %lu قائمة · انقر للفحص‏"; +"%lu preferences · tap to inspect" = "‏%lu تفضيلات · انقر للفحص‏"; +"(empty)" = "‏(فارغ)‏"; +"(no analyzer data)" = "‏(لا توجد بيانات محلل)‏"; +"(no lists)" = "‏(لا توجد قوائم)‏"; +"About Profile Analyzer" = "‏عن محلل الملف الشخصي‏"; +"All preferences (%lu)" = "‏جميع التفضيلات (%lu)‏"; +"Apply imported data?" = "‏تطبيق البيانات المستوردة؟‏"; +"Batch follow" = "‏متابعة جماعية‏"; +"Batch follow finished" = "‏اكتملت المتابعة الجماعية‏"; +"Batch unfollow" = "‏إلغاء متابعة جماعي‏"; +"Batch unfollow finished" = "‏اكتمل إلغاء المتابعة الجماعي‏"; +"Continue" = "‏متابعة‏"; +"Current snapshot" = "‏اللقطة الحالية‏"; +"Embed domains" = "‏نطاقات التضمين‏"; +"Excluded lists" = "‏القوائم المستبعدة‏"; +"Excluded story users" = "‏مستخدمو القصص المستبعدون‏"; +"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "‏سيتم استبدال القيم الحالية للنطاق المحدد. قد يحتاج التطبيق إلى إعادة التشغيل ليسري مفعول بعض التغييرات.‏"; +"Export" = "‏تصدير‏"; +"File has no importable sections." = "‏لا يحتوي الملف على أقسام قابلة للاستيراد.‏"; +"File is not a valid RyukGram export." = "‏الملف ليس تصديرًا صالحًا من RyukGram.‏"; +"Filter" = "‏تصفية‏"; +"First scan: we collect your followers and following lists and save them locally." = "‏أول تحليل: نجمع قوائم المتابعين والمتابَعين ونحفظها محليًا.‏"; +"Follow %lu" = "‏متابعة %lu‏"; +"Followers" = "‏المتابعون‏"; +"Following… %lu / %lu" = "‏جارٍ المتابعة… %lu / %lu‏"; +"Full name" = "‏الاسم الكامل‏"; +"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "‏تنبيه: هذه الميزة تجريبية وتستخدم واجهة Instagram الخاصة. تشغيلها بشكل متتالٍ أو بعد نشاط متابعة/إلغاء متابعة كثيف قد يسبب حدًا مؤقتًا. استخدمها باعتدال وعلى مسؤوليتك.‏"; +"Import complete" = "‏اكتمل الاستيراد‏"; +"Include" = "‏تضمين‏"; +"Included story users" = "‏مستخدمو القصص المدرجون‏"; +"Inspect the full payload" = "‏افحص البيانات الكاملة‏"; +"Keep scan history" = "‏الاحتفاظ بسجل التحليلات‏"; +"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "‏الحسابات الكبيرة محظورة: يتم تعطيل التحليل فوق 13,000 متابع لتجنّب قيام Instagram بتحديد معدل التطبيق بأكمله.‏"; +"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "‏لا يتم رفع أي شيء — كل البيانات تبقى على هذا الجهاز ويمكن مسحها من أيقونة سلة المهملات.‏"; +"Not verified only" = "‏غير الموثقة فقط‏"; +"Nothing was applied." = "‏لم يتم تطبيق شيء.‏"; +"Posts" = "‏المنشورات‏"; +"Preferences" = "‏التفضيلات‏"; +"Previous snapshot" = "‏اللقطة السابقة‏"; +"Private only" = "‏الخاصة فقط‏"; +"Profile Analyzer data" = "‏بيانات محلل الملف الشخصي‏"; +"Raw" = "‏خام‏"; +"Raw JSON" = "‏JSON الخام‏"; +"Reset analyzer data" = "‏إعادة تعيين بيانات المحلل‏"; +"Reset complete" = "‏اكتملت إعادة التعيين‏"; +"Reset selected data?" = "‏إعادة تعيين البيانات المحددة؟‏"; +"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "‏من التحليل الثاني فصاعدًا: يقارن كل تحليل بما قبله، لتظهر المتابعين الجدد والمفقودين وإجراءاتك من متابعة/إلغاء متابعة وتحديثات الملف الشخصي.‏"; +"Select all" = "‏تحديد الكل‏"; +"Selected data will be cleared. Tap any row to see what's stored." = "‏سيتم مسح البيانات المحددة. انقر على أي صف لرؤية ما هو مخزّن.‏"; +"Settings" = "‏الإعدادات‏"; +"Sort" = "‏ترتيب‏"; +"This can't be undone." = "‏لا يمكن التراجع عن هذا الإجراء.‏"; +"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "‏حدّد ما تريد تطبيقه. انقر على أي صف للفحص. الأقسام غير الموجودة في الملف معطّلة.‏"; +"Tick what to include. Tap any row to inspect its contents." = "‏حدّد ما تريد تضمينه. انقر على أي صف لفحص محتوياته.‏"; +"Unfollow %lu" = "‏إلغاء متابعة %lu‏"; +"Unfollowing… %lu / %lu" = "‏جارٍ إلغاء المتابعة… %lu / %lu‏"; +"Username A → Z" = "‏اسم المستخدم أ ← ي‏"; +"Username Z → A" = "‏اسم المستخدم ي ← أ‏"; +"Verified only" = "‏الموثقة فقط‏"; +"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "‏عند التفعيل، تُقارن التحليلات بتحليلك الأول فلا يختفي المتابعون الجدد/المفقودون وتحديثات الملفات الشخصية بين التحليلات.‏"; + ////////////////////////////////////////////////////////////////////////////// // SETTINGS VIEWS & DIALOGS // // Excluded-lists managers, backup/restore flows, in-picker labels. // @@ -758,72 +1011,62 @@ "Add chat" = "‏إضافة دردشة‏"; "Add custom domain" = "‏إضافة نطاق مخصص‏"; +"Add preset…" = "‏إضافة إعداد مسبق…‏"; "Add to list?" = "‏إضافة للقائمة؟‏"; "Add user" = "‏إضافة مستخدم‏"; -"Could not resolve user ID" = "‏تعذر تحليل معرف المستخدم‏"; -"Enter username" = "‏أدخل اسم المستخدم‏"; -"Enter username of the DM thread" = "‏أدخل اسم المستخدم لمحادثة الرسائل الخاصة‏"; -"No DM thread found with @%@" = "‏لم يتم العثور على محادثة رسائل مع @%@‏"; -"User '%@' not found" = "‏لم يتم العثور على المستخدم '%@'‏"; -"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." = "‏سيتم إعادة تعيين جميع إعدادات ريوك غرام للافتراضي وتطبيق القيم المستوردة. سيحتاج التطبيق لإعادة التشغيل لتفعيل التغييرات.‏"; "Apply" = "‏تطبيق‏"; -"Apply imported settings?" = "‏تطبيق الإعدادات المستوردة؟‏"; "Apply to" = "‏تطبيق على‏"; "Chats" = "‏الدردشات‏"; "Could not read file." = "‏تعذرت قراءة الملف.‏"; +"Could not resolve user ID" = "‏تعذر تحليل معرف المستخدم‏"; "Could not write temporary file." = "‏تعذرت كتابة الملف المؤقت.‏"; "Current location" = "‏الموقع الحالي‏"; "Custom" = "‏مخصص‏"; "Date Format" = "‏تنسيق التاريخ‏"; "Delete" = "‏حذف‏"; -"Done editing" = "‏تم التعديل‏"; -"Edit values" = "‏تعديل القيم‏"; "Enable fake location" = "‏تفعيل الموقع الوهمي‏"; -"Every RyukGram preference will revert to its built-in default. This can't be undone." = "‏ستعود كل تفضيلات ريوك غرام إلى قيمها الافتراضية المدمجة. لا يمكن التراجع عن هذا.‏"; +"Enter username" = "‏أدخل اسم المستخدم‏"; +"Enter username of the DM thread" = "‏أدخل اسم المستخدم لمحادثة الرسائل الخاصة‏"; "Excluded chats" = "‏الدردشات المستبعدة‏"; "Excluded users" = "‏المستخدمون المستبعدون‏"; -"File is not a valid RyukGram settings export." = "‏الملف ليس ملف تصدير إعدادات ريوك غرام صالح.‏"; "Follow default" = "‏اتباع الافتراضي‏"; "Force OFF (allow unsends)" = "‏إيقاف إجباري (السماح بإلغاء الإرسال)‏"; "Force ON (preserve unsends)" = "‏تشغيل إجباري (الاحتفاظ بالرسائل الملغاة)‏"; -"Form view" = "‏عرض النموذج‏"; "Format" = "‏التنسيق‏"; "Import failed" = "‏فشل الاستيراد‏"; -"Import preview" = "‏معاينة الاستيراد‏"; "Included chats" = "‏الدردشات المشمولة‏"; "Included users" = "‏المستخدمون المشمولون‏"; -"KD: ON" = "‏الاحتفاظ: تشغيل‏"; "KD: default" = "‏الاحتفاظ: الافتراضي‏"; +"KD: ON" = "‏الاحتفاظ: تشغيل‏"; "Keep-deleted" = "‏الاحتفاظ بالمحذوف‏"; "Keep-deleted override" = "‏تجاوز الاحتفاظ بالمحذوف‏"; +"Name (A–Z)" = "‏الاسم (أ–ي)‏"; +"No DM thread found with @%@" = "‏لم يتم العثور على محادثة رسائل مع @%@‏"; "Off" = "‏إيقاف‏"; -"On" = "‏تشغيل‏"; "Presets" = "‏الإعدادات المسبقة‏"; -"Raw JSON view" = "‏عرض جيسون (JSON) الخام‏"; -"Remove Selected" = "‏إزالة المحدد‏"; +"Recently added" = "‏المُضاف مؤخراً‏"; "Remove from list" = "‏إزالة من القائمة‏"; +"Remove Selected" = "‏إزالة المحدد‏"; "Reset" = "‏إعادة تعيين‏"; -"Reset all settings?" = "‏إعادة تعيين كافة الإعدادات؟‏"; "Saved presets are reusable. Tap a preset to make it the active location." = "‏الإعدادات المسبقة المحفوظة قابلة لإعادة الاستخدام. انقر على الإعداد لجعله الموقع النشط.‏"; +"Search" = "‏بحث‏"; "Search address or place" = "‏بحث عن عنوان أو مكان‏"; "Search by name or username" = "‏بحث بالاسم أو اسم المستخدم‏"; "Search by username or name" = "‏البحث باسم المستخدم أو الاسم‏"; -"Search settings" = "‏إعدادات البحث‏"; "Select" = "‏تحديد‏"; "Select location on map" = "‏تحديد الموقع على الخريطة‏"; "Set current location" = "‏تعيين الموقع الحالي‏"; "Set keep-deleted override" = "‏تعيين تجاوز الاحتفاظ بالمحذوف‏"; "Settings exported" = "‏تم تصدير الإعدادات‏"; -"Settings imported" = "‏تم استيراد الإعدادات‏"; +"Show map button" = "‏إظهار زر الخريطة‏"; "Show seconds" = "‏إظهار الثواني‏"; "Sort by" = "‏فرز حسب‏"; "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." = "‏بدّل كل أداة تنسيق NSDate يستخدمها إنستغرام. الأقسام المختلفة (اليوميات، التعليقات، القصص، الرسائل الخاصة) لها طرق مختلفة — فعّل ما تريد تطبيق التنسيق المخصص عليه.‏"; "Use this location" = "‏استخدام هذا الموقع‏"; -"When on, all CoreLocation requests inside Instagram return the location below." = "‏عند التشغيل، ستعيد كل طلبات الموقع (CoreLocation) داخل إنستغرام الموقع أدناه.‏"; +"User '%@' not found" = "‏لم يتم العثور على المستخدم '%@'‏"; +"Username (A–Z)" = "‏اسم المستخدم (أ–ي)‏"; "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" = "‏إظهار زر الخريطة‏"; ////////////////////////////////////////////////////////////////////////////// // REELS (FEATURES) // @@ -859,6 +1102,7 @@ "720p • progressive • fastest" = "‏720p • تدريجي • الأسرع‏"; "Are you sure?" = "‏هل أنت متأكد؟‏"; +"Bundle" = "‏الحزمة‏"; "Copy audio URL" = "‏نسخ رابط الصوت‏"; "Copy quality info" = "‏نسخ معلومات الجودة‏"; "Copy video URL" = "‏نسخ رابط الفيديو‏"; @@ -871,11 +1115,14 @@ "Could not extract video url from reel" = "‏تعذر استخراج رابط الفيديو من مقطع ريلز‏"; "Could not extract video url from story" = "‏تعذر استخراج رابط الفيديو من القصة‏"; "Download Quality" = "‏جودة التنزيل‏"; +"Extras" = "Extras"; "FFmpegKit Debug" = "‏تصحيح أخطاء FFmpegKit‏"; "Later" = "‏لاحقًا‏"; "No!" = "‏لا!‏"; +"OK" = "‏حسناً‏"; "Restart" = "‏إعادة تشغيل‏"; "Restart required" = "‏إعادة التشغيل مطلوبة‏"; +"username" = "‏اسم المستخدم‏"; "Yes" = "‏نعم‏"; "You must restart the app to apply this change" = "‏يجب عليك إعادة تشغيل التطبيق لتطبيق هذا التغيير‏"; @@ -884,44 +1131,58 @@ // Strings from the About / Credits footer of Settings. // ////////////////////////////////////////////////////////////////////////////// -"%@ — view source, report issues, see releases" = "‏%@ — عرض المصدر، الإبلاغ عن مشاكل، رؤية الإصدارات‏"; +"%@ — GitHub & Telegram" = "‏%@ — جيت هاب وتيليجرام‏"; +"About" = "‏حول‏"; +"Arabic translation" = "‏الترجمة العربية‏"; +"Chinese (Traditional) translation" = "الترجمة الصينية (التقليدية)"; "Credits" = "‏شكر وتقدير‏"; -"Developer" = "‏المطور‏"; +"Developers" = "‏المطوّرون‏"; "Donate to SoCuul" = "‏تبرع إلى SoCuul‏"; +"installed" = "‏مُثبّت‏"; +"Korean translation" = "‏الترجمة الكورية‏"; +"latest" = "‏الأحدث‏"; +"Links" = "‏الروابط‏"; +"No releases" = "‏لا توجد إصدارات‏"; "Original SCInsta developer" = "‏مطور SCInsta الأصلي‏"; -"Ryuk" = "‏ريوك (Ryuk)‏"; -"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "‏ريوك غرام %@\n\nإنستغرام إصدار %@\n\nمبني على SCInsta بواسطة SoCuul\n\nالتعريب بواسطة @bruuhim‏"; -"RyukGram on GitHub" = "‏ريوك غرام على غيت هاب (GitHub)‏"; -"SoCuul" = "‏SoCuul‏"; +"Release notes" = "‏ملاحظات الإصدار‏"; +"Releases" = "‏الإصدارات‏"; +"Report an issue" = "‏الإبلاغ عن مشكلة‏"; +"Russian translation" = "‏الترجمة الروسية‏"; +"RyukGram developer" = "‏مطوّر RyukGram‏"; +"Join Telegram channel" = "‏انضم إلى قناة تيليجرام‏"; +"Source code" = "‏الكود المصدري‏"; +"View on GitHub" = "‏عرض على جيت هاب‏"; +"Spanish translation" = "‏الترجمة الإسبانية‏"; "Support the original developer" = "‏ادعم المطور الأصلي‏"; -"View Repo" = "‏عرض المستودع‏"; -"View the source code on GitHub" = "‏عرض الكود المصدري على غيت هاب (GitHub)‏"; +"Telegram channel" = "‏قناة تيليجرام‏"; +"Testing and feature suggestions" = "‏الاختبار واقتراحات الميزات‏"; +"Tweak settings" = "‏إعدادات التعديل‏"; +"Version" = "‏الإصدار‏"; +"Version, credits, and links" = "‏الإصدار والاعتمادات والروابط‏"; +"What's new in RyukGram" = "‏جديد RyukGram‏"; ////////////////////////////////////////////////////////////////////////////// // HD DOWNLOADS // // Enhanced / HD downloads settings (DASH + FFmpegKit encoding). // ////////////////////////////////////////////////////////////////////////////// +"720p • progressive • silent" = "‏720p • تدريجي • صامت‏"; +"Audio extract failed" = "‏فشل استخراج الصوت‏"; +"Audio only" = "‏الصوت فقط‏"; +"Audio ready" = "‏الصوت جاهز‏"; "Download video at the highest available quality" = "‏تنزيل الفيديو بأعلى جودة متاحة‏"; "Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "‏تنزيل الفيديو عالي الدقة (HD) عبر بث داش (DASH) وترميزه إلى H.264. يتطلب حزمة FFmpegKit.‏"; "Encoding speed" = "‏سرعة الترميز‏"; "Enhanced downloads" = "‏تنزيلات محسّنة‏"; -"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "‏حزمة FFmpegKit غير متوفرة. قم بتثبيت تطبيق IPA المُحمّل جانبياً أو نسخة _ffmpeg .deb للتفعيل.‏"; "Faster = lower quality" = "‏أسرع = جودة أقل‏"; +"FFmpeg not available" = "‏FFmpeg غير متاح‏"; +"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "‏حزمة FFmpegKit غير متوفرة. قم بتثبيت تطبيق IPA المُحمّل جانبياً أو نسخة _ffmpeg .deb للتفعيل.‏"; +"No audio stream available" = "‏لا يتوفر مسار صوتي‏"; +"No audio track found" = "‏لم يتم العثور على مسار صوتي‏"; +"Photo" = "‏صورة‏"; "Photo quality" = "‏جودة الصورة‏"; +"Raw image (no audio, no video)" = "‏صورة خام (بدون صوت أو فيديو)‏"; +"silent" = "‏صامت‏"; "Use highest resolution available" = "‏استخدام أعلى دقة متاحة‏"; "Video quality" = "‏جودة الفيديو‏"; "Which quality to download" = "‏الجودة المراد تنزيلها‏"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL / DEBUG // -// Placeholder rows only shown in the experimental settings sandbox. // -////////////////////////////////////////////////////////////////////////////// - -"Navigation Cell" = "‏خلية التنقل‏"; -"Localization" = "‏التعريب‏"; -"Update localization file" = "‏تحديث ملف التعريب‏"; -"Import a .strings file for a language" = "‏استيراد ملف .strings للغة‏"; -"Import a .strings file to update a translation. Pick a language, select the file, restart." = "‏استورد ملف .strings لتحديث الترجمة. اختر لغة، حدد الملف، وأعد التشغيل.‏"; -"Export English strings" = "‏تصدير نصوص الإنجليزية‏"; -"Share the base English .strings file for translating" = "‏مشاركة ملف .strings الأساسي بالإنجليزية للترجمة‏"; diff --git a/src/Localization/Resources/en.lproj/Localizable.strings b/src/Localization/Resources/en.lproj/Localizable.strings index 018c295..7b3ca0f 100644 --- a/src/Localization/Resources/en.lproj/Localizable.strings +++ b/src/Localization/Resources/en.lproj/Localizable.strings @@ -55,6 +55,7 @@ * - 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 // @@ -65,11 +66,11 @@ "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.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.help_translate" = "Help translate"; +"settings.language.ok" = "OK"; "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"; @@ -83,6 +84,8 @@ "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"; +"Anonymous live viewing" = "Anonymous live viewing"; +"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count"; "Browser" = "Browser"; "Comments" = "Comments"; "Copy comment text" = "Copy comment text"; @@ -103,13 +106,14 @@ "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 Meta AI" = "Hide Meta AI"; "Hide metrics" = "Hide metrics"; "Hide notes tray" = "Hide notes tray"; "Hide trending searches" = "Hide trending searches"; +"Hide UI on capture" = "Hide UI on capture"; "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"; @@ -119,23 +123,30 @@ "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"; +"Live" = "Live"; "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"; +"Long-press the heart button in a live to hide or show the comments" = "Long-press the heart button in a live to hide or show the comments"; +"Long-press the search tab to open a copied Instagram link" = "Long-press the search tab to open a copied Instagram link"; "No suggested chats" = "No suggested chats"; "No suggested users" = "No suggested users"; "Notes" = "Notes"; +"Open app icon picker" = "Open app icon picker"; +"Open link from clipboard" = "Open link from clipboard"; "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"; +"Privacy" = "Privacy"; +"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "Redacts RyukGram buttons from screenshots, screen recordings, and mirroring"; "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."; +"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"; "Replace domain in shared links" = "Replace domain in 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."; "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)."; +"Toggle live comments" = "Toggle live comments"; "Use detailed color picker" = "Use detailed color picker"; ////////////////////////////////////////////////////////////////////////////// @@ -230,9 +241,6 @@ "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"; "Auto-scroll reels" = "Auto-scroll reels"; -"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG default: native behavior. RyukGram: re-advances after swiping back."; -"IG default" = "IG default"; -"RyukGram" = "RyukGram"; "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"; @@ -244,6 +252,8 @@ "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"; +"IG default" = "IG default"; +"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG default: native behavior. RyukGram: re-advances after swiping back."; "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 %@ %@"; @@ -251,11 +261,14 @@ "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"; +"RyukGram" = "RyukGram"; "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"; +"Tap to mute on photo reels" = "Tap to mute on photo reels"; "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"; +"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture"; ////////////////////////////////////////////////////////////////////////////// // PROFILE // @@ -265,14 +278,25 @@ "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"; +"Fake follower count" = "Fake follower count"; +"Fake following count" = "Fake following count"; +"Fake post count" = "Fake post count"; +"Fake profile stats" = "Fake profile stats"; +"Fake verified badge" = "Fake verified badge"; "Follow indicator" = "Follow indicator"; +"Follower count" = "Follower count"; +"Following count" = "Following count"; "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."; +"Only affects your own profile header. Other users see the real numbers." = "Only affects your own profile header. Other users see the real numbers."; +"Post count" = "Post count"; "Profile copy button" = "Profile copy button"; "Save profile picture" = "Save profile picture"; +"Show a checkmark next to your name on your own profile" = "Show a checkmark next to your name on your own profile"; "Shows whether the profile user follows you" = "Shows whether the profile user follows you"; +"Tap to set" = "Tap to set"; "View highlight cover" = "View highlight cover"; "Zoom profile photo" = "Zoom profile photo"; @@ -328,16 +352,25 @@ "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"; +"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server"; "Master toggle. When off, the list is ignored" = "Master toggle. When off, the list is ignored"; "Other" = "Other"; "Playback" = "Playback"; -"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server"; "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"; +"Stickers" = "Stickers"; +"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray."; +"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally."; +"Force Quiz sticker in tray" = "Force Quiz sticker in tray"; +"Adds Quiz back to the story sticker picker" = "Adds Quiz back to the story sticker picker"; +"Show quiz answer" = "Show quiz answer"; +"Circle the correct option on quiz stickers, or the leading option on polls" = "Circle the correct option on quiz stickers, or the leading option on polls"; +"Show poll vote counts" = "Show poll vote counts"; +"Show vote tallies on poll options and slider count/average before you vote" = "Show vote tallies on poll options and slider count/average before you vote"; "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"; @@ -382,7 +415,7 @@ "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 vanish mode swipe" = "Disable vanish mode swipe"; "Disable screenshot detection" = "Disable screenshot detection"; "Disable typing status" = "Disable typing status"; "Disable view-once limitations" = "Disable view-once limitations"; @@ -403,7 +436,7 @@ "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"; +"Prevents accidental swipe-up activation of vanish mode" = "Prevents accidental swipe-up activation of vanish 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"; @@ -418,7 +451,6 @@ "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"; @@ -437,11 +469,13 @@ // Settings → Navigation tab // ////////////////////////////////////////////////////////////////////////////// +"Also hide the bottom tab bar — only the inbox is visible" = "Also hide the bottom tab bar — only the inbox is visible"; "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"; +"Hide tab bar" = "Hide tab bar"; "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"; @@ -466,7 +500,8 @@ ////////////////////////////////////////////////////////////////////////////// "Confirm actions" = "Confirm actions"; -"Confirm call" = "Confirm call"; +"Confirm video call" = "Confirm video call"; +"Confirm voice call" = "Confirm voice call"; "Confirm changing theme" = "Confirm changing theme"; "Confirm follow" = "Confirm follow"; "Confirm follow requests" = "Confirm follow requests"; @@ -474,24 +509,26 @@ "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 vanish mode" = "Confirm vanish mode"; +"Confirm sticker interaction (stories)" = "Confirm sticker interaction (stories)"; +"Confirm sticker interaction (highlights)" = "Confirm sticker interaction (highlights)"; "Confirm story emoji reaction" = "Confirm story emoji reaction"; "Confirm story like" = "Confirm story like"; "Confirm unfollow" = "Confirm unfollow"; -"Shows an alert before sending an emoji reaction on a story" = "Shows an alert before sending an emoji reaction on a story"; -"Shows an alert when you click the like button on posts to confirm the like" = "Shows an alert when you click the like button on posts to confirm the like"; -"Shows an alert when you click the like button on stories to confirm the like" = "Shows an alert when you click the like button on stories to confirm the like"; "Confirm voice messages" = "Confirm voice messages"; +"Shows an alert before sending an emoji reaction on a story" = "Shows an alert before sending an emoji reaction on a story"; "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 to confirm before toggling vanish mode" = "Shows an alert to confirm before toggling vanish mode"; "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 tap a sticker on someone's story" = "Shows an alert when you tap a sticker on someone's story"; +"Shows an alert when you tap a sticker inside a highlight" = "Shows an alert when you tap a sticker inside a highlight"; +"Shows an alert when you click the video call button to confirm before calling" = "Shows an alert when you click the video call button to confirm before calling"; +"Shows an alert when you click the voice call button to confirm before calling" = "Shows an alert when you click the voice 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 posts to confirm the like" = "Shows an alert when you click the like button on posts 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 like button on stories to confirm the like" = "Shows an alert when you click the like button on stories 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"; @@ -502,22 +539,6 @@ ////////////////////////////////////////////////////////////////////////////// "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 // @@ -525,17 +546,76 @@ ////////////////////////////////////////////////////////////////////////////// "Advanced" = "Advanced"; +"Auto-clear cache" = "Auto-clear cache"; "Automatically opens settings when the app launches" = "Automatically opens settings when the app launches"; +"Cache" = "Cache"; +"Cache cleared" = "Cache cleared"; +"Calculating cache size…" = "Calculating cache size…"; +"Clear" = "Clear"; +"Clear cache" = "Clear cache"; +"Clear cache (%@)" = "Clear cache (%@)"; +"Clear cache?" = "Clear cache?"; +"Clearing cache…" = "Clearing cache…"; +"Clearing still scans on demand." = "Clearing still scans on demand."; +"Daily" = "Daily"; "Disable safe mode" = "Disable safe mode"; "Enable tweak settings quick-access" = "Enable tweak settings quick-access"; +"Free %@ of Instagram cache. A restart is recommended." = "Free %@ of Instagram cache. A restart is recommended."; +"Freed %@. Restart to apply." = "Freed %@. Restart to apply."; "Hold on the home tab to open RyukGram settings" = "Hold on the home tab to open RyukGram settings"; "Instagram" = "Instagram"; +"Monthly" = "Monthly"; +"Nothing to clear" = "Nothing to clear"; +"Off skips the size scan when Advanced opens." = "Off skips the size scan when Advanced opens."; "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)"; +"Remove Instagram's cached images, videos, and temporary files." = "Remove Instagram's cached images, videos, and temporary files."; "Reset onboarding state" = "Reset onboarding state"; -"Settings" = "Settings"; +"Run a silent cache clear on launch when the interval has elapsed." = "Run a silent cache clear on launch when the interval has elapsed."; +"Show cache size" = "Show cache size"; "Show tweak settings on app launch" = "Show tweak settings on app launch"; +"Weekly" = "Weekly"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED EXPERIMENTAL // +// Settings → Advanced → Advanced experimental features // +////////////////////////////////////////////////////////////////////////////// + +"Actions" = "Actions"; +"Advanced experimental features" = "Advanced experimental features"; +"All experimental toggles will be turned off. Instagram will restart." = "All experimental toggles will be turned off. Instagram will restart."; +"Direct Notes — Audio reply" = "Direct Notes — Audio reply"; +"Direct Notes — Avatar reply" = "Direct Notes — Avatar reply"; +"Direct Notes — Friend Map" = "Direct Notes — Friend Map"; +"Direct Notes — GIFs & stickers reply" = "Direct Notes — GIFs & stickers reply"; +"Direct Notes — Photo reply" = "Direct Notes — Photo reply"; +"Disabled after repeated crashes." = "Disabled after repeated crashes."; +"Enables GIF/sticker replies" = "Enables GIF/sticker replies"; +"Enables photo replies" = "Enables photo replies"; +"Enables the audio-note reply type" = "Enables the audio-note reply type"; +"Enables the avatar reply type" = "Enables the avatar reply type"; +"Experimental flags reset" = "Experimental flags reset"; +"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times."; +"Forces Prism-gated experiments on" = "Forces Prism-gated experiments on"; +"Forces the Homecoming home surface / nav on" = "Forces the Homecoming home surface / nav on"; +"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray"; +"Got it" = "Got it"; +"Heads up" = "Heads up"; +"Hidden Instagram experiments" = "Hidden Instagram experiments"; +"Hidden Instagram experiments (in Advanced)" = "Hidden Instagram experiments (in Advanced)"; +"Homecoming" = "Homecoming"; +"Notes & QuickSnap" = "Notes & QuickSnap"; +"Prism design system" = "Prism design system"; +"QuickSnap (Instants)" = "QuickSnap (Instants)"; +"Reset all experimental flags" = "Reset all experimental flags"; +"Reset experimental flags?" = "Reset experimental flags?"; +"Restart Instagram to apply changes" = "Restart Instagram to apply changes"; +"Shows the friend map entry in Direct Notes" = "Shows the friend map entry in Direct Notes"; +"Surfaces" = "Surfaces"; +"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts."; +"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "Toggle hidden Instagram experiments. Some may not work on every account or IG version."; +"Turn every experimental toggle off" = "Turn every experimental toggle off"; ////////////////////////////////////////////////////////////////////////////// // DEBUG // @@ -544,24 +624,38 @@ "Button Cell" = "Button Cell"; "Change the value on the right" = "Change the value on the right"; +"Could not delete: %@" = "Could not delete: %@"; "Debug" = "Debug"; +"Delete an imported override and fall back to the shipped strings" = "Delete an imported override and fall back to the shipped strings"; +"Deleted %@ override. Restart to apply." = "Deleted %@ override. Restart to apply."; "Enable FLEX gesture" = "Enable FLEX gesture"; +"Export English strings" = "Export English strings"; "Hold 5 fingers on the screen to open FLEX" = "Hold 5 fingers on the screen to open FLEX"; "I have %@%@" = "I have %@%@"; +"Import a .strings file for a language" = "Import a .strings file for a language"; +"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Import a .strings file to update a translation. Pick a language, select the file, restart."; "Link Cell" = "Link Cell"; +"Localization" = "Localization"; "Menu Cell" = "Menu Cell"; +"Navigation Cell" = "Navigation Cell"; +"No imported localization files to reset." = "No imported localization files to reset."; +"No overrides" = "No overrides"; "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"; +"Pick a language to delete the imported file" = "Pick a language to delete the imported file"; +"Reset localization" = "Reset localization"; +"Share the base English .strings file for translating" = "Share the base English .strings file for translating"; "Static Cell" = "Static Cell"; "Stepper cell" = "Stepper cell"; "Switch Cell" = "Switch Cell"; "Switch Cell (Restart)" = "Switch Cell (Restart)"; "Tap the switch" = "Tap the switch"; +"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "These features rely on hidden Instagram flags and may not work on all accounts or versions."; +"Update localization file" = "Update localization file"; "Using icon" = "Using icon"; "Using image" = "Using image"; -"_ Example" = "_ Example"; ////////////////////////////////////////////////////////////////////////////// // DOWNLOADS & MEDIA ACTIONS // @@ -593,16 +687,16 @@ "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 media URL" = "No media URL"; +"No URLs" = "No URLs"; +"No URLs found" = "No URLs found"; "No video URL" = "No video URL"; "Not a carousel" = "Not a carousel"; "Nothing to save" = "Nothing to save"; @@ -635,6 +729,7 @@ "Add to block list" = "Add to block list"; "Add to block list?" = "Add to block list?"; "Added to block list" = "Added to block list"; +"Added to exclude list" = "Added to exclude 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"; @@ -648,12 +743,14 @@ "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"; +"Disappearing media" = "Disappearing media"; "Done!" = "Done!"; "Download audio" = "Download audio"; "Downloading audio..." = "Downloading audio..."; "Enable read receipts" = "Enable read receipts"; "Error: %@" = "Error: %@"; "Exclude chat" = "Exclude chat"; +"Exclude from seen" = "Exclude from seen"; "Exclude story seen" = "Exclude story seen"; "Excluded" = "Excluded"; "Extracting audio..." = "Extracting audio..."; @@ -661,6 +758,10 @@ "File sending not supported" = "File sending not supported"; "Follow" = "Follow"; "Following" = "Following"; +"Inserts a button on disappearing media overlays" = "Inserts a button on disappearing media overlays"; +"Inserts a speaker button to mute/unmute disappearing media" = "Inserts a speaker button to mute/unmute disappearing media"; +"Inserts an eye button to mark the current disappearing media as viewed" = "Inserts an eye button to mark the current disappearing media as viewed"; +"Mark as viewed" = "Mark as viewed"; "Mark messages as seen" = "Mark messages as seen"; "Mark seen" = "Mark seen"; "Marked as seen" = "Marked as seen"; @@ -669,6 +770,7 @@ "Mentions" = "Mentions"; "Message sender not found" = "Message sender not found"; "Messages settings" = "Messages settings"; +"Audio URL not available" = "Audio URL not available"; "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"; @@ -684,26 +786,25 @@ "Remove" = "Remove"; "Remove from block list" = "Remove from block list"; "Remove from block list?" = "Remove from block list?"; +"Remove from exclude list" = "Remove from exclude list"; "Removed" = "Removed"; +"Removed from list" = "Removed from list"; "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 Audio" = "Send Audio"; "Send failed: %@" = "Send failed: %@"; "Send service not found" = "Send service not found"; -"Share" = "Share"; +"Show audio toggle" = "Show audio toggle"; +"Show mark-as-viewed button" = "Show mark-as-viewed button"; "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"; @@ -726,6 +827,9 @@ "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"; +"Clipboard is not an Instagram URL" = "Clipboard is not an Instagram URL"; +"Comments hidden" = "Comments hidden"; +"Comments shown" = "Comments shown"; "Copied text to clipboard" = "Copied text to clipboard"; "Copy" = "Copy"; "Copy all" = "Copy all"; @@ -736,19 +840,168 @@ "Current: %@" = "Current: %@"; "Disable" = "Disable"; "Download GIF" = "Download GIF"; +"Dropped pin" = "Dropped pin"; "Enable" = "Enable"; +"Enable Location Services for Instagram in Settings to use your current location." = "Enable Location Services for Instagram in Settings to use your current location."; "Enter Emoji Text" = "Enter Emoji Text"; "Fake location" = "Fake location"; +"Location access denied" = "Location access denied"; +"Location Services off" = "Location Services off"; "Name" = "Name"; "Nothing to copy" = "Nothing to copy"; +"Open Settings" = "Open Settings"; +"Pick location" = "Pick location"; "Save" = "Save"; "Save preset" = "Save preset"; "Saved locations" = "Saved locations"; "Select color" = "Select color"; "Set location" = "Set location"; "Settings…" = "Settings…"; +"Turn Location Services on in Settings → Privacy to use your current location." = "Turn Location Services on in Settings → Privacy to use your current location."; "Type emoji..." = "Type emoji..."; +"Theme" = "Theme"; +"Appearance" = "Appearance"; +"Keyboard" = "Keyboard"; +"Force dark mode" = "Force dark mode"; +"Keep Instagram in dark appearance regardless of iOS system setting" = "Keep Instagram in dark appearance regardless of iOS system setting"; +"Full OLED" = "Full OLED"; +"Replace Instagram's dark grays with pure black across the entire app" = "Replace Instagram's dark grays with pure black across the entire app"; +"OLED chat theme" = "OLED chat theme"; +"Pure black DM thread background and incoming message bubbles" = "Pure black DM thread background and incoming message bubbles"; +"Keyboard theme" = "Keyboard theme"; +"Override the keyboard appearance when typing inside Instagram" = "Override the keyboard appearance when typing inside Instagram"; +"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black."; +"Dark" = "Dark"; +"OLED" = "OLED"; +"Apply & restart" = "Apply & restart"; +"Restart Instagram to apply your theme changes" = "Restart Instagram to apply your theme changes"; +"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "Theme changes only take effect after an app restart. Tap Apply below when you're done choosing."; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE ANALYZER // +// Settings → Profile Analyzer // +////////////////////////////////////////////////////////////////////////////// + +"%lu followers · %lu following" = "%lu followers · %lu following"; +"%lu of %lu" = "%lu of %lu"; +"Analysis complete" = "Analysis complete"; +"Analysis failed" = "Analysis failed"; +"Another analysis is already running" = "Another analysis is already running"; +"Available after your next scan" = "Available after your next scan"; +"Cancelled" = "Cancelled"; +"Categories" = "Categories"; +"Couldn't fetch profile information" = "Couldn't fetch profile information"; +"Fetching followers (%lu/%ld)…" = "Fetching followers (%lu/%ld)…"; +"Fetching following (%lu/%ld)…" = "Fetching following (%lu/%ld)…"; +"Fetching profile info…" = "Fetching profile info…"; +"First scan: %@" = "First scan: %@"; +"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "Follower count exceeds %ld — analysis disabled to avoid rate limits."; +"Gained since last scan" = "Gained since last scan"; +"Last scan: %@" = "Last scan: %@"; +"Lost followers" = "Lost followers"; +"Mutual followers" = "Mutual followers"; +"Name: %@ → %@" = "Name: %@ → %@"; +"New followers" = "New followers"; +"No results" = "No results"; +"No active Instagram session found" = "No active Instagram session found"; +"No scan yet" = "No scan yet"; +"Not following you back" = "Not following you back"; +"OK" = "OK"; +"Private account" = "Private account"; +"Profile Analyzer" = "Profile Analyzer"; +"Profile picture changed" = "Profile picture changed"; +"Profile updates" = "Profile updates"; +"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "Removes cached snapshots for this account. You'll lose since-last-scan diffs."; +"Request failed" = "Request failed"; +"Reset analyzer data?" = "Reset analyzer data?"; +"Run analysis" = "Run analysis"; +"Run your first analysis" = "Run your first analysis"; +"Search username or name" = "Search username or name"; +"Since last scan" = "Since last scan"; +"Starting…" = "Starting…"; +"They follow you, you don't follow back" = "They follow you, you don't follow back"; +"Too many followers" = "Too many followers"; +"Too many followers to analyze" = "Too many followers to analyze"; +"Unfollow" = "Unfollow"; +"Unfollow @%@?" = "Unfollow @%@?"; +"Unfollowed you since last scan" = "Unfollowed you since last scan"; +"Username, name or picture changes" = "Username, name or picture changes"; +"Username: @%@ → @%@" = "Username: @%@ → @%@"; +"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits."; +"You both follow each other" = "You both follow each other"; +"You don't follow back" = "You don't follow back"; +"You follow them, they don't follow back" = "You follow them, they don't follow back"; +"You started following" = "You started following"; +"You unfollowed" = "You unfollowed"; + +"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "%@ %lu accounts? The first %ld will be processed to avoid rate limits."; +"%@ %lu accounts? This runs sequentially with a short pause between each." = "%@ %lu accounts? This runs sequentially with a short pause between each."; +"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu account(s) · %lu snapshot(s) · tap to inspect"; +"%lu accounts followed" = "%lu accounts followed"; +"%lu accounts unfollowed" = "%lu accounts unfollowed"; +"%lu entries across %lu lists · tap to inspect" = "%lu entries across %lu lists · tap to inspect"; +"%lu preferences · tap to inspect" = "%lu preferences · tap to inspect"; +"(empty)" = "(empty)"; +"(no analyzer data)" = "(no analyzer data)"; +"(no lists)" = "(no lists)"; +"About Profile Analyzer" = "About Profile Analyzer"; +"All preferences (%lu)" = "All preferences (%lu)"; +"Apply imported data?" = "Apply imported data?"; +"Batch follow" = "Batch follow"; +"Batch follow finished" = "Batch follow finished"; +"Batch unfollow" = "Batch unfollow"; +"Batch unfollow finished" = "Batch unfollow finished"; +"Continue" = "Continue"; +"Current snapshot" = "Current snapshot"; +"Embed domains" = "Embed domains"; +"Excluded lists" = "Excluded lists"; +"Excluded story users" = "Excluded story users"; +"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect."; +"Export" = "Export"; +"File has no importable sections." = "File has no importable sections."; +"File is not a valid RyukGram export." = "File is not a valid RyukGram export."; +"Filter" = "Filter"; +"First scan: we collect your followers and following lists and save them locally." = "First scan: we collect your followers and following lists and save them locally."; +"Follow %lu" = "Follow %lu"; +"Followers" = "Followers"; +"Following… %lu / %lu" = "Following… %lu / %lu"; +"Full name" = "Full name"; +"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk."; +"Import complete" = "Import complete"; +"Include" = "Include"; +"Included story users" = "Included story users"; +"Inspect the full payload" = "Inspect the full payload"; +"Keep scan history" = "Keep scan history"; +"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app."; +"Not verified only" = "Not verified only"; +"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "Nothing is uploaded — everything stays on this device and can be wiped from the trash icon."; +"Nothing was applied." = "Nothing was applied."; +"Posts" = "Posts"; +"Preferences" = "Preferences"; +"Previous snapshot" = "Previous snapshot"; +"Private only" = "Private only"; +"Profile Analyzer data" = "Profile Analyzer data"; +"Raw" = "Raw"; +"Raw JSON" = "Raw JSON"; +"Reset analyzer data" = "Reset analyzer data"; +"Reset complete" = "Reset complete"; +"Reset selected data?" = "Reset selected data?"; +"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates."; +"Select all" = "Select all"; +"Selected data will be cleared. Tap any row to see what's stored." = "Selected data will be cleared. Tap any row to see what's stored."; +"Settings" = "Settings"; +"Sort" = "Sort"; +"This can't be undone." = "This can't be undone."; +"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "Tick what to apply. Tap any row to inspect. Sections not in the file are disabled."; +"Tick what to include. Tap any row to inspect its contents." = "Tick what to include. Tap any row to inspect its contents."; +"Unfollow %lu" = "Unfollow %lu"; +"Unfollowing… %lu / %lu" = "Unfollowing… %lu / %lu"; +"Username A → Z" = "Username A → Z"; +"Username Z → A" = "Username Z → A"; +"Verified only" = "Verified only"; +"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans."; + ////////////////////////////////////////////////////////////////////////////// // SETTINGS VIEWS & DIALOGS // // Excluded-lists managers, backup/restore flows, in-picker labels. // @@ -756,72 +1009,62 @@ "Add chat" = "Add chat"; "Add custom domain" = "Add custom domain"; +"Add preset…" = "Add preset…"; "Add to list?" = "Add to list?"; "Add user" = "Add user"; -"Could not resolve user ID" = "Could not resolve user ID"; -"Enter username" = "Enter username"; -"Enter username of the DM thread" = "Enter username of the DM thread"; -"No DM thread found with @%@" = "No DM thread found with @%@"; -"User '%@' not found" = "User '%@' not found"; -"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 resolve user ID" = "Could not resolve user ID"; "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."; +"Enter username" = "Enter username"; +"Enter username of the DM thread" = "Enter username of the DM thread"; "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"; +"KD: ON" = "KD: ON"; "Keep-deleted" = "Keep-deleted"; "Keep-deleted override" = "Keep-deleted override"; +"Name (A–Z)" = "Name (A–Z)"; +"No DM thread found with @%@" = "No DM thread found with @%@"; "Off" = "Off"; -"On" = "On"; "Presets" = "Presets"; -"Raw JSON view" = "Raw JSON view"; -"Remove Selected" = "Remove Selected"; +"Recently added" = "Recently added"; "Remove from list" = "Remove from list"; +"Remove Selected" = "Remove Selected"; "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" = "Search"; "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 map button" = "Show map button"; "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."; +"User '%@' not found" = "User '%@' not found"; +"Username (A–Z)" = "Username (A–Z)"; "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) // @@ -857,6 +1100,7 @@ "720p • progressive • fastest" = "720p • progressive • fastest"; "Are you sure?" = "Are you sure?"; +"Bundle" = "Bundle"; "Copy audio URL" = "Copy audio URL"; "Copy quality info" = "Copy quality info"; "Copy video URL" = "Copy video URL"; @@ -869,11 +1113,14 @@ "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"; +"Extras" = "Extras"; "FFmpegKit Debug" = "FFmpegKit Debug"; "Later" = "Later"; "No!" = "No!"; +"OK" = "OK"; "Restart" = "Restart"; "Restart required" = "Restart required"; +"username" = "username"; "Yes" = "Yes"; "You must restart the app to apply this change" = "You must restart the app to apply this change"; @@ -882,45 +1129,58 @@ // Strings from the About / Credits footer of Settings. // ////////////////////////////////////////////////////////////////////////////// -"%@ — view source, report issues, see releases" = "%@ — view source, report issues, see releases"; +"%@ — GitHub & Telegram" = "%@ — GitHub & Telegram"; +"About" = "About"; +"Arabic translation" = "Arabic translation"; +"Chinese (Traditional) translation" = "Chinese (Traditional) translation"; "Credits" = "Credits"; -"Developer" = "Developer"; +"Developers" = "Developers"; "Donate to SoCuul" = "Donate to SoCuul"; +"installed" = "installed"; +"Korean translation" = "Korean translation"; +"latest" = "latest"; +"Links" = "Links"; +"No releases" = "No releases"; "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"; +"Release notes" = "Release notes"; +"Releases" = "Releases"; +"Report an issue" = "Report an issue"; +"Russian translation" = "Russian translation"; +"RyukGram developer" = "RyukGram developer"; +"Join Telegram channel" = "Join Telegram channel"; +"Source code" = "Source code"; +"View on GitHub" = "View on GitHub"; +"Spanish translation" = "Spanish translation"; "Support the original developer" = "Support the original developer"; -"View Repo" = "View Repo"; -"View the source code on GitHub" = "View the source code on GitHub"; +"Telegram channel" = "Telegram channel"; +"Testing and feature suggestions" = "Testing and feature suggestions"; +"Tweak settings" = "Tweak settings"; +"Version" = "Version"; +"Version, credits, and links" = "Version, credits, and links"; +"What's new in RyukGram" = "What's new in RyukGram"; ////////////////////////////////////////////////////////////////////////////// // HD DOWNLOADS // // Enhanced / HD downloads settings (DASH + FFmpegKit encoding). // ////////////////////////////////////////////////////////////////////////////// +"720p • progressive • silent" = "720p • progressive • silent"; +"Audio extract failed" = "Audio extract failed"; +"Audio only" = "Audio only"; +"Audio ready" = "Audio ready"; "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"; +"FFmpeg not available" = "FFmpeg not available"; +"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."; +"No audio stream available" = "No audio stream available"; +"No audio track found" = "No audio track found"; +"Photo" = "Photo"; "Photo quality" = "Photo quality"; +"Raw image (no audio, no video)" = "Raw image (no audio, no video)"; +"silent" = "silent"; "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"; -"Localization" = "Localization"; -"Update localization file" = "Update localization file"; -"Import a .strings file for a language" = "Import a .strings file for a language"; -"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Import a .strings file to update a translation. Pick a language, select the file, restart."; -"Export English strings" = "Export English strings"; -"Share the base English .strings file for translating" = "Share the base English .strings file for translating"; - diff --git a/src/Localization/Resources/es.lproj/Localizable.strings b/src/Localization/Resources/es.lproj/Localizable.strings index 60aa829..e726ae2 100644 --- a/src/Localization/Resources/es.lproj/Localizable.strings +++ b/src/Localization/Resources/es.lproj/Localizable.strings @@ -57,6 +57,7 @@ * - 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 // @@ -67,11 +68,11 @@ "settings.firstrun.message" = "Para el futuro: Mantener pulsadas las tres líneas en la parte superior derecha en la página de perfil, para volver a abrir la configuración de RyukGram"; "settings.firstrun.ok" = "Entiendo!"; "settings.firstrun.title" = "Información de configuración de RyukGram"; +"settings.language.english_only" = "Por el momento, RyukGram solo está disponible en Inglés. ¡Las traducciones son bienvenidas!"; +"settings.language.help_translate" = "Ayudar a traducir"; +"settings.language.ok" = "OK"; "settings.language.system" = "Por defecto del sistema"; "settings.language.title" = "Idioma"; -/* [ADDED_BY_DEV] */ "settings.language.english_only" = "Por el momento, RyukGram solo está disponible en Inglés. ¡Las traducciones son bienvenidas!"; -/* [ADDED_BY_DEV] */ "settings.language.help_translate" = "Ayudar a traducir"; -/* [ADDED_BY_DEV] */ "settings.language.ok" = "OK"; "settings.results.many" = "%lu resultados"; "settings.results.none" = "Sin resultados"; "settings.results.one" = "%lu resultado"; @@ -85,6 +86,8 @@ "Adds a copy option to the comment long-press menu" = "Añade la opción de copiar en el menú que aparece al mantener pulsado un comentario"; "Adds a download option for GIF comments" = "Añade la opción de descargar los GIF en comentarios"; +"Anonymous live viewing" = "Ver directos de forma anónima"; +"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "Bloquea el latido del contador de espectadores para que el transmisor no te vea — tampoco verás el contador de espectadores"; "Browser" = "Navegador"; "Comments" = "Comentarios"; "Copy comment text" = "Copiar texto del comentario"; @@ -94,7 +97,6 @@ "Disable app haptics" = "Deshabilitar respuesta háptica de la aplicación"; "Disables haptics/vibrations within the app" = "Deshabilita la respuesta háptica y vibraciones dentro de la aplicación"; "Do not save recent searches" = "No guardar búsquedas recientes"; -/* [ADDED_BY_DEV] */ "Search bars will no longer save your recent searches" = "Las barras de búsqueda ya no guardarán tus búsquedas recientes"; "Download GIF comments" = "Descargar GIF en comentarios"; "Embed domain" = "Dominio embebido"; "Embed domain: %@" = "Dominio embebido: %@"; @@ -106,13 +108,14 @@ "Experimental features" = "Funciones experimentales"; "Focus/distractions" = "Concentración/Distracciones"; "General" = "General"; -"Hide Meta AI" = "Ocultar Meta AI"; "Hide ads" = "Ocultar anuncios"; "Hide explore posts grid" = "Ocultar la cuadrícula de publicaciones"; "Hide friends map" = "Ocultar el mapa de amigos"; +"Hide Meta AI" = "Ocultar Meta AI"; "Hide metrics" = "Ocultar métricas"; "Hide notes tray" = "Ocultar bandeja de notas"; "Hide trending searches" = "Ocultar búsquedas en tendencia"; +"Hide UI on capture" = "Ocultar UI al capturar"; "Hides all suggested users for you to follow, outside your feed" = "Oculta 'Sugerencias para ti' en tu Feed (Inicio)"; "Hides like/comment/share counts on posts and reels" = "Oculta el contador de me gusta, comentarios y compartidos en publicaciones y reels"; "Hides the friends map icon in the notes tray" = "Oculta el ícono de mapa de amigos en la bandeja de notas"; @@ -122,22 +125,30 @@ "Hides the suggested broadcast channels in direct messages" = "Oculta los canales sugeridos en mensajes"; "Hides the trending searches under the explore search bar" = "Oculta las búsquedas en tendencia debajo de la barra de búsqueda"; "Hold down on the Instagram logo to change the app icon" = "Mantén pulsado el logo de Instagram para cambiar el ícono de la aplicación"; +"Live" = "En vivo"; "Long press on the eyedropper tool in stories to customize the text color more precisely" = "Mantener pulsada la herramienta de selección de color en historias para seleccionar el color del texto de manera más precisa"; +"Long-press the heart button in a live to hide or show the comments" = "Mantén presionado el botón de corazón en un directo para ocultar o mostrar los comentarios"; +"Long-press the search tab to open a copied Instagram link" = "Mantén presionado el botón de búsqueda para abrir un enlace de Instagram copiado"; "No suggested chats" = "Ocultar conversaciones sugeridas"; "No suggested users" = "Ocultar usuarios sugeridos"; "Notes" = "Notas"; +"Open app icon picker" = "Abrir selector de ícono de app"; +"Open link from clipboard" = "Abrir enlace desde el portapapeles"; "Open links in external browser" = "Abrir enlaces en navegador externo"; "Opens links in Safari instead of Instagram's in-app browser" = "Abrir enlaces en Safari en vez del navegador interno de Instagram"; -"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Elimina intermediarios de rastreo de Instagram (l.instagram.com) y los parámetros UTM/fbclid de los enlaces"; +"Privacy" = "Privacidad"; +"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "Oculta los botones de RyukGram en capturas, grabaciones y duplicado de pantalla"; "Removes all ads from the Instagram app" = "Elimina todos los anuncios de la aplicación de Instagram"; "Removes igsh, utm_source, and other tracking parameters from shared links" = "Elimina igsh, utm_source, y otros parámetros de rastreo de los enlaces compartidos"; -"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Reemplaza las marcas de tiempo relativas de Instagram (\"Hace 3d\") con un formato personalizado. Escoge sobre cuales superficies se aplica dentro del selector."; +"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Elimina intermediarios de rastreo de Instagram (l.instagram.com) y los parámetros UTM/fbclid de los enlaces"; "Replace domain in shared links" = "Reemplazar dominio en enlaces compartidos"; +"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Reemplaza las marcas de tiempo relativas de Instagram (\"Hace 3d\") con un formato personalizado. Escoge sobre cuales superficies se aplica dentro del selector."; "Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "Reescribe enlaces copiados/compartidos para utilizar un dominio compatible con vistas previas embebidas en Discord, Telegram, etc."; +"Search bars will no longer save your recent searches" = "Las barras de búsqueda ya no guardarán tus búsquedas recientes"; "Sharing" = "Compartir"; "Strip tracking from links" = "Eliminar rastreo de los enlaces"; "Strip tracking params" = "Eliminar parámetros de rastreo"; -"These features rely on hidden Instagram flags and may not work on all accounts or versions.\nExperimental flags research by @euoradan (Radan)." = "Estas funciones se basan en opciones ocultas de Instagram y es posible que no funcionen en todas las cuentas o versiones.\nInvestigación sobre opciones experimentales por @euoradan (Radan)."; +"Toggle live comments" = "Alternar comentarios en vivo"; "Use detailed color picker" = "Usar selector de color detallado"; ////////////////////////////////////////////////////////////////////////////// @@ -232,9 +243,6 @@ "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." = "Añade un botón de acción de RyukGram sobre la barra lateral del reel con las opciones ver portada, descargar, compartir, copiar, ampliar y repost. Tocar abre el menú de forma predeterminada.\nArriba puedes cambiar el comportamiento al tocar."; "Always show progress scrubber" = "Siempre mostrar el indicador de progreso"; "Auto-scroll reels" = "Desplazamiento automático de reels"; -"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG por defecto: comportamiento nativo. RyukGram: vuelve a avanzar después de deslizar hacia atrás."; -"IG default" = "IG por defecto"; -"RyukGram" = "RyukGram"; "Change what happens when you tap on a reel" = "Cambia lo que ocurre cuando tocas en un reel"; "Confirm reel refresh" = "Confirmar actualización de reels"; "Disable auto-unmuting reels" = "Deshabilitar el reactivado automático del sonido en los reels"; @@ -246,6 +254,8 @@ "Hides the repost button on the reels sidebar" = "Oculta el botón repost en la barra lateral de los reels"; "Hides the top navigation bar when watching reels" = "Oculta la barra de navegación superior al ver reels"; "Hiding" = "Ocultar"; +"IG default" = "IG por defecto"; +"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG por defecto: comportamiento nativo. RyukGram: vuelve a avanzar después de deslizar hacia atrás."; "Limits" = "Límites"; "Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "Limita la cantidad de reels disponibles para desplazar en cualquier momento, y evita que se actualice"; "Only loads %@ %@" = "Solo cargar %@ %@"; @@ -253,11 +263,14 @@ "Prevent doom scrolling" = "Evitar doom scrolling"; "Prevents reels from being scrolled to the next video" = "Evita que los reels se desplacen al siguiente video"; "Prevents reels from unmuting when the volume/silent button is pressed" = "Evita que los reels dejen de estar silenciados cuando se presionan los botones de volumen o silencio"; +"RyukGram" = "RyukGram"; "Shows an alert when you trigger a reels refresh" = "Muestra una alerta al solicitar una actualización de reels"; "Shows buttons to reveal and auto-fill the password on locked reels" = "Muestra botones para revelar y auto-completar la contraseña en reels bloqueados"; "Tap Controls" = "Controles táctiles"; +"Tap to mute on photo reels" = "Tocar para silenciar en reels de fotos"; "Tapping the Reels tab while on reels does nothing" = "Pulsar el botón de reels no hace nada cuando te encuentres en la pestaña de Reels"; "Unlock password-locked reels" = "Desbloquea reels bloqueados por contraseña"; +"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "Cuando el modo pausa está activo, al tocar reels de fotos se alterna el audio en lugar del gesto nativo"; ////////////////////////////////////////////////////////////////////////////// // PROFILE // @@ -267,14 +280,25 @@ "Adds a button next to the burger menu on profiles to copy username, name or bio" = "Añade un botón junto al menú de hamburguesa (☰) en los perfiles para copiar nombre de usuario, nombre o presentación"; "Adds a view option to the highlight long-press menu to open the cover in full-screen" = "Añade una opción de visualización en el menú que aparece al mantener pulsado sobre la historia destacada para abrir la portada en pantalla completa"; "Copy note on long press" = "Copia la nota al mantener pulsado"; +"Fake follower count" = "Seguidores falsos"; +"Fake following count" = "Seguidos falsos"; +"Fake post count" = "Publicaciones falsas"; +"Fake profile stats" = "Estadísticas de perfil falsas"; +"Fake verified badge" = "Insignia verificada falsa"; "Follow indicator" = "Indicador de seguido"; +"Follower count" = "Número de seguidores"; +"Following count" = "Número de seguidos"; "Long press a profile picture to open it in full-screen with zoom, share, and save" = "Mantener pulsado en una foto de perfil para abrirla en pantalla completa para ampliar, compartir y guardar"; "Long press the note bubble on a profile to copy the text" = "Mantén pulsado la burbuja de una nota en un perfil para copiar el texto"; "Long press to download directly (ignored when zoom is on)" = "Mantén pulsado para descargar directamente (Se ignora cuando la foto está ampliada)"; "Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "Los gestos al mantener pulsado en los elementos del perfil, se mantienen separados de los botones de acción específicos de cada función."; +"Only affects your own profile header. Other users see the real numbers." = "Solo afecta al encabezado de tu propio perfil. Los demás usuarios ven los números reales."; +"Post count" = "Número de publicaciones"; "Profile copy button" = "Botón de copiar perfil"; "Save profile picture" = "Guardar foto de perfil"; +"Show a checkmark next to your name on your own profile" = "Muestra una marca de verificación junto a tu nombre en tu propio perfil"; "Shows whether the profile user follows you" = "Muestra si el usuario del perfil te sigue"; +"Tap to set" = "Tocar para establecer"; "View highlight cover" = "Ver portada de la historia destacada"; "Zoom profile photo" = "Ampliar foto de perfil"; @@ -330,16 +354,25 @@ "Mark seen on story reply" = "Marcar visualización de historia al responder"; "Marks a story as seen the moment you tap the heart, even with seen blocking on" = "Marca una historia como vista en el momento en que tocas el corazón, incluso con el bloqueo de aviso de visualización activado"; "Marks a story as seen when you send a reply or emoji reaction, even with seen blocking on" = "Marca una historia como vista cuando envías una respuesta o una reacción con emoji, incluso con el bloqueo de aviso de visualización activado"; +"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marca las historias como vistas localmente (círculo gris) mientras sigue bloqueando el recibo de visto en el servidor"; "Master toggle. When off, the list is ignored" = "Control general. Cuando está desactivado, la lista es ignorada"; "Other" = "Otros"; "Playback" = "Reproducción"; -"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Marca las historias como vistas localmente (círculo gris) mientras sigue bloqueando el recibo de visto en el servidor"; "Quick list button in stories" = "Botón de lista rápida en historias"; "Search, sort, swipe to remove" = "Buscar y ordenar. Desliza para eliminar"; "Seen receipts" = "Confirmación de visualización"; "Sending a reply or emoji reaction automatically advances to the next story" = "Enviar una respuesta o una reacción con emoji automáticamente avanza a la siguiente historia"; "Show mentioned users in eye button and story menu" = "Mostrar usuarios mencionados en el botón con forma de ojo (👁) y el menú de la historia"; "Shows an eye button on stories to add/remove users from the list. Off = use the 3-dot menu or long-press only" = "Muestra un botón con forma de ojo (👁) en las historias para añadir o eliminar usuarios de la lista. Desactivado = Usar el menú de los 3 puntos o solo mantener pulsado"; +"Stickers" = "Stickers"; +"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "Mira los resultados de encuestas/cuestionarios/deslizador antes de interactuar — aún puedes tocar para votar con normalidad. 'Forzar cuestionario' devuelve el sticker clásico de cuestionario a la bandeja del editor de historias."; +"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "Mira los resultados de encuestas/cuestionarios/deslizador en reels antes de interactuar — aún puedes tocar para votar con normalidad."; +"Force Quiz sticker in tray" = "Forzar sticker de cuestionario"; +"Adds Quiz back to the story sticker picker" = "Devuelve el cuestionario al selector de stickers de historia"; +"Show quiz answer" = "Mostrar respuesta del cuestionario"; +"Circle the correct option on quiz stickers, or the leading option on polls" = "Resalta la opción correcta en cuestionarios, o la más votada en encuestas"; +"Show poll vote counts" = "Mostrar votos de encuestas"; +"Show vote tallies on poll options and slider count/average before you vote" = "Muestra los votos en opciones de encuesta y la media/conteo del deslizador antes de votar"; "Stop story auto-advance" = "Detener avance automático de las historias"; "Stories" = "Historias"; "Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "Historias no avanzan automáticamente a la siguiente cuando el temporizador termina. Toca para avanzar manualmente"; @@ -384,7 +417,7 @@ "Copy text on hold" = "Copiar texto al mantener pulsado"; "Custom emojis and background/text colors" = "Emojis, color de fondo y texto personalizado"; "Custom note themes" = "Tema de notas personalizado"; -"Disable disappearing mode swipe" = "Deshabilitar deslizamiento para mensajes temporales"; +"Disable vanish mode swipe" = "Deshabilitar deslizamiento al modo vanish"; "Disable screenshot detection" = "Deshabilitar detección de capturas de pantalla"; "Disable typing status" = "Deshabilitar estado de escritura"; "Disable view-once limitations" = "Deshabilitar limitaciones de ver una vez"; @@ -405,7 +438,7 @@ "Note actions" = "Acciones en notas"; "Preserve messages that others unsend" = "Guardar los mensajes que los demás eliminen"; "Preserves messages that others unsend" = "Guarda los mensajes que los demás eliminen"; -"Prevents accidental swipe-up activation of disappearing mode" = "Evita la activación accidental de los mensajes temporales al deslizar hacia arriba"; +"Prevents accidental swipe-up activation of vanish mode" = "Evita la activación accidental del modo vanish al deslizar hacia arriba"; "Quick list button in chats" = "Botón de lista rápida en conversaciones"; "Removes the audio call button from DM thread header" = "Elimina el botón de llamada en las conversaciones"; "Removes the screenshot-prevention features for visual messages in DMs" = "Elimina las funciones que impiden hacer capturas de pantalla para mensajes visuales en las conversaciones"; @@ -420,7 +453,6 @@ "Shows an \"Unsent\" label on preserved messages" = "Muestra una etiqueta \"Mensaje eliminado\" en mensajes guardados"; "Unlimited replay of visual messages" = "Reproducción ilimitada de mensajes visuales"; "Unsent message notification" = "Notificación de eliminación de mensaje"; -"Visual messages" = "Mensajes visuales"; "Voice messages" = "Mensajes de voz"; "Warn before clearing on refresh" = "Mostrar un aviso antes de actualizar"; "Which chats get read-receipt blocking" = "Cuales conversaciones tienen bloqueada la confirmación de lectura"; @@ -439,11 +471,13 @@ // Settings → Navigation tab // ////////////////////////////////////////////////////////////////////////////// +"Also hide the bottom tab bar — only the inbox is visible" = "Oculta también la barra de pestañas inferior — solo se ve la bandeja de entrada"; "Hide create tab" = "Ocultar pestaña Crear"; "Hide explore tab" = "Ocultar pestaña Explorar"; "Hide feed tab" = "Ocultar pestaña Feed (Inicio)"; "Hide messages tab" = "Ocultar pestaña Mensajes"; "Hide reels tab" = "Ocultar pestaña Reels"; +"Hide tab bar" = "Ocultar barra de pestañas"; "Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab." = "Oculta todas las pestañas excepto Mensajes y Perfil. Inicia la aplicación en la pestaña Mensajes. El acceso rápido a la configuración se cambia a mantener pulsada la pestaña Mensajes."; "Hides the create tab on the bottom navigation bar" = "Oculta la pestaña Crear en la barra de navegación inferior"; "Hides the direct messages tab on the bottom navigation bar" = "Oculta la pestaña Mensajes en la barra de navegación inferior"; @@ -468,35 +502,38 @@ ////////////////////////////////////////////////////////////////////////////// "Confirm actions" = "Confirmar acciones"; -"Confirm call" = "Confirmar llamada"; +"Confirm video call" = "Confirmar videollamada"; +"Confirm voice call" = "Confirmar llamada de voz"; "Confirm changing theme" = "Confirmar cambiar el tema"; "Confirm follow" = "Confirmar seguir"; "Confirm follow requests" = "Confirmar solicitud de seguimiento"; -/* [ADDED_BY_DEV] */ "Confirm like: Posts" = "Confirmar me gusta en publicaciones"; -/* [ADDED_BY_DEV] */ "Confirm story like" = "Confirmar me gusta en historias"; -/* [ADDED_BY_DEV] */ "Confirm story emoji reaction" = "Confirmar reacción con emojis en historias"; +"Confirm like: Posts" = "Confirmar me gusta en publicaciones"; "Confirm like: Reels" = "Confirmar me gusta en reels"; "Confirm posting comment" = "Confirmar publicar comentario"; "Confirm repost" = "Confirmar repost"; -"Confirm shh mode" = "Confirmar mensajes temporales"; -"Confirm sticker interaction" = "Confirma interacción con stickers"; +"Confirm vanish mode" = "Confirmar modo Vanish"; +"Confirm sticker interaction (stories)" = "Confirma interacción con stickers (historias)"; +"Confirm sticker interaction (highlights)" = "Confirma interacción con stickers (destacadas)"; +"Confirm story emoji reaction" = "Confirmar reacción con emojis en historias"; +"Confirm story like" = "Confirmar me gusta en historias"; "Confirm unfollow" = "Confirmar dejar de seguir"; "Confirm voice messages" = "Confirmar mensaje de voz"; +"Shows an alert before sending an emoji reaction on a story" = "Muestra una alerta antes de enviar una reacción con emojis en una historia"; "Shows an alert to confirm before sending a voice message" = "Muestra una alerta para confirmar antes de enviar un mensaje de voz"; -"Shows an alert to confirm before toggling disappearing messages" = "Muestra una alerta para confirmar antes de activar los mensajes temporales"; +"Shows an alert to confirm before toggling vanish mode" = "Muestra una alerta para confirmar antes de activar el modo Vanish"; "Shows an alert when you accept/decline a follow request" = "Muestra una alerta cuando aceptas o rechazas una solicitud de seguimiento"; "Shows an alert when you change a chat theme to confirm" = "Muestra una alerta para confirmar cuando cambias el tema en una conversación"; -"Shows an alert when you click a sticker on someone's story to confirm the action" = "Muestra una alerta para confirmar la acción cuando tocas un sticker en la historia de alguien"; -"Shows an alert when you click the audio/video call button to confirm before calling" = "Muestra una alerta cuando tocas los botones de llamada y video llamada, antes de llamar"; +"Shows an alert when you tap a sticker on someone's story" = "Muestra una alerta cuando tocas un sticker en la historia de alguien"; +"Shows an alert when you tap a sticker inside a highlight" = "Muestra una alerta cuando tocas un sticker dentro de una historia destacada"; +"Shows an alert when you click the video call button to confirm before calling" = "Muestra una alerta cuando tocas el botón de videollamada, antes de llamar"; +"Shows an alert when you click the voice call button to confirm before calling" = "Muestra una alerta cuando tocas el botón de llamada de voz, antes de llamar"; "Shows an alert when you click the follow button to confirm the follow" = "Muestra una alerta para confirmar cuando tocas el botón de seguir"; -/* [ADDED_BY_DEV] */ "Shows an alert when you click the like button on posts to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en publicaciones"; +"Shows an alert when you click the like button on posts to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en publicaciones"; "Shows an alert when you click the like button on reels to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en reels"; -/* [ADDED_BY_DEV] */ "Shows an alert when you click the like button on stories to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en historias"; -/* [ADDED_BY_DEV] */ "Shows an alert before sending an emoji reaction on a story" = "Muestra una alerta antes de enviar una reacción con emojis en una historia"; +"Shows an alert when you click the like button on stories to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en historias"; "Shows an alert when you click the post comment button to confirm" = "Muestra una alerta para confirmar cuando tocas el botón de publicar comentario"; "Shows an alert when you click the repost button to confirm before resposting" = "Muestra una alerta para confirmar cuando tocas el botón de repost"; "Shows an alert when you click the unfollow button to confirm" = "Muestra una alerta para confirmar cuando tocas el botón de dejar de seguir"; -"Shows an alert when you click the like button on posts or stories to confirm the like" = "Muestra una alerta para confirmar cuando tocas el botón de me gusta en publicaciones o historias"; ////////////////////////////////////////////////////////////////////////////// // BACKUP & RESTORE // @@ -504,22 +541,6 @@ ////////////////////////////////////////////////////////////////////////////// "Backup & Restore" = "Copia de seguridad & restauración"; -"Export settings" = "Exportar configuración"; -"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." = "Exporta tu configuración de RyukGram a un archivo JSON para importarlos mas tarde. Al importar, se restaurarán los valores predeterminados antes de aplicar la nueva configuración. Podrás ver una vista previa antes de confirmar los cambios."; -"Import settings" = "Importar configuración"; -"Load settings from a JSON file" = "Cargar configuración desde un archivo JSON"; -"Reset to defaults" = "Restablecer los valores predeterminados"; -"Revert every RyukGram preference" = "Restablecer todas las preferencias de RyukGram"; -"Save settings as a JSON file" = "Guarda configuración como un archivo JSON"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL // -// Settings → Experimental tab // -////////////////////////////////////////////////////////////////////////////// - -"Experimental" = "Experimental"; -"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "Estas funciones son inestables y provocan que la aplicación de Instagram se cierre inesperadamente.\n\n¡Úsalas bajo tu propia responsabilidad!"; -"Warning" = "Advertencia"; ////////////////////////////////////////////////////////////////////////////// // ADVANCED // @@ -527,17 +548,76 @@ ////////////////////////////////////////////////////////////////////////////// "Advanced" = "Avanzado"; +"Auto-clear cache" = "Limpieza automática de caché"; "Automatically opens settings when the app launches" = "Abre la configuración automáticamente cuando se inicia la aplicación"; +"Cache" = "Caché"; +"Cache cleared" = "Caché limpiada"; +"Calculating cache size…" = "Calculando tamaño de caché…"; +"Clear" = "Limpiar"; +"Clear cache" = "Limpiar caché"; +"Clear cache (%@)" = "Limpiar caché (%@)"; +"Clear cache?" = "¿Limpiar caché?"; +"Clearing cache…" = "Limpiando caché…"; +"Clearing still scans on demand." = "La limpieza sigue escaneando bajo demanda."; +"Daily" = "Diario"; "Disable safe mode" = "Deshabilitar modo seguro"; "Enable tweak settings quick-access" = "Habilitar acceso rápido a la configuración del Tweak"; +"Free %@ of Instagram cache. A restart is recommended." = "Liberar %@ de la caché de Instagram. Se recomienda reiniciar."; +"Freed %@. Restart to apply." = "Se liberaron %@. Reinicia para aplicar."; "Hold on the home tab to open RyukGram settings" = "Mantén pulsada la pestaña Feed (Inicio) para abrir la configuración de RyukGram"; "Instagram" = "Instagram"; +"Monthly" = "Mensual"; +"Nothing to clear" = "Nada que limpiar"; +"Off skips the size scan when Advanced opens." = "Desactivado omite el escaneo de tamaño al abrir Avanzado."; "Pause playback when opening settings" = "Pausa la reproducción al abrir la configuración"; "Pauses any playing video/audio when settings opens" = "Pausa cualquier reproducción de video o audio cuando se abre la configuración"; "Prevents Instagram from resetting settings after crashes (at your own risk)" = "Evita que Instagram restablezca la configuración después de un cierre inesperado\n(¡Bajo tu propia responsabilidad!)"; -"Reset onboarding state" = "Restablecer estado onboarding"; // Verify onboarding - Verificar onboarding -"Settings" = "Configuración"; +"Remove Instagram's cached images, videos, and temporary files." = "Elimina imágenes, videos y archivos temporales en caché de Instagram."; +"Reset onboarding state" = "Restablecer estado onboarding"; +"Run a silent cache clear on launch when the interval has elapsed." = "Ejecuta una limpieza silenciosa al iniciar cuando ha pasado el intervalo."; +"Show cache size" = "Mostrar tamaño de caché"; "Show tweak settings on app launch" = "Muestra la configuración del Tweak cuando se inicia la aplicación"; +"Weekly" = "Semanal"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED EXPERIMENTAL // +// Settings → Advanced → Advanced experimental features // +////////////////////////////////////////////////////////////////////////////// + +"Actions" = "Acciones"; +"Advanced experimental features" = "Funciones experimentales avanzadas"; +"All experimental toggles will be turned off. Instagram will restart." = "Se desactivarán todas las funciones experimentales. Instagram se reiniciará."; +"Direct Notes — Audio reply" = "Notas directas — Respuesta de audio"; +"Direct Notes — Avatar reply" = "Notas directas — Respuesta con avatar"; +"Direct Notes — Friend Map" = "Notas directas — Mapa de amigos"; +"Direct Notes — GIFs & stickers reply" = "Notas directas — Respuesta con GIF y stickers"; +"Direct Notes — Photo reply" = "Notas directas — Respuesta con foto"; +"Disabled after repeated crashes." = "Desactivado tras varios fallos."; +"Enables GIF/sticker replies" = "Activa las respuestas con GIF y stickers"; +"Enables photo replies" = "Activa las respuestas con foto"; +"Enables the audio-note reply type" = "Activa el tipo de respuesta con nota de audio"; +"Enables the avatar reply type" = "Activa el tipo de respuesta con avatar"; +"Experimental flags reset" = "Funciones experimentales restablecidas"; +"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "Activa lo que quieras y pulsa Aplicar para reiniciar. Algunas pueden no funcionar en todas las cuentas o versiones de IG. Se restablecen solas si IG falla al iniciar 3 veces."; +"Forces Prism-gated experiments on" = "Fuerza la activación de los experimentos basados en Prism"; +"Forces the Homecoming home surface / nav on" = "Fuerza la activación del inicio y la navegación de Homecoming"; +"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "Fuerza la aparición de QuickSnap / Instants en el feed, bandeja, historias y notas"; +"Got it" = "Entendido"; +"Heads up" = "Atención"; +"Hidden Instagram experiments" = "Experimentos ocultos de Instagram"; +"Hidden Instagram experiments (in Advanced)" = "Experimentos ocultos de Instagram (en Avanzado)"; +"Homecoming" = "Homecoming"; +"Notes & QuickSnap" = "Notas y QuickSnap"; +"Prism design system" = "Sistema de diseño Prism"; +"QuickSnap (Instants)" = "QuickSnap (Instants)"; +"Reset all experimental flags" = "Restablecer todas las funciones experimentales"; +"Reset experimental flags?" = "¿Restablecer las funciones experimentales?"; +"Restart Instagram to apply changes" = "Reinicia Instagram para aplicar los cambios"; +"Shows the friend map entry in Direct Notes" = "Muestra el acceso al mapa de amigos en Notas directas"; +"Surfaces" = "Superficies"; +"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "Estos interruptores activan experimentos ocultos de Instagram. Algunas funciones pueden no funcionar en todas las cuentas o versiones de IG. Si IG sigue fallando al iniciar, se restablecen solas tras 3 inicios fallidos."; +"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "Activa experimentos ocultos de Instagram. Algunos pueden no funcionar en todas las cuentas o versiones de IG."; +"Turn every experimental toggle off" = "Desactivar todas las funciones experimentales"; ////////////////////////////////////////////////////////////////////////////// // DEBUG // @@ -546,24 +626,38 @@ "Button Cell" = "Celda de botón"; "Change the value on the right" = "Cambia el valor a la derecha"; +"Could not delete: %@" = "No se pudo eliminar: %@"; "Debug" = "Debug"; +"Delete an imported override and fall back to the shipped strings" = "Elimina un archivo importado y vuelve a las cadenas integradas"; +"Deleted %@ override. Restart to apply." = "Se eliminó el archivo %@. Reinicia para aplicar."; "Enable FLEX gesture" = "Habilitar gesto FLEX"; +"Export English strings" = "Exportar archivo .strings en Inglés"; "Hold 5 fingers on the screen to open FLEX" = "Mantén pulsados 5 dedos en la pantalla para abrir FLEX"; "I have %@%@" = "Tengo %@%@"; +"Import a .strings file for a language" = "Importa un archivo .strings para añadir un idioma"; +"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Importa un archivo .strings para actualizar una traducción. Escoge un idioma, selecciona el archivo y reinicia"; "Link Cell" = "Celda de enlace"; +"Localization" = "Traducción"; "Menu Cell" = "Celda de menú"; +"Navigation Cell" = "Celda de navegación"; +"No imported localization files to reset." = "No hay archivos de localización importados para restablecer."; +"No overrides" = "Sin anulaciones"; "Open FLEX on app focus" = "Abrir FLEX al enfocar la aplicación"; "Open FLEX on app launch" = "Abrir FLEX al iniciar la aplicación"; "Opens FLEX when the app is focused" = "Abre FLEX cuando la aplicación es enfocada"; "Opens FLEX when the app launches" = "Abre FLEX cuando la aplicación se inicia"; +"Pick a language to delete the imported file" = "Elige un idioma para borrar el archivo importado"; +"Reset localization" = "Restablecer localización"; +"Share the base English .strings file for translating" = "Comparte el archivo .strings en Inglés para traducir"; "Static Cell" = "Celda estática"; "Stepper cell" = "Celda de paso"; "Switch Cell" = "Celda interruptor"; "Switch Cell (Restart)" = "Cambiar celda (Reinicio)"; "Tap the switch" = "Toca el interruptor"; +"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "Estas funciones dependen de marcadores ocultos de Instagram y pueden no funcionar en todas las cuentas o versiones."; +"Update localization file" = "Actualizar traducción"; "Using icon" = "Usar ícono"; "Using image" = "Usar imagen"; -"_ Example" = "_ Ejemplo"; ////////////////////////////////////////////////////////////////////////////// // DOWNLOADS & MEDIA ACTIONS // @@ -595,16 +689,16 @@ "Failed to save" = "Error al guardar"; "HD download complete" = "Descarga HD completada"; "Mute audio" = "Silenciar sonido"; -"No URLs" = "Sin enlaces"; -"No URLs found" = "Sin enlaces encontrados"; "No caption on this post" = "No hay descripción en esta publicación"; "No carousel children" = "Sin carrusel hijo"; "No cover image" = "Sin imagen de portada"; "No files downloaded" = "No se descargaron archivos"; "No media" = "Sin medios"; -"No media URL" = "Sin enlace de medios"; "No media to expand" = "Sin medios para ampliar"; "No media to show" = "Sin medios para mostrar"; +"No media URL" = "Sin enlace de medios"; +"No URLs" = "Sin enlaces"; +"No URLs found" = "Sin enlaces encontrados"; "No video URL" = "Sin enlace de video"; "Not a carousel" = "No es un carrusel"; "Nothing to save" = "Nada para guardar"; @@ -637,6 +731,7 @@ "Add to block list" = "Añadir a la lista de bloqueo"; "Add to block list?" = "¿Añadir a la lista de bloqueo?"; "Added to block list" = "Añadido a la lista de bloqueo"; +"Added to exclude list" = "Añadido a la lista de excluidos"; "Audio not loaded yet. Play the message first and try again." = "Audio aún no cargado. Reproduce el mensaje primero y vuelve a intentar."; "Audio sent" = "Audio enviado"; "Audio/Video from Files" = "Audio/Video desde Archivos"; @@ -650,12 +745,14 @@ "Could not get audio data. Try again after refreshing the chat." = "No se encontró datos de audio. Intenta nuevamente luego de actualizar la conversación."; "Could not get video URL" = "No se encontró enlace al video"; "Disable read receipts" = "Deshabilitar confirmación de lectura"; +"Disappearing media" = "Medios efímeros"; "Done!" = "¡Finalizado!"; "Download audio" = "Descargar audio"; "Downloading audio..." = "Descargando audio..."; "Enable read receipts" = "Habilitar confirmación de lectura"; "Error: %@" = "Error: %@"; "Exclude chat" = "Excluir chat"; +"Exclude from seen" = "Excluir de vistos"; "Exclude story seen" = "Excluir de visualización de historia"; "Excluded" = "Excluído"; "Extracting audio..." = "Extrayendo audio..."; @@ -663,6 +760,10 @@ "File sending not supported" = "Enviar archivos no soportado"; "Follow" = "Seguir"; "Following" = "Siguiendo"; +"Inserts a button on disappearing media overlays" = "Añade un botón sobre los medios efímeros"; +"Inserts a speaker button to mute/unmute disappearing media" = "Añade un botón de altavoz para silenciar/activar medios efímeros"; +"Inserts an eye button to mark the current disappearing media as viewed" = "Añade un botón de ojo para marcar los medios efímeros como vistos"; +"Mark as viewed" = "Marcar como visto"; "Mark messages as seen" = "Marcar mensajes como vistos"; "Mark seen" = "Marcar como vista"; "Marked as seen" = "Marcado como visto"; @@ -671,6 +772,7 @@ "Mentions" = "Menciones"; "Message sender not found" = "No se encontró remitente del mensaje"; "Messages settings" = "Configuración de Mensajes"; +"Audio URL not available" = "URL de audio no disponible"; "Mute story audio" = "Silenciar sonido de historia"; "No audio URL found. Try again after refreshing the chat." = "No se encontró enlace de audio. Intenta nuevamente luego de actualizar la conversación."; "No mentions in this story" = "Sin menciones en esta historia"; @@ -686,32 +788,31 @@ "Remove" = "Eliminar"; "Remove from block list" = "Eliminar de la lista de bloqueo"; "Remove from block list?" = "¿Eliminar de la lista de bloqueo?"; +"Remove from exclude list" = "Quitar de la lista de excluidos"; "Removed" = "Eliminado"; +"Removed from list" = "Eliminado de la lista"; "Save GIF" = "Guardar GIF"; "Selection too short (min 0.5s)" = "Selección demasiado corta (min 0.5s)"; -"Send Audio" = "Enviar audio"; "Send anyway" = "Enviar de todos modos"; +"Send Audio" = "Enviar audio"; "Send failed: %@" = "Envío fallido: %@"; "Send service not found" = "Servicio de envío no encontrado"; -"Share" = "Compartir"; +"Show audio toggle" = "Mostrar interruptor de audio"; +"Show mark-as-viewed button" = "Mostrar botón de marcar como visto"; "Story read receipts disabled" = "Aviso de visualización de historia DESACTIVADO"; "Story read receipts enabled" = "Aviso de visualización de historia ACTIVADO"; -"Story seen receipts will be blocked for @%@." = "Aviso de visualización de historia será bloqueado para @%@."; "This chat will resume normal read-receipt behavior." = "Este chat volverá a funcionar con el sistema habitual de confirmaciones de lectura."; "Total: %@" = "Total: %@"; -"Un-exclude" = "No excluir"; "Un-exclude chat" = "No excluir conversación"; "Un-exclude chat?" = "¿No excluir conversación?"; "Un-exclude story seen" = "No excluir de visualización de la historia"; -"Un-exclude story seen?" = "¿No excluir de visualización de la historia?"; "Un-excluded" = "No excluído"; -"Unblock" = "Desbloquear"; "Unblocked" = "Desbloqueado"; "Unlimited replay enabled" = "Reproducción ilimitada ACTIVADA"; "Unmute story audio" = "Activar sonido de la historia"; "Unsent" = "Mensaje eliminado"; "Upload Audio" = "Subir Audio"; -"VC not found" = "VC no encontrado"; // Verify - Verificar +"VC not found" = "VC no encontrado"; "Video from Library" = "Video desde Fototeca"; "Visual messages will expire" = "Mensajes visuales expirarán"; "Visual messages: expiring" = "Mensajes visuales: Expirando"; @@ -728,6 +829,9 @@ "Add preset" = "Añadir ajuste preestablecido"; "Change location" = "Cambiar ubicación"; "Click the Apply button after this to see the emoji" = "Toca el botón Aplicar después de esto para ver el emoji"; +"Clipboard is not an Instagram URL" = "El portapapeles no contiene un enlace de Instagram"; +"Comments hidden" = "Comentarios ocultos"; +"Comments shown" = "Comentarios mostrados"; "Copied text to clipboard" = "Texto copiado al portapapeles"; "Copy" = "Copiar"; "Copy all" = "Copiar todo"; @@ -738,19 +842,168 @@ "Current: %@" = "Actual: %@"; "Disable" = "Desactivado"; "Download GIF" = "Descargar GIF"; +"Dropped pin" = "Marcador"; "Enable" = "Activado"; +"Enable Location Services for Instagram in Settings to use your current location." = "Activa los servicios de ubicación para Instagram en Ajustes para usar tu ubicación actual."; "Enter Emoji Text" = "Introduce Texto con Emoji"; "Fake location" = "Ubicación falsa"; +"Location access denied" = "Acceso a ubicación denegado"; +"Location Services off" = "Servicios de ubicación desactivados"; "Name" = "Nombre"; "Nothing to copy" = "Nada para copiar"; +"Open Settings" = "Abrir Ajustes"; +"Pick location" = "Elegir ubicación"; "Save" = "Guardar"; "Save preset" = "Guardar ajuste preestablecido"; "Saved locations" = "Ubicaciones guardadas"; "Select color" = "Escoger color"; "Set location" = "Establecer ubicación"; "Settings…" = "Configuración…"; +"Turn Location Services on in Settings → Privacy to use your current location." = "Activa los servicios de ubicación en Ajustes → Privacidad para usar tu ubicación actual."; "Type emoji..." = "Introduce emoji..."; +"Theme" = "Tema"; +"Appearance" = "Apariencia"; +"Keyboard" = "Teclado"; +"Force dark mode" = "Forzar modo oscuro"; +"Keep Instagram in dark appearance regardless of iOS system setting" = "Mantén Instagram en apariencia oscura sin importar el ajuste de iOS"; +"Full OLED" = "OLED completo"; +"Replace Instagram's dark grays with pure black across the entire app" = "Sustituye los grises oscuros de Instagram por negro puro en toda la app"; +"OLED chat theme" = "Tema OLED en chats"; +"Pure black DM thread background and incoming message bubbles" = "Fondo negro puro en los mensajes directos y en las burbujas entrantes"; +"Keyboard theme" = "Tema del teclado"; +"Override the keyboard appearance when typing inside Instagram" = "Cambia la apariencia del teclado al escribir dentro de Instagram"; +"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "Oscuro usa el teclado oscuro del sistema. OLED fuerza el fondo del teclado a negro puro."; +"Dark" = "Oscuro"; +"OLED" = "OLED"; +"Apply & restart" = "Aplicar y reiniciar"; +"Restart Instagram to apply your theme changes" = "Reinicia Instagram para aplicar los cambios de tema"; +"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "Los cambios de tema solo se aplican tras reiniciar la app. Toca Aplicar cuando termines de elegir."; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE ANALYZER // +// Settings → General → Profile Analyzer // +////////////////////////////////////////////////////////////////////////////// + +"%lu followers · %lu following" = "%lu seguidores · %lu seguidos"; +"%lu of %lu" = "%lu de %lu"; +"Analysis complete" = "Análisis completado"; +"Analysis failed" = "Error en el análisis"; +"Another analysis is already running" = "Ya hay otro análisis en curso"; +"Available after your next scan" = "Disponible tras tu próximo análisis"; +"Cancelled" = "Cancelado"; +"Couldn't fetch profile information" = "No se pudo obtener la información del perfil"; +"Fetching followers (%lu/%ld)…" = "Obteniendo seguidores (%lu/%ld)…"; +"Fetching following (%lu/%ld)…" = "Obteniendo seguidos (%lu/%ld)…"; +"Fetching profile info…" = "Obteniendo información del perfil…"; +"Categories" = "Categorías"; +"First scan: %@" = "Primer análisis: %@"; +"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "Número de seguidores supera %ld — análisis desactivado para evitar límites de la API."; +"Gained since last scan" = "Ganados desde el último análisis"; +"Last scan: %@" = "Último análisis: %@"; +"Lost followers" = "Seguidores perdidos"; +"Mutual followers" = "Seguidores mutuos"; +"Name: %@ → %@" = "Nombre: %@ → %@"; +"New followers" = "Nuevos seguidores"; +"No results" = "Sin resultados"; +"No active Instagram session found" = "No se encontró una sesión de Instagram activa"; +"No scan yet" = "Aún sin análisis"; +"Not following you back" = "No te siguen de vuelta"; +"OK" = "OK"; +"Private account" = "Cuenta privada"; +"Profile Analyzer" = "Analizador de perfil"; +"Profile picture changed" = "Foto de perfil cambiada"; +"Profile updates" = "Cambios de perfil"; +"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "Borra las instantáneas guardadas de esta cuenta. Perderás los cambios desde el último análisis."; +"Request failed" = "Solicitud fallida"; +"Reset analyzer data?" = "¿Restablecer datos del analizador?"; +"Run analysis" = "Ejecutar análisis"; +"Run your first analysis" = "Ejecuta tu primer análisis"; +"Search username or name" = "Buscar usuario o nombre"; +"Since last scan" = "Desde el último análisis"; +"Starting…" = "Iniciando…"; +"They follow you, you don't follow back" = "Te siguen, no los sigues de vuelta"; +"Too many followers" = "Demasiados seguidores"; +"Too many followers to analyze" = "Demasiados seguidores para analizar"; +"Unfollow" = "Dejar de seguir"; +"Unfollow @%@?" = "¿Dejar de seguir a @%@?"; +"Unfollowed you since last scan" = "Te dejaron de seguir desde el último análisis"; +"Username, name or picture changes" = "Cambios de usuario, nombre o foto"; +"Username: @%@ → @%@" = "Usuario: @%@ → @%@"; +"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "No se ejecuta cuando los seguidores superan %ld, para evitar límites de Instagram."; +"You both follow each other" = "Os seguís mutuamente"; +"You don't follow back" = "No los sigues de vuelta"; +"You follow them, they don't follow back" = "Los sigues, no te siguen de vuelta"; +"You started following" = "Empezaste a seguir"; +"You unfollowed" = "Dejaste de seguir"; + +"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "¿%@ %lu cuentas? Se procesarán las primeras %ld para evitar límites de la API."; +"%@ %lu accounts? This runs sequentially with a short pause between each." = "¿%@ %lu cuentas? Se ejecuta en orden con una pausa breve entre cada una."; +"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu cuenta(s) · %lu captura(s) · toca para ver"; +"%lu accounts followed" = "%lu cuentas seguidas"; +"%lu accounts unfollowed" = "%lu cuentas sin seguir"; +"%lu entries across %lu lists · tap to inspect" = "%lu entradas en %lu listas · toca para ver"; +"%lu preferences · tap to inspect" = "%lu preferencias · toca para ver"; +"(empty)" = "(vacío)"; +"(no analyzer data)" = "(sin datos del analizador)"; +"(no lists)" = "(sin listas)"; +"About Profile Analyzer" = "Acerca del analizador de perfil"; +"All preferences (%lu)" = "Todas las preferencias (%lu)"; +"Apply imported data?" = "¿Aplicar datos importados?"; +"Batch follow" = "Seguir en lote"; +"Batch follow finished" = "Seguir en lote finalizado"; +"Batch unfollow" = "Dejar de seguir en lote"; +"Batch unfollow finished" = "Dejar de seguir en lote finalizado"; +"Continue" = "Continuar"; +"Current snapshot" = "Captura actual"; +"Embed domains" = "Dominios de embed"; +"Excluded lists" = "Listas excluidas"; +"Excluded story users" = "Usuarios de historias excluidos"; +"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "Los valores actuales del ámbito seleccionado serán reemplazados. Puede ser necesario reiniciar la app para que algunos cambios surtan efecto."; +"Export" = "Exportar"; +"File has no importable sections." = "El archivo no contiene secciones importables."; +"File is not a valid RyukGram export." = "El archivo no es una exportación válida de RyukGram."; +"Filter" = "Filtrar"; +"First scan: we collect your followers and following lists and save them locally." = "Primer análisis: recopilamos tus listas de seguidores y seguidos y las guardamos localmente."; +"Follow %lu" = "Seguir %lu"; +"Followers" = "Seguidores"; +"Following… %lu / %lu" = "Siguiendo… %lu / %lu"; +"Full name" = "Nombre completo"; +"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "Aviso: esta función está en beta y utiliza la API privada de Instagram. Ejecutarla de forma consecutiva o justo tras mucha actividad de seguir/dejar de seguir puede provocar un límite temporal. Úsala con moderación y bajo tu propio riesgo."; +"Import complete" = "Importación completada"; +"Include" = "Incluir"; +"Included story users" = "Usuarios de historias incluidos"; +"Inspect the full payload" = "Inspeccionar la carga completa"; +"Keep scan history" = "Conservar historial de análisis"; +"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "Las cuentas grandes están bloqueadas: el análisis se desactiva por encima de 13.000 seguidores para evitar que Instagram aplique un límite a toda la app."; +"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "No se sube nada — todo queda en este dispositivo y puede borrarse desde el icono de la papelera."; +"Not verified only" = "Solo no verificadas"; +"Nothing was applied." = "No se aplicó nada."; +"Posts" = "Publicaciones"; +"Preferences" = "Preferencias"; +"Previous snapshot" = "Captura anterior"; +"Private only" = "Solo privadas"; +"Profile Analyzer data" = "Datos del analizador de perfil"; +"Raw" = "Bruto"; +"Raw JSON" = "JSON bruto"; +"Reset analyzer data" = "Restablecer datos del analizador"; +"Reset complete" = "Restablecimiento completado"; +"Reset selected data?" = "¿Restablecer los datos seleccionados?"; +"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "A partir del segundo análisis: cada análisis se compara con el anterior, mostrando seguidores nuevos/perdidos, tus propios movimientos de seguir/dejar de seguir y cambios de perfil."; +"Select all" = "Seleccionar todo"; +"Selected data will be cleared. Tap any row to see what's stored." = "Los datos seleccionados se borrarán. Toca cualquier fila para ver qué hay guardado."; +"Settings" = "Ajustes"; +"Sort" = "Ordenar"; +"This can't be undone." = "No se puede deshacer."; +"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "Marca lo que quieras aplicar. Toca cualquier fila para inspeccionar. Las secciones ausentes del archivo están deshabilitadas."; +"Tick what to include. Tap any row to inspect its contents." = "Marca lo que quieras incluir. Toca cualquier fila para ver su contenido."; +"Unfollow %lu" = "Dejar de seguir %lu"; +"Unfollowing… %lu / %lu" = "Dejando de seguir… %lu / %lu"; +"Username A → Z" = "Usuario A → Z"; +"Username Z → A" = "Usuario Z → A"; +"Verified only" = "Solo verificadas"; +"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "Cuando está activado, cada análisis se compara con el primero, así los seguidores nuevos/perdidos y los cambios de perfil no desaparecen entre análisis."; + ////////////////////////////////////////////////////////////////////////////// // SETTINGS VIEWS & DIALOGS // // Excluded-lists managers, backup/restore flows, in-picker labels. // @@ -761,68 +1014,58 @@ "Add preset…" = "Añadir ajuste preestablecido"; "Add to list?" = "¿Añadir a la lista?"; "Add user" = "Añadir usuario"; -"Could not resolve user ID" = "No se pudo resolver el ID del usuario"; -"Enter username" = "Introducir nombre de usuario"; -"Enter username of the DM thread" = "Introducir nombre de usuario de la conversación"; -"No DM thread found with @%@" = "No se encontró conversación con @%@"; -"User '%@' not found" = "Usuario '%@' no encontrado"; -"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." = "Todas las configuraciones de RyukGram se restablecerán a los valores predeterminados y se aplicarán los valores importados. Será necesario reiniciar la aplicación para que algunos cambios surtan efecto."; "Apply" = "Aplicar"; -"Apply imported settings?" = "¿Aplicar configuración importada?"; "Apply to" = "Aplicar a"; "Chats" = "Conversaciones"; "Could not read file." = "No se logró leer el archivo."; +"Could not resolve user ID" = "No se pudo resolver el ID del usuario"; "Could not write temporary file." = "No se logró escribir el archivo temporal."; "Current location" = "Ubicación actual"; "Custom" = "Personalizada"; "Date Format" = "Formato de Fecha"; "Delete" = "Eliminar"; -"Done editing" = "Finalizar edición"; -"Edit values" = "Editar valores"; "Enable fake location" = "Habilitar ubicación falsa"; -"Every RyukGram preference will revert to its built-in default. This can't be undone." = "Todas las preferencias de RyukGram volverán a los valores predeterminados. Esto no se puede deshacer."; +"Enter username" = "Introducir nombre de usuario"; +"Enter username of the DM thread" = "Introducir nombre de usuario de la conversación"; "Excluded chats" = "Conversaciones excluídas"; "Excluded users" = "Usuarios excluídos"; -"File is not a valid RyukGram settings export." = "Archivo no es una exportación válida de la configuración de RyukGram."; "Follow default" = "Seguir predeterminada"; "Force OFF (allow unsends)" = "Forzar DESACTIVADO (Permite anular envío)"; "Force ON (preserve unsends)" = "Forzar ACTIVADO (Mantiene eliminados)"; -"Form view" = "Vista de forma"; "Format" = "Formato"; "Import failed" = "Importación fallida"; -"Import preview" = "Previsualizar importación"; "Included chats" = "Conversaciones incluidas"; "Included users" = "Usuarios incluidos"; -"KD: ON" = "KD: ACTIVADO"; "KD: default" = "ME: Predeterminado"; +"KD: ON" = "KD: ACTIVADO"; "Keep-deleted" = "Mantener eliminados"; "Keep-deleted override" = "Anular mantener eliminados"; +"Name (A–Z)" = "Nombre (A–Z)"; +"No DM thread found with @%@" = "No se encontró conversación con @%@"; "Off" = "DESACTIVADO"; -"On" = "ACTIVADO"; "Presets" = "Preajustes"; -"Raw JSON view" = "Ver JSON sin formato"; -"Remove Selected" = "Eliminar Seleccionados"; +"Recently added" = "Añadidos recientemente"; "Remove from list" = "Eliminar de la lista"; +"Remove Selected" = "Eliminar Seleccionados"; "Reset" = "Restablecer"; -"Reset all settings?" = "¿Restablecer todas las configuraciones?"; "Saved presets are reusable. Tap a preset to make it the active location." = "Los ajustes preestablecidos guardados se pueden reutilizar. Toca un ajuste preestablecido para convertirlo en la ubicación activa."; +"Search" = "Buscar"; "Search address or place" = "Buscar dirección o lugar"; "Search by name or username" = "Buscar por nombre o nombre de usuario"; "Search by username or name" = "Buscar por nombre de usuario o nombre"; -"Search settings" = "Buscar en configuración"; "Select" = "Seleccionar"; "Select location on map" = "Seleccionar ubicación en el mapa"; "Set current location" = "Establecer ubicación actual"; "Set keep-deleted override" = "Establecer anulación de mantener eliminados"; "Settings exported" = "Configuración exportada"; -"Settings imported" = "Configuración importada"; +"Show map button" = "Botón de mostrar mapa"; "Show seconds" = "Mostrar segundos"; "Sort by" = "Ordenar por"; "Story users" = "Usuarios de historias"; "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." = "Alterna cada formato NSDate que utiliza Instagram. Las distintas secciones (Feed (Inicio), comentarios, historias, mensajes) utilizan métodos diferentes: activa aquellos a los que quieras aplicar el formato personalizado."; "Use this location" = "Usar esta ubicación"; -"When on, all CoreLocation requests inside Instagram return the location below." = "Cuando está activada, todas las solicitudes de CoreLocation dentro de Instagram devuelven la ubicación que se indica a continuación."; -"Show map button" = "Botón de mostrar mapa"; +"User '%@' not found" = "Usuario '%@' no encontrado"; +"Username (A–Z)" = "Usuario (A–Z)"; "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." = "Cuando está activado, todas las solicitudes de CoreLocation dentro de Instagram devuelven la ubicación que se muestra a continuación. Pulsa el botón del mapa para mostrar u ocultar el control rápido en la vista mapa de amigos."; ////////////////////////////////////////////////////////////////////////////// @@ -859,6 +1102,7 @@ "720p • progressive • fastest" = "720p • Progresivo • Más rápido"; "Are you sure?" = "¿Estás seguro?"; +"Bundle" = "Paquete"; "Copy audio URL" = "Copiar enlace de audio"; "Copy quality info" = "Copiar información sobre la calidad"; "Copy video URL" = "Copiar enlace de video"; @@ -871,60 +1115,74 @@ "Could not extract video url from reel" = "No se logró extraer enlace del video del reel"; "Could not extract video url from story" = "No se logró extraer enlace del video de la historia"; "Download Quality" = "Calidad de descarga"; +"Extras" = "Extras"; "FFmpegKit Debug" = "FFmpegKit Debug"; "Later" = "Mas tarde"; "No!" = "¡No!"; +"OK" = "OK"; "Restart" = "Reiniciar"; "Restart required" = "Reinicio requerido"; +"username" = "nombre de usuario"; "Yes" = "Si"; "You must restart the app to apply this change" = "Debes reiniciar la aplicación para aplicar este cambio"; ////////////////////////////////////////////////////////////////////////////// // ABOUT / CREDITS // -// Settings → Credits footer. // +// Strings from the About / Credits footer of Settings. // ////////////////////////////////////////////////////////////////////////////// -"%@ — view source, report issues, see releases" = "%@ — ver código fuente, reportar problemas y ver lanzamientos"; +"%@ — GitHub & Telegram" = "%@ — GitHub y Telegram"; +"About" = "Acerca de"; +"Arabic translation" = "Traducción al árabe"; +"Chinese (Traditional) translation" = "Traducción al chino (tradicional)"; "Credits" = "Créditos"; -"Developer" = "Desarrollador"; +"Developers" = "Desarrolladores"; "Donate to SoCuul" = "Donar a SoCuul"; +"installed" = "instalado"; +"Korean translation" = "Traducción al coreano"; +"latest" = "última"; +"Links" = "Enlaces"; +"No releases" = "Sin versiones"; "Original SCInsta developer" = "Desarrollador Original SCInsta"; -"Ryuk" = "Ryuk"; -"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "RyukGram %@\n\nInstagram v%@\n\nBasado en SCInsta por SoCuul"; -"RyukGram on GitHub" = "RyukGram en GitHub"; -"SoCuul" = "SoCuul"; +"Release notes" = "Notas de la versión"; +"Releases" = "Versiones"; +"Report an issue" = "Informar de un problema"; +"Russian translation" = "Traducción al ruso"; +"RyukGram developer" = "Desarrollador de RyukGram"; +"Join Telegram channel" = "Unirse al canal de Telegram"; +"View on GitHub" = "Ver en GitHub"; +"Source code" = "Código fuente"; +"Spanish translation" = "Traducción al español"; "Support the original developer" = "Apoyar al desarrollador original"; -"View Repo" = "Ver Repo"; -"View the source code on GitHub" = "Ver el código fuente en GitHub"; -/* [ADDED_BY_TRANSLATOR] */ "Translator" = "Traductor"; -/* [ADDED_BY_TRANSLATOR] */ "Flamako" = "Flamako"; +"Telegram channel" = "Canal de Telegram"; +"Testing and feature suggestions" = "Pruebas y sugerencias de funciones"; +"Tweak settings" = "Ajustes del tweak"; +"Version" = "Versión"; +"Version, credits, and links" = "Versión, créditos y enlaces"; +"What's new in RyukGram" = "Novedades de RyukGram"; ////////////////////////////////////////////////////////////////////////////// // HD DOWNLOADS // // Enhanced / HD downloads settings (DASH + FFmpegKit encoding). // ////////////////////////////////////////////////////////////////////////////// +"720p • progressive • silent" = "720p • progresivo • silencioso"; +"Audio extract failed" = "Falló la extracción de audio"; +"Audio only" = "Solo audio"; +"Audio ready" = "Audio listo"; "Download video at the highest available quality" = "Descargar video en la mejor calidad disponible"; "Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "Descarga el video en HD mediante transmisión DASH y lo codifica en H.264. Requiere FFmpegKit."; "Encoding speed" = "Velocidad de codificación"; "Enhanced downloads" = "Mejorar descargas"; -"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit no está disponible. Instala el archivo IPA descargado o la variante .deb de _ffmpeg para activarlo."; "Faster = lower quality" = "Más rápido = Menor calidad"; +"FFmpeg not available" = "FFmpeg no disponible"; +"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit no está disponible. Instala el archivo IPA descargado o la variante .deb de _ffmpeg para activarlo."; +"No audio stream available" = "No hay pista de audio disponible"; +"No audio track found" = "No se encontró pista de audio"; +"Photo" = "Foto"; "Photo quality" = "Calidad de imagen"; +"Raw image (no audio, no video)" = "Imagen sin procesar (sin audio, sin video)"; +"silent" = "silencioso"; "Use highest resolution available" = "Usar la resolución mas alta disponible"; "Video quality" = "Calidad de video"; "Which quality to download" = "En qué calidad descargar"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL / DEBUG // -// Placeholder rows only shown in the experimental settings sandbox. // -////////////////////////////////////////////////////////////////////////////// - -"Navigation Cell" = "Celda de navegación"; -/* [ADDED_BY_DEV] */ "Localization" = "Traducción"; -/* [ADDED_BY_DEV] */ "Update localization file" = "Actualizar traducción"; -/* [ADDED_BY_DEV] */ "Import a .strings file for a language" = "Importa un archivo .strings para añadir un idioma"; -/* [ADDED_BY_DEV] */ "Import a .strings file to update a translation. Pick a language, select the file, restart." = "Importa un archivo .strings para actualizar una traducción. Escoge un idioma, selecciona el archivo y reinicia"; -/* [ADDED_BY_DEV] */ "Export English strings" = "Exportar archivo .strings en Inglés"; -/* [ADDED_BY_DEV] */ "Share the base English .strings file for translating" = "Comparte el archivo .strings en Inglés para traducir"; - diff --git a/src/Localization/Resources/ko.lproj/Localizable.strings b/src/Localization/Resources/ko.lproj/Localizable.strings index 3de7121..e338fd3 100644 --- a/src/Localization/Resources/ko.lproj/Localizable.strings +++ b/src/Localization/Resources/ko.lproj/Localizable.strings @@ -1,893 +1,1186 @@ -/* - * 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 // -////////////////////////////////////////////////////////////////////////////// - -"settings.firstrun.message" = "향후: 프로필 페이지 우측 상단의 3선 메뉴를 길게 눌러 RyukGram 설정을 다시 열 수 있습니다."; -"settings.firstrun.ok" = "이해했습니다!"; -"settings.firstrun.title" = "RyukGram 설정 정보"; -"settings.language.system" = "시스템 기본값"; -"settings.language.title" = "언어"; -"settings.language.english_only" = "RyukGram은 현재 영어만 기본 제공합니다. 다른 언어는 번역을 기다리고 있습니다 — README의 짧은 가이드를 따라 한국어 번역에 참여해 보세요."; -"settings.language.ok" = "확인"; -"settings.language.help_translate" = "번역 돕기"; -"settings.results.many" = "%lu개의 결과"; -"settings.results.none" = "결과 없음"; -"settings.results.one" = "%lu개의 결과"; -"settings.search.placeholder" = "설정 검색"; -"settings.title" = "RyukGram 설정"; - -////////////////////////////////////////////////////////////////////////////// -// GENERAL // -////////////////////////////////////////////////////////////////////////////// - -"Adds a copy option to the comment long-press menu" = "댓글을 길게 누를 때 복사 옵션을 추가합니다"; -"Adds a download option for GIF comments" = "GIF 댓글 다운로드 옵션을 추가합니다"; -"Browser" = "브라우저"; -"Comments" = "댓글"; -"Copy comment text" = "댓글 텍스트 복사"; -"Copy description" = "설명 복사"; -"Copy description text fields by long-pressing on them" = "설명 텍스트 필드를 길게 눌러 복사합니다"; -"Date format" = "날짜 형식"; -"Disable app haptics" = "앱 햅틱 비활성화"; -"Disables haptics/vibrations within the app" = "앱 내의 햅틱/진동을 비활성화합니다"; -"Do not save recent searches" = "최근 검색어 저장 안 함"; -"Download GIF comments" = "GIF 댓글 다운로드"; -"Embed domain" = "임베드 도메인"; -"Embed domain: %@" = "임베드 도메인: %@"; -"Enable liquid glass buttons" = "리퀴드 글래스 버튼 활성화"; -"Enable liquid glass surfaces" = "리퀴드 글래스 표면 활성화"; -"Enable teen app icons" = "틴(Teen) 앱 아이콘 활성화"; -"Enables experimental liquid glass buttons" = "실험적인 리퀴드 글래스 버튼을 활성화합니다"; -"Enables liquid glass tab bar, floating navigation, and other UI elements" = "리퀴드 글래스 탭 바, 플로팅 내비게이션 및 기타 UI 요소를 활성화합니다"; -"Experimental features" = "실험실 기능"; -"Focus/distractions" = "집중/방해 요소"; -"General" = "일반"; -"Hide Meta AI" = "Meta AI 숨기기"; -"Hide ads" = "광고 숨기기"; -"Hide explore posts grid" = "탐색 탭 게시물 그리드 숨기기"; -"Hide friends map" = "친구 지도 숨기기"; -"Hide metrics" = "수치 숨기기"; -"Hide notes tray" = "노트 트레이 숨기기"; -"Hide trending searches" = "인기 검색어 숨기기"; -"Hides all suggested users for you to follow, outside your feed" = "피드 외부에서 팔로우할 추천 사용자를 모두 숨깁니다"; -"Hides like/comment/share counts on posts and reels" = "게시물 및 릴스의 좋아요/댓글/공유 횟수를 숨깁니다"; -"Hides the friends map icon in the notes tray" = "노트 트레이에서 친구 지도 아이콘을 숨깁니다"; -"Hides the grid of suggested posts on the explore/search tab" = "탐색/검색 탭에서 추천 게시물 그리드를 숨깁니다"; -"Hides the meta ai buttons/functionality within the app" = "앱 내의 Meta AI 버튼/기능을 숨깁니다"; -"Hides the notes tray in the DM inbox" = "DM 받은 편지함에서 노트 트레이를 숨깁니다"; -"Hides the suggested broadcast channels in direct messages" = "다이렉트 메시지에서 추천 공지 채널을 숨깁니다"; -"Hides the trending searches under the explore search bar" = "탐색 검색창 아래의 인기 검색어를 숨깁니다"; -"Hold down on the Instagram logo to change the app icon" = "Instagram 로고를 길게 눌러 앱 아이콘을 변경합니다"; -"Long press on the eyedropper tool in stories to customize the text color more precisely" = "스토리의 스포이트 도구를 길게 눌러 텍스트 색상을 더 정밀하게 사용자 지정합니다"; -"No suggested chats" = "추천 채팅 없음"; -"No suggested users" = "추천 사용자 없음"; -"Notes" = "노트"; -"Open links in external browser" = "외부 브라우저에서 링크 열기"; -"Opens links in Safari instead of Instagram's in-app browser" = "Instagram 인앱 브라우저 대신 Safari에서 링크를 엽니다"; -"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "URL에서 Instagram 추적 래퍼(l.instagram.com) 및 UTM/fbclid 매개변수를 제거합니다"; -"Removes all ads from the Instagram app" = "Instagram 앱에서 모든 광고를 제거합니다"; -"Removes igsh, utm_source, and other tracking parameters from shared links" = "공유 링크에서 igsh, utm_source 및 기타 추적 매개변수를 제거합니다"; -"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "IG의 상대적 타임스탬프(\"3일 전\")를 사용자 지정 형식으로 바꿉니다. 선택기 내에서 적용할 영역을 전환하세요."; -"Replace domain in shared links" = "공유 링크의 도메인 교체"; -"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "복사/공유된 링크를 재작성하여 Discord, Telegram 등에서 미리보기가 잘 되는 도메인을 사용합니다."; -"Search bars will no longer save your recent searches" = "검색창에 최근 검색어가 더 이상 저장되지 않습니다"; -"Sharing" = "공유"; -"Strip tracking from links" = "링크에서 추적 제거"; -"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)." = "이 기능들은 숨겨진 Instagram 플래그에 의존하며 모든 계정이나 버전에서 작동하지 않을 수 있습니다.\n실험적 플래그 연구: @euoradan (Radan)."; -"Use detailed color picker" = "상세 색상 선택기 사용"; - -////////////////////////////////////////////////////////////////////////////// -// DATE FORMAT // -////////////////////////////////////////////////////////////////////////////// - -"Alternate" = "대체"; -"Always ask" = "항상 묻기"; -"Balanced" = "균형"; -"Block all" = "모두 차단"; -"Block selected" = "선택 차단"; -"Button" = "버튼"; -"Classic" = "클래식"; -"Date format — %@" = "날짜 형식 — %@"; -"Default" = "기본값"; -"Disabled" = "비활성화됨"; -"Download and share" = "다운로드 및 공유"; -"Download to Photos" = "사진 앱에 다운로드"; -"Enabled" = "활성화됨"; -"Expand" = "확장"; -"Explore" = "탐색"; -"Fast" = "빠름"; -"Feed" = "피드"; -"High" = "높음"; -"Inbox" = "받은 편지함"; -"Low" = "낮음"; -"Max" = "최대"; -"Medium" = "보통"; -"Mute/Unmute" = "음소거/해제"; -"Open menu" = "메뉴 열기"; -"Pause/Play" = "일시정지/재생"; -"Profile" = "프로필"; -"Quality" = "품질"; -"Reels" = "릴스"; -"Requires restart" = "재시작 필요"; -"Save to Photos" = "사진 앱에 저장"; -"Share sheet" = "공유 시트"; -"Standard" = "표준"; -"Toggle" = "토글"; - -////////////////////////////////////////////////////////////////////////////// -// FEED // -////////////////////////////////////////////////////////////////////////////// - -"Action button" = "작업 버튼"; -"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." = "각 피드 게시물 아래에 다운로드/공유/복사/확장/리포스트 항목이 있는 RyukGram 작업 버튼을 추가합니다. 탭하면 기본적으로 메뉴가 열립니다. 아래에서 탭 동작을 변경하세요."; -"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." = "피드 새로고침 시점과 방법을 제어합니다. 백그라운드 새로고침은 약 10분 후 앱으로 돌아올 때 발생합니다. 홈 버튼 새로고침은 이미 홈 탭에 있을 때 홈 탭을 탭하면 발생합니다."; -"Default tap action" = "기본 탭 작업"; -"Disable background refresh" = "백그라운드 새로고침 비활성화"; -"Disable home button refresh" = "홈 버튼 새로고침 비활성화"; -"Disable home button scroll" = "홈 버튼 스크롤 비활성화"; -"Disable video autoplay" = "동영상 자동 재생 비활성화"; -"Hide" = "숨기기"; -"Hide entire feed" = "전체 피드 숨기기"; -"Hide repost button" = "리포스트 버튼 숨기기"; -"Hide stories tray" = "스토리 트레이 숨기기"; -"Hide suggested stories" = "추천 스토리 숨기기"; -"Hides suggested accounts" = "추천 계정 숨기기"; -"Hides suggested reels" = "추천 릴스 숨기기"; -"Hides suggested threads posts" = "추천 스레드 게시물 숨기기"; -"Hides the repost button on feed posts" = "피드 게시물에서 리포스트 버튼을 숨깁니다"; -"Hides the story tray at the top" = "상단의 스토리 트레이를 숨깁니다"; -"Inserts a button row below like/comment/share on each post" = "각 게시물의 좋아요/댓글/공유 아래에 버튼 행을 삽입합니다"; -"Long press on media to expand in full-screen viewer" = "미디어를 길게 눌러 전체 화면 뷰어로 확대합니다"; -"Media" = "미디어"; -"Media zoom" = "미디어 확대"; -"No suggested for you" = "회원님을 위한 추천 없음"; -"No suggested posts" = "추천 게시물 없음"; -"No suggested reels" = "추천 릴스 없음"; -"No suggested threads" = "추천 스레드 없음"; -"Prevents feed from reloading when returning from background" = "백그라운드에서 돌아올 때 피드가 다시 로드되는 것을 방지합니다"; -"Prevents videos from playing automatically" = "동영상이 자동으로 재생되는 것을 방지합니다"; -"Refresh" = "새로고침"; -"Removes all content from your home feed" = "홈 피드에서 모든 콘텐츠를 제거합니다"; -"Removes suggested accounts from the stories tray" = "스토리 트레이에서 추천 계정을 제거합니다"; -"Removes suggested posts" = "추천 게시물을 제거합니다"; -"Scroll to top without refreshing when tapping Home" = "홈을 탭할 때 새로고침하지 않고 맨 위로 스크롤합니다"; -"Show action button" = "작업 버튼 표시"; -"Stories tray" = "스토리 트레이"; -"Tapping Home does nothing when already on feed" = "이미 피드에 있을 때 홈을 탭해도 아무 작업도 수행하지 않습니다"; -"Tray long-press actions" = "트레이 길게 누르기 작업"; -"What happens on a single tap. Long-press always opens the full menu" = "한 번 탭할 때의 동작입니다. 길게 누르면 항상 전체 메뉴가 열립니다."; - -////////////////////////////////////////////////////////////////////////////// -// REELS // -////////////////////////////////////////////////////////////////////////////// - -"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." = "릴스 사이드바 위에 커버 보기/다운로드/공유/복사/확장/리포스트 항목이 있는 RyukGram 작업 버튼을 추가합니다. 탭하면 기본적으로 메뉴가 열립니다. 아래에서 탭 동작을 변경하세요."; -"Always show progress scrubber" = "항상 재생 진행 바 표시"; -"Auto-scroll reels" = "릴스 자동 스크롤"; -"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG 기본값: 기본 동작. RyukGram: 뒤로 스와이프한 후 다시 진행합니다."; -"IG default" = "IG 기본값"; -"RyukGram" = "RyukGram"; -"Change what happens when you tap on a reel" = "릴스를 탭할 때 발생하는 동작을 변경합니다"; -"Confirm reel refresh" = "릴스 새로고침 확인"; -"Disable auto-unmuting reels" = "릴스 자동 음소거 해제 비활성화"; -"Disable scrolling reels" = "릴스 스크롤 비활성화"; -"Disable tab button refresh" = "탭 버튼 새로고침 비활성화"; -"Doom scrolling limit" = "둠스크롤링 제한"; -"Forces the progress bar to appear on every reel" = "모든 릴스에 재생 진행 바를 강제로 표시합니다"; -"Hide reels header" = "릴스 헤더 숨기기"; -"Hides the repost button on the reels sidebar" = "릴스 사이드바에서 리포스트 버튼을 숨깁니다"; -"Hides the top navigation bar when watching reels" = "릴스를 시청할 때 상단 내비게이션 바를 숨깁니다"; -"Hiding" = "숨김"; -"Limits" = "제한"; -"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "한 번에 스크롤할 수 있는 릴스의 양을 제한하고 새로고침을 방지합니다"; -"Only loads %@ %@" = "%@ %@만 로드합니다"; -"Places a button above the like/comment/share column on each reel" = "각 릴스의 좋아요/댓글/공유 열 위에 버튼을 배치합니다"; -"Prevent doom scrolling" = "둠스크롤링 방지"; -"Prevents reels from being scrolled to the next video" = "릴스가 다음 동영상으로 스크롤되는 것을 방지합니다"; -"Prevents reels from unmuting when the volume/silent button is pressed" = "볼륨/무음 버튼을 누를 때 릴스의 음소거가 해제되는 것을 방지합니다"; -"Shows an alert when you trigger a reels refresh" = "릴스 새로고침을 트리거할 때 경고를 표시합니다"; -"Shows buttons to reveal and auto-fill the password on locked reels" = "잠긴 릴스에서 비밀번호를 표시하고 자동 입력하는 버튼을 표시합니다"; -"Tap Controls" = "탭 제어"; -"Tapping the Reels tab while on reels does nothing" = "릴스 탭에 있을 때 릴스 탭을 탭해도 아무 작업도 수행하지 않습니다"; -"Unlock password-locked reels" = "비밀번호로 잠긴 릴스 잠금 해제"; - -////////////////////////////////////////////////////////////////////////////// -// PROFILE // -////////////////////////////////////////////////////////////////////////////// - -"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" = "하이라이트를 길게 누를 때의 메뉴에 커버를 전체 화면으로 여는 보기 옵션을 추가합니다"; -"Copy note on long press" = "길게 눌러 노트 복사"; -"Follow indicator" = "팔로우 표시기"; -"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 to download directly (ignored when zoom is on)" = "길게 눌러 바로 다운로드 (확대가 켜져 있으면 무시됨)"; -"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "프로필 요소의 길게 누르기 제스처 — 기능별 작업 버튼과 별도로 유지됩니다."; -"Profile copy button" = "프로필 복사 버튼"; -"Save profile picture" = "프로필 사진 저장"; -"Shows whether the profile user follows you" = "프로필 사용자가 회원님을 팔로우하는지 표시합니다"; -"View highlight cover" = "하이라이트 커버 보기"; -"Zoom profile photo" = "프로필 사진 확대"; - -////////////////////////////////////////////////////////////////////////////// -// SAVING & DOWNLOADS // -////////////////////////////////////////////////////////////////////////////// - -"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." = "더 이상 사용되지 않습니다. RyukGram 작업 버튼(피드/릴스/스토리에서 기능별로 구성됨)이 미디어를 다운로드하는 새로운 방법입니다. 미디어 위에서 여러 손가락으로 길게 누르는 기존 방식을 선호하는 경우에만 이 마스터 토글을 활성화하세요."; -"Downloads" = "다운로드"; -"Downloads with %@ %@" = "%@ %@(으)로 다운로드합니다"; -"Enable long-press gesture" = "길게 누르기 제스처 활성화"; -"Finger count for long-press" = "길게 누르기 손가락 개수"; -"Legacy long-press gesture" = "기존 길게 누르기 제스처 (레거시)"; -"Long-press hold time" = "길게 누르기 유지 시간"; -"Master toggle for the deprecated gesture workflow (off by default)" = "더 이상 사용되지 않는 제스처 워크플로를 위한 마스터 토글 (기본적으로 꺼져 있음)"; -"Press finger(s) for %@ %@" = "%@ %@ 동안 손가락을 누르세요"; -"Route saves into a dedicated album in Photos instead of the camera roll root" = "기본 카메라 롤 대신 사진 앱의 전용 앨범으로 저장 경로를 지정합니다"; -"Save action" = "저장 동작"; -"Save to RyukGram album" = "RyukGram 앨범에 저장"; -"Saving" = "저장 중"; -"Show a confirmation dialog before starting a download" = "다운로드를 시작하기 전에 확인 대화 상자를 표시합니다"; -"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." = "\"RyukGram 앨범에 저장\"이 켜져 있으면, 다운로드 및 공유 시트의 \"사진 앱에 저장\" 선택 항목이 사진 라이브러리의 전용 \"RyukGram\" 앨범으로 라우팅됩니다."; - -////////////////////////////////////////////////////////////////////////////// -// STORIES // -////////////////////////////////////////////////////////////////////////////// - -"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." = "스토리의 눈 모양 버튼 옆에 다운로드/공유/복사/확장/리포스트/멘션 보기 항목이 있는 RyukGram 작업 버튼을 추가합니다. 탭하면 기본적으로 메뉴가 열립니다. 아래에서 탭 동작을 변경하세요."; -"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu" = "스토리 오버레이에 오디오 음소거/해제를 위한 스피커 버튼을 추가합니다. 점 3개 메뉴에서도 사용할 수 있습니다."; -"Advance on story like" = "스토리 좋아요 시 다음으로 넘어가기"; -"Advance on story reply" = "스토리 답장 시 다음으로 넘어가기"; -"Advance when marking as seen" = "읽음으로 표시할 때 다음으로 넘어가기"; -"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." = "모두 차단: 모든 스토리가 차단됨 — 목록에 있는 사용자는 예외입니다.\n선택 차단: 목록에 있는 사용자만 차단됨 — 그 외에는 정상입니다.\n두 목록은 독립적으로 저장됩니다."; -"Blocking mode" = "차단 모드"; -"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" = "버튼 = 한 번 탭하여 읽음으로 표시. 토글 = 탭하여 스토리 읽음 표시를 켜고 끕니다 (켜져 있으면 눈이 파란색으로 채워짐)"; -"Disable instants creation" = "인스턴트 생성 비활성화"; -"Disable story seen receipt" = "스토리 읽음 표시 비활성화 (몰래 보기)"; -"Enable story user list" = "스토리 사용자 목록 활성화"; -"Hides the functionality to create/send instants" = "인스턴트를 생성/전송하는 기능을 숨깁니다"; -"Hides the notification for others when you view their story" = "회원님이 스토리를 조회할 때 다른 사람에게 알림이 가지 않도록 숨깁니다"; -"Inserts a button next to the seen/eye button on story overlays" = "스토리 오버레이의 읽음/눈 버튼 옆에 버튼을 삽입합니다"; -"Keep stories visually seen locally" = "스토리를 로컬에서 시각적으로 읽은 상태 유지"; -"Liking a story automatically advances to the next one after a short delay" = "스토리에 좋아요를 누르면 짧은 지연 시간 후 자동으로 다음 스토리로 넘어갑니다"; -"Manage list" = "목록 관리"; -"Manage list (%lu)" = "목록 관리 (%lu)"; -"Manual seen button mode" = "수동 읽음 버튼 모드"; -"Mark seen on story like" = "스토리 좋아요 시 읽음 표시"; -"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 when you send a reply or emoji reaction, even with seen blocking on" = "읽음 차단이 켜져 있어도 답장이나 이모티콘 반응을 보낼 때 스토리를 읽음으로 표시합니다"; -"Master toggle. When off, the list is ignored" = "마스터 토글. 꺼져 있으면 목록이 무시됩니다"; -"Other" = "기타"; -"Playback" = "재생"; -"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "서버에서는 여전히 읽음 표시를 차단하면서 로컬에서는 스토리를 읽음으로 표시(회색 링)합니다"; -"Quick list button in stories" = "스토리의 빠른 목록 버튼"; -"Search, sort, swipe to remove" = "검색, 정렬, 스와이프하여 제거"; -"Seen receipts" = "읽음 표시"; -"Sending a reply or emoji reaction automatically advances to the next story" = "답장이나 이모티콘 반응을 보내면 자동으로 다음 스토리로 넘어갑니다"; -"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" = "스토리에 눈 모양 버튼을 표시하여 목록에서 사용자를 추가/제거합니다. 끄기 = 점 3개 메뉴를 사용하거나 길게 누르기만 허용"; -"Stop story auto-advance" = "스토리 자동 넘어가기 중지"; -"Stories" = "스토리"; -"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "타이머가 끝나도 스토리가 자동으로 다음 스토리로 건너뛰지 않습니다. 수동으로 넘어가려면 탭하세요."; -"Story audio toggle" = "스토리 오디오 토글"; -"Story user list" = "스토리 사용자 목록"; -"Tapping the eye button to mark a story as seen advances to the next story automatically" = "눈 버튼을 탭하여 스토리를 읽음으로 표시하면 자동으로 다음 스토리로 넘어갑니다"; -"View story mentions" = "스토리 멘션 보기"; -"Which stories get seen-receipt blocking" = "읽음 표시 차단을 적용할 스토리"; - -////////////////////////////////////////////////////////////////////////////// -// MESSAGES — READ RECEIPTS // -////////////////////////////////////////////////////////////////////////////// - -"Adds a button to DM threads to mark messages as seen" = "DM 스레드에 메시지를 읽음으로 표시하는 버튼을 추가합니다"; -"Auto mark seen on interact" = "상호작용 시 자동 읽음 표시"; -"Auto mark seen on typing" = "입력 시 자동 읽음 표시"; -"Control when messages are marked as seen" = "메시지가 읽음으로 표시되는 시점 제어"; -"How the seen button behaves" = "읽음 버튼의 동작 방식"; -"Manually mark messages as seen" = "수동으로 메시지 읽음 표시"; -"Marks messages as seen when you send any message" = "메시지를 보낼 때 모든 메시지를 읽음으로 표시합니다"; -"Marks messages as seen when you start typing" = "입력을 시작할 때 메시지를 읽음으로 표시합니다"; -"Read receipt mode" = "읽음 표시 모드"; -"Read receipts" = "읽음 표시"; - -////////////////////////////////////////////////////////////////////////////// -// MESSAGES — KEEP DELETED // -////////////////////////////////////////////////////////////////////////////// - -"Activity" = "활동"; -"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" = "음성 메시지를 길게 누를 때의 메뉴에 '다운로드' 옵션을 추가하여 M4A 오디오로 저장합니다"; -"Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram" = "DM의 더보기(+) 메뉴에 '파일 보내기' 옵션을 추가합니다. 지원되는 파일 형식은 Instagram에 의해 제한될 수 있습니다."; -"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages" = "DM의 더보기(+) 메뉴에 '오디오 파일' 옵션을 추가하여 오디오 파일을 음성 메시지로 보냅니다"; -"Adds copy text, download GIF/audio to the note long-press menu" = "노트를 길게 누를 때의 메뉴에 텍스트 복사, GIF/오디오 다운로드를 추가합니다"; -"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." = "모두 차단: 모든 채팅 차단됨 — 목록에 있는 채팅은 예외입니다.\n선택 차단: 목록에 있는 채팅만 차단됨 — 그 외에는 정상입니다.\n두 목록은 독립적으로 저장됩니다. 받은 편지함에서 채팅을 길게 눌러 추가하거나 제거하세요."; -"Block keep-deleted for excluded chats" = "제외된 채팅에 대해 삭제된 메시지 보관 차단"; -"Block keep-deleted for unlisted chats" = "목록에 없는 채팅에 대해 삭제된 메시지 보관 차단"; -"Chat list" = "채팅 목록"; -"Confirmation dialog before clearing preserved messages" = "보존된 메시지를 지우기 전 확인 대화 상자"; -"Copies note text directly on long press without opening the menu" = "메뉴를 열지 않고 길게 누르면 노트 텍스트를 바로 복사합니다"; -"Copy text on hold" = "길게 눌러 텍스트 복사"; -"Custom emojis and background/text colors" = "사용자 지정 이모티콘 및 배경/텍스트 색상"; -"Custom note themes" = "사용자 지정 노트 테마"; -"Disable disappearing mode swipe" = "사라지는 모드 스와이프 비활성화"; -"Disable screenshot detection" = "스크린샷 감지 비활성화"; -"Disable typing status" = "입력 중 상태 비활성화"; -"Disable view-once limitations" = "한 번 보기 제한 비활성화"; -"Download voice messages" = "음성 메시지 다운로드"; -"Enable chat list" = "채팅 목록 활성화"; -"Enable note theming" = "노트 테마 활성화"; -"Enables the notes theme picker" = "노트 테마 선택기를 활성화합니다"; -"Files" = "파일"; -"Full last active date" = "전체 마지막 활동 날짜"; -"Hide reels blend button" = "릴스 블렌드 버튼 숨기기"; -"Hide video call button" = "영상 통화 버튼 숨기기"; -"Hide voice call button" = "음성 통화 버튼 숨기기"; -"Hides the blend button in DMs" = "DM에서 블렌드 버튼을 숨깁니다"; -"Hides typing indicator from others" = "다른 사람에게 회원님의 입력 중 상태를 숨깁니다"; -"Indicate unsent messages" = "전송 취소된 메시지 표시"; -"Keep deleted messages" = "삭제된 메시지 보관"; -"Makes view-once messages behave like normal visual messages (loopable/pauseable)" = "한 번 보기 메시지를 일반 시각적 메시지처럼 작동하게 합니다(반복 재생/일시 정지 가능)"; -"Note actions" = "노트 작업"; -"Preserve messages that others unsend" = "다른 사람이 전송 취소한 메시지 보존"; -"Preserves messages that others unsend" = "다른 사람이 전송 취소한 메시지를 보존합니다"; -"Prevents accidental swipe-up activation of disappearing mode" = "실수로 위로 스와이프하여 사라지는 모드가 활성화되는 것을 방지합니다"; -"Quick list button in chats" = "채팅의 빠른 목록 버튼"; -"Removes the audio call button from DM thread header" = "DM 스레드 헤더에서 음성 통화 버튼을 제거합니다"; -"Removes the screenshot-prevention features for visual messages in DMs" = "DM에서 시각적 메시지에 대한 스크린샷 방지 기능을 제거합니다"; -"Removes the video call button from DM thread header" = "DM 스레드 헤더에서 영상 통화 버튼을 제거합니다"; -"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" = "검색, 정렬, 스와이프하여 제거 또는 삭제된 메시지 보관 토글"; -"Send audio as file" = "오디오를 파일로 보내기"; -"Send files (experimental)" = "파일 보내기 (실험실 기능)"; -"Show full date instead of \"Active 2h ago\"" = "\"2시간 전 활동\" 대신 전체 날짜 표시"; -"Shows a button in DM threads to add/remove chats from the list. Long-press for more options" = "DM 스레드에 목록에서 채팅을 추가/제거하는 버튼을 표시합니다. 더 많은 옵션을 보려면 길게 누르세요."; -"Shows a notification pill when a message is unsent" = "메시지가 전송 취소될 때 알림 필(pill)을 표시합니다"; -"Shows an \"Unsent\" label on preserved messages" = "보존된 메시지에 \"전송 취소됨\" 라벨을 표시합니다"; -"Unlimited replay of visual messages" = "시각적 메시지 무제한 다시 보기"; -"Unsent message notification" = "전송 취소된 메시지 알림"; -"Visual messages" = "시각적 메시지"; -"Voice messages" = "음성 메시지"; -"Warn before clearing on refresh" = "새로고침 시 지우기 전에 경고"; -"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." = "⚠️ DM 탭에서 당겨서 새로고침하면 보존된 모든 메시지가 지워집니다. 확인 대화 상자를 표시하려면 아래 경고를 활성화하세요."; - -////////////////////////////////////////////////////////////////////////////// -// MESSAGES // -////////////////////////////////////////////////////////////////////////////// - -"Messages" = "메시지"; -"Threads" = "스레드"; - -////////////////////////////////////////////////////////////////////////////// -// NAVIGATION // -////////////////////////////////////////////////////////////////////////////// - -"Hide create tab" = "만들기 탭 숨기기"; -"Hide explore tab" = "탐색 탭 숨기기"; -"Hide feed tab" = "피드 탭 숨기기"; -"Hide messages 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." = "DM 받은 편지함 + 프로필을 제외한 모든 탭을 숨기고 강제로 받은 편지함에서 시작하게 합니다. 설정 바로가기는 받은 편지함 탭을 길게 누르는 것으로 이동됩니다."; -"Hides the create 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 feed/home tab on the bottom navigation bar" = "하단 내비게이션 바에서 피드/홈 탭을 숨깁니다"; -"Hides the reels tab on the bottom navigation bar" = "하단 내비게이션 바에서 릴스 탭을 숨깁니다"; -"Hiding tabs" = "탭 숨기기"; -"Icon order" = "아이콘 순서"; -"Launch tab" = "시작 탭"; -"Lets you swipe to switch between navigation bar tabs" = "스와이프하여 내비게이션 바 탭 간에 전환할 수 있습니다"; -"Messages only" = "메시지 전용"; -"Messages-only mode" = "메시지 전용 모드"; -"Navigation" = "내비게이션"; -"Swipe between tabs" = "탭 간 스와이프 전환"; -"Tab the app opens to. Ignored when Messages-only is on" = "앱이 열리는 탭입니다. 메시지 전용 모드가 켜져 있으면 무시됩니다."; -"The order of the icons on the bottom navigation bar" = "하단 내비게이션 바의 아이콘 순서입니다"; -"Turn IG into a DM-only client" = "IG를 DM 전용 클라이언트로 전환합니다"; - -////////////////////////////////////////////////////////////////////////////// -// CONFIRM ACTIONS // -////////////////////////////////////////////////////////////////////////////// - -"Confirm actions" = "작업 확인"; -"Confirm call" = "통화 확인"; -"Confirm changing theme" = "테마 변경 확인"; -"Confirm follow" = "팔로우 확인"; -"Confirm follow requests" = "팔로우 요청 확인"; -"Confirm like: Posts" = "게시물 좋아요 확인"; -"Confirm like: Reels" = "릴스 좋아요 확인"; -"Confirm posting comment" = "댓글 게시 확인"; -"Confirm repost" = "리포스트 확인"; -"Confirm shh mode" = "쉿 모드 확인"; -"Confirm sticker interaction" = "스티커 상호작용 확인"; -"Confirm story emoji reaction" = "스토리 이모티콘 반응 확인"; -"Confirm story like" = "스토리 좋아요 확인"; -"Confirm unfollow" = "언팔로우 확인"; -"Shows an alert before sending an emoji reaction on a story" = "스토리에 이모티콘 반응을 보내기 전에 경고를 표시합니다"; -"Shows an alert when you click the like button on posts to confirm the like" = "게시물에서 좋아요 버튼을 클릭할 때 좋아요를 확인할 경고를 표시합니다"; -"Shows an alert when you click the like button on stories to confirm the like" = "스토리에서 좋아요 버튼을 클릭할 때 좋아요를 확인할 경고를 표시합니다"; -"Confirm voice messages" = "음성 메시지 확인"; -"Shows an alert to confirm before sending a voice message" = "음성 메시지를 보내기 전에 확인할 경고를 표시합니다"; -"Shows an alert to confirm before toggling disappearing messages" = "사라지는 메시지를 전환하기 전에 확인할 경고를 표시합니다"; -"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 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 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 reels to confirm the like" = "릴스에서 좋아요 버튼을 클릭할 때 좋아요를 확인할 경고를 표시합니다"; -"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 unfollow button to confirm" = "언팔로우 버튼을 클릭할 때 확인할 경고를 표시합니다"; - -////////////////////////////////////////////////////////////////////////////// -// BACKUP & RESTORE // -////////////////////////////////////////////////////////////////////////////// - -"Backup & Restore" = "백업 및 복원"; -"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." = "RyukGram 설정을 JSON 파일로 내보내고 나중에 가져옵니다. 가져오기를 수행하면 가져온 값을 적용하기 전에 모든 설정을 기본값으로 재설정하며, 변경되기 전에 미리보기를 표시합니다."; -"Import settings" = "설정 가져오기"; -"Load settings from a JSON file" = "JSON 파일에서 설정을 불러옵니다"; -"Reset to defaults" = "기본값으로 재설정"; -"Revert every RyukGram preference" = "모든 RyukGram 환경설정을 되돌립니다"; -"Save settings as a JSON file" = "설정을 JSON 파일로 저장합니다"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL // -////////////////////////////////////////////////////////////////////////////// - -"Experimental" = "실험실 기능"; -"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "이 기능들은 불안정하며 Instagram 앱이 예기치 않게 충돌할 수 있습니다.\n\n본인의 책임 하에 사용하세요!"; -"Warning" = "경고"; - -////////////////////////////////////////////////////////////////////////////// -// ADVANCED // -////////////////////////////////////////////////////////////////////////////// - -"Advanced" = "고급"; -"Automatically opens settings when the app launches" = "앱을 실행할 때 자동으로 설정을 엽니다"; -"Disable safe mode" = "안전 모드 비활성화"; -"Enable tweak settings quick-access" = "트윅 설정 빠른 접근 활성화"; -"Hold on the home tab to open RyukGram settings" = "홈 탭을 길게 눌러 RyukGram 설정을 엽니다"; -"Instagram" = "Instagram"; -"Pause playback when opening settings" = "설정을 열 때 재생 일시 정지"; -"Pauses any playing video/audio when settings opens" = "설정이 열릴 때 재생 중인 비디오/오디오를 일시 정지합니다"; -"Prevents Instagram from resetting settings after crashes (at your own risk)" = "충돌 후 Instagram이 설정을 재설정하는 것을 방지합니다 (본인 책임)"; -"Reset onboarding state" = "온보딩 상태 재설정"; -"Settings" = "설정"; -"Show tweak settings on app launch" = "앱 실행 시 트윅 설정 표시"; - -////////////////////////////////////////////////////////////////////////////// -// DEBUG // -////////////////////////////////////////////////////////////////////////////// - -"Button Cell" = "버튼 셀"; -"Change the value on the right" = "오른쪽의 값을 변경하세요"; -"Debug" = "디버그"; -"Enable FLEX gesture" = "FLEX 제스처 활성화"; -"Hold 5 fingers on the screen to open FLEX" = "화면에 손가락 5개를 대고 있으면 FLEX가 열립니다"; -"I have %@%@" = "나는 %@%@를 가지고 있습니다"; -"Link Cell" = "링크 셀"; -"Menu Cell" = "메뉴 셀"; -"Open FLEX on app focus" = "앱이 포커스될 때 FLEX 열기"; -"Open FLEX on app launch" = "앱 실행 시 FLEX 열기"; -"Opens FLEX when the app is focused" = "앱이 활성화될 때 FLEX를 엽니다"; -"Opens FLEX when the app launches" = "앱을 실행할 때 FLEX를 엽니다"; -"Static Cell" = "정적 셀"; -"Stepper cell" = "스테퍼 셀"; -"Switch Cell" = "스위치 셀"; -"Switch Cell (Restart)" = "스위치 셀 (재시작)"; -"Tap the switch" = "스위치를 탭하세요"; -"Using icon" = "아이콘 사용 중"; -"Using image" = "이미지 사용 중"; -"_ Example" = "_ 예시"; - -////////////////////////////////////////////////////////////////////////////// -// DOWNLOADS & MEDIA ACTIONS // -////////////////////////////////////////////////////////////////////////////// - -"%@ settings" = "%@ 설정"; -"Cancelled" = "취소됨"; -"Copied %lu URLs" = "%lu개의 URL 복사됨"; -"Copied caption" = "캡션 복사됨"; -"Copied download URL" = "다운로드 URL 복사됨"; -"Copy all URLs" = "모든 URL 복사"; -"Copy caption" = "캡션 복사"; -"Copy download URL" = "다운로드 URL 복사"; -"Could not extract any URLs" = "URL을 추출할 수 없습니다"; -"Could not extract media URL" = "미디어 URL을 추출할 수 없습니다"; -"Could not extract photo URL" = "사진 URL을 추출할 수 없습니다"; -"Could not extract video URL" = "동영상 URL을 추출할 수 없습니다"; -"Done" = "완료"; -"Download all (%lu)" = "모두 다운로드 (%lu)"; -"Download all stories and share?" = "모든 스토리를 다운로드하고 공유하시겠습니까?"; -"Download all to Photos" = "사진 앱에 모두 다운로드"; -"Download and share all" = "모두 다운로드 및 공유"; -"Download and share?" = "다운로드하고 공유하시겠습니까?"; -"Download failed" = "다운로드 실패"; -"Downloaded %lu items" = "%lu개의 항목 다운로드됨"; -"Downloading %@..." = "%@ 다운로드 중..."; -"Downloading..." = "다운로드 중..."; -"Failed to save" = "저장 실패"; -"HD download complete" = "HD 다운로드 완료"; -"Mute audio" = "오디오 음소거"; -"No URLs" = "URL 없음"; -"No URLs found" = "URL을 찾을 수 없습니다"; -"No caption on this post" = "이 게시물에 캡션이 없습니다"; -"No carousel children" = "캐러셀 항목이 없습니다"; -"No cover image" = "커버 이미지가 없습니다"; -"No files downloaded" = "다운로드된 파일이 없습니다"; -"No media" = "미디어 없음"; -"No media URL" = "미디어 URL 없음"; -"No media to expand" = "확장할 미디어가 없습니다"; -"No media to show" = "표시할 미디어가 없습니다"; -"No video URL" = "동영상 URL 없음"; -"Not a carousel" = "캐러셀이 아닙니다"; -"Nothing to save" = "저장할 내용 없음"; -"Nothing to share" = "공유할 내용 없음"; -"Opening creator..." = "크리에이터 여는 중..."; -"Photo library access denied" = "사진 보관함 접근 거부됨"; -"Photos access denied" = "사진 앱 접근 거부됨"; -"Preparing repost..." = "리포스트 준비 중..."; -"Repost" = "리포스트"; -"Repost unavailable" = "리포스트를 사용할 수 없습니다"; -"Save all stories to Photos?" = "모든 스토리를 사진 앱에 저장하시겠습니까?"; -"Save failed" = "저장 실패"; -"Save to Photos?" = "사진 앱에 저장하시겠습니까?"; -"Saved %lu items" = "%lu개의 항목 저장됨"; -"Saved to Photos" = "사진 앱에 저장됨"; -"Saved to RyukGram" = "RyukGram에 저장됨"; -"Tap to cancel" = "탭하여 취소"; -"Unmute audio" = "오디오 음소거 해제"; -"View cover" = "커버 보기"; -"View mentions" = "멘션 보기"; - -////////////////////////////////////////////////////////////////////////////// -// STORIES & MESSAGES (FEATURES) // -////////////////////////////////////////////////////////////////////////////// - -"A message was unsent" = "메시지 전송이 취소되었습니다"; -"Add" = "추가"; -"Add to block list" = "차단 목록에 추가"; -"Add to block list?" = "차단 목록에 추가하시겠습니까?"; -"Added to block list" = "차단 목록에 추가됨"; -"Audio not loaded yet. Play the message first and try again." = "오디오가 아직 로드되지 않았습니다. 먼저 메시지를 재생한 다음 다시 시도하세요."; -"Audio sent" = "오디오 전송됨"; -"Audio/Video from Files" = "파일 앱의 오디오/비디오"; -"Blocked" = "차단됨"; -"Cancel" = "취소"; -"Clear preserved messages?" = "보존된 메시지를 지우시겠습니까?"; -"Converting..." = "변환 중..."; -"Copy text" = "텍스트 복사"; -"Could not find media" = "미디어를 찾을 수 없습니다"; -"Could not find story media" = "스토리 미디어를 찾을 수 없습니다"; -"Could not get audio data. Try again after refreshing the chat." = "오디오 데이터를 가져올 수 없습니다. 채팅을 새로고침한 후 다시 시도하세요."; -"Could not get video URL" = "동영상 URL을 가져올 수 없습니다"; -"Disable read receipts" = "읽음 표시 비활성화"; -"Done!" = "완료!"; -"Download audio" = "오디오 다운로드"; -"Downloading audio..." = "오디오 다운로드 중..."; -"Enable read receipts" = "읽음 표시 활성화"; -"Error: %@" = "오류: %@"; -"Exclude chat" = "채팅 제외"; -"Exclude story seen" = "스토리 읽음 제외"; -"Excluded" = "제외됨"; -"Extracting audio..." = "오디오 추출 중..."; -"Failed to encode GIF" = "GIF 인코딩 실패"; -"File sending not supported" = "파일 전송은 지원되지 않습니다"; -"Follow" = "팔로우"; -"Following" = "팔로잉"; -"Mark messages as seen" = "메시지를 읽음으로 표시"; -"Mark seen" = "읽음 표시"; -"Marked as seen" = "읽음으로 표시됨"; -"Marked as viewed" = "조회한 것으로 표시됨"; -"Marked messages as seen" = "메시지를 읽음으로 표시함"; -"Mentions" = "멘션"; -"Message sender not found" = "메시지 보낸 사람을 찾을 수 없습니다"; -"Messages settings" = "메시지 설정"; -"Mute story audio" = "스토리 오디오 음소거"; -"No audio URL found. Try again after refreshing the chat." = "오디오 URL을 찾을 수 없습니다. 채팅을 새로고침한 후 다시 시도하세요."; -"No mentions in this story" = "이 스토리에 멘션이 없습니다"; -"No thread key" = "스레드 키가 없습니다"; -"No voice send method found" = "음성 전송 방법을 찾을 수 없습니다"; -"Note not found" = "노트를 찾을 수 없습니다"; -"Note text copied" = "노트 텍스트 복사됨"; -"Open GitHub" = "GitHub 열기"; -"Read receipts disabled" = "읽음 표시가 비활성화되었습니다"; -"Read receipts enabled" = "읽음 표시가 활성화되었습니다"; -"Read receipts will be blocked for this chat." = "이 채팅에 대해 읽음 표시가 차단됩니다."; -"Read receipts will no longer be blocked for this chat." = "이 채팅에 대해 더 이상 읽음 표시가 차단되지 않습니다."; -"Remove" = "제거"; -"Remove from block list" = "차단 목록에서 제거"; -"Remove from block list?" = "차단 목록에서 제거하시겠습니까?"; -"Removed" = "제거됨"; -"Save GIF" = "GIF 저장"; -"Selection too short (min 0.5s)" = "선택 영역이 너무 짧습니다 (최소 0.5초)"; -"Send Audio" = "오디오 보내기"; -"Send anyway" = "그래도 보내기"; -"Send failed: %@" = "전송 실패: %@"; -"Send service not found" = "전송 서비스를 찾을 수 없습니다"; -"Share" = "공유"; -"Story read receipts disabled" = "스토리 읽음 표시가 비활성화되었습니다"; -"Story read receipts enabled" = "스토리 읽음 표시가 활성화되었습니다"; -"Story seen receipts will be blocked for @%@." = "@%@님에 대한 스토리 읽음 표시가 차단됩니다."; -"This chat will resume normal read-receipt behavior." = "이 채팅은 정상적인 읽음 표시 동작을 재개합니다."; -"Total: %@" = "총: %@"; -"Un-exclude" = "제외 취소"; -"Un-exclude chat" = "채팅 제외 취소"; -"Un-exclude chat?" = "채팅 제외를 취소하시겠습니까?"; -"Un-exclude story seen" = "스토리 읽음 제외 취소"; -"Un-exclude story seen?" = "스토리 읽음 제외를 취소하시겠습니까?"; -"Un-excluded" = "제외 취소됨"; -"Unblock" = "차단 해제"; -"Unblocked" = "차단 해제됨"; -"Unlimited replay enabled" = "무제한 다시 보기 활성화됨"; -"Unmute story audio" = "스토리 오디오 음소거 해제"; -"Unsent" = "전송 취소됨"; -"Upload Audio" = "오디오 업로드"; -"VC not found" = "VC를 찾을 수 없습니다"; -"Video from Library" = "라이브러리에서 비디오 선택"; -"Visual messages will expire" = "시각적 메시지가 만료됩니다"; -"Visual messages: expiring" = "시각적 메시지: 만료 예정"; -"Visual messages: unlimited replay" = "시각적 메시지: 무제한 다시 보기"; -"Will sync when leaving stories" = "스토리를 나갈 때 동기화됩니다"; - -////////////////////////////////////////////////////////////////////////////// -// GENERAL FEATURES // -////////////////////////////////////////////////////////////////////////////// - -"Add location" = "위치 추가"; -"Add preset" = "프리셋 추가"; -"Change location" = "위치 변경"; -"Click the Apply button after this to see the emoji" = "이모티콘을 보려면 이후에 적용 버튼을 클릭하세요"; -"Copied text to clipboard" = "클립보드에 텍스트 복사됨"; -"Copy" = "복사"; -"Copy all" = "모두 복사"; -"Copy bio" = "소개 복사"; -"Copy from profile" = "프로필에서 복사"; -"Copy name" = "이름 복사"; -"Could not find cover image" = "커버 이미지를 찾을 수 없습니다"; -"Current: %@" = "현재: %@"; -"Disable" = "비활성화"; -"Download GIF" = "GIF 다운로드"; -"Enable" = "활성화"; -"Enter Emoji Text" = "이모티콘 텍스트 입력"; -"Fake location" = "가짜 위치"; -"Name" = "이름"; -"Nothing to copy" = "복사할 내용 없음"; -"Save" = "저장"; -"Save preset" = "프리셋 저장"; -"Saved locations" = "저장된 위치"; -"Select color" = "색상 선택"; -"Set location" = "위치 설정"; -"Settings…" = "설정…"; -"Type emoji..." = "이모티콘 입력..."; - -////////////////////////////////////////////////////////////////////////////// -// SETTINGS VIEWS & DIALOGS // -////////////////////////////////////////////////////////////////////////////// - -"Add chat" = "채팅 추가"; -"Add custom domain" = "사용자 지정 도메인 추가"; -"Add to list?" = "목록에 추가하시겠습니까?"; -"Add user" = "사용자 추가"; -"Could not resolve user ID" = "사용자 ID를 확인할 수 없습니다"; -"Enter username" = "사용자 이름 입력"; -"Enter username of the DM thread" = "DM 스레드의 사용자 이름을 입력하세요"; -"No DM thread found with @%@" = "@%@의 DM 스레드를 찾을 수 없습니다"; -"User '%@' not found" = "'%@' 사용자를 찾을 수 없습니다"; -"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." = "모든 RyukGram 설정이 기본값으로 재설정되고 가져온 값이 적용됩니다. 일부 변경 사항을 적용하려면 앱을 다시 시작해야 합니다."; -"Apply" = "적용"; -"Apply imported settings?" = "가져온 설정을 적용하시겠습니까?"; -"Apply to" = "다음에 적용"; -"Chats" = "채팅"; -"Could not read file." = "파일을 읽을 수 없습니다."; -"Could not write temporary file." = "임시 파일을 쓸 수 없습니다."; -"Current location" = "현재 위치"; -"Custom" = "사용자 지정"; -"Date Format" = "날짜 형식"; -"Delete" = "삭제"; -"Done editing" = "편집 완료"; -"Edit values" = "값 편집"; -"Enable fake location" = "가짜 위치 활성화"; -"Every RyukGram preference will revert to its built-in default. This can't be undone." = "모든 RyukGram 환경설정이 내장된 기본값으로 되돌아갑니다. 이 작업은 취소할 수 없습니다."; -"Excluded chats" = "제외된 채팅"; -"Excluded users" = "제외된 사용자"; -"File is not a valid RyukGram settings export." = "파일이 올바른 RyukGram 설정 내보내기 형식이 아닙니다."; -"Follow default" = "기본값 따르기"; -"Force OFF (allow unsends)" = "강제 끄기 (전송 취소 허용)"; -"Force ON (preserve unsends)" = "강제 켜기 (전송 취소 보존)"; -"Form view" = "양식 보기"; -"Format" = "형식"; -"Import failed" = "가져오기 실패"; -"Import preview" = "가져오기 미리보기"; -"Included chats" = "포함된 채팅"; -"Included users" = "포함된 사용자"; -"KD: ON" = "KD: 켜짐"; -"KD: default" = "KD: 기본값"; -"Keep-deleted" = "삭제 메시지 보관"; -"Keep-deleted override" = "삭제 메시지 보관 재정의"; -"Off" = "끄기"; -"On" = "켜기"; -"Presets" = "프리셋"; -"Raw JSON view" = "원시 JSON 보기"; -"Remove Selected" = "선택 항목 제거"; -"Remove from list" = "목록에서 제거"; -"Reset" = "재설정"; -"Reset all settings?" = "모든 설정을 재설정하시겠습니까?"; -"Saved presets are reusable. Tap a preset to make it the active location." = "저장된 프리셋은 재사용할 수 있습니다. 프리셋을 탭하여 활성 위치로 만드세요."; -"Search address or place" = "주소 또는 장소 검색"; -"Search by name or username" = "이름 또는 사용자 이름으로 검색"; -"Search by username or name" = "사용자 이름 또는 이름으로 검색"; -"Search settings" = "설정 검색"; -"Select" = "선택"; -"Select location on map" = "지도에서 위치 선택"; -"Set current location" = "현재 위치 설정"; -"Set keep-deleted override" = "삭제 메시지 보관 재정의 설정"; -"Settings exported" = "설정 내보내기 완료"; -"Settings imported" = "설정 가져오기 완료"; -"Show seconds" = "초 표시"; -"Sort by" = "정렬 기준"; -"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." = "IG가 사용하는 각 NSDate 포맷터를 전환합니다. 각 영역(피드, 댓글, 스토리, DM)은 다른 방법을 거칩니다 — 사용자 지정 형식을 적용할 영역을 활성화하세요."; -"Use this location" = "이 위치 사용"; -"When on, all CoreLocation requests inside Instagram return the location below." = "켜져 있으면 Instagram 내의 모든 CoreLocation 요청이 아래 위치를 반환합니다."; -"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." = "켜져 있으면 Instagram 내의 모든 CoreLocation 요청이 아래 위치를 반환합니다. 지도 버튼을 토글하여 친구 지도 보기에 빠른 토글을 표시하거나 숨길 수 있습니다."; -"Show map button" = "지도 버튼 표시"; - -////////////////////////////////////////////////////////////////////////////// -// REELS (FEATURES) // -////////////////////////////////////////////////////////////////////////////// - -"Copied!" = "복사되었습니다!"; -"No password found" = "비밀번호를 찾을 수 없습니다"; -"No text field found" = "텍스트 필드를 찾을 수 없습니다"; -"Password" = "비밀번호"; -"Refresh Reels?" = "릴스를 새로고침하시겠습니까?"; - -////////////////////////////////////////////////////////////////////////////// -// PROFILE (FEATURES) // -////////////////////////////////////////////////////////////////////////////// - -"Doesn't follow you" = "회원님을 팔로우하지 않음"; -"Follows you" = "회원님을 팔로우함"; -"Note copied" = "노트 복사됨"; - -////////////////////////////////////////////////////////////////////////////// -// CONFIRM DIALOGS (IN-FEATURE) // -////////////////////////////////////////////////////////////////////////////// - -"Unfollow?" = "언팔로우하시겠습니까?"; - -////////////////////////////////////////////////////////////////////////////// -// MISC // -////////////////////////////////////////////////////////////////////////////// - -"720p • progressive • fastest" = "720p • 프로그레시브 • 가장 빠름"; -"Are you sure?" = "확실합니까?"; -"Copy audio URL" = "오디오 URL 복사"; -"Copy quality info" = "품질 정보 복사"; -"Copy video URL" = "동영상 URL 복사"; -"Could not access reel media" = "릴스 미디어에 접근할 수 없습니다"; -"Could not access reel photo" = "릴스 사진에 접근할 수 없습니다"; -"Could not extract photo url from post" = "게시물에서 사진 URL을 추출할 수 없습니다"; -"Could not extract photo url from reel" = "릴스에서 사진 URL을 추출할 수 없습니다"; -"Could not extract photo url from story" = "스토리에서 사진 URL을 추출할 수 없습니다"; -"Could not extract video url from post" = "게시물에서 동영상 URL을 추출할 수 없습니다"; -"Could not extract video url from reel" = "릴스에서 동영상 URL을 추출할 수 없습니다"; -"Could not extract video url from story" = "스토리에서 동영상 URL을 추출할 수 없습니다"; -"Download Quality" = "다운로드 품질"; -"FFmpegKit Debug" = "FFmpegKit 디버그"; -"Later" = "나중에"; -"No!" = "아니요!"; -"Restart" = "재시작"; -"Restart required" = "재시작 필요"; -"Yes" = "예"; -"You must restart the app to apply this change" = "이 변경 사항을 적용하려면 앱을 다시 시작해야 합니다"; - -////////////////////////////////////////////////////////////////////////////// -// ABOUT / CREDITS // -////////////////////////////////////////////////////////////////////////////// - -"%@ — view source, report issues, see releases" = "%@ — 소스 보기, 문제 신고, 릴리스 보기"; -"Credits" = "크레딧"; -"Developer" = "개발자"; -"Donate to SoCuul" = "SoCuul에게 후원하기"; -"Original SCInsta developer" = "오리지널 SCInsta 개발자"; -"Ryuk" = "Ryuk"; -"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "RyukGram %@\n\nInstagram v%@\n\nSoCuul의 SCInsta 기반"; -"RyukGram on GitHub" = "GitHub의 RyukGram"; -"SoCuul" = "SoCuul"; -"Support the original developer" = "원작자 후원하기"; -"View Repo" = "저장소 보기"; -"View the source code on GitHub" = "GitHub에서 소스 코드 보기"; - -////////////////////////////////////////////////////////////////////////////// -// HD DOWNLOADS // -////////////////////////////////////////////////////////////////////////////// - -"Download video at the highest available quality" = "가능한 가장 높은 품질로 동영상 다운로드"; -"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "DASH 스트림을 통해 HD 동영상을 다운로드하고 H.264로 인코딩합니다. FFmpegKit가 필요합니다."; -"Encoding speed" = "인코딩 속도"; -"Enhanced downloads" = "향상된 다운로드"; -"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit을 사용할 수 없습니다. 사이드로딩된 IPA 또는 _ffmpeg .deb 버전을 설치하여 활성화하세요."; -"Faster = lower quality" = "빠름 = 낮은 품질"; -"Photo quality" = "사진 품질"; -"Use highest resolution available" = "사용 가능한 최고 해상도 사용"; -"Video quality" = "비디오 품질"; -"Which quality to download" = "어떤 품질로 다운로드할지 선택하세요"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL / DEBUG // -////////////////////////////////////////////////////////////////////////////// - -"Navigation Cell" = "내비게이션 셀"; -"Localization" = "지역화 (번역)"; -"Update localization file" = "지역화 파일 업데이트"; -"Import a .strings file for a language" = "언어에 대한 .strings 파일 가져오기"; -"Import a .strings file to update a translation. Pick a language, select the file, restart." = ".strings 파일을 가져와 번역을 업데이트합니다. 언어를 선택하고 파일을 선택한 후 다시 시작하세요."; -"Export English strings" = "영어 문자열 내보내기"; -"Share the base English .strings file for translating" = "번역을 위해 기본 영어 .strings 파일 공유"; \ No newline at end of file +/* + * 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" = "향후: 프로필 페이지 우측 상단의 3선 메뉴를 길게 눌러 RyukGram 설정을 다시 열 수 있습니다."; +"settings.firstrun.ok" = "이해했습니다!"; +"settings.firstrun.title" = "RyukGram 설정 정보"; +"settings.language.english_only" = "RyukGram은 현재 영어만 기본 제공합니다. 다른 언어는 번역을 기다리고 있습니다 — README의 짧은 가이드를 따라 한국어 번역에 참여해 보세요."; +"settings.language.help_translate" = "번역 돕기"; +"settings.language.ok" = "확인"; +"settings.language.system" = "시스템 기본값"; +"settings.language.title" = "언어"; +"settings.results.many" = "%lu개의 결과"; +"settings.results.none" = "결과 없음"; +"settings.results.one" = "%lu개의 결과"; +"settings.search.placeholder" = "설정 검색"; +"settings.title" = "RyukGram 설정"; + +////////////////////////////////////////////////////////////////////////////// +// GENERAL // +// Settings → General tab // +////////////////////////////////////////////////////////////////////////////// + +"Adds a copy option to the comment long-press menu" = "댓글을 길게 누를 때 복사 옵션을 추가합니다"; +"Adds a download option for GIF comments" = "GIF 댓글 다운로드 옵션을 추가합니다"; +"Anonymous live viewing" = "익명 라이브 시청"; +"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "시청자 수 하트비트를 차단하여 방송자가 당신을 보지 못하게 합니다 — 당신도 시청자 수를 볼 수 없습니다"; +"Browser" = "브라우저"; +"Comments" = "댓글"; +"Copy comment text" = "댓글 텍스트 복사"; +"Copy description" = "설명 복사"; +"Copy description text fields by long-pressing on them" = "설명 텍스트 필드를 길게 눌러 복사합니다"; +"Date format" = "날짜 형식"; +"Disable app haptics" = "앱 햅틱 비활성화"; +"Disables haptics/vibrations within the app" = "앱 내의 햅틱/진동을 비활성화합니다"; +"Do not save recent searches" = "최근 검색어 저장 안 함"; +"Download GIF comments" = "GIF 댓글 다운로드"; +"Embed domain" = "임베드 도메인"; +"Embed domain: %@" = "임베드 도메인: %@"; +"Enable liquid glass buttons" = "리퀴드 글래스 버튼 활성화"; +"Enable liquid glass surfaces" = "리퀴드 글래스 표면 활성화"; +"Enable teen app icons" = "틴(Teen) 앱 아이콘 활성화"; +"Enables experimental liquid glass buttons" = "실험적인 리퀴드 글래스 버튼을 활성화합니다"; +"Enables liquid glass tab bar, floating navigation, and other UI elements" = "리퀴드 글래스 탭 바, 플로팅 내비게이션 및 기타 UI 요소를 활성화합니다"; +"Experimental features" = "실험실 기능"; +"Focus/distractions" = "집중/방해 요소"; +"General" = "일반"; +"Hide ads" = "광고 숨기기"; +"Hide explore posts grid" = "탐색 탭 게시물 그리드 숨기기"; +"Hide friends map" = "친구 지도 숨기기"; +"Hide Meta AI" = "Meta AI 숨기기"; +"Hide metrics" = "수치 숨기기"; +"Hide notes tray" = "노트 트레이 숨기기"; +"Hide trending searches" = "인기 검색어 숨기기"; +"Hide UI on capture" = "캡처 시 UI 숨기기"; +"Hides all suggested users for you to follow, outside your feed" = "피드 외부에서 팔로우할 추천 사용자를 모두 숨깁니다"; +"Hides like/comment/share counts on posts and reels" = "게시물 및 릴스의 좋아요/댓글/공유 횟수를 숨깁니다"; +"Hides the friends map icon in the notes tray" = "노트 트레이에서 친구 지도 아이콘을 숨깁니다"; +"Hides the grid of suggested posts on the explore/search tab" = "탐색/검색 탭에서 추천 게시물 그리드를 숨깁니다"; +"Hides the meta ai buttons/functionality within the app" = "앱 내의 Meta AI 버튼/기능을 숨깁니다"; +"Hides the notes tray in the DM inbox" = "DM 받은 편지함에서 노트 트레이를 숨깁니다"; +"Hides the suggested broadcast channels in direct messages" = "다이렉트 메시지에서 추천 공지 채널을 숨깁니다"; +"Hides the trending searches under the explore search bar" = "탐색 검색창 아래의 인기 검색어를 숨깁니다"; +"Hold down on the Instagram logo to change the app icon" = "Instagram 로고를 길게 눌러 앱 아이콘을 변경합니다"; +"Live" = "라이브"; +"Long press on the eyedropper tool in stories to customize the text color more precisely" = "스토리의 스포이트 도구를 길게 눌러 텍스트 색상을 더 정밀하게 사용자 지정합니다"; +"Long-press the heart button in a live to hide or show the comments" = "라이브 중 하트 버튼을 길게 눌러 댓글을 숨기거나 표시"; +"Long-press the search tab to open a copied Instagram link" = "검색 탭을 길게 눌러 복사한 Instagram 링크 열기"; +"No suggested chats" = "추천 채팅 없음"; +"No suggested users" = "추천 사용자 없음"; +"Notes" = "노트"; +"Open app icon picker" = "앱 아이콘 선택기 열기"; +"Open link from clipboard" = "클립보드에서 링크 열기"; +"Open links in external browser" = "외부 브라우저에서 링크 열기"; +"Opens links in Safari instead of Instagram's in-app browser" = "Instagram 인앱 브라우저 대신 Safari에서 링크를 엽니다"; +"Privacy" = "개인정보"; +"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "스크린샷, 화면 녹화, 미러링에서 RyukGram 버튼을 숨깁니다"; +"Removes all ads from the Instagram app" = "Instagram 앱에서 모든 광고를 제거합니다"; +"Removes igsh, utm_source, and other tracking parameters from shared links" = "공유 링크에서 igsh, utm_source 및 기타 추적 매개변수를 제거합니다"; +"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "URL에서 Instagram 추적 래퍼(l.instagram.com) 및 UTM/fbclid 매개변수를 제거합니다"; +"Replace domain in shared links" = "공유 링크의 도메인 교체"; +"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "IG의 상대적 타임스탬프(\"3일 전\")를 사용자 지정 형식으로 바꿉니다. 선택기 내에서 적용할 영역을 전환하세요."; +"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "복사/공유된 링크를 재작성하여 Discord, Telegram 등에서 미리보기가 잘 되는 도메인을 사용합니다."; +"Search bars will no longer save your recent searches" = "검색창에 최근 검색어가 더 이상 저장되지 않습니다"; +"Sharing" = "공유"; +"Strip tracking from links" = "링크에서 추적 제거"; +"Strip tracking params" = "추적 매개변수 제거"; +"Toggle live comments" = "라이브 댓글 전환"; +"Use detailed color picker" = "상세 색상 선택기 사용"; + +////////////////////////////////////////////////////////////////////////////// +// DATE FORMAT // +// Settings → Date format tab // +////////////////////////////////////////////////////////////////////////////// + +"Alternate" = "대체"; +"Always ask" = "항상 묻기"; +"Balanced" = "균형"; +"Block all" = "모두 차단"; +"Block selected" = "선택 차단"; +"Button" = "버튼"; +"Classic" = "클래식"; +"Date format — %@" = "날짜 형식 — %@"; +"Default" = "기본값"; +"Disabled" = "비활성화됨"; +"Download and share" = "다운로드 및 공유"; +"Download to Photos" = "사진 앱에 다운로드"; +"Enabled" = "활성화됨"; +"Expand" = "확장"; +"Explore" = "탐색"; +"Fast" = "빠름"; +"Feed" = "피드"; +"High" = "높음"; +"Inbox" = "받은 편지함"; +"Low" = "낮음"; +"Max" = "최대"; +"Medium" = "보통"; +"Mute/Unmute" = "음소거/해제"; +"Open menu" = "메뉴 열기"; +"Pause/Play" = "일시정지/재생"; +"Profile" = "프로필"; +"Quality" = "품질"; +"Reels" = "릴스"; +"Requires restart" = "재시작 필요"; +"Save to Photos" = "사진 앱에 저장"; +"Share sheet" = "공유 시트"; +"Standard" = "표준"; +"Toggle" = "토글"; + +////////////////////////////////////////////////////////////////////////////// +// FEED // +// Settings → Feed tab // +////////////////////////////////////////////////////////////////////////////// + +"Action button" = "작업 버튼"; +"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." = "각 피드 게시물 아래에 다운로드/공유/복사/확장/리포스트 항목이 있는 RyukGram 작업 버튼을 추가합니다. 탭하면 기본적으로 메뉴가 열립니다. 아래에서 탭 동작을 변경하세요."; +"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." = "피드 새로고침 시점과 방법을 제어합니다. 백그라운드 새로고침은 약 10분 후 앱으로 돌아올 때 발생합니다. 홈 버튼 새로고침은 이미 홈 탭에 있을 때 홈 탭을 탭하면 발생합니다."; +"Default tap action" = "기본 탭 작업"; +"Disable background refresh" = "백그라운드 새로고침 비활성화"; +"Disable home button refresh" = "홈 버튼 새로고침 비활성화"; +"Disable home button scroll" = "홈 버튼 스크롤 비활성화"; +"Disable video autoplay" = "동영상 자동 재생 비활성화"; +"Hide" = "숨기기"; +"Hide entire feed" = "전체 피드 숨기기"; +"Hide repost button" = "리포스트 버튼 숨기기"; +"Hide stories tray" = "스토리 트레이 숨기기"; +"Hide suggested stories" = "추천 스토리 숨기기"; +"Hides suggested accounts" = "추천 계정 숨기기"; +"Hides suggested reels" = "추천 릴스 숨기기"; +"Hides suggested threads posts" = "추천 스레드 게시물 숨기기"; +"Hides the repost button on feed posts" = "피드 게시물에서 리포스트 버튼을 숨깁니다"; +"Hides the story tray at the top" = "상단의 스토리 트레이를 숨깁니다"; +"Inserts a button row below like/comment/share on each post" = "각 게시물의 좋아요/댓글/공유 아래에 버튼 행을 삽입합니다"; +"Long press on media to expand in full-screen viewer" = "미디어를 길게 눌러 전체 화면 뷰어로 확대합니다"; +"Media" = "미디어"; +"Media zoom" = "미디어 확대"; +"No suggested for you" = "회원님을 위한 추천 없음"; +"No suggested posts" = "추천 게시물 없음"; +"No suggested reels" = "추천 릴스 없음"; +"No suggested threads" = "추천 스레드 없음"; +"Prevents feed from reloading when returning from background" = "백그라운드에서 돌아올 때 피드가 다시 로드되는 것을 방지합니다"; +"Prevents videos from playing automatically" = "동영상이 자동으로 재생되는 것을 방지합니다"; +"Refresh" = "새로고침"; +"Removes all content from your home feed" = "홈 피드에서 모든 콘텐츠를 제거합니다"; +"Removes suggested accounts from the stories tray" = "스토리 트레이에서 추천 계정을 제거합니다"; +"Removes suggested posts" = "추천 게시물을 제거합니다"; +"Scroll to top without refreshing when tapping Home" = "홈을 탭할 때 새로고침하지 않고 맨 위로 스크롤합니다"; +"Show action button" = "작업 버튼 표시"; +"Stories tray" = "스토리 트레이"; +"Tapping Home does nothing when already on feed" = "이미 피드에 있을 때 홈을 탭해도 아무 작업도 수행하지 않습니다"; +"Tray long-press actions" = "트레이 길게 누르기 작업"; +"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." = "릴스 사이드바 위에 커버 보기/다운로드/공유/복사/확장/리포스트 항목이 있는 RyukGram 작업 버튼을 추가합니다. 탭하면 기본적으로 메뉴가 열립니다. 아래에서 탭 동작을 변경하세요."; +"Always show progress scrubber" = "항상 재생 진행 바 표시"; +"Auto-scroll reels" = "릴스 자동 스크롤"; +"Change what happens when you tap on a reel" = "릴스를 탭할 때 발생하는 동작을 변경합니다"; +"Confirm reel refresh" = "릴스 새로고침 확인"; +"Disable auto-unmuting reels" = "릴스 자동 음소거 해제 비활성화"; +"Disable scrolling reels" = "릴스 스크롤 비활성화"; +"Disable tab button refresh" = "탭 버튼 새로고침 비활성화"; +"Doom scrolling limit" = "둠스크롤링 제한"; +"Forces the progress bar to appear on every reel" = "모든 릴스에 재생 진행 바를 강제로 표시합니다"; +"Hide reels header" = "릴스 헤더 숨기기"; +"Hides the repost button on the reels sidebar" = "릴스 사이드바에서 리포스트 버튼을 숨깁니다"; +"Hides the top navigation bar when watching reels" = "릴스를 시청할 때 상단 내비게이션 바를 숨깁니다"; +"Hiding" = "숨김"; +"IG default" = "IG 기본값"; +"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG 기본값: 기본 동작. RyukGram: 뒤로 스와이프한 후 다시 진행합니다."; +"Limits" = "제한"; +"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "한 번에 스크롤할 수 있는 릴스의 양을 제한하고 새로고침을 방지합니다"; +"Only loads %@ %@" = "%@ %@만 로드합니다"; +"Places a button above the like/comment/share column on each reel" = "각 릴스의 좋아요/댓글/공유 열 위에 버튼을 배치합니다"; +"Prevent doom scrolling" = "둠스크롤링 방지"; +"Prevents reels from being scrolled to the next video" = "릴스가 다음 동영상으로 스크롤되는 것을 방지합니다"; +"Prevents reels from unmuting when the volume/silent button is pressed" = "볼륨/무음 버튼을 누를 때 릴스의 음소거가 해제되는 것을 방지합니다"; +"RyukGram" = "RyukGram"; +"Shows an alert when you trigger a reels refresh" = "릴스 새로고침을 트리거할 때 경고를 표시합니다"; +"Shows buttons to reveal and auto-fill the password on locked reels" = "잠긴 릴스에서 비밀번호를 표시하고 자동 입력하는 버튼을 표시합니다"; +"Tap Controls" = "탭 제어"; +"Tap to mute on photo reels" = "사진 릴에서 탭으로 음소거"; +"Tapping the Reels tab while on reels does nothing" = "릴스 탭에 있을 때 릴스 탭을 탭해도 아무 작업도 수행하지 않습니다"; +"Unlock password-locked reels" = "비밀번호로 잠긴 릴스 잠금 해제"; +"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "일시정지 모드가 켜져 있을 때 사진 릴을 탭하면 기본 일시정지 대신 오디오가 전환됩니다"; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE // +// Settings → Profile tab // +////////////////////////////////////////////////////////////////////////////// + +"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" = "하이라이트를 길게 누를 때의 메뉴에 커버를 전체 화면으로 여는 보기 옵션을 추가합니다"; +"Copy note on long press" = "길게 눌러 노트 복사"; +"Fake follower count" = "가짜 팔로워 수"; +"Fake following count" = "가짜 팔로잉 수"; +"Fake post count" = "가짜 게시물 수"; +"Fake profile stats" = "가짜 프로필 통계"; +"Fake verified badge" = "가짜 인증 배지"; +"Follow indicator" = "팔로우 표시기"; +"Follower count" = "팔로워 수"; +"Following count" = "팔로잉 수"; +"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 to download directly (ignored when zoom is on)" = "길게 눌러 바로 다운로드 (확대가 켜져 있으면 무시됨)"; +"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "프로필 요소의 길게 누르기 제스처 — 기능별 작업 버튼과 별도로 유지됩니다."; +"Only affects your own profile header. Other users see the real numbers." = "본인 프로필 헤더에만 적용됩니다. 다른 사용자에게는 실제 숫자가 표시됩니다."; +"Post count" = "게시물 수"; +"Profile copy button" = "프로필 복사 버튼"; +"Save profile picture" = "프로필 사진 저장"; +"Show a checkmark next to your name on your own profile" = "본인 프로필 이름 옆에 체크 표시를 보여줍니다"; +"Shows whether the profile user follows you" = "프로필 사용자가 회원님을 팔로우하는지 표시합니다"; +"Tap to set" = "탭하여 설정"; +"View highlight cover" = "하이라이트 커버 보기"; +"Zoom profile photo" = "프로필 사진 확대"; + +////////////////////////////////////////////////////////////////////////////// +// SAVING & DOWNLOADS // +// Settings → Saving tab // +////////////////////////////////////////////////////////////////////////////// + +"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." = "더 이상 사용되지 않습니다. RyukGram 작업 버튼(피드/릴스/스토리에서 기능별로 구성됨)이 미디어를 다운로드하는 새로운 방법입니다. 미디어 위에서 여러 손가락으로 길게 누르는 기존 방식을 선호하는 경우에만 이 마스터 토글을 활성화하세요."; +"Downloads" = "다운로드"; +"Downloads with %@ %@" = "%@ %@(으)로 다운로드합니다"; +"Enable long-press gesture" = "길게 누르기 제스처 활성화"; +"Finger count for long-press" = "길게 누르기 손가락 개수"; +"Legacy long-press gesture" = "기존 길게 누르기 제스처 (레거시)"; +"Long-press hold time" = "길게 누르기 유지 시간"; +"Master toggle for the deprecated gesture workflow (off by default)" = "더 이상 사용되지 않는 제스처 워크플로를 위한 마스터 토글 (기본적으로 꺼져 있음)"; +"Press finger(s) for %@ %@" = "%@ %@ 동안 손가락을 누르세요"; +"Route saves into a dedicated album in Photos instead of the camera roll root" = "기본 카메라 롤 대신 사진 앱의 전용 앨범으로 저장 경로를 지정합니다"; +"Save action" = "저장 동작"; +"Save to RyukGram album" = "RyukGram 앨범에 저장"; +"Saving" = "저장 중"; +"Show a confirmation dialog before starting a download" = "다운로드를 시작하기 전에 확인 대화 상자를 표시합니다"; +"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." = "\"RyukGram 앨범에 저장\"이 켜져 있으면, 다운로드 및 공유 시트의 \"사진 앱에 저장\" 선택 항목이 사진 라이브러리의 전용 \"RyukGram\" 앨범으로 라우팅됩니다."; + +////////////////////////////////////////////////////////////////////////////// +// 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." = "스토리의 눈 모양 버튼 옆에 다운로드/공유/복사/확장/리포스트/멘션 보기 항목이 있는 RyukGram 작업 버튼을 추가합니다. 탭하면 기본적으로 메뉴가 열립니다. 아래에서 탭 동작을 변경하세요."; +"Adds a speaker button to the story overlay to unmute/mute audio. Also available in the 3-dot menu" = "스토리 오버레이에 오디오 음소거/해제를 위한 스피커 버튼을 추가합니다. 점 3개 메뉴에서도 사용할 수 있습니다."; +"Advance on story like" = "스토리 좋아요 시 다음으로 넘어가기"; +"Advance on story reply" = "스토리 답장 시 다음으로 넘어가기"; +"Advance when marking as seen" = "읽음으로 표시할 때 다음으로 넘어가기"; +"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." = "모두 차단: 모든 스토리가 차단됨 — 목록에 있는 사용자는 예외입니다.\n선택 차단: 목록에 있는 사용자만 차단됨 — 그 외에는 정상입니다.\n두 목록은 독립적으로 저장됩니다."; +"Blocking mode" = "차단 모드"; +"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" = "버튼 = 한 번 탭하여 읽음으로 표시. 토글 = 탭하여 스토리 읽음 표시를 켜고 끕니다 (켜져 있으면 눈이 파란색으로 채워짐)"; +"Disable instants creation" = "인스턴트 생성 비활성화"; +"Disable story seen receipt" = "스토리 읽음 표시 비활성화 (몰래 보기)"; +"Enable story user list" = "스토리 사용자 목록 활성화"; +"Hides the functionality to create/send instants" = "인스턴트를 생성/전송하는 기능을 숨깁니다"; +"Hides the notification for others when you view their story" = "회원님이 스토리를 조회할 때 다른 사람에게 알림이 가지 않도록 숨깁니다"; +"Inserts a button next to the seen/eye button on story overlays" = "스토리 오버레이의 읽음/눈 버튼 옆에 버튼을 삽입합니다"; +"Keep stories visually seen locally" = "스토리를 로컬에서 시각적으로 읽은 상태 유지"; +"Liking a story automatically advances to the next one after a short delay" = "스토리에 좋아요를 누르면 짧은 지연 시간 후 자동으로 다음 스토리로 넘어갑니다"; +"Manage list" = "목록 관리"; +"Manage list (%lu)" = "목록 관리 (%lu)"; +"Manual seen button mode" = "수동 읽음 버튼 모드"; +"Mark seen on story like" = "스토리 좋아요 시 읽음 표시"; +"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 when you send a reply or emoji reaction, even with seen blocking on" = "읽음 차단이 켜져 있어도 답장이나 이모티콘 반응을 보낼 때 스토리를 읽음으로 표시합니다"; +"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "서버에서는 여전히 읽음 표시를 차단하면서 로컬에서는 스토리를 읽음으로 표시(회색 링)합니다"; +"Master toggle. When off, the list is ignored" = "마스터 토글. 꺼져 있으면 목록이 무시됩니다"; +"Other" = "기타"; +"Playback" = "재생"; +"Quick list button in stories" = "스토리의 빠른 목록 버튼"; +"Search, sort, swipe to remove" = "검색, 정렬, 스와이프하여 제거"; +"Seen receipts" = "읽음 표시"; +"Sending a reply or emoji reaction automatically advances to the next story" = "답장이나 이모티콘 반응을 보내면 자동으로 다음 스토리로 넘어갑니다"; +"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" = "스토리에 눈 모양 버튼을 표시하여 목록에서 사용자를 추가/제거합니다. 끄기 = 점 3개 메뉴를 사용하거나 길게 누르기만 허용"; +"Stickers" = "스티커"; +"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "상호작용하기 전에 투표/퀴즈/슬라이더 결과를 미리 봅니다 — 평소처럼 탭해서 투표할 수 있습니다. '퀴즈 강제 활성화'는 제거된 레거시 퀴즈 스티커를 스토리 편집기 트레이에 복원합니다."; +"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "릴스에서 상호작용하기 전에 투표/퀴즈/슬라이더 결과를 미리 봅니다 — 평소처럼 탭해서 투표할 수 있습니다."; +"Force Quiz sticker in tray" = "트레이에 퀴즈 스티커 강제 표시"; +"Adds Quiz back to the story sticker picker" = "스토리 스티커 선택기에 퀴즈 복원"; +"Show quiz answer" = "퀴즈 정답 표시"; +"Circle the correct option on quiz stickers, or the leading option on polls" = "퀴즈 스티커의 정답 또는 투표의 최다 득표 옵션을 강조"; +"Show poll vote counts" = "투표 수 표시"; +"Show vote tallies on poll options and slider count/average before you vote" = "투표 전에 투표 옵션의 득표 수와 슬라이더의 평균/개수를 표시"; +"Stop story auto-advance" = "스토리 자동 넘어가기 중지"; +"Stories" = "스토리"; +"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "타이머가 끝나도 스토리가 자동으로 다음 스토리로 건너뛰지 않습니다. 수동으로 넘어가려면 탭하세요."; +"Story audio toggle" = "스토리 오디오 토글"; +"Story user list" = "스토리 사용자 목록"; +"Tapping the eye button to mark a story as seen advances to the next story automatically" = "눈 버튼을 탭하여 스토리를 읽음으로 표시하면 자동으로 다음 스토리로 넘어갑니다"; +"View story mentions" = "스토리 멘션 보기"; +"Which stories get seen-receipt blocking" = "읽음 표시 차단을 적용할 스토리"; + +////////////////////////////////////////////////////////////////////////////// +// MESSAGES — READ RECEIPTS // +// Settings → Read receipts tab // +////////////////////////////////////////////////////////////////////////////// + +"Adds a button to DM threads to mark messages as seen" = "DM 스레드에 메시지를 읽음으로 표시하는 버튼을 추가합니다"; +"Auto mark seen on interact" = "상호작용 시 자동 읽음 표시"; +"Auto mark seen on typing" = "입력 시 자동 읽음 표시"; +"Control when messages are marked as seen" = "메시지가 읽음으로 표시되는 시점 제어"; +"How the seen button behaves" = "읽음 버튼의 동작 방식"; +"Manually mark messages as seen" = "수동으로 메시지 읽음 표시"; +"Marks messages as seen when you send any message" = "메시지를 보낼 때 모든 메시지를 읽음으로 표시합니다"; +"Marks messages as seen when you start typing" = "입력을 시작할 때 메시지를 읽음으로 표시합니다"; +"Read receipt mode" = "읽음 표시 모드"; +"Read receipts" = "읽음 표시"; + +////////////////////////////////////////////////////////////////////////////// +// MESSAGES — KEEP DELETED // +// Settings → Keep deleted messages tab // +////////////////////////////////////////////////////////////////////////////// + +"Activity" = "활동"; +"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" = "음성 메시지를 길게 누를 때의 메뉴에 '다운로드' 옵션을 추가하여 M4A 오디오로 저장합니다"; +"Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram" = "DM의 더보기(+) 메뉴에 '파일 보내기' 옵션을 추가합니다. 지원되는 파일 형식은 Instagram에 의해 제한될 수 있습니다."; +"Adds an 'Audio File' option to the plus menu in DMs to send audio files as voice messages" = "DM의 더보기(+) 메뉴에 '오디오 파일' 옵션을 추가하여 오디오 파일을 음성 메시지로 보냅니다"; +"Adds copy text, download GIF/audio to the note long-press menu" = "노트를 길게 누를 때의 메뉴에 텍스트 복사, GIF/오디오 다운로드를 추가합니다"; +"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." = "모두 차단: 모든 채팅 차단됨 — 목록에 있는 채팅은 예외입니다.\n선택 차단: 목록에 있는 채팅만 차단됨 — 그 외에는 정상입니다.\n두 목록은 독립적으로 저장됩니다. 받은 편지함에서 채팅을 길게 눌러 추가하거나 제거하세요."; +"Block keep-deleted for excluded chats" = "제외된 채팅에 대해 삭제된 메시지 보관 차단"; +"Block keep-deleted for unlisted chats" = "목록에 없는 채팅에 대해 삭제된 메시지 보관 차단"; +"Chat list" = "채팅 목록"; +"Confirmation dialog before clearing preserved messages" = "보존된 메시지를 지우기 전 확인 대화 상자"; +"Copies note text directly on long press without opening the menu" = "메뉴를 열지 않고 길게 누르면 노트 텍스트를 바로 복사합니다"; +"Copy text on hold" = "길게 눌러 텍스트 복사"; +"Custom emojis and background/text colors" = "사용자 지정 이모티콘 및 배경/텍스트 색상"; +"Custom note themes" = "사용자 지정 노트 테마"; +"Disable vanish mode swipe" = "배니시 모드 스와이프 비활성화"; +"Disable screenshot detection" = "스크린샷 감지 비활성화"; +"Disable typing status" = "입력 중 상태 비활성화"; +"Disable view-once limitations" = "한 번 보기 제한 비활성화"; +"Download voice messages" = "음성 메시지 다운로드"; +"Enable chat list" = "채팅 목록 활성화"; +"Enable note theming" = "노트 테마 활성화"; +"Enables the notes theme picker" = "노트 테마 선택기를 활성화합니다"; +"Files" = "파일"; +"Full last active date" = "전체 마지막 활동 날짜"; +"Hide reels blend button" = "릴스 블렌드 버튼 숨기기"; +"Hide video call button" = "영상 통화 버튼 숨기기"; +"Hide voice call button" = "음성 통화 버튼 숨기기"; +"Hides the blend button in DMs" = "DM에서 블렌드 버튼을 숨깁니다"; +"Hides typing indicator from others" = "다른 사람에게 회원님의 입력 중 상태를 숨깁니다"; +"Indicate unsent messages" = "전송 취소된 메시지 표시"; +"Keep deleted messages" = "삭제된 메시지 보관"; +"Makes view-once messages behave like normal visual messages (loopable/pauseable)" = "한 번 보기 메시지를 일반 시각적 메시지처럼 작동하게 합니다(반복 재생/일시 정지 가능)"; +"Note actions" = "노트 작업"; +"Preserve messages that others unsend" = "다른 사람이 전송 취소한 메시지 보존"; +"Preserves messages that others unsend" = "다른 사람이 전송 취소한 메시지를 보존합니다"; +"Prevents accidental swipe-up activation of vanish mode" = "실수로 위로 스와이프하여 배니시 모드가 활성화되는 것을 방지합니다"; +"Quick list button in chats" = "채팅의 빠른 목록 버튼"; +"Removes the audio call button from DM thread header" = "DM 스레드 헤더에서 음성 통화 버튼을 제거합니다"; +"Removes the screenshot-prevention features for visual messages in DMs" = "DM에서 시각적 메시지에 대한 스크린샷 방지 기능을 제거합니다"; +"Removes the video call button from DM thread header" = "DM 스레드 헤더에서 영상 통화 버튼을 제거합니다"; +"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" = "검색, 정렬, 스와이프하여 제거 또는 삭제된 메시지 보관 토글"; +"Send audio as file" = "오디오를 파일로 보내기"; +"Send files (experimental)" = "파일 보내기 (실험실 기능)"; +"Show full date instead of \"Active 2h ago\"" = "\"2시간 전 활동\" 대신 전체 날짜 표시"; +"Shows a button in DM threads to add/remove chats from the list. Long-press for more options" = "DM 스레드에 목록에서 채팅을 추가/제거하는 버튼을 표시합니다. 더 많은 옵션을 보려면 길게 누르세요."; +"Shows a notification pill when a message is unsent" = "메시지가 전송 취소될 때 알림 필(pill)을 표시합니다"; +"Shows an \"Unsent\" label on preserved messages" = "보존된 메시지에 \"전송 취소됨\" 라벨을 표시합니다"; +"Unlimited replay of visual messages" = "시각적 메시지 무제한 다시 보기"; +"Unsent message notification" = "전송 취소된 메시지 알림"; +"Voice messages" = "음성 메시지"; +"Warn before clearing on refresh" = "새로고침 시 지우기 전에 경고"; +"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." = "⚠️ DM 탭에서 당겨서 새로고침하면 보존된 모든 메시지가 지워집니다. 확인 대화 상자를 표시하려면 아래 경고를 활성화하세요."; + +////////////////////////////////////////////////////////////////////////////// +// MESSAGES // +// Settings → Messages tab // +////////////////////////////////////////////////////////////////////////////// + +"Messages" = "메시지"; +"Threads" = "스레드"; + +////////////////////////////////////////////////////////////////////////////// +// NAVIGATION // +// Settings → Navigation tab // +////////////////////////////////////////////////////////////////////////////// + +"Also hide the bottom tab bar — only the inbox is visible" = "하단 탭 바도 숨기기 — 받은 편지함만 표시"; +"Hide create tab" = "만들기 탭 숨기기"; +"Hide explore tab" = "탐색 탭 숨기기"; +"Hide feed tab" = "피드 탭 숨기기"; +"Hide messages tab" = "메시지 탭 숨기기"; +"Hide reels tab" = "릴스 탭 숨기기"; +"Hide tab bar" = "탭 바 숨기기"; +"Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab." = "DM 받은 편지함 + 프로필을 제외한 모든 탭을 숨기고 강제로 받은 편지함에서 시작하게 합니다. 설정 바로가기는 받은 편지함 탭을 길게 누르는 것으로 이동됩니다."; +"Hides the create 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 feed/home tab on the bottom navigation bar" = "하단 내비게이션 바에서 피드/홈 탭을 숨깁니다"; +"Hides the reels tab on the bottom navigation bar" = "하단 내비게이션 바에서 릴스 탭을 숨깁니다"; +"Hiding tabs" = "탭 숨기기"; +"Icon order" = "아이콘 순서"; +"Launch tab" = "시작 탭"; +"Lets you swipe to switch between navigation bar tabs" = "스와이프하여 내비게이션 바 탭 간에 전환할 수 있습니다"; +"Messages only" = "메시지 전용"; +"Messages-only mode" = "메시지 전용 모드"; +"Navigation" = "내비게이션"; +"Swipe between tabs" = "탭 간 스와이프 전환"; +"Tab the app opens to. Ignored when Messages-only is on" = "앱이 열리는 탭입니다. 메시지 전용 모드가 켜져 있으면 무시됩니다."; +"The order of the icons on the bottom navigation bar" = "하단 내비게이션 바의 아이콘 순서입니다"; +"Turn IG into a DM-only client" = "IG를 DM 전용 클라이언트로 전환합니다"; + +////////////////////////////////////////////////////////////////////////////// +// CONFIRM ACTIONS // +// Settings → Confirm actions tab // +////////////////////////////////////////////////////////////////////////////// + +"Confirm actions" = "작업 확인"; +"Confirm video call" = "영상 통화 확인"; +"Confirm voice call" = "음성 통화 확인"; +"Confirm changing theme" = "테마 변경 확인"; +"Confirm follow" = "팔로우 확인"; +"Confirm follow requests" = "팔로우 요청 확인"; +"Confirm like: Posts" = "게시물 좋아요 확인"; +"Confirm like: Reels" = "릴스 좋아요 확인"; +"Confirm posting comment" = "댓글 게시 확인"; +"Confirm repost" = "리포스트 확인"; +"Confirm vanish mode" = "배니시 모드 확인"; +"Confirm sticker interaction (stories)" = "스티커 상호작용 확인 (스토리)"; +"Confirm sticker interaction (highlights)" = "스티커 상호작용 확인 (하이라이트)"; +"Confirm story emoji reaction" = "스토리 이모티콘 반응 확인"; +"Confirm story like" = "스토리 좋아요 확인"; +"Confirm unfollow" = "언팔로우 확인"; +"Confirm voice messages" = "음성 메시지 확인"; +"Shows an alert before sending an emoji reaction on a story" = "스토리에 이모티콘 반응을 보내기 전에 경고를 표시합니다"; +"Shows an alert to confirm before sending a voice message" = "음성 메시지를 보내기 전에 확인할 경고를 표시합니다"; +"Shows an alert to confirm before toggling vanish mode" = "배니시 모드를 전환하기 전에 확인 경고를 표시합니다"; +"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 tap a sticker on someone's story" = "누군가의 스토리에서 스티커를 누를 때 경고를 표시합니다"; +"Shows an alert when you tap a sticker inside a highlight" = "하이라이트 안의 스티커를 누를 때 경고를 표시합니다"; +"Shows an alert when you click the video call button to confirm before calling" = "영상 통화 버튼을 클릭할 때 통화 전에 확인할 경고를 표시합니다"; +"Shows an alert when you click the voice 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 like button on posts 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 stories to confirm the like" = "스토리에서 좋아요 버튼을 클릭할 때 좋아요를 확인할 경고를 표시합니다"; +"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 unfollow button to confirm" = "언팔로우 버튼을 클릭할 때 확인할 경고를 표시합니다"; + +////////////////////////////////////////////////////////////////////////////// +// BACKUP & RESTORE // +// Settings → Backup & Restore tab // +////////////////////////////////////////////////////////////////////////////// + +"Backup & Restore" = "백업 및 복원"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED // +// Settings → Advanced tab // +////////////////////////////////////////////////////////////////////////////// + +"Advanced" = "고급"; +"Auto-clear cache" = "캐시 자동 지우기"; +"Automatically opens settings when the app launches" = "앱을 실행할 때 자동으로 설정을 엽니다"; +"Cache" = "캐시"; +"Cache cleared" = "캐시 지워짐"; +"Calculating cache size…" = "캐시 크기 계산 중…"; +"Clear" = "지우기"; +"Clear cache" = "캐시 지우기"; +"Clear cache (%@)" = "캐시 지우기 (%@)"; +"Clear cache?" = "캐시를 지우시겠습니까?"; +"Clearing cache…" = "캐시 지우는 중…"; +"Clearing still scans on demand." = "지우기는 필요할 때 여전히 스캔합니다."; +"Daily" = "매일"; +"Disable safe mode" = "안전 모드 비활성화"; +"Enable tweak settings quick-access" = "트윅 설정 빠른 접근 활성화"; +"Free %@ of Instagram cache. A restart is recommended." = "Instagram 캐시에서 %@을(를) 확보합니다. 재시작을 권장합니다."; +"Freed %@. Restart to apply." = "%@ 확보됨. 적용하려면 재시작하세요."; +"Hold on the home tab to open RyukGram settings" = "홈 탭을 길게 눌러 RyukGram 설정을 엽니다"; +"Instagram" = "Instagram"; +"Monthly" = "매월"; +"Nothing to clear" = "지울 것이 없습니다"; +"Off skips the size scan when Advanced opens." = "꺼짐은 고급 설정을 열 때 크기 스캔을 건너뜁니다."; +"Pause playback when opening settings" = "설정을 열 때 재생 일시 정지"; +"Pauses any playing video/audio when settings opens" = "설정이 열릴 때 재생 중인 비디오/오디오를 일시 정지합니다"; +"Prevents Instagram from resetting settings after crashes (at your own risk)" = "충돌 후 Instagram이 설정을 재설정하는 것을 방지합니다 (본인 책임)"; +"Remove Instagram's cached images, videos, and temporary files." = "Instagram의 캐시된 이미지, 동영상, 임시 파일을 제거합니다."; +"Reset onboarding state" = "온보딩 상태 재설정"; +"Run a silent cache clear on launch when the interval has elapsed." = "간격이 지나면 실행 시 조용히 캐시를 지웁니다."; +"Show cache size" = "캐시 크기 표시"; +"Show tweak settings on app launch" = "앱 실행 시 트윅 설정 표시"; +"Weekly" = "매주"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED EXPERIMENTAL // +// Settings → Advanced → Advanced experimental features // +////////////////////////////////////////////////////////////////////////////// + +"Actions" = "동작"; +"Advanced experimental features" = "고급 실험 기능"; +"All experimental toggles will be turned off. Instagram will restart." = "모든 실험 기능이 꺼지고 Instagram이 다시 시작됩니다."; +"Direct Notes — Audio reply" = "Direct 노트 — 오디오 답장"; +"Direct Notes — Avatar reply" = "Direct 노트 — 아바타 답장"; +"Direct Notes — Friend Map" = "Direct 노트 — 친구 지도"; +"Direct Notes — GIFs & stickers reply" = "Direct 노트 — GIF 및 스티커 답장"; +"Direct Notes — Photo reply" = "Direct 노트 — 사진 답장"; +"Disabled after repeated crashes." = "반복된 충돌로 비활성화됨."; +"Enables GIF/sticker replies" = "GIF/스티커 답장을 활성화합니다"; +"Enables photo replies" = "사진 답장을 활성화합니다"; +"Enables the audio-note reply type" = "오디오 노트 답장 유형을 활성화합니다"; +"Enables the avatar reply type" = "아바타 답장 유형을 활성화합니다"; +"Experimental flags reset" = "실험 플래그가 재설정됨"; +"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "원하는 항목을 켠 다음 적용을 눌러 다시 시작하세요. 일부는 모든 계정이나 IG 버전에서 작동하지 않을 수 있습니다. IG가 실행 중 3번 충돌하면 자동으로 재설정됩니다."; +"Forces Prism-gated experiments on" = "Prism 기반 실험을 강제로 켭니다"; +"Forces the Homecoming home surface / nav on" = "Homecoming 홈 화면/내비게이션을 강제로 켭니다"; +"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "피드, 받은 편지함, 스토리, 노트 트레이에서 QuickSnap / Instants를 강제로 표시합니다"; +"Got it" = "확인"; +"Heads up" = "알림"; +"Hidden Instagram experiments" = "Instagram 숨겨진 실험"; +"Hidden Instagram experiments (in Advanced)" = "Instagram 숨겨진 실험 (고급 설정 안)"; +"Homecoming" = "Homecoming"; +"Notes & QuickSnap" = "노트와 QuickSnap"; +"Prism design system" = "Prism 디자인 시스템"; +"QuickSnap (Instants)" = "QuickSnap (Instants)"; +"Reset all experimental flags" = "모든 실험 플래그 재설정"; +"Reset experimental flags?" = "실험 플래그를 재설정할까요?"; +"Restart Instagram to apply changes" = "변경 사항을 적용하려면 Instagram을 다시 시작하세요"; +"Shows the friend map entry in Direct Notes" = "Direct 노트에 친구 지도 항목을 표시합니다"; +"Surfaces" = "화면"; +"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "이 토글은 숨겨진 Instagram 실험을 켭니다. 일부 기능은 모든 계정이나 IG 버전에서 작동하지 않을 수 있습니다. 실행 시 IG가 계속 충돌하면 3번 실패 후 자동으로 재설정됩니다."; +"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "숨겨진 Instagram 실험을 켜세요. 일부는 모든 계정이나 IG 버전에서 작동하지 않을 수 있습니다."; +"Turn every experimental toggle off" = "모든 실험 토글 끄기"; + +////////////////////////////////////////////////////////////////////////////// +// DEBUG // +// Settings → Debug tab // +////////////////////////////////////////////////////////////////////////////// + +"Button Cell" = "버튼 셀"; +"Change the value on the right" = "오른쪽의 값을 변경하세요"; +"Could not delete: %@" = "삭제할 수 없음: %@"; +"Debug" = "디버그"; +"Delete an imported override and fall back to the shipped strings" = "가져온 재정의를 삭제하고 기본 문자열로 되돌리기"; +"Deleted %@ override. Restart to apply." = "%@ 재정의를 삭제했습니다. 적용하려면 재시작하세요."; +"Enable FLEX gesture" = "FLEX 제스처 활성화"; +"Export English strings" = "영어 문자열 내보내기"; +"Hold 5 fingers on the screen to open FLEX" = "화면에 손가락 5개를 대고 있으면 FLEX가 열립니다"; +"I have %@%@" = "나는 %@%@를 가지고 있습니다"; +"Import a .strings file for a language" = "언어에 대한 .strings 파일 가져오기"; +"Import a .strings file to update a translation. Pick a language, select the file, restart." = ".strings 파일을 가져와 번역을 업데이트합니다. 언어를 선택하고 파일을 선택한 후 다시 시작하세요."; +"Link Cell" = "링크 셀"; +"Localization" = "지역화 (번역)"; +"Menu Cell" = "메뉴 셀"; +"Navigation Cell" = "내비게이션 셀"; +"No imported localization files to reset." = "재설정할 가져온 로컬라이제이션 파일이 없습니다."; +"No overrides" = "재정의 없음"; +"Open FLEX on app focus" = "앱이 포커스될 때 FLEX 열기"; +"Open FLEX on app launch" = "앱 실행 시 FLEX 열기"; +"Opens FLEX when the app is focused" = "앱이 활성화될 때 FLEX를 엽니다"; +"Opens FLEX when the app launches" = "앱을 실행할 때 FLEX를 엽니다"; +"Pick a language to delete the imported file" = "가져온 파일을 삭제할 언어 선택"; +"Reset localization" = "로컬라이제이션 재설정"; +"Share the base English .strings file for translating" = "번역을 위해 기본 영어 .strings 파일 공유"; +"Static Cell" = "정적 셀"; +"Stepper cell" = "스테퍼 셀"; +"Switch Cell" = "스위치 셀"; +"Switch Cell (Restart)" = "스위치 셀 (재시작)"; +"Tap the switch" = "스위치를 탭하세요"; +"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "이 기능은 숨겨진 Instagram 플래그에 의존하며 모든 계정이나 버전에서 작동하지 않을 수 있습니다."; +"Update localization file" = "지역화 파일 업데이트"; +"Using icon" = "아이콘 사용 중"; +"Using image" = "이미지 사용 중"; + +////////////////////////////////////////////////////////////////////////////// +// DOWNLOADS & MEDIA ACTIONS // +// Action button menus, download/share/copy toasts, quality picker pills. // +////////////////////////////////////////////////////////////////////////////// + +"%@ settings" = "%@ 설정"; +"Cancelled" = "취소됨"; +"Copied %lu URLs" = "%lu개의 URL 복사됨"; +"Copied caption" = "캡션 복사됨"; +"Copied download URL" = "다운로드 URL 복사됨"; +"Copy all URLs" = "모든 URL 복사"; +"Copy caption" = "캡션 복사"; +"Copy download URL" = "다운로드 URL 복사"; +"Could not extract any URLs" = "URL을 추출할 수 없습니다"; +"Could not extract media URL" = "미디어 URL을 추출할 수 없습니다"; +"Could not extract photo URL" = "사진 URL을 추출할 수 없습니다"; +"Could not extract video URL" = "동영상 URL을 추출할 수 없습니다"; +"Done" = "완료"; +"Download all (%lu)" = "모두 다운로드 (%lu)"; +"Download all stories and share?" = "모든 스토리를 다운로드하고 공유하시겠습니까?"; +"Download all to Photos" = "사진 앱에 모두 다운로드"; +"Download and share all" = "모두 다운로드 및 공유"; +"Download and share?" = "다운로드하고 공유하시겠습니까?"; +"Download failed" = "다운로드 실패"; +"Downloaded %lu items" = "%lu개의 항목 다운로드됨"; +"Downloading %@..." = "%@ 다운로드 중..."; +"Downloading..." = "다운로드 중..."; +"Failed to save" = "저장 실패"; +"HD download complete" = "HD 다운로드 완료"; +"Mute audio" = "오디오 음소거"; +"No caption on this post" = "이 게시물에 캡션이 없습니다"; +"No carousel children" = "캐러셀 항목이 없습니다"; +"No cover image" = "커버 이미지가 없습니다"; +"No files downloaded" = "다운로드된 파일이 없습니다"; +"No media" = "미디어 없음"; +"No media to expand" = "확장할 미디어가 없습니다"; +"No media to show" = "표시할 미디어가 없습니다"; +"No media URL" = "미디어 URL 없음"; +"No URLs" = "URL 없음"; +"No URLs found" = "URL을 찾을 수 없습니다"; +"No video URL" = "동영상 URL 없음"; +"Not a carousel" = "캐러셀이 아닙니다"; +"Nothing to save" = "저장할 내용 없음"; +"Nothing to share" = "공유할 내용 없음"; +"Opening creator..." = "크리에이터 여는 중..."; +"Photo library access denied" = "사진 보관함 접근 거부됨"; +"Photos access denied" = "사진 앱 접근 거부됨"; +"Preparing repost..." = "리포스트 준비 중..."; +"Repost" = "리포스트"; +"Repost unavailable" = "리포스트를 사용할 수 없습니다"; +"Save all stories to Photos?" = "모든 스토리를 사진 앱에 저장하시겠습니까?"; +"Save failed" = "저장 실패"; +"Save to Photos?" = "사진 앱에 저장하시겠습니까?"; +"Saved %lu items" = "%lu개의 항목 저장됨"; +"Saved to Photos" = "사진 앱에 저장됨"; +"Saved to RyukGram" = "RyukGram에 저장됨"; +"Tap to cancel" = "탭하여 취소"; +"Unmute audio" = "오디오 음소거 해제"; +"View cover" = "커버 보기"; +"View mentions" = "멘션 보기"; + +////////////////////////////////////////////////////////////////////////////// +// STORIES & MESSAGES (FEATURES) // +// Buttons, menu entries, toasts and alerts shown while watching stories or // +// inside DM threads. // +////////////////////////////////////////////////////////////////////////////// + +"A message was unsent" = "메시지 전송이 취소되었습니다"; +"Add" = "추가"; +"Add to block list" = "차단 목록에 추가"; +"Add to block list?" = "차단 목록에 추가하시겠습니까?"; +"Added to block list" = "차단 목록에 추가됨"; +"Added to exclude list" = "제외 목록에 추가됨"; +"Audio not loaded yet. Play the message first and try again." = "오디오가 아직 로드되지 않았습니다. 먼저 메시지를 재생한 다음 다시 시도하세요."; +"Audio sent" = "오디오 전송됨"; +"Audio/Video from Files" = "파일 앱의 오디오/비디오"; +"Blocked" = "차단됨"; +"Cancel" = "취소"; +"Clear preserved messages?" = "보존된 메시지를 지우시겠습니까?"; +"Converting..." = "변환 중..."; +"Copy text" = "텍스트 복사"; +"Could not find media" = "미디어를 찾을 수 없습니다"; +"Could not find story media" = "스토리 미디어를 찾을 수 없습니다"; +"Could not get audio data. Try again after refreshing the chat." = "오디오 데이터를 가져올 수 없습니다. 채팅을 새로고침한 후 다시 시도하세요."; +"Could not get video URL" = "동영상 URL을 가져올 수 없습니다"; +"Disable read receipts" = "읽음 표시 비활성화"; +"Disappearing media" = "사라지는 미디어"; +"Done!" = "완료!"; +"Download audio" = "오디오 다운로드"; +"Downloading audio..." = "오디오 다운로드 중..."; +"Enable read receipts" = "읽음 표시 활성화"; +"Error: %@" = "오류: %@"; +"Exclude chat" = "채팅 제외"; +"Exclude from seen" = "본 것에서 제외"; +"Exclude story seen" = "스토리 읽음 제외"; +"Excluded" = "제외됨"; +"Extracting audio..." = "오디오 추출 중..."; +"Failed to encode GIF" = "GIF 인코딩 실패"; +"File sending not supported" = "파일 전송은 지원되지 않습니다"; +"Follow" = "팔로우"; +"Following" = "팔로잉"; +"Inserts a button on disappearing media overlays" = "사라지는 미디어 오버레이에 버튼을 추가"; +"Inserts a speaker button to mute/unmute disappearing media" = "사라지는 미디어를 음소거/해제하는 스피커 버튼을 추가"; +"Inserts an eye button to mark the current disappearing media as viewed" = "현재 사라지는 미디어를 본 것으로 표시하는 눈 버튼을 추가"; +"Mark as viewed" = "본 것으로 표시"; +"Mark messages as seen" = "메시지를 읽음으로 표시"; +"Mark seen" = "읽음 표시"; +"Marked as seen" = "읽음으로 표시됨"; +"Marked as viewed" = "조회한 것으로 표시됨"; +"Marked messages as seen" = "메시지를 읽음으로 표시함"; +"Mentions" = "멘션"; +"Message sender not found" = "메시지 보낸 사람을 찾을 수 없습니다"; +"Messages settings" = "메시지 설정"; +"Audio URL not available" = "오디오 URL을 사용할 수 없음"; +"Mute story audio" = "스토리 오디오 음소거"; +"No audio URL found. Try again after refreshing the chat." = "오디오 URL을 찾을 수 없습니다. 채팅을 새로고침한 후 다시 시도하세요."; +"No mentions in this story" = "이 스토리에 멘션이 없습니다"; +"No thread key" = "스레드 키가 없습니다"; +"No voice send method found" = "음성 전송 방법을 찾을 수 없습니다"; +"Note not found" = "노트를 찾을 수 없습니다"; +"Note text copied" = "노트 텍스트 복사됨"; +"Open GitHub" = "GitHub 열기"; +"Read receipts disabled" = "읽음 표시가 비활성화되었습니다"; +"Read receipts enabled" = "읽음 표시가 활성화되었습니다"; +"Read receipts will be blocked for this chat." = "이 채팅에 대해 읽음 표시가 차단됩니다."; +"Read receipts will no longer be blocked for this chat." = "이 채팅에 대해 더 이상 읽음 표시가 차단되지 않습니다."; +"Remove" = "제거"; +"Remove from block list" = "차단 목록에서 제거"; +"Remove from block list?" = "차단 목록에서 제거하시겠습니까?"; +"Remove from exclude list" = "제외 목록에서 제거"; +"Removed" = "제거됨"; +"Removed from list" = "목록에서 제거됨"; +"Save GIF" = "GIF 저장"; +"Selection too short (min 0.5s)" = "선택 영역이 너무 짧습니다 (최소 0.5초)"; +"Send anyway" = "그래도 보내기"; +"Send Audio" = "오디오 보내기"; +"Send failed: %@" = "전송 실패: %@"; +"Send service not found" = "전송 서비스를 찾을 수 없습니다"; +"Show audio toggle" = "오디오 토글 표시"; +"Show mark-as-viewed button" = "보기 표시 버튼 표시"; +"Story read receipts disabled" = "스토리 읽음 표시가 비활성화되었습니다"; +"Story read receipts enabled" = "스토리 읽음 표시가 활성화되었습니다"; +"This chat will resume normal read-receipt behavior." = "이 채팅은 정상적인 읽음 표시 동작을 재개합니다."; +"Total: %@" = "총: %@"; +"Un-exclude chat" = "채팅 제외 취소"; +"Un-exclude chat?" = "채팅 제외를 취소하시겠습니까?"; +"Un-exclude story seen" = "스토리 읽음 제외 취소"; +"Un-excluded" = "제외 취소됨"; +"Unblocked" = "차단 해제됨"; +"Unlimited replay enabled" = "무제한 다시 보기 활성화됨"; +"Unmute story audio" = "스토리 오디오 음소거 해제"; +"Unsent" = "전송 취소됨"; +"Upload Audio" = "오디오 업로드"; +"VC not found" = "VC를 찾을 수 없습니다"; +"Video from Library" = "라이브러리에서 비디오 선택"; +"Visual messages will expire" = "시각적 메시지가 만료됩니다"; +"Visual messages: expiring" = "시각적 메시지: 만료 예정"; +"Visual messages: unlimited replay" = "시각적 메시지: 무제한 다시 보기"; +"Will sync when leaving stories" = "스토리를 나갈 때 동기화됩니다"; + +////////////////////////////////////////////////////////////////////////////// +// GENERAL FEATURES // +// Strings inside per-feature overlays: fake location, color picker, notes // +// customization, profile copy, etc. // +////////////////////////////////////////////////////////////////////////////// + +"Add location" = "위치 추가"; +"Add preset" = "프리셋 추가"; +"Change location" = "위치 변경"; +"Click the Apply button after this to see the emoji" = "이모티콘을 보려면 이후에 적용 버튼을 클릭하세요"; +"Clipboard is not an Instagram URL" = "클립보드가 Instagram URL이 아닙니다"; +"Comments hidden" = "댓글 숨김"; +"Comments shown" = "댓글 표시됨"; +"Copied text to clipboard" = "클립보드에 텍스트 복사됨"; +"Copy" = "복사"; +"Copy all" = "모두 복사"; +"Copy bio" = "소개 복사"; +"Copy from profile" = "프로필에서 복사"; +"Copy name" = "이름 복사"; +"Could not find cover image" = "커버 이미지를 찾을 수 없습니다"; +"Current: %@" = "현재: %@"; +"Disable" = "비활성화"; +"Download GIF" = "GIF 다운로드"; +"Dropped pin" = "놓은 핀"; +"Enable" = "활성화"; +"Enable Location Services for Instagram in Settings to use your current location." = "현재 위치를 사용하려면 설정에서 Instagram의 위치 서비스를 활성화하세요."; +"Enter Emoji Text" = "이모티콘 텍스트 입력"; +"Fake location" = "가짜 위치"; +"Location access denied" = "위치 접근이 거부됨"; +"Location Services off" = "위치 서비스 꺼짐"; +"Name" = "이름"; +"Nothing to copy" = "복사할 내용 없음"; +"Open Settings" = "설정 열기"; +"Pick location" = "위치 선택"; +"Save" = "저장"; +"Save preset" = "프리셋 저장"; +"Saved locations" = "저장된 위치"; +"Select color" = "색상 선택"; +"Set location" = "위치 설정"; +"Settings…" = "설정…"; +"Turn Location Services on in Settings → Privacy to use your current location." = "현재 위치를 사용하려면 설정 → 개인정보에서 위치 서비스를 켜세요."; +"Type emoji..." = "이모티콘 입력..."; + +"Theme" = "테마"; +"Appearance" = "모양"; +"Keyboard" = "키보드"; +"Force dark mode" = "다크 모드 강제"; +"Keep Instagram in dark appearance regardless of iOS system setting" = "iOS 시스템 설정과 관계없이 Instagram을 다크 모드로 유지"; +"Full OLED" = "전체 OLED"; +"Replace Instagram's dark grays with pure black across the entire app" = "앱 전체에서 Instagram의 어두운 회색을 순수한 검정으로 교체"; +"OLED chat theme" = "OLED 채팅 테마"; +"Pure black DM thread background and incoming message bubbles" = "DM 스레드 배경과 수신 메시지 말풍선을 순수한 검정으로"; +"Keyboard theme" = "키보드 테마"; +"Override the keyboard appearance when typing inside Instagram" = "Instagram 안에서 입력할 때 키보드 모양을 재정의"; +"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "다크는 시스템 다크 키보드를 사용합니다. OLED는 키보드 배경을 순수한 검정으로 강제합니다."; +"Dark" = "다크"; +"OLED" = "OLED"; +"Apply & restart" = "적용 및 재시작"; +"Restart Instagram to apply your theme changes" = "테마 변경을 적용하려면 Instagram을 재시작하세요"; +"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "테마 변경은 앱을 재시작해야 적용됩니다. 선택이 끝나면 아래 적용 버튼을 누르세요."; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE ANALYZER // +// Settings → General → Profile Analyzer // +////////////////////////////////////////////////////////////////////////////// + +"%lu followers · %lu following" = "팔로워 %lu · 팔로잉 %lu"; +"%lu of %lu" = "%lu / %lu"; +"Analysis complete" = "분석 완료"; +"Analysis failed" = "분석 실패"; +"Another analysis is already running" = "이미 다른 분석이 진행 중입니다"; +"Available after your next scan" = "다음 분석 후 사용 가능"; +"Cancelled" = "취소됨"; +"Couldn't fetch profile information" = "프로필 정보를 가져오지 못했습니다"; +"Fetching followers (%lu/%ld)…" = "팔로워 가져오는 중 (%lu/%ld)…"; +"Fetching following (%lu/%ld)…" = "팔로잉 가져오는 중 (%lu/%ld)…"; +"Fetching profile info…" = "프로필 정보 가져오는 중…"; +"Categories" = "카테고리"; +"First scan: %@" = "첫 분석: %@"; +"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "팔로워 수가 %ld명을 초과하여 API 제한을 피하기 위해 분석이 비활성화되었습니다."; +"Gained since last scan" = "마지막 분석 이후 증가"; +"Last scan: %@" = "마지막 분석: %@"; +"Lost followers" = "이탈 팔로워"; +"Mutual followers" = "맞팔 팔로워"; +"Name: %@ → %@" = "이름: %@ → %@"; +"New followers" = "새 팔로워"; +"No results" = "결과 없음"; +"No active Instagram session found" = "활성화된 Instagram 세션을 찾을 수 없습니다"; +"No scan yet" = "분석 기록 없음"; +"Not following you back" = "맞팔하지 않음"; +"OK" = "확인"; +"Private account" = "비공개 계정"; +"Profile Analyzer" = "프로필 분석"; +"Profile picture changed" = "프로필 사진 변경됨"; +"Profile updates" = "프로필 변경"; +"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "이 계정의 저장된 스냅샷을 삭제합니다. 마지막 분석 이후 변경 사항이 사라집니다."; +"Request failed" = "요청 실패"; +"Reset analyzer data?" = "분석 데이터를 초기화할까요?"; +"Run analysis" = "분석 실행"; +"Run your first analysis" = "첫 분석을 실행하세요"; +"Search username or name" = "아이디 또는 이름 검색"; +"Since last scan" = "마지막 분석 이후"; +"Starting…" = "시작 중…"; +"They follow you, you don't follow back" = "나를 팔로우 중이지만 내가 맞팔하지 않음"; +"Too many followers" = "팔로워가 너무 많음"; +"Too many followers to analyze" = "분석하기에는 팔로워가 너무 많습니다"; +"Unfollow" = "언팔로우"; +"Unfollow @%@?" = "@%@ 언팔로우할까요?"; +"Unfollowed you since last scan" = "마지막 분석 이후 언팔로우함"; +"Username, name or picture changes" = "아이디·이름·사진 변경"; +"Username: @%@ → @%@" = "아이디: @%@ → @%@"; +"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "Instagram API 제한을 피하기 위해 팔로워 수가 %ld명을 초과하면 실행하지 않습니다."; +"You both follow each other" = "서로 팔로우하는 사이"; +"You don't follow back" = "내가 맞팔하지 않은 사람"; +"You follow them, they don't follow back" = "팔로우 중이지만 맞팔하지 않음"; +"You started following" = "새로 팔로우 시작"; +"You unfollowed" = "언팔로우함"; + +"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "%@ %lu개 계정? API 제한을 피하기 위해 처음 %ld개만 처리됩니다."; +"%@ %lu accounts? This runs sequentially with a short pause between each." = "%@ %lu개 계정? 각 요청 사이에 짧은 간격을 두고 순차적으로 실행됩니다."; +"%lu account(s) · %lu snapshot(s) · tap to inspect" = "계정 %lu · 스냅샷 %lu · 탭하여 확인"; +"%lu accounts followed" = "%lu명 팔로우함"; +"%lu accounts unfollowed" = "%lu명 언팔로우함"; +"%lu entries across %lu lists · tap to inspect" = "%lu개 목록에 %lu개 항목 · 탭하여 확인"; +"%lu preferences · tap to inspect" = "설정 %lu개 · 탭하여 확인"; +"(empty)" = "(비어 있음)"; +"(no analyzer data)" = "(분석 데이터 없음)"; +"(no lists)" = "(목록 없음)"; +"About Profile Analyzer" = "프로필 분석 정보"; +"All preferences (%lu)" = "모든 설정 (%lu)"; +"Apply imported data?" = "가져온 데이터를 적용할까요?"; +"Batch follow" = "일괄 팔로우"; +"Batch follow finished" = "일괄 팔로우 완료"; +"Batch unfollow" = "일괄 언팔로우"; +"Batch unfollow finished" = "일괄 언팔로우 완료"; +"Continue" = "계속"; +"Current snapshot" = "현재 스냅샷"; +"Embed domains" = "임베드 도메인"; +"Excluded lists" = "제외된 목록"; +"Excluded story users" = "스토리 제외 사용자"; +"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "선택한 범위의 기존 값이 대체됩니다. 일부 변경 사항을 적용하려면 앱을 다시 시작해야 할 수 있습니다."; +"Export" = "내보내기"; +"File has no importable sections." = "파일에 가져올 수 있는 섹션이 없습니다."; +"File is not a valid RyukGram export." = "유효한 RyukGram 내보내기 파일이 아닙니다."; +"Filter" = "필터"; +"First scan: we collect your followers and following lists and save them locally." = "첫 분석: 팔로워와 팔로잉 목록을 수집해 기기에 저장합니다."; +"Follow %lu" = "팔로우 %lu"; +"Followers" = "팔로워"; +"Following… %lu / %lu" = "팔로우 중… %lu / %lu"; +"Full name" = "이름"; +"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "참고: 이 기능은 베타이며 Instagram 비공개 API를 사용합니다. 연속 실행하거나 팔로우/언팔로우를 많이 한 직후에 실행하면 짧은 제한에 걸릴 수 있습니다. 드물게, 본인 책임하에 사용하세요."; +"Import complete" = "가져오기 완료"; +"Include" = "포함"; +"Included story users" = "스토리 포함 사용자"; +"Inspect the full payload" = "전체 데이터 보기"; +"Keep scan history" = "분석 기록 유지"; +"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "대형 계정 차단: 팔로워가 13,000명을 초과하면 Instagram이 앱 전체에 제한을 걸 수 있어 분석이 비활성화됩니다."; +"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "업로드되지 않습니다 — 모든 데이터는 이 기기에 저장되며 휴지통 아이콘으로 삭제할 수 있습니다."; +"Not verified only" = "미인증 계정만"; +"Nothing was applied." = "적용된 내용이 없습니다."; +"Posts" = "게시물"; +"Preferences" = "설정"; +"Previous snapshot" = "이전 스냅샷"; +"Private only" = "비공개만"; +"Profile Analyzer data" = "프로필 분석 데이터"; +"Raw" = "원본"; +"Raw JSON" = "원본 JSON"; +"Reset analyzer data" = "분석 데이터 초기화"; +"Reset complete" = "초기화 완료"; +"Reset selected data?" = "선택한 데이터를 초기화할까요?"; +"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "두 번째 분석부터: 매 분석마다 이전과 비교해 새/이탈 팔로워, 본인의 팔로우/언팔로우 변화, 프로필 변경을 보여줍니다."; +"Select all" = "모두 선택"; +"Selected data will be cleared. Tap any row to see what's stored." = "선택한 데이터가 삭제됩니다. 저장된 내용을 보려면 항목을 탭하세요."; +"Settings" = "설정"; +"Sort" = "정렬"; +"This can't be undone." = "되돌릴 수 없습니다."; +"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "적용할 항목을 선택하세요. 각 항목을 탭해 확인할 수 있습니다. 파일에 없는 섹션은 비활성화됩니다."; +"Tick what to include. Tap any row to inspect its contents." = "포함할 항목을 선택하세요. 각 항목을 탭해 내용을 확인할 수 있습니다."; +"Unfollow %lu" = "언팔로우 %lu"; +"Unfollowing… %lu / %lu" = "언팔로우 중… %lu / %lu"; +"Username A → Z" = "아이디 A → Z"; +"Username Z → A" = "아이디 Z → A"; +"Verified only" = "인증 계정만"; +"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "켜면 각 분석이 첫 분석과 비교되어 새/이탈 팔로워와 프로필 변경이 분석 사이에 사라지지 않습니다."; + +////////////////////////////////////////////////////////////////////////////// +// SETTINGS VIEWS & DIALOGS // +// Excluded-lists managers, backup/restore flows, in-picker labels. // +////////////////////////////////////////////////////////////////////////////// + +"Add chat" = "채팅 추가"; +"Add custom domain" = "사용자 지정 도메인 추가"; +"Add preset…" = "프리셋 추가…"; +"Add to list?" = "목록에 추가하시겠습니까?"; +"Add user" = "사용자 추가"; +"Apply" = "적용"; +"Apply to" = "다음에 적용"; +"Chats" = "채팅"; +"Could not read file." = "파일을 읽을 수 없습니다."; +"Could not resolve user ID" = "사용자 ID를 확인할 수 없습니다"; +"Could not write temporary file." = "임시 파일을 쓸 수 없습니다."; +"Current location" = "현재 위치"; +"Custom" = "사용자 지정"; +"Date Format" = "날짜 형식"; +"Delete" = "삭제"; +"Enable fake location" = "가짜 위치 활성화"; +"Enter username" = "사용자 이름 입력"; +"Enter username of the DM thread" = "DM 스레드의 사용자 이름을 입력하세요"; +"Excluded chats" = "제외된 채팅"; +"Excluded users" = "제외된 사용자"; +"Follow default" = "기본값 따르기"; +"Force OFF (allow unsends)" = "강제 끄기 (전송 취소 허용)"; +"Force ON (preserve unsends)" = "강제 켜기 (전송 취소 보존)"; +"Format" = "형식"; +"Import failed" = "가져오기 실패"; +"Included chats" = "포함된 채팅"; +"Included users" = "포함된 사용자"; +"KD: default" = "KD: 기본값"; +"KD: ON" = "KD: 켜짐"; +"Keep-deleted" = "삭제 메시지 보관"; +"Keep-deleted override" = "삭제 메시지 보관 재정의"; +"Name (A–Z)" = "이름 (가나다)"; +"No DM thread found with @%@" = "@%@의 DM 스레드를 찾을 수 없습니다"; +"Off" = "끄기"; +"Presets" = "프리셋"; +"Recently added" = "최근 추가됨"; +"Remove from list" = "목록에서 제거"; +"Remove Selected" = "선택 항목 제거"; +"Reset" = "재설정"; +"Saved presets are reusable. Tap a preset to make it the active location." = "저장된 프리셋은 재사용할 수 있습니다. 프리셋을 탭하여 활성 위치로 만드세요."; +"Search" = "검색"; +"Search address or place" = "주소 또는 장소 검색"; +"Search by name or username" = "이름 또는 사용자 이름으로 검색"; +"Search by username or name" = "사용자 이름 또는 이름으로 검색"; +"Select" = "선택"; +"Select location on map" = "지도에서 위치 선택"; +"Set current location" = "현재 위치 설정"; +"Set keep-deleted override" = "삭제 메시지 보관 재정의 설정"; +"Settings exported" = "설정 내보내기 완료"; +"Show map button" = "지도 버튼 표시"; +"Show seconds" = "초 표시"; +"Sort by" = "정렬 기준"; +"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." = "IG가 사용하는 각 NSDate 포맷터를 전환합니다. 각 영역(피드, 댓글, 스토리, DM)은 다른 방법을 거칩니다 — 사용자 지정 형식을 적용할 영역을 활성화하세요."; +"Use this location" = "이 위치 사용"; +"User '%@' not found" = "'%@' 사용자를 찾을 수 없습니다"; +"Username (A–Z)" = "사용자명 (가나다)"; +"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." = "켜져 있으면 Instagram 내의 모든 CoreLocation 요청이 아래 위치를 반환합니다. 지도 버튼을 토글하여 친구 지도 보기에 빠른 토글을 표시하거나 숨길 수 있습니다."; + +////////////////////////////////////////////////////////////////////////////// +// REELS (FEATURES) // +// Strings from Reels. // +////////////////////////////////////////////////////////////////////////////// + +"Copied!" = "복사되었습니다!"; +"No password found" = "비밀번호를 찾을 수 없습니다"; +"No text field found" = "텍스트 필드를 찾을 수 없습니다"; +"Password" = "비밀번호"; +"Refresh Reels?" = "릴스를 새로고침하시겠습니까?"; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE (FEATURES) // +// Strings from Profile. // +////////////////////////////////////////////////////////////////////////////// + +"Doesn't follow you" = "회원님을 팔로우하지 않음"; +"Follows you" = "회원님을 팔로우함"; +"Note copied" = "노트 복사됨"; + +////////////////////////////////////////////////////////////////////////////// +// CONFIRM DIALOGS (IN-FEATURE) // +// Strings from Confirm dialogs. // +////////////////////////////////////////////////////////////////////////////// + +"Unfollow?" = "언팔로우하시겠습니까?"; + +////////////////////////////////////////////////////////////////////////////// +// MISC // +// Anything that didn't fit a named section. Usually short labels. // +////////////////////////////////////////////////////////////////////////////// + +"720p • progressive • fastest" = "720p • 프로그레시브 • 가장 빠름"; +"Are you sure?" = "확실합니까?"; +"Bundle" = "번들"; +"Copy audio URL" = "오디오 URL 복사"; +"Copy quality info" = "품질 정보 복사"; +"Copy video URL" = "동영상 URL 복사"; +"Could not access reel media" = "릴스 미디어에 접근할 수 없습니다"; +"Could not access reel photo" = "릴스 사진에 접근할 수 없습니다"; +"Could not extract photo url from post" = "게시물에서 사진 URL을 추출할 수 없습니다"; +"Could not extract photo url from reel" = "릴스에서 사진 URL을 추출할 수 없습니다"; +"Could not extract photo url from story" = "스토리에서 사진 URL을 추출할 수 없습니다"; +"Could not extract video url from post" = "게시물에서 동영상 URL을 추출할 수 없습니다"; +"Could not extract video url from reel" = "릴스에서 동영상 URL을 추출할 수 없습니다"; +"Could not extract video url from story" = "스토리에서 동영상 URL을 추출할 수 없습니다"; +"Download Quality" = "다운로드 품질"; +"Extras" = "Extras"; +"FFmpegKit Debug" = "FFmpegKit 디버그"; +"Later" = "나중에"; +"No!" = "아니요!"; +"OK" = "확인"; +"Restart" = "재시작"; +"Restart required" = "재시작 필요"; +"username" = "사용자명"; +"Yes" = "예"; +"You must restart the app to apply this change" = "이 변경 사항을 적용하려면 앱을 다시 시작해야 합니다"; + +////////////////////////////////////////////////////////////////////////////// +// ABOUT / CREDITS // +// Strings from the About / Credits footer of Settings. // +////////////////////////////////////////////////////////////////////////////// + +"%@ — GitHub & Telegram" = "%@ — GitHub & Telegram"; +"About" = "정보"; +"Arabic translation" = "아랍어 번역"; +"Chinese (Traditional) translation" = "중국어(번체) 번역"; +"Credits" = "크레딧"; +"Developers" = "개발자"; +"Donate to SoCuul" = "SoCuul에게 후원하기"; +"installed" = "설치됨"; +"Korean translation" = "한국어 번역"; +"latest" = "최신"; +"Links" = "링크"; +"No releases" = "릴리스 없음"; +"Original SCInsta developer" = "오리지널 SCInsta 개발자"; +"Release notes" = "릴리스 노트"; +"Releases" = "릴리스"; +"Report an issue" = "문제 신고"; +"Russian translation" = "러시아어 번역"; +"RyukGram developer" = "RyukGram 개발자"; +"Join Telegram channel" = "Telegram 채널 참여"; +"View on GitHub" = "GitHub에서 보기"; +"Source code" = "소스 코드"; +"Spanish translation" = "스페인어 번역"; +"Support the original developer" = "원작자 후원하기"; +"Telegram channel" = "Telegram 채널"; +"Testing and feature suggestions" = "테스트 및 기능 제안"; +"Tweak settings" = "트윅 설정"; +"Version" = "버전"; +"Version, credits, and links" = "버전, 크레딧, 링크"; +"What's new in RyukGram" = "RyukGram의 새로운 기능"; + +////////////////////////////////////////////////////////////////////////////// +// HD DOWNLOADS // +// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). // +////////////////////////////////////////////////////////////////////////////// + +"720p • progressive • silent" = "720p • 프로그레시브 • 무음"; +"Audio extract failed" = "오디오 추출 실패"; +"Audio only" = "오디오만"; +"Audio ready" = "오디오 준비됨"; +"Download video at the highest available quality" = "가능한 가장 높은 품질로 동영상 다운로드"; +"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "DASH 스트림을 통해 HD 동영상을 다운로드하고 H.264로 인코딩합니다. FFmpegKit가 필요합니다."; +"Encoding speed" = "인코딩 속도"; +"Enhanced downloads" = "향상된 다운로드"; +"Faster = lower quality" = "빠름 = 낮은 품질"; +"FFmpeg not available" = "FFmpeg 사용 불가"; +"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit을 사용할 수 없습니다. 사이드로딩된 IPA 또는 _ffmpeg .deb 버전을 설치하여 활성화하세요."; +"No audio stream available" = "사용 가능한 오디오 스트림 없음"; +"No audio track found" = "오디오 트랙을 찾을 수 없음"; +"Photo" = "사진"; +"Photo quality" = "사진 품질"; +"Raw image (no audio, no video)" = "원시 이미지 (오디오 없음, 비디오 없음)"; +"silent" = "무음"; +"Use highest resolution available" = "사용 가능한 최고 해상도 사용"; +"Video quality" = "비디오 품질"; +"Which quality to download" = "어떤 품질로 다운로드할지 선택하세요"; diff --git a/src/Localization/Resources/ru.lproj/Localizable.strings b/src/Localization/Resources/ru.lproj/Localizable.strings index c89f95e..6a09620 100644 --- a/src/Localization/Resources/ru.lproj/Localizable.strings +++ b/src/Localization/Resources/ru.lproj/Localizable.strings @@ -55,6 +55,7 @@ * - 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 // @@ -65,11 +66,11 @@ "settings.firstrun.message" = "На будущее: удерживайте кнопку с тремя линиями в правом верхнем углу страницы профиля, чтобы снова открыть настройки RyukGram."; "settings.firstrun.ok" = "Понятно!"; "settings.firstrun.title" = "Информация о настройках RyukGram"; +"settings.language.english_only" = "Сейчас RyukGram поставляется только с английским языком. Другие языки уже подключены и ждут перевода — помочь перевести приложение на свой язык можно по короткой инструкции в README."; +"settings.language.help_translate" = "Помочь с переводом"; +"settings.language.ok" = "ОК"; "settings.language.system" = "Системный язык"; "settings.language.title" = "Язык"; -"settings.language.english_only" = "Сейчас RyukGram поставляется только с английским языком. Другие языки уже подключены и ждут перевода — помочь перевести приложение на свой язык можно по короткой инструкции в README."; -"settings.language.ok" = "ОК"; -"settings.language.help_translate" = "Помочь с переводом"; "settings.results.many" = "%lu результатов"; "settings.results.none" = "Нет результатов"; "settings.results.one" = "%lu результат"; @@ -83,6 +84,8 @@ "Adds a copy option to the comment long-press menu" = "Добавляет пункт копирования в меню удержания комментария"; "Adds a download option for GIF comments" = "Добавляет пункт скачивания для GIF-комментариев"; +"Anonymous live viewing" = "Анонимный просмотр трансляций"; +"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "Блокирует heartbeat счётчика зрителей, чтобы трансляция не видела вас — вы также не увидите счётчик зрителей"; "Browser" = "Браузер"; "Comments" = "Комментарии"; "Copy comment text" = "Копировать текст комментария"; @@ -103,13 +106,14 @@ "Experimental features" = "Экспериментальные функции"; "Focus/distractions" = "Фокус/отвлечения"; "General" = "Общие"; -"Hide Meta AI" = "Скрыть Meta AI"; "Hide ads" = "Скрыть рекламу"; "Hide explore posts grid" = "Скрыть сетку постов в Explore"; "Hide friends map" = "Скрыть карту друзей"; +"Hide Meta AI" = "Скрыть Meta AI"; "Hide metrics" = "Скрыть метрики"; "Hide notes tray" = "Скрыть панель заметок"; "Hide trending searches" = "Скрыть популярные запросы"; +"Hide UI on capture" = "Скрыть интерфейс при захвате"; "Hides all suggested users for you to follow, outside your feed" = "Скрывает всех рекомендуемых пользователей вне вашей ленты"; "Hides like/comment/share counts on posts and reels" = "Скрывает количество лайков, комментариев и репостов у постов и рилсов"; "Hides the friends map icon in the notes tray" = "Скрывает значок карты друзей на панели заметок"; @@ -119,23 +123,30 @@ "Hides the suggested broadcast channels in direct messages" = "Скрывает рекомендуемые каналы вещания в личных сообщениях"; "Hides the trending searches under the explore search bar" = "Скрывает популярные запросы под строкой поиска Explore"; "Hold down on the Instagram logo to change the app icon" = "Удерживайте логотип Instagram, чтобы сменить иконку приложения"; +"Live" = "Прямые трансляции"; "Long press on the eyedropper tool in stories to customize the text color more precisely" = "Удерживайте пипетку в историях, чтобы точнее настроить цвет текста"; +"Long-press the heart button in a live to hide or show the comments" = "Удерживайте кнопку сердца в прямой трансляции, чтобы скрыть или показать комментарии"; +"Long-press the search tab to open a copied Instagram link" = "Удерживайте вкладку поиска, чтобы открыть скопированную ссылку Instagram"; "No suggested chats" = "Без рекомендуемых чатов"; "No suggested users" = "Без рекомендуемых пользователей"; "Notes" = "Заметки"; +"Open app icon picker" = "Открыть выбор иконки приложения"; +"Open link from clipboard" = "Открыть ссылку из буфера обмена"; "Open links in external browser" = "Открывать ссылки во внешнем браузере"; "Opens links in Safari instead of Instagram's in-app browser" = "Открывает ссылки в Safari вместо встроенного браузера Instagram"; -"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Удаляет обёртки отслеживания Instagram (l.instagram.com) и параметры UTM/fbclid из URL"; +"Privacy" = "Конфиденциальность"; +"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "Скрывает кнопки RyukGram на скриншотах, записи экрана и зеркалировании"; "Removes all ads from the Instagram app" = "Удаляет всю рекламу из приложения Instagram"; "Removes igsh, utm_source, and other tracking parameters from shared links" = "Удаляет igsh, utm_source и другие параметры отслеживания из общих ссылок"; -"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Заменяет относительные метки времени IG (\"3d ago\") на пользовательский формат. Внутри выбора можно указать, к каким разделам это применять."; +"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "Удаляет обёртки отслеживания Instagram (l.instagram.com) и параметры UTM/fbclid из URL"; "Replace domain in shared links" = "Заменять домен в общих ссылках"; +"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "Заменяет относительные метки времени IG (\"3d ago\") на пользовательский формат. Внутри выбора можно указать, к каким разделам это применять."; "Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "Переписывает скопированные и отправляемые ссылки на домен, удобный для предпросмотра в Discord, Telegram и т.д."; "Search bars will no longer save your recent searches" = "Строки поиска больше не будут сохранять недавние запросы"; "Sharing" = "Поделиться"; "Strip tracking from links" = "Удалять трекинг из ссылок"; "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)." = "Эти функции зависят от скрытых флагов Instagram и могут работать не на всех аккаунтах или версиях.\nИсследование экспериментальных флагов: @euoradan (Radan)."; +"Toggle live comments" = "Переключить комментарии прямой трансляции"; "Use detailed color picker" = "Использовать подробный выбор цвета"; ////////////////////////////////////////////////////////////////////////////// @@ -241,8 +252,8 @@ "Hides the repost button on the reels sidebar" = "Скрывает кнопку репоста на боковой панели рилсов"; "Hides the top navigation bar when watching reels" = "Скрывает верхнюю панель навигации при просмотре рилсов"; "Hiding" = "Скрытие"; -"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG по умолчанию: стандартное поведение. RyukGram: снова продвигает вперёд после свайпа назад."; "IG default" = "IG по умолчанию"; +"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG по умолчанию: стандартное поведение. RyukGram: снова продвигает вперёд после свайпа назад."; "Limits" = "Ограничения"; "Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "Ограничивает количество рилсов, доступных для прокрутки в любой момент, и запрещает обновление"; "Only loads %@ %@" = "Загружает только %@ %@"; @@ -254,8 +265,10 @@ "Shows an alert when you trigger a reels refresh" = "Показывает предупреждение при попытке обновить рилсы"; "Shows buttons to reveal and auto-fill the password on locked reels" = "Показывает кнопки для отображения и автозаполнения пароля на защищённых рилсах"; "Tap Controls" = "Управление нажатием"; +"Tap to mute on photo reels" = "Нажмите для отключения звука в фото-Reels"; "Tapping the Reels tab while on reels does nothing" = "Нажатие вкладки Reels ничего не делает, если вы уже в рилсах"; "Unlock password-locked reels" = "Разблокировать рилсы с паролем"; +"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "Когда режим паузы включён, нажатие на фото-Reels переключает звук вместо стандартной паузы"; ////////////////////////////////////////////////////////////////////////////// // PROFILE // @@ -265,14 +278,25 @@ "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" = "Добавляет пункт просмотра в меню удержания хайлайта, чтобы открыть обложку во весь экран"; "Copy note on long press" = "Копировать заметку по удержанию"; +"Fake follower count" = "Поддельное число подписчиков"; +"Fake following count" = "Поддельное число подписок"; +"Fake post count" = "Поддельное число публикаций"; +"Fake profile stats" = "Поддельная статистика профиля"; +"Fake verified badge" = "Поддельная галочка"; "Follow indicator" = "Индикатор подписки"; +"Follower count" = "Число подписчиков"; +"Following count" = "Число подписок"; "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 to download directly (ignored when zoom is on)" = "Удерживайте для прямого скачивания (игнорируется, если включено увеличение)"; "Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "Жесты долгого нажатия на элементы профиля — отдельно от кнопок действий конкретных функций."; +"Only affects your own profile header. Other users see the real numbers." = "Влияет только на заголовок вашего собственного профиля. Другие пользователи видят реальные числа."; +"Post count" = "Число публикаций"; "Profile copy button" = "Кнопка копирования в профиле"; "Save profile picture" = "Сохранить фото профиля"; +"Show a checkmark next to your name on your own profile" = "Показывать галочку рядом с вашим именем в вашем профиле"; "Shows whether the profile user follows you" = "Показывает, подписан ли пользователь профиля на вас"; +"Tap to set" = "Нажмите, чтобы задать"; "View highlight cover" = "Посмотреть обложку хайлайта"; "Zoom profile photo" = "Увеличение фото профиля"; @@ -320,7 +344,6 @@ "Hides the notification for others when you view their story" = "Скрывает уведомление для других пользователей, когда вы смотрите их историю"; "Inserts a button next to the seen/eye button on story overlays" = "Добавляет кнопку рядом с кнопкой просмотра/глаза в интерфейсе историй"; "Keep stories visually seen locally" = "Помечать истории просмотренными только локально"; -"Keep stories visually unseen" = "Оставлять истории визуально непросмотренными"; "Liking a story automatically advances to the next one after a short delay" = "После лайка истории автоматически переходит к следующей через короткую задержку"; "Manage list" = "Управлять списком"; "Manage list (%lu)" = "Управлять списком (%lu)"; @@ -329,17 +352,25 @@ "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 when you send a reply or emoji reaction, even with seen blocking on" = "Отмечает историю как просмотренную при отправке ответа или реакции эмодзи, даже если блокировка просмотров включена"; -"Master toggle. When off, the list is ignored" = "Главный переключатель. Когда выключен, список игнорируется"; "Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "Помечает истории как просмотренные локально (серое кольцо), при этом блокируя уведомление о просмотре на сервере"; +"Master toggle. When off, the list is ignored" = "Главный переключатель. Когда выключен, список игнорируется"; "Other" = "Другое"; "Playback" = "Воспроизведение"; -"Prevents stories from visually marking as seen in the tray (keeps colorful ring)" = "Не даёт историям визуально отмечаться как просмотренные в панели (цветное кольцо остаётся)"; "Quick list button in stories" = "Кнопка быстрого списка в историях"; "Search, sort, swipe to remove" = "Поиск, сортировка, свайп для удаления"; "Seen receipts" = "Уведомления о просмотре"; "Sending a reply or emoji reaction automatically advances to the next story" = "Отправка ответа или реакции эмодзи автоматически переключает на следующую историю"; "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" = "Показывает кнопку глаза в историях для добавления и удаления пользователей из списка. Выкл. = только меню с тремя точками или долгое нажатие"; +"Stickers" = "Стикеры"; +"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "Смотрите результаты опросов/викторин/слайдера до взаимодействия — вы по-прежнему можете нажать для голосования. «Принудительно добавить викторину» возвращает устаревший стикер викторины в редактор истории."; +"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "Смотрите результаты опросов/викторин/слайдера в reels до взаимодействия — вы по-прежнему можете нажать для голосования."; +"Force Quiz sticker in tray" = "Принудительно добавить стикер викторины"; +"Adds Quiz back to the story sticker picker" = "Возвращает викторину в выбор стикеров истории"; +"Show quiz answer" = "Показывать ответ викторины"; +"Circle the correct option on quiz stickers, or the leading option on polls" = "Обводит правильный вариант в викторине или лидирующий в опросе"; +"Show poll vote counts" = "Показывать количество голосов опроса"; +"Show vote tallies on poll options and slider count/average before you vote" = "Показывает число голосов по вариантам опроса и среднее/количество слайдера до голосования"; "Stop story auto-advance" = "Остановить авто-переход историй"; "Stories" = "Истории"; "Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "Истории не будут автоматически переключаться на следующую после окончания таймера. Нажимайте для ручного перехода"; @@ -384,7 +415,7 @@ "Copy text on hold" = "Копировать текст по удержанию"; "Custom emojis and background/text colors" = "Пользовательские эмодзи и цвета фона/текста"; "Custom note themes" = "Пользовательские темы заметок"; -"Disable disappearing mode swipe" = "Отключить свайп для исчезающего режима"; +"Disable vanish mode swipe" = "Отключить свайп для режима Vanish"; "Disable screenshot detection" = "Отключить обнаружение скриншотов"; "Disable typing status" = "Отключить статус набора"; "Disable view-once limitations" = "Отключить ограничения view-once"; @@ -405,7 +436,7 @@ "Note actions" = "Действия с заметками"; "Preserve messages that others unsend" = "Сохранять сообщения, которые другие отзывают"; "Preserves messages that others unsend" = "Сохраняет сообщения, которые другие отзывают"; -"Prevents accidental swipe-up activation of disappearing mode" = "Предотвращает случайное включение исчезающего режима свайпом вверх"; +"Prevents accidental swipe-up activation of vanish mode" = "Предотвращает случайное включение режима Vanish свайпом вверх"; "Quick list button in chats" = "Кнопка быстрого списка в чатах"; "Removes the audio call button from DM thread header" = "Убирает кнопку аудиозвонка из заголовка диалога DM"; "Removes the screenshot-prevention features for visual messages in DMs" = "Убирает защиту от скриншотов для визуальных сообщений в DM"; @@ -420,7 +451,6 @@ "Shows an \"Unsent\" label on preserved messages" = "Показывает метку \"Отозвано\" на сохранённых сообщениях"; "Unlimited replay of visual messages" = "Неограниченный повтор визуальных сообщений"; "Unsent message notification" = "Уведомление об отозванном сообщении"; -"Visual messages" = "Визуальные сообщения"; "Voice messages" = "Голосовые сообщения"; "Warn before clearing on refresh" = "Предупреждать перед очисткой при обновлении"; "Which chats get read-receipt blocking" = "Для каких чатов блокируются уведомления о прочтении"; @@ -439,11 +469,13 @@ // Settings → Navigation tab // ////////////////////////////////////////////////////////////////////////////// +"Also hide the bottom tab bar — only the inbox is visible" = "Также скрыть нижнюю панель вкладок — видна только папка входящих"; "Hide create tab" = "Скрыть вкладку создания"; "Hide explore tab" = "Скрыть вкладку Explore"; "Hide feed tab" = "Скрыть вкладку ленты"; "Hide messages tab" = "Скрыть вкладку сообщений"; "Hide reels tab" = "Скрыть вкладку рилсов"; +"Hide tab bar" = "Скрыть панель вкладок"; "Hides every tab except DM inbox + profile and forces launch into the inbox. Settings shortcut moves to long-press on the inbox tab." = "Скрывает все вкладки, кроме входящих DM и профиля, и принудительно запускает приложение во входящих. Быстрый доступ к настройкам переносится на долгое нажатие по вкладке входящих."; "Hides the create tab on the bottom navigation bar" = "Скрывает вкладку создания на нижней панели навигации"; "Hides the direct messages tab on the bottom navigation bar" = "Скрывает вкладку личных сообщений на нижней панели навигации"; @@ -468,31 +500,33 @@ ////////////////////////////////////////////////////////////////////////////// "Confirm actions" = "Подтверждение действий"; -"Confirm call" = "Подтверждать звонок"; +"Confirm video call" = "Подтверждать видеозвонок"; +"Confirm voice call" = "Подтверждать аудиозвонок"; "Confirm changing theme" = "Подтверждать смену темы"; "Confirm follow" = "Подтверждать подписку"; "Confirm follow requests" = "Подтверждать запросы на подписку"; "Confirm like: Posts" = "Подтверждать лайк: посты"; -"Confirm like: Posts/Stories" = "Подтверждать лайк: посты/истории"; "Confirm like: Reels" = "Подтверждать лайк: рилсы"; "Confirm posting comment" = "Подтверждать отправку комментария"; "Confirm repost" = "Подтверждать репост"; -"Confirm shh mode" = "Подтверждать shh-режим"; -"Confirm sticker interaction" = "Подтверждать взаимодействие со стикером"; +"Confirm vanish mode" = "Подтверждать режим исчезновения"; +"Confirm sticker interaction (stories)" = "Подтверждать взаимодействие со стикером (истории)"; +"Confirm sticker interaction (highlights)" = "Подтверждать взаимодействие со стикером (актуальное)"; "Confirm story emoji reaction" = "Подтверждать эмодзи-реакцию на историю"; "Confirm story like" = "Подтверждать лайк истории"; "Confirm unfollow" = "Подтверждать отписку"; "Confirm voice messages" = "Подтверждать голосовые сообщения"; "Shows an alert before sending an emoji reaction on a story" = "Показывает подтверждение перед отправкой эмодзи-реакции на историю"; "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 vanish mode" = "Показывает подтверждение перед включением режима исчезновения"; "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 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 tap a sticker on someone's story" = "Показывает подтверждение при нажатии на стикер в чьей-то истории"; +"Shows an alert when you tap a sticker inside a highlight" = "Показывает подтверждение при нажатии на стикер в актуальном"; +"Shows an alert when you click the video call button to confirm before calling" = "Показывает подтверждение при нажатии кнопки видеозвонка"; +"Shows an alert when you click the voice 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 like button on posts 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 stories to confirm the like" = "Показывает подтверждение при нажатии кнопки лайка на историях"; "Shows an alert when you click the post comment button to confirm" = "Показывает подтверждение при нажатии кнопки отправки комментария"; @@ -505,22 +539,6 @@ ////////////////////////////////////////////////////////////////////////////// "Backup & Restore" = "Резервная копия и восстановление"; -"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." = "Экспортируйте настройки RyukGram в JSON-файл и импортируйте их позже. При импорте все настройки сначала сбрасываются к значениям по умолчанию, затем применяются импортированные значения, а перед этим показывается предварительный просмотр."; -"Import settings" = "Импорт настроек"; -"Load settings from a JSON file" = "Загрузить настройки из JSON-файла"; -"Reset to defaults" = "Сбросить по умолчанию"; -"Revert every RyukGram preference" = "Сбросить все параметры RyukGram"; -"Save settings as a JSON file" = "Сохранить настройки в JSON-файл"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL // -// Settings → Experimental tab // -////////////////////////////////////////////////////////////////////////////// - -"Experimental" = "Экспериментальное"; -"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!" = "Эти функции нестабильны и могут неожиданно вызывать вылеты Instagram.\n\nИспользуйте на свой страх и риск!"; -"Warning" = "Предупреждение"; ////////////////////////////////////////////////////////////////////////////// // ADVANCED // @@ -528,17 +546,76 @@ ////////////////////////////////////////////////////////////////////////////// "Advanced" = "Дополнительно"; +"Auto-clear cache" = "Автоочистка кэша"; "Automatically opens settings when the app launches" = "Автоматически открывает настройки при запуске приложения"; +"Cache" = "Кэш"; +"Cache cleared" = "Кэш очищен"; +"Calculating cache size…" = "Вычисление размера кэша…"; +"Clear" = "Очистить"; +"Clear cache" = "Очистить кэш"; +"Clear cache (%@)" = "Очистить кэш (%@)"; +"Clear cache?" = "Очистить кэш?"; +"Clearing cache…" = "Очистка кэша…"; +"Clearing still scans on demand." = "Очистка по-прежнему сканирует по запросу."; +"Daily" = "Ежедневно"; "Disable safe mode" = "Отключить безопасный режим"; "Enable tweak settings quick-access" = "Включить быстрый доступ к настройкам твика"; +"Free %@ of Instagram cache. A restart is recommended." = "Освободить %@ кэша Instagram. Рекомендуется перезапуск."; +"Freed %@. Restart to apply." = "Освобождено %@. Перезапустите для применения."; "Hold on the home tab to open RyukGram settings" = "Удерживайте вкладку Home, чтобы открыть настройки RyukGram"; "Instagram" = "Instagram"; +"Monthly" = "Ежемесячно"; +"Nothing to clear" = "Нечего очищать"; +"Off skips the size scan when Advanced opens." = "В выключенном состоянии пропускает сканирование размера при открытии «Дополнительно»."; "Pause playback when opening settings" = "Ставить воспроизведение на паузу при открытии настроек"; "Pauses any playing video/audio when settings opens" = "Ставит на паузу любое воспроизводимое видео или аудио при открытии настроек"; "Prevents Instagram from resetting settings after crashes (at your own risk)" = "Не даёт Instagram сбрасывать настройки после сбоев (на ваш страх и риск)"; +"Remove Instagram's cached images, videos, and temporary files." = "Удаляет кэшированные изображения, видео и временные файлы Instagram."; "Reset onboarding state" = "Сбросить состояние онбординга"; -"Settings" = "Настройки"; +"Run a silent cache clear on launch when the interval has elapsed." = "Выполняет тихую очистку кэша при запуске, когда истёк интервал."; +"Show cache size" = "Показывать размер кэша"; "Show tweak settings on app launch" = "Показывать настройки твика при запуске приложения"; +"Weekly" = "Еженедельно"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED EXPERIMENTAL // +// Settings → Advanced → Advanced experimental features // +////////////////////////////////////////////////////////////////////////////// + +"Actions" = "Действия"; +"Advanced experimental features" = "Расширенные экспериментальные функции"; +"All experimental toggles will be turned off. Instagram will restart." = "Все экспериментальные переключатели будут отключены. Instagram перезапустится."; +"Direct Notes — Audio reply" = "Direct-заметки — ответ голосом"; +"Direct Notes — Avatar reply" = "Direct-заметки — ответ аватаром"; +"Direct Notes — Friend Map" = "Direct-заметки — карта друзей"; +"Direct Notes — GIFs & stickers reply" = "Direct-заметки — ответ GIF и стикерами"; +"Direct Notes — Photo reply" = "Direct-заметки — ответ фото"; +"Disabled after repeated crashes." = "Отключено после повторных сбоев."; +"Enables GIF/sticker replies" = "Включает ответы GIF и стикерами"; +"Enables photo replies" = "Включает ответы фото"; +"Enables the audio-note reply type" = "Включает ответы голосовой заметкой"; +"Enables the avatar reply type" = "Включает ответы аватаром"; +"Experimental flags reset" = "Экспериментальные флаги сброшены"; +"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "Включите нужное и нажмите «Применить», чтобы перезапустить. Некоторые флаги могут не работать на всех аккаунтах или версиях IG. Сбрасываются автоматически после 3 сбоев запуска."; +"Forces Prism-gated experiments on" = "Принудительно включает эксперименты под Prism"; +"Forces the Homecoming home surface / nav on" = "Принудительно включает интерфейс и навигацию Homecoming"; +"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "Принудительно показывает QuickSnap / Instants в ленте, чатах, историях и заметках"; +"Got it" = "Понятно"; +"Heads up" = "Внимание"; +"Hidden Instagram experiments" = "Скрытые эксперименты Instagram"; +"Hidden Instagram experiments (in Advanced)" = "Скрытые эксперименты Instagram (в разделе «Дополнительно»)"; +"Homecoming" = "Homecoming"; +"Notes & QuickSnap" = "Заметки и QuickSnap"; +"Prism design system" = "Дизайн-система Prism"; +"QuickSnap (Instants)" = "QuickSnap (Instants)"; +"Reset all experimental flags" = "Сбросить все экспериментальные флаги"; +"Reset experimental flags?" = "Сбросить экспериментальные флаги?"; +"Restart Instagram to apply changes" = "Перезапустите Instagram, чтобы применить изменения"; +"Shows the friend map entry in Direct Notes" = "Показывает вход на карту друзей в Direct-заметках"; +"Surfaces" = "Интерфейсы"; +"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "Эти переключатели включают скрытые эксперименты Instagram. Некоторые функции могут не работать на всех аккаунтах или версиях IG. Если IG падает при запуске, флаги сбрасываются после 3 неудачных стартов."; +"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "Переключайте скрытые эксперименты Instagram. Некоторые могут не работать на всех аккаунтах или версиях IG."; +"Turn every experimental toggle off" = "Отключить все экспериментальные переключатели"; ////////////////////////////////////////////////////////////////////////////// // DEBUG // @@ -547,24 +624,38 @@ "Button Cell" = "Ячейка кнопки"; "Change the value on the right" = "Измените значение справа"; +"Could not delete: %@" = "Не удалось удалить: %@"; "Debug" = "Отладка"; +"Delete an imported override and fall back to the shipped strings" = "Удалить импортированный файл и вернуться к встроенным строкам"; +"Deleted %@ override. Restart to apply." = "Файл %@ удалён. Перезапустите для применения."; "Enable FLEX gesture" = "Включить жест FLEX"; +"Export English strings" = "Экспортировать английские строки"; "Hold 5 fingers on the screen to open FLEX" = "Удерживайте 5 пальцев на экране, чтобы открыть FLEX"; "I have %@%@" = "У меня есть %@%@"; +"Import a .strings file for a language" = "Импортировать файл .strings для языка"; +"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Импортируйте файл .strings, чтобы обновить перевод. Выберите язык, укажите файл и перезапустите приложение."; "Link Cell" = "Ячейка ссылки"; +"Localization" = "Локализация"; "Menu Cell" = "Ячейка меню"; +"Navigation Cell" = "Ячейка навигации"; +"No imported localization files to reset." = "Нет импортированных файлов локализации для сброса."; +"No overrides" = "Нет переопределений"; "Open FLEX on app focus" = "Открывать FLEX при возврате в приложение"; "Open FLEX on app launch" = "Открывать FLEX при запуске приложения"; "Opens FLEX when the app is focused" = "Открывает FLEX, когда приложение становится активным"; "Opens FLEX when the app launches" = "Открывает FLEX при запуске приложения"; +"Pick a language to delete the imported file" = "Выберите язык, чтобы удалить импортированный файл"; +"Reset localization" = "Сбросить локализацию"; +"Share the base English .strings file for translating" = "Поделиться базовым английским файлом .strings для перевода"; "Static Cell" = "Статическая ячейка"; "Stepper cell" = "Ячейка степпера"; "Switch Cell" = "Ячейка переключателя"; "Switch Cell (Restart)" = "Ячейка переключателя (перезапуск)"; "Tap the switch" = "Нажмите переключатель"; +"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "Эти функции используют скрытые флаги Instagram и могут работать не на всех аккаунтах или версиях."; +"Update localization file" = "Обновить файл локализации"; "Using icon" = "Используя иконку"; "Using image" = "Используя изображение"; -"_ Example" = "_ Пример"; ////////////////////////////////////////////////////////////////////////////// // DOWNLOADS & MEDIA ACTIONS // @@ -596,16 +687,16 @@ "Failed to save" = "Не удалось сохранить"; "HD download complete" = "HD-загрузка завершена"; "Mute audio" = "Выключить звук"; -"No URLs" = "Нет URL"; -"No URLs found" = "URL не найдены"; "No caption on this post" = "У этого поста нет подписи"; "No carousel children" = "У карусели нет элементов"; "No cover image" = "Нет обложки"; "No files downloaded" = "Файлы не скачаны"; "No media" = "Нет медиа"; -"No media URL" = "Нет URL медиа"; "No media to expand" = "Нет медиа для разворачивания"; "No media to show" = "Нет медиа для показа"; +"No media URL" = "Нет URL медиа"; +"No URLs" = "Нет URL"; +"No URLs found" = "URL не найдены"; "No video URL" = "Нет URL видео"; "Not a carousel" = "Это не карусель"; "Nothing to save" = "Нечего сохранять"; @@ -638,6 +729,7 @@ "Add to block list" = "Добавить в список блокировки"; "Add to block list?" = "Добавить в список блокировки?"; "Added to block list" = "Добавлено в список блокировки"; +"Added to exclude list" = "Добавлено в список исключений"; "Audio not loaded yet. Play the message first and try again." = "Аудио ещё не загружено. Сначала воспроизведите сообщение и попробуйте снова."; "Audio sent" = "Аудио отправлено"; "Audio/Video from Files" = "Аудио/видео из Файлов"; @@ -651,12 +743,14 @@ "Could not get audio data. Try again after refreshing the chat." = "Не удалось получить аудиоданные. Попробуйте снова после обновления чата."; "Could not get video URL" = "Не удалось получить URL видео"; "Disable read receipts" = "Отключить уведомления о прочтении"; +"Disappearing media" = "Исчезающие медиа"; "Done!" = "Готово!"; "Download audio" = "Скачать аудио"; "Downloading audio..." = "Скачивание аудио..."; "Enable read receipts" = "Включить уведомления о прочтении"; "Error: %@" = "Ошибка: %@"; "Exclude chat" = "Исключить чат"; +"Exclude from seen" = "Исключить из просмотренных"; "Exclude story seen" = "Исключить просмотр истории"; "Excluded" = "Исключено"; "Extracting audio..." = "Извлечение аудио..."; @@ -664,6 +758,10 @@ "File sending not supported" = "Отправка файлов не поддерживается"; "Follow" = "Подписаться"; "Following" = "Подписки"; +"Inserts a button on disappearing media overlays" = "Добавляет кнопку на оверлей исчезающих медиа"; +"Inserts a speaker button to mute/unmute disappearing media" = "Добавляет кнопку динамика для звука исчезающих медиа"; +"Inserts an eye button to mark the current disappearing media as viewed" = "Добавляет кнопку-глаз для отметки текущего исчезающего медиа как просмотренного"; +"Mark as viewed" = "Отметить как просмотренное"; "Mark messages as seen" = "Отметить сообщения как просмотренные"; "Mark seen" = "Отметить просмотр"; "Marked as seen" = "Отмечено как просмотренное"; @@ -672,6 +770,7 @@ "Mentions" = "Упоминания"; "Message sender not found" = "Отправитель сообщения не найден"; "Messages settings" = "Настройки сообщений"; +"Audio URL not available" = "URL аудио недоступен"; "Mute story audio" = "Выключить звук истории"; "No audio URL found. Try again after refreshing the chat." = "URL аудио не найден. Попробуйте снова после обновления чата."; "No mentions in this story" = "В этой истории нет упоминаний"; @@ -687,26 +786,25 @@ "Remove" = "Удалить"; "Remove from block list" = "Убрать из списка блокировки"; "Remove from block list?" = "Убрать из списка блокировки?"; +"Remove from exclude list" = "Удалить из списка исключений"; "Removed" = "Удалено"; +"Removed from list" = "Удалено из списка"; "Save GIF" = "Сохранить GIF"; "Selection too short (min 0.5s)" = "Слишком короткий фрагмент (минимум 0.5 с)"; -"Send Audio" = "Отправить аудио"; "Send anyway" = "Всё равно отправить"; +"Send Audio" = "Отправить аудио"; "Send failed: %@" = "Ошибка отправки: %@"; "Send service not found" = "Сервис отправки не найден"; -"Share" = "Поделиться"; +"Show audio toggle" = "Показывать переключатель звука"; +"Show mark-as-viewed button" = "Показывать кнопку отметки о просмотре"; "Story read receipts disabled" = "Уведомления о просмотре историй отключены"; "Story read receipts enabled" = "Уведомления о просмотре историй включены"; -"Story seen receipts will be blocked for @%@." = "Уведомления о просмотре историй будут заблокированы для @%@."; "This chat will resume normal read-receipt behavior." = "Для этого чата будет восстановлено обычное поведение уведомлений о прочтении."; "Total: %@" = "Всего: %@"; -"Un-exclude" = "Убрать исключение"; "Un-exclude chat" = "Убрать чат из исключений"; "Un-exclude chat?" = "Убрать чат из исключений?"; "Un-exclude story seen" = "Убрать просмотр истории из исключений"; -"Un-exclude story seen?" = "Убрать просмотр истории из исключений?"; "Un-excluded" = "Исключение убрано"; -"Unblock" = "Разблокировать"; "Unblocked" = "Разблокировано"; "Unlimited replay enabled" = "Неограниченный повтор включён"; "Unmute story audio" = "Включить звук истории"; @@ -729,6 +827,9 @@ "Add preset" = "Добавить пресет"; "Change location" = "Изменить местоположение"; "Click the Apply button after this to see the emoji" = "После этого нажмите кнопку Apply, чтобы увидеть эмодзи"; +"Clipboard is not an Instagram URL" = "В буфере обмена нет ссылки Instagram"; +"Comments hidden" = "Комментарии скрыты"; +"Comments shown" = "Комментарии показаны"; "Copied text to clipboard" = "Текст скопирован в буфер обмена"; "Copy" = "Копировать"; "Copy all" = "Копировать всё"; @@ -739,34 +840,179 @@ "Current: %@" = "Текущее: %@"; "Disable" = "Отключить"; "Download GIF" = "Скачать GIF"; +"Dropped pin" = "Установленная метка"; "Enable" = "Включить"; +"Enable Location Services for Instagram in Settings to use your current location." = "Включите службы геолокации для Instagram в настройках, чтобы использовать текущее местоположение."; "Enter Emoji Text" = "Введите текст эмодзи"; "Fake location" = "Поддельное местоположение"; +"Location access denied" = "Доступ к геолокации запрещён"; +"Location Services off" = "Службы геолокации выключены"; "Name" = "Имя"; "Nothing to copy" = "Нечего копировать"; +"Open Settings" = "Открыть настройки"; +"Pick location" = "Выбрать местоположение"; "Save" = "Сохранить"; "Save preset" = "Сохранить пресет"; "Saved locations" = "Сохранённые местоположения"; "Select color" = "Выбрать цвет"; "Set location" = "Установить местоположение"; "Settings…" = "Настройки…"; +"Turn Location Services on in Settings → Privacy to use your current location." = "Включите службы геолокации в Настройках → Конфиденциальность, чтобы использовать текущее местоположение."; "Type emoji..." = "Введите эмодзи..."; -"direct-inbox-tab" = "direct-inbox-tab"; -"mainfeed-tab" = "mainfeed-tab"; + +"Theme" = "Тема"; +"Appearance" = "Оформление"; +"Keyboard" = "Клавиатура"; +"Force dark mode" = "Принудительная тёмная тема"; +"Keep Instagram in dark appearance regardless of iOS system setting" = "Оставлять Instagram в тёмном оформлении независимо от настроек iOS"; +"Full OLED" = "Полный OLED"; +"Replace Instagram's dark grays with pure black across the entire app" = "Заменить тёмно-серые тона Instagram на чистый чёрный по всему приложению"; +"OLED chat theme" = "Тема OLED для чатов"; +"Pure black DM thread background and incoming message bubbles" = "Чистый чёрный фон в переписках и входящих сообщениях"; +"Keyboard theme" = "Тема клавиатуры"; +"Override the keyboard appearance when typing inside Instagram" = "Переопределить внешний вид клавиатуры при вводе в Instagram"; +"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "Тёмный использует системную тёмную клавиатуру. OLED принудительно делает фон клавиатуры чисто чёрным."; +"Dark" = "Тёмный"; +"OLED" = "OLED"; +"Apply & restart" = "Применить и перезапустить"; +"Restart Instagram to apply your theme changes" = "Перезапустите Instagram, чтобы применить изменения темы"; +"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "Изменения темы вступают в силу только после перезапуска приложения. Нажмите Применить ниже, когда закончите выбор."; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE ANALYZER // +// Settings → General → Profile Analyzer // +////////////////////////////////////////////////////////////////////////////// + +"%lu followers · %lu following" = "%lu подписчиков · %lu подписок"; +"%lu of %lu" = "%lu из %lu"; +"Analysis complete" = "Анализ завершён"; +"Analysis failed" = "Ошибка анализа"; +"Another analysis is already running" = "Другой анализ уже выполняется"; +"Available after your next scan" = "Доступно после следующего анализа"; +"Cancelled" = "Отменено"; +"Couldn't fetch profile information" = "Не удалось получить информацию о профиле"; +"Fetching followers (%lu/%ld)…" = "Загрузка подписчиков (%lu/%ld)…"; +"Fetching following (%lu/%ld)…" = "Загрузка подписок (%lu/%ld)…"; +"Fetching profile info…" = "Загрузка информации профиля…"; +"Categories" = "Категории"; +"First scan: %@" = "Первый анализ: %@"; +"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "Число подписчиков больше %ld — анализ отключён, чтобы не упереться в лимиты API."; +"Gained since last scan" = "Появились с последнего анализа"; +"Last scan: %@" = "Последний анализ: %@"; +"Lost followers" = "Потерянные подписчики"; +"Mutual followers" = "Взаимные подписчики"; +"Name: %@ → %@" = "Имя: %@ → %@"; +"New followers" = "Новые подписчики"; +"No results" = "Нет результатов"; +"No active Instagram session found" = "Активная сессия Instagram не найдена"; +"No scan yet" = "Анализа ещё нет"; +"Not following you back" = "Не подписаны на вас в ответ"; +"OK" = "OK"; +"Private account" = "Закрытый аккаунт"; +"Profile Analyzer" = "Анализ профиля"; +"Profile picture changed" = "Фото профиля изменено"; +"Profile updates" = "Изменения профиля"; +"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "Удаляет сохранённые снимки для этого аккаунта. Вы потеряете изменения с последнего анализа."; +"Request failed" = "Запрос не выполнен"; +"Reset analyzer data?" = "Сбросить данные анализа?"; +"Run analysis" = "Запустить анализ"; +"Run your first analysis" = "Запустите первый анализ"; +"Search username or name" = "Поиск по логину или имени"; +"Since last scan" = "С последнего анализа"; +"Starting…" = "Начинаем…"; +"They follow you, you don't follow back" = "Они подписаны на вас, вы — нет"; +"Too many followers" = "Слишком много подписчиков"; +"Too many followers to analyze" = "Слишком много подписчиков для анализа"; +"Unfollow" = "Отписаться"; +"Unfollow @%@?" = "Отписаться от @%@?"; +"Unfollowed you since last scan" = "Отписались от вас с последнего анализа"; +"Username, name or picture changes" = "Изменения логина, имени или фото"; +"Username: @%@ → @%@" = "Логин: @%@ → @%@"; +"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "Не запускается, когда подписчиков больше %ld — во избежание лимитов Instagram."; +"You both follow each other" = "Вы подписаны друг на друга"; +"You don't follow back" = "Вы не подписаны в ответ"; +"You follow them, they don't follow back" = "Вы подписаны на них, они — нет"; +"You started following" = "Вы подписались"; +"You unfollowed" = "Вы отписались"; + +"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "%@ %lu аккаунт(ов)? Обработаны будут первые %ld, чтобы не упереться в лимиты."; +"%@ %lu accounts? This runs sequentially with a short pause between each." = "%@ %lu аккаунт(ов)? Запускается последовательно с небольшой паузой между каждым."; +"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu аккаунт(ов) · %lu снимок(ов) · нажмите, чтобы просмотреть"; +"%lu accounts followed" = "Подписались на %lu"; +"%lu accounts unfollowed" = "Отписались от %lu"; +"%lu entries across %lu lists · tap to inspect" = "%lu записей в %lu списках · нажмите для просмотра"; +"%lu preferences · tap to inspect" = "%lu настроек · нажмите для просмотра"; +"(empty)" = "(пусто)"; +"(no analyzer data)" = "(нет данных анализатора)"; +"(no lists)" = "(списков нет)"; +"About Profile Analyzer" = "О разделе «Анализ профиля»"; +"All preferences (%lu)" = "Все настройки (%lu)"; +"Apply imported data?" = "Применить импортированные данные?"; +"Batch follow" = "Массовая подписка"; +"Batch follow finished" = "Массовая подписка завершена"; +"Batch unfollow" = "Массовая отписка"; +"Batch unfollow finished" = "Массовая отписка завершена"; +"Continue" = "Продолжить"; +"Current snapshot" = "Текущий снимок"; +"Embed domains" = "Домены встраивания"; +"Excluded lists" = "Списки исключений"; +"Excluded story users" = "Исключённые пользователи историй"; +"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "Текущие значения выбранной области будут заменены. Приложение, возможно, нужно будет перезапустить, чтобы изменения вступили в силу."; +"Export" = "Экспорт"; +"File has no importable sections." = "В файле нет секций для импорта."; +"File is not a valid RyukGram export." = "Файл не является корректным экспортом RyukGram."; +"Filter" = "Фильтр"; +"First scan: we collect your followers and following lists and save them locally." = "Первый анализ: собираем ваши списки подписчиков и подписок и сохраняем их локально."; +"Follow %lu" = "Подписаться на %lu"; +"Followers" = "Подписчики"; +"Following… %lu / %lu" = "Подписка… %lu / %lu"; +"Full name" = "Имя"; +"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "Внимание: функция в бете и использует приватный API Instagram. Запуск подряд или сразу после активных подписок/отписок может привести к временному лимиту. Используйте с умеренностью и на свой риск."; +"Import complete" = "Импорт завершён"; +"Include" = "Включить"; +"Included story users" = "Включённые пользователи историй"; +"Inspect the full payload" = "Посмотреть полные данные"; +"Keep scan history" = "Сохранять историю анализов"; +"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "Большие аккаунты заблокированы: анализ отключён при количестве подписчиков больше 13 000, чтобы Instagram не ограничил всё приложение."; +"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "Ничего не загружается — все данные остаются на этом устройстве и могут быть удалены по иконке корзины."; +"Not verified only" = "Только неверифицированные"; +"Nothing was applied." = "Ничего не применено."; +"Posts" = "Публикации"; +"Preferences" = "Настройки"; +"Previous snapshot" = "Предыдущий снимок"; +"Private only" = "Только закрытые"; +"Profile Analyzer data" = "Данные анализатора профиля"; +"Raw" = "Сырое"; +"Raw JSON" = "Сырой JSON"; +"Reset analyzer data" = "Сбросить данные анализатора"; +"Reset complete" = "Сброс завершён"; +"Reset selected data?" = "Сбросить выбранные данные?"; +"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "Со второго анализа: каждый сравнивается с предыдущим — видно новых/потерянных подписчиков, ваши собственные подписки/отписки и изменения профилей."; +"Select all" = "Выбрать всё"; +"Selected data will be cleared. Tap any row to see what's stored." = "Выбранные данные будут удалены. Нажмите любую строку, чтобы увидеть, что сохранено."; +"Settings" = "Настройки"; +"Sort" = "Сортировка"; +"This can't be undone." = "Отменить это будет нельзя."; +"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "Отметьте, что применить. Нажмите строку, чтобы просмотреть. Секции, которых нет в файле, отключены."; +"Tick what to include. Tap any row to inspect its contents." = "Отметьте, что включить. Нажмите строку, чтобы увидеть её содержимое."; +"Unfollow %lu" = "Отписаться от %lu"; +"Unfollowing… %lu / %lu" = "Отписка… %lu / %lu"; +"Username A → Z" = "Логин А → Я"; +"Username Z → A" = "Логин Я → А"; +"Verified only" = "Только верифицированные"; +"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "Когда включено, каждый анализ сравнивается с первым — новые/потерянные подписчики и изменения профилей не теряются между анализами."; ////////////////////////////////////////////////////////////////////////////// // SETTINGS VIEWS & DIALOGS // // Excluded-lists managers, backup/restore flows, in-picker labels. // ////////////////////////////////////////////////////////////////////////////// -"Add custom domain" = "Добавить свой домен"; "Add chat" = "Добавить чат"; +"Add custom domain" = "Добавить свой домен"; +"Add preset…" = "Добавить пресет…"; "Add to list?" = "Добавить в список?"; "Add user" = "Добавить пользователя"; -"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." = "Все настройки RyukGram будут сброшены к значениям по умолчанию, после чего будут применены импортированные значения. Для применения некоторых изменений потребуется перезапуск приложения."; "Apply" = "Применить"; -"Apply imported settings?" = "Применить импортированные настройки?"; "Apply to" = "Применить к"; "Chats" = "Чаты"; "Could not read file." = "Не удалось прочитать файл."; @@ -776,57 +1022,49 @@ "Custom" = "Свои"; "Date Format" = "Формат даты"; "Delete" = "Удалить"; -"Done editing" = "Завершить редактирование"; -"Edit values" = "Редактировать значения"; "Enable fake location" = "Включить поддельное местоположение"; "Enter username" = "Введите имя пользователя"; "Enter username of the DM thread" = "Введите имя пользователя диалога DM"; -"Every RyukGram preference will revert to its built-in default. This can't be undone." = "Все параметры RyukGram будут сброшены к встроенным значениям по умолчанию. Это действие нельзя отменить."; "Excluded chats" = "Исключённые чаты"; "Excluded users" = "Исключённые пользователи"; -"File is not a valid RyukGram settings export." = "Файл не является корректным экспортом настроек RyukGram."; "Follow default" = "Следовать значению по умолчанию"; "Force OFF (allow unsends)" = "Принудительно ВЫКЛ. (разрешить отзыв)"; "Force ON (preserve unsends)" = "Принудительно ВКЛ. (сохранять отозванные)"; -"Form view" = "Форма"; "Format" = "Формат"; "Import failed" = "Ошибка импорта"; -"Import preview" = "Предпросмотр импорта"; "Included chats" = "Включённые чаты"; "Included users" = "Включённые пользователи"; -"KD: ON" = "KD: ВКЛ."; "KD: default" = "KD: по умолчанию"; -"Keep-deleted" = "Keep-deleted"; +"KD: ON" = "KD: ВКЛ."; +"Keep-deleted" = "Хранить удалённые"; "Keep-deleted override" = "Переопределение keep-deleted"; +"Name (A–Z)" = "Имя (А–Я)"; "No DM thread found with @%@" = "Диалог DM с @%@ не найден"; "Off" = "Выкл."; -"On" = "Вкл."; "Presets" = "Пресеты"; -"Raw JSON view" = "Просмотр сырого JSON"; -"Remove Selected" = "Удалить выбранное"; +"Recently added" = "Недавно добавленные"; "Remove from list" = "Убрать из списка"; +"Remove Selected" = "Удалить выбранное"; "Reset" = "Сбросить"; -"Reset all settings?" = "Сбросить все настройки?"; "Saved presets are reusable. Tap a preset to make it the active location." = "Сохранённые пресеты можно использовать повторно. Нажмите на пресет, чтобы сделать его активным местоположением."; +"Search" = "Поиск"; "Search address or place" = "Искать адрес или место"; "Search by name or username" = "Поиск по имени или имени пользователя"; "Search by username or name" = "Поиск по имени пользователя или имени"; -"Search settings" = "Поиск по настройкам"; "Select" = "Выбрать"; "Select location on map" = "Выбрать местоположение на карте"; "Set current location" = "Установить текущее местоположение"; "Set keep-deleted override" = "Задать переопределение keep-deleted"; "Settings exported" = "Настройки экспортированы"; -"Settings imported" = "Настройки импортированы"; +"Show map button" = "Показывать кнопку карты"; "Show seconds" = "Показывать секунды"; "Sort by" = "Сортировать по"; "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." = "Переключайте каждый форматтер NSDate, который использует IG. Разные разделы (лента, комментарии, истории, DM) проходят через разные методы — включите те, к которым хотите применить свой формат."; -"User '%@' not found" = "Пользователь '%@' не найден"; "Use this location" = "Использовать это местоположение"; -"When on, all CoreLocation requests inside Instagram return the location below." = "Когда включено, все запросы CoreLocation внутри Instagram возвращают местоположение ниже."; +"User '%@' not found" = "Пользователь '%@' не найден"; +"Username (A–Z)" = "Пользователь (А–Я)"; "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." = "Когда включено, все запросы CoreLocation внутри Instagram возвращают местоположение ниже. Переключите кнопку карты, чтобы показать или скрыть быстрый переключатель на экране Friends Map."; -"Show map button" = "Показывать кнопку карты"; ////////////////////////////////////////////////////////////////////////////// // REELS (FEATURES) // @@ -860,8 +1098,9 @@ // Anything that didn't fit a named section. Usually short labels. // ////////////////////////////////////////////////////////////////////////////// -"720p • progressive • fastest" = "720p • progressive • fastest"; +"720p • progressive • fastest" = "720p • прогрессивный • самый быстрый"; "Are you sure?" = "Вы уверены?"; +"Bundle" = "Пакет"; "Copy audio URL" = "Копировать URL аудио"; "Copy quality info" = "Копировать информацию о качестве"; "Copy video URL" = "Копировать URL видео"; @@ -874,17 +1113,14 @@ "Could not extract video url from reel" = "Не удалось извлечь URL видео из рилса"; "Could not extract video url from story" = "Не удалось извлечь URL видео из истории"; "Download Quality" = "Качество загрузки"; +"Extras" = "Extras"; "FFmpegKit Debug" = "Отладка FFmpegKit"; "Later" = "Позже"; "No!" = "Нет!"; +"OK" = "ОК"; "Restart" = "Перезапустить"; -"Localization" = "Локализация"; -"Update localization file" = "Обновить файл локализации"; -"Import a .strings file for a language" = "Импортировать файл .strings для языка"; -"Import a .strings file to update a translation. Pick a language, select the file, restart." = "Импортируйте файл .strings, чтобы обновить перевод. Выберите язык, укажите файл и перезапустите приложение."; -"Export English strings" = "Экспортировать английские строки"; -"Share the base English .strings file for translating" = "Поделиться базовым английским файлом .strings для перевода"; "Restart required" = "Требуется перезапуск"; +"username" = "имя пользователя"; "Yes" = "Да"; "You must restart the app to apply this change" = "Чтобы применить это изменение, нужно перезапустить приложение"; @@ -893,38 +1129,58 @@ // Strings from the About / Credits footer of Settings. // ////////////////////////////////////////////////////////////////////////////// -"%@ — view source, report issues, see releases" = "%@ — посмотреть исходники, сообщить о проблемах, посмотреть релизы"; +"%@ — GitHub & Telegram" = "%@ — GitHub и Telegram"; +"About" = "О программе"; +"Arabic translation" = "Арабский перевод"; +"Chinese (Traditional) translation" = "Китайский (традиционный) перевод"; "Credits" = "Благодарности"; -"Developer" = "Разработчик"; +"Developers" = "Разработчики"; "Donate to SoCuul" = "Поддержать SoCuul"; +"installed" = "установлено"; +"Korean translation" = "Корейский перевод"; +"latest" = "последняя"; +"Links" = "Ссылки"; +"No releases" = "Нет выпусков"; "Original SCInsta developer" = "Оригинальный разработчик SCInsta"; -"Ryuk" = "Ryuk"; -"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul" = "RyukGram %@\n\nInstagram v%@\n\nОсновано на SCInsta от SoCuul"; -"RyukGram on GitHub" = "RyukGram на GitHub"; -"SoCuul" = "SoCuul"; +"Release notes" = "Примечания к выпуску"; +"Releases" = "Выпуски"; +"Report an issue" = "Сообщить о проблеме"; +"Russian translation" = "Русский перевод"; +"RyukGram developer" = "Разработчик RyukGram"; +"Join Telegram channel" = "Присоединиться к Telegram-каналу"; +"View on GitHub" = "Открыть на GitHub"; +"Source code" = "Исходный код"; +"Spanish translation" = "Испанский перевод"; "Support the original developer" = "Поддержать оригинального разработчика"; -"View Repo" = "Открыть репозиторий"; -"View the source code on GitHub" = "Посмотреть исходный код на GitHub"; +"Telegram channel" = "Telegram-канал"; +"Testing and feature suggestions" = "Тестирование и предложения функций"; +"Tweak settings" = "Настройки твика"; +"Version" = "Версия"; +"Version, credits, and links" = "Версия, благодарности и ссылки"; +"What's new in RyukGram" = "Что нового в RyukGram"; ////////////////////////////////////////////////////////////////////////////// // HD DOWNLOADS // // Enhanced / HD downloads settings (DASH + FFmpegKit encoding). // ////////////////////////////////////////////////////////////////////////////// +"720p • progressive • silent" = "720p • прогрессивный • без звука"; +"Audio extract failed" = "Не удалось извлечь аудио"; +"Audio only" = "Только аудио"; +"Audio ready" = "Аудио готово"; "Download video at the highest available quality" = "Скачивать видео в максимально доступном качестве"; "Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "Скачивает HD-видео через DASH-потоки и кодирует в H.264. Требуется FFmpegKit."; "Encoding speed" = "Скорость кодирования"; "Enhanced downloads" = "Расширенные загрузки"; -"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit недоступен. Сайдлоуните IPA или установите файл .deb с _ffmpeg, чтобы включить эту функцию."; "Faster = lower quality" = "Быстрее = ниже качество"; +"FFmpeg not available" = "FFmpeg недоступен"; +"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit недоступен. Сайдлоуните IPA или установите файл .deb с _ffmpeg, чтобы включить эту функцию."; +"No audio stream available" = "Аудиопоток недоступен"; +"No audio track found" = "Аудиодорожка не найдена"; +"Photo" = "Фото"; "Photo quality" = "Качество фото"; +"Raw image (no audio, no video)" = "Исходное изображение (без аудио и видео)"; +"silent" = "без звука"; "Use highest resolution available" = "Использовать максимально доступное разрешение"; "Video quality" = "Качество видео"; "Which quality to download" = "Какое качество скачивать"; - -////////////////////////////////////////////////////////////////////////////// -// EXPERIMENTAL / DEBUG // -// Placeholder rows only shown in the experimental settings sandbox. // -////////////////////////////////////////////////////////////////////////////// - -"Navigation Cell" = "Ячейка навигации"; diff --git a/src/Localization/Resources/zh-Hant.lproj/Localizable.strings b/src/Localization/Resources/zh-Hant.lproj/Localizable.strings new file mode 100644 index 0000000..47991a4 --- /dev/null +++ b/src/Localization/Resources/zh-Hant.lproj/Localizable.strings @@ -0,0 +1,1187 @@ +/* + * 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" = "未來:在個人頁面右上角的三條線上長按,即可重新開啟 RyukGram 設定。"; +"settings.firstrun.ok" = "我了解了!"; +"settings.firstrun.title" = "RyukGram 設定資訊"; +"settings.language.english_only" = "RyukGram 目前僅內建英文。其他語言已接好接線等待翻譯 — 請依 README 的簡短指南協助翻譯。"; +"settings.language.help_translate" = "協助翻譯"; +"settings.language.ok" = "確定"; +"settings.language.system" = "系統預設"; +"settings.language.title" = "語言"; +"settings.results.many" = "%lu 筆結果"; +"settings.results.none" = "無結果"; +"settings.results.one" = "%lu 筆結果"; +"settings.search.placeholder" = "搜尋設定"; +"settings.title" = "RyukGram 設定"; + +////////////////////////////////////////////////////////////////////////////// +// GENERAL // +// Settings → General tab // +////////////////////////////////////////////////////////////////////////////// + +"Adds a copy option to the comment long-press menu" = "在留言長按選單中新增複製選項"; +"Adds a download option for GIF comments" = "新增 GIF 留言下載選項"; +"Anonymous live viewing" = "匿名觀看直播"; +"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count" = "封鎖觀眾數心跳,讓直播主看不到你 — 你也看不到觀眾數"; +"Browser" = "瀏覽器"; +"Comments" = "留言"; +"Copy comment text" = "複製留言文字"; +"Copy description" = "複製描述"; +"Copy description text fields by long-pressing on them" = "長按描述文字欄位即可複製"; +"Date format" = "日期格式"; +"Disable app haptics" = "關閉應用觸覺反饋"; +"Disables haptics/vibrations within the app" = "關閉應用內的觸覺/震動反饋"; +"Do not save recent searches" = "不儲存最近搜尋紀錄"; +"Download GIF comments" = "下載 GIF 留言"; +"Embed domain" = "嵌入網域"; +"Embed domain: %@" = "嵌入網域:%@"; +"Enable liquid glass buttons" = "啟用液態玻璃按鈕"; +"Enable liquid glass surfaces" = "啟用液態玻璃介面"; +"Enable teen app icons" = "啟用青少年應用程式圖示"; +"Enables experimental liquid glass buttons" = "啟用實驗性液態玻璃按鈕"; +"Enables liquid glass tab bar, floating navigation, and other UI elements" = "啟用液態玻璃標籤列、浮動導航及其他介面元素"; +"Experimental features" = "實驗性功能"; +"Focus/distractions" = "專注/分心"; +"General" = "一般"; +"Hide ads" = "隱藏廣告"; +"Hide explore posts grid" = "隱藏探索貼文網格"; +"Hide friends map" = "隱藏好友地圖"; +"Hide Meta AI" = "隱藏 Meta AI"; +"Hide metrics" = "隱藏指標"; +"Hide notes tray" = "隱藏筆記托盤"; +"Hide trending searches" = "隱藏熱門搜尋"; +"Hide UI on capture" = "錄影/截圖時隱藏 UI"; +"Hides all suggested users for you to follow, outside your feed" = "隱藏所有建議追蹤的用戶,位於動態牆外"; +"Hides like/comment/share counts on posts and reels" = "隱藏貼文和短片的喜歡/留言/分享數"; +"Hides the friends map icon in the notes tray" = "隱藏筆記托盤中的朋友地圖圖示"; +"Hides the grid of suggested posts on the explore/search tab" = "隱藏探索/搜尋分頁中的建議貼文網格"; +"Hides the meta ai buttons/functionality within the app" = "隱藏應用程式內的 Meta AI 按鈕/功能"; +"Hides the notes tray in the DM inbox" = "隱藏私訊收件匣中的筆記托盤"; +"Hides the suggested broadcast channels in direct messages" = "隱藏私訊中的建議廣播頻道"; +"Hides the trending searches under the explore search bar" = "隱藏探索搜尋列下方的熱門搜尋"; +"Hold down on the Instagram logo to change the app icon" = "長按 Instagram 標誌以更換應用程式圖示"; +"Live" = "直播"; +"Long press on the eyedropper tool in stories to customize the text color more precisely" = "在限時動態中長按吸管工具以更精確自訂文字顏色"; +"Long-press the heart button in a live to hide or show the comments" = "在直播中長按愛心按鈕以隱藏或顯示留言"; +"Long-press the search tab to open a copied Instagram link" = "長按搜尋分頁以開啟複製的 Instagram 連結"; +"No suggested chats" = "無建議聊天"; +"No suggested users" = "無建議用戶"; +"Notes" = "筆記"; +"Open app icon picker" = "開啟應用程式圖示選擇器"; +"Open link from clipboard" = "從剪貼簿開啟連結"; +"Open links in external browser" = "在外部瀏覽器開啟連結"; +"Opens links in Safari instead of Instagram's in-app browser" = "在 Safari 而非 Instagram 內建瀏覽器開啟連結"; +"Privacy" = "隱私"; +"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring" = "從截圖、螢幕錄影及鏡像中隱藏 RyukGram 按鈕"; +"Removes all ads from the Instagram app" = "移除 Instagram 應用程式中的所有廣告"; +"Removes igsh, utm_source, and other tracking parameters from shared links" = "移除分享連結中的 igsh、utm_source 及其他追蹤參數"; +"Removes Instagram tracking wrappers (l.instagram.com) and UTM/fbclid params from URLs" = "移除 Instagram 追蹤包裝器(l.instagram.com)及 URL 中的 UTM/fbclid 參數"; +"Replace domain in shared links" = "替換分享連結中的網域"; +"Replace IG's relative timestamps (\"3d ago\") with a custom format. Toggle which surfaces it applies to inside the picker." = "將 IG 的相對時間戳記(\"3d ago\")替換為自訂格式。可在選擇器中切換套用範圍。"; +"Rewrites copied/shared links to use an embed-friendly domain for previews in Discord, Telegram, etc." = "重寫複製/分享的連結,使用適合 Discord、Telegram 等預覽的嵌入友好網域"; +"Search bars will no longer save your recent searches" = "搜尋列將不再儲存您的近期搜尋紀錄"; +"Sharing" = "分享"; +"Strip tracking from links" = "移除連結中的追蹤"; +"Strip tracking params" = "移除追蹤參數"; +"Toggle live comments" = "切換直播留言"; +"Use detailed color picker" = "使用詳細色彩選擇器"; + +////////////////////////////////////////////////////////////////////////////// +// DATE FORMAT // +// Settings → Date format tab // +////////////////////////////////////////////////////////////////////////////// + +"Alternate" = "替代"; +"Always ask" = "總是詢問"; +"Balanced" = "平衡"; +"Block all" = "全部封鎖"; +"Block selected" = "封鎖選取項目"; +"Button" = "按鈕"; +"Classic" = "經典"; +"Date format — %@" = "日期格式 — %@"; +"Default" = "預設"; +"Disabled" = "停用"; +"Download and share" = "下載並分享"; +"Download to Photos" = "下載至相簿"; +"Enabled" = "啟用"; +"Expand" = "展開"; +"Explore" = "探索"; +"Fast" = "快速"; +"Feed" = "動態消息"; +"High" = "高"; +"Inbox" = "收件箱"; +"Low" = "低"; +"Max" = "最大"; +"Medium" = "中等"; +"Mute/Unmute" = "靜音/取消靜音"; +"Open menu" = "打開選單"; +"Pause/Play" = "暫停/播放"; +"Profile" = "個人檔案"; +"Quality" = "畫質"; +"Reels" = "短片"; +"Requires restart" = "需要重新啟動"; +"Save to Photos" = "儲存到相簿"; +"Share sheet" = "分享表單"; +"Standard" = "標準"; +"Toggle" = "切換"; + +////////////////////////////////////////////////////////////////////////////// +// FEED // +// Settings → Feed tab // +////////////////////////////////////////////////////////////////////////////// + +"Action button" = "動作按鈕"; +"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." = "在每則動態消息貼文下方新增 RyukGram 動作按鈕,包含下載/分享/複製/展開/轉貼選項。預設點擊會開啟選單;可於下方更改點擊行為。"; +"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." = "控制動態消息的刷新時間與方式。背景刷新會在約 10 分鐘後返回應用時執行。首頁按鈕刷新會在已在首頁標籤時點擊該標籤執行。"; +"Default tap action" = "預設點擊動作"; +"Disable background refresh" = "停用背景刷新"; +"Disable home button refresh" = "停用首頁按鈕刷新"; +"Disable home button scroll" = "停用首頁按鈕滾動"; +"Disable video autoplay" = "停用影片自動播放"; +"Hide" = "隱藏"; +"Hide entire feed" = "隱藏整個動態消息"; +"Hide repost button" = "隱藏轉貼按鈕"; +"Hide stories tray" = "隱藏限時動態列"; +"Hide suggested stories" = "隱藏建議的限時動態"; +"Hides suggested accounts" = "隱藏建議帳號"; +"Hides suggested reels" = "隱藏建議短片"; +"Hides suggested threads posts" = "隱藏建議的 Threads 貼文"; +"Hides the repost button on feed posts" = "隱藏動態消息貼文上的轉貼按鈕"; +"Hides the story tray at the top" = "隱藏頂部限時動態列"; +"Inserts a button row below like/comment/share on each post" = "在每則貼文的讚/留言/分享下方插入按鈕列"; +"Long press on media to expand in full-screen viewer" = "長按媒體以在全螢幕檢視器中展開"; +"Media" = "媒體"; +"Media zoom" = "媒體縮放"; +"No suggested for you" = "沒有為您推薦"; +"No suggested posts" = "沒有推薦的貼文"; +"No suggested reels" = "沒有推薦的短片"; +"No suggested threads" = "沒有推薦的串列"; +"Prevents feed from reloading when returning from background" = "防止從背景返回時重新載入動態消息"; +"Prevents videos from playing automatically" = "防止影片自動播放"; +"Refresh" = "重新整理"; +"Removes all content from your home feed" = "移除主頁動態中的所有內容"; +"Removes suggested accounts from the stories tray" = "從限時動態列移除推薦帳號"; +"Removes suggested posts" = "移除推薦貼文"; +"Scroll to top without refreshing when tapping Home" = "點擊首頁時捲動至頂端但不重新整理"; +"Show action button" = "顯示操作按鈕"; +"Stories tray" = "限時動態列"; +"Tapping Home does nothing when already on feed" = "已在動態頁時點擊首頁不執行任何操作"; +"Tray long-press actions" = "限時動態列長按操作"; +"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." = "在短片側邊欄上方新增 RyukGram 操作按鈕,包含觀看封面/下載/分享/複製/展開/轉貼項目。預設點擊開啟選單;可在下方更改點擊行為。"; +"Always show progress scrubber" = "始終顯示進度條"; +"Auto-scroll reels" = "自動捲動短片"; +"Change what happens when you tap on a reel" = "更改點擊短片時的動作"; +"Confirm reel refresh" = "確認短片重新整理"; +"Disable auto-unmuting reels" = "停用短片自動取消靜音"; +"Disable scrolling reels" = "停用短片捲動"; +"Disable tab button refresh" = "停用分頁按鈕重新整理"; +"Doom scrolling limit" = "限制無止盡捲動"; +"Forces the progress bar to appear on every reel" = "強制在每個短片上顯示進度條"; +"Hide reels header" = "隱藏短片標題列"; +"Hides the repost button on the reels sidebar" = "隱藏短片側邊欄的轉貼按鈕"; +"Hides the top navigation bar when watching reels" = "觀看短片時隱藏頂部導覽列"; +"Hiding" = "隱藏"; +"IG default" = "IG 預設"; +"IG default: native behavior. RyukGram: re-advances after swiping back." = "IG 預設:原生行為。RyukGram:在向後滑動後會重新前進。"; +"Limits" = "限制"; +"Limits the amount of reels available to scroll at any given time, and prevents refreshing" = "限制同時可捲動的短片數量,並防止重新整理"; +"Only loads %@ %@" = "只載入 %@ %@"; +"Places a button above the like/comment/share column on each reel" = "在每個短片的按讚/留言/分享欄上方放置一個按鈕"; +"Prevent doom scrolling" = "防止無止盡捲動"; +"Prevents reels from being scrolled to the next video" = "防止短片捲動至下一支影片"; +"Prevents reels from unmuting when the volume/silent button is pressed" = "按下音量/靜音按鈕時防止短片取消靜音"; +"RyukGram" = "RyukGram"; +"Shows an alert when you trigger a reels refresh" = "觸發短片重新整理時顯示警告"; +"Shows buttons to reveal and auto-fill the password on locked reels" = "顯示按鈕以揭露並自動填入鎖定短片的密碼"; +"Tap Controls" = "點擊控制"; +"Tap to mute on photo reels" = "點擊圖片短片以靜音"; +"Tapping the Reels tab while on reels does nothing" = "在短片頁面點擊短片標籤無反應"; +"Unlock password-locked reels" = "解鎖密碼保護的短片"; +"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture" = "啟用暫停模式時,點擊圖片短片會切換音訊,而非原生的暫停手勢"; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE // +// Settings → Profile tab // +////////////////////////////////////////////////////////////////////////////// + +"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" = "在精選長按選單新增檢視選項,可全螢幕開啟封面"; +"Copy note on long press" = "長按複製備註"; +"Fake follower count" = "偽造粉絲數"; +"Fake following count" = "偽造追蹤數"; +"Fake post count" = "偽造貼文數"; +"Fake profile stats" = "偽造個人檔案數據"; +"Fake verified badge" = "偽造認證徽章"; +"Follow indicator" = "追蹤指示器"; +"Follower count" = "粉絲數"; +"Following count" = "追蹤數"; +"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 to download directly (ignored when zoom is on)" = "長按直接下載(啟用縮放時忽略)"; +"Long-press gestures on profile elements — kept separate from the per-feature action buttons." = "個人檔案元素的長按手勢 — 與每項功能的操作按鈕分開維護。"; +"Only affects your own profile header. Other users see the real numbers." = "僅影響你自己的個人檔案頁首。其他用戶看到的是真實數字。"; +"Post count" = "貼文數"; +"Profile copy button" = "個人檔案複製按鈕"; +"Save profile picture" = "儲存個人檔案圖片"; +"Show a checkmark next to your name on your own profile" = "在你自己的個人檔案名稱旁顯示打勾符號"; +"Shows whether the profile user follows you" = "顯示該用戶是否追蹤你"; +"Tap to set" = "點擊以設定"; +"View highlight cover" = "檢視精選封面"; +"Zoom profile photo" = "縮放個人檔案照片"; + +////////////////////////////////////////////////////////////////////////////// +// SAVING & DOWNLOADS // +// Settings → Saving tab // +////////////////////////////////////////////////////////////////////////////// + +"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." = "已棄用。RyukGram 動作按鈕(在動態/短片/限時動態中依功能設定)是下載媒體的新方式。僅當你偏好舊版多指長按直接下載媒體時,才啟用此主開關。"; +"Downloads" = "下載"; +"Downloads with %@ %@" = "下載 %@ %@"; +"Enable long-press gesture" = "啟用長按手勢"; +"Finger count for long-press" = "長按所需手指數"; +"Legacy long-press gesture" = "舊版長按手勢"; +"Long-press hold time" = "長按持續時間"; +"Master toggle for the deprecated gesture workflow (off by default)" = "已棄用手勢流程的主開關(預設關閉)"; +"Press finger(s) for %@ %@" = "按壓 %@ %@ 手指"; +"Route saves into a dedicated album in Photos instead of the camera roll root" = "將儲存路徑導向專用相簿,而非相機膠卷根目錄"; +"Save action" = "儲存動作"; +"Save to RyukGram album" = "儲存至 RyukGram 相簿"; +"Saving" = "儲存中"; +"Show a confirmation dialog before starting a download" = "開始下載前顯示確認對話框"; +"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." = "啟用 \"儲存至 RyukGram 相簿\" 時,下載及分享選單中的 \"儲存至照片\" 將導向你照片庫中的專用 \"RyukGram\" 相簿。"; + +////////////////////////////////////////////////////////////////////////////// +// 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." = "在限時動態的觀看按鈕旁新增 RyukGram 動作按鈕,包含下載/分享/複製/展開/轉發/查看提及等選項。預設點擊開啟選單;可在下方更改點擊行為。"; +"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 reply" = "回覆後自動切換限時動態"; +"Advance when marking as seen" = "標記為已看後自動切換限時動態"; +"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." = "全部封鎖:封鎖所有限時動態 — 列表用戶為例外。\n選擇封鎖:僅封鎖列表用戶 — 其他正常。\n兩個列表獨立儲存。"; +"Blocking mode" = "封鎖模式"; +"Button = single-tap mark seen. Toggle = tap toggles story read receipts on/off (eye fills blue when on)" = "按鈕 = 單擊標記已查看。切換 = 點擊切換故事已讀回條開/關(開啟時眼睛填滿藍色)"; +"Disable instants creation" = "停用即時創建"; +"Disable story seen receipt" = "停用故事已讀回條"; +"Enable story user list" = "啟用故事用戶列表"; +"Hides the functionality to create/send instants" = "隱藏創建/發送即時訊息的功能"; +"Hides the notification for others when you view their story" = "隱藏你查看他人故事時的通知"; +"Inserts a button next to the seen/eye button on story overlays" = "在故事覆蓋層的已查看/眼睛按鈕旁插入一個按鈕"; +"Keep stories visually seen locally" = "在本機保留限時動態為已觀看"; +"Liking a story automatically advances to the next one after a short delay" = "喜歡故事後短暫延遲自動跳到下一則"; +"Manage list" = "管理列表"; +"Manage list (%lu)" = "管理列表 (%lu)"; +"Manual seen button mode" = "手動已查看按鈕模式"; +"Mark seen on story like" = "喜歡故事時標記為已查看"; +"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 when you send a reply or emoji reaction, even with seen blocking on" = "發送回覆或表情反應時標記故事為已查看,即使啟用已查看封鎖"; +"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server" = "在本機標記限時動態為已觀看(灰圈),同時仍封鎖伺服器端的已讀回條"; +"Master toggle. When off, the list is ignored" = "主開關。關閉時忽略列表"; +"Other" = "其他"; +"Playback" = "播放"; +"Quick list button in stories" = "故事中的快速列表按鈕"; +"Search, sort, swipe to remove" = "搜尋、排序、滑動移除"; +"Seen receipts" = "已讀回條"; +"Sending a reply or emoji reaction automatically advances to the next story" = "發送回覆或表情反應後自動跳到下一則故事"; +"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" = "在故事上顯示眼睛按鈕以新增/移除用戶。關閉 = 僅使用三點選單或長按"; +"Stickers" = "貼圖"; +"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray." = "在互動前預覽投票/測驗/滑桿結果 — 仍可照常點擊投票。「強制測驗」會將舊版測驗貼圖加回限動製作工具列。"; +"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally." = "在 Reels 互動前預覽投票/測驗/滑桿結果 — 仍可照常點擊投票。"; +"Force Quiz sticker in tray" = "強制顯示測驗貼圖"; +"Adds Quiz back to the story sticker picker" = "將測驗加回限動貼圖選擇器"; +"Show quiz answer" = "顯示測驗答案"; +"Circle the correct option on quiz stickers, or the leading option on polls" = "圈選測驗貼圖中的正確選項,或投票中得票最多的選項"; +"Show poll vote counts" = "顯示投票計數"; +"Show vote tallies on poll options and slider count/average before you vote" = "在投票前顯示投票選項的票數以及滑桿的平均/計數"; +"Stop story auto-advance" = "停止故事自動跳轉"; +"Stories" = "故事"; +"Stories won't auto-skip to the next one when the timer ends. Tap to advance manually" = "故事計時結束時不會自動跳到下一則。點擊手動前進"; +"Story audio toggle" = "故事音訊切換"; +"Story user list" = "故事用戶列表"; +"Tapping the eye button to mark a story as seen advances to the next story automatically" = "點擊眼睛按鈕標記故事為已查看後自動跳到下一則故事"; +"View story mentions" = "查看故事提及"; +"Which stories get seen-receipt blocking" = "哪些故事會封鎖已讀回條"; + +////////////////////////////////////////////////////////////////////////////// +// MESSAGES — READ RECEIPTS // +// Settings → Read receipts tab // +////////////////////////////////////////////////////////////////////////////// + +"Adds a button to DM threads to mark messages as seen" = "在私訊串中新增標記訊息為已查看的按鈕"; +"Auto mark seen on interact" = "互動時自動標記為已查看"; +"Auto mark seen on typing" = "輸入時自動標記為已查看"; +"Control when messages are marked as seen" = "控制訊息何時標記為已查看"; +"How the seen button behaves" = "已查看按鈕的行為方式"; +"Manually mark messages as seen" = "手動標記訊息為已讀"; +"Marks messages as seen when you send any message" = "當你發送任何訊息時標記訊息為已讀"; +"Marks messages as seen when you start typing" = "當你開始輸入時標記訊息為已讀"; +"Read receipt mode" = "已讀回條模式"; +"Read receipts" = "已讀回條"; + +////////////////////////////////////////////////////////////////////////////// +// MESSAGES — KEEP DELETED // +// Settings → Keep deleted messages tab // +////////////////////////////////////////////////////////////////////////////// + +"Activity" = "活動"; +"Adds a 'Download' option to the long-press menu on voice messages to save them as M4A audio" = "在語音訊息的長按選單中新增「下載」選項,以儲存為 M4A 音訊檔"; +"Adds a 'Send File' option to the plus menu in DMs. Supported file types may be limited by Instagram" = "在私訊的加號選單中新增「傳送檔案」選項。支援的檔案類型可能受 Instagram 限制"; +"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" = "在備註的長按選單中新增複製文字、下載 GIF/音訊功能"; +"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." = "全部封鎖:所有聊天封鎖 — 列出的聊天為例外。\n選擇封鎖:僅封鎖列出的聊天 — 其他皆正常。\n兩個列表獨立儲存。長按收件匣中的聊天以新增或移除。"; +"Block keep-deleted for excluded chats" = "對排除的聊天封鎖保留刪除訊息"; +"Block keep-deleted for unlisted chats" = "對未列出的聊天封鎖保留刪除訊息"; +"Chat list" = "聊天列表"; +"Confirmation dialog before clearing preserved messages" = "清除保留訊息前的確認對話框"; +"Copies note text directly on long press without opening the menu" = "長按直接複製備註文字,不開啟選單"; +"Copy text on hold" = "長按複製文字"; +"Custom emojis and background/text colors" = "自訂表情符號及背景/文字顏色"; +"Custom note themes" = "自訂備註主題"; +"Disable vanish mode swipe" = "停用 Vanish 模式滑動手勢"; +"Disable screenshot detection" = "停用截圖偵測"; +"Disable typing status" = "停用輸入狀態"; +"Disable view-once limitations" = "停用一次觀看限制"; +"Download voice messages" = "下載語音訊息"; +"Enable chat list" = "啟用聊天列表"; +"Enable note theming" = "啟用備註主題功能"; +"Enables the notes theme picker" = "啟用備註主題選擇器"; +"Files" = "檔案"; +"Full last active date" = "完整最後活躍日期"; +"Hide reels blend button" = "隱藏 Reels 混合按鈕"; +"Hide video call button" = "隱藏視訊通話按鈕"; +"Hide voice call button" = "隱藏語音通話按鈕"; +"Hides the blend button in DMs" = "在私訊中隱藏混合按鈕"; +"Hides typing indicator from others" = "隱藏他人輸入指示器"; +"Indicate unsent messages" = "標示未送出訊息"; +"Keep deleted messages" = "保留已刪除訊息"; +"Makes view-once messages behave like normal visual messages (loopable/pauseable)" = "讓一次觀看訊息行為如一般視覺訊息(可循環/暫停)"; +"Note actions" = "備註操作"; +"Preserve messages that others unsend" = "保留他人收回的訊息"; +"Preserves messages that others unsend" = "保留他人收回的訊息"; +"Prevents accidental swipe-up activation of vanish mode" = "防止誤觸上滑啟用 Vanish 模式"; +"Quick list button in chats" = "聊天中的快速列表按鈕"; +"Removes the audio call button from DM thread header" = "從私訊頁首移除語音通話按鈕"; +"Removes the screenshot-prevention features for visual messages in DMs" = "移除私訊中視覺訊息的截圖防護功能"; +"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" = "重播視覺訊息且不會過期。可在眼睛按鈕選單切換,或當眼睛按鈕被禁用時作為獨立按鈕"; +"Search, sort, swipe to remove or toggle keep-deleted" = "搜尋、排序、滑動移除或切換保留已刪除"; +"Send audio as file" = "以檔案形式傳送音訊"; +"Send files (experimental)" = "傳送檔案(實驗性功能)"; +"Show full date instead of \"Active 2h ago\"" = "顯示完整日期,取代 \"Active 2h ago\""; +"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 an \"Unsent\" label on preserved messages" = "在保留訊息上顯示 \"Unsent\" 標籤"; +"Unlimited replay of visual messages" = "視覺訊息無限重播"; +"Unsent message notification" = "訊息撤回通知"; +"Voice messages" = "語音訊息"; +"Warn before clearing on refresh" = "刷新時清除前先警告"; +"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." = "⚠️ 私訊分頁下拉刷新會清除所有保留訊息。啟用以下警告可顯示確認對話框。"; + +////////////////////////////////////////////////////////////////////////////// +// MESSAGES // +// Settings → Messages tab // +////////////////////////////////////////////////////////////////////////////// + +"Messages" = "訊息"; +"Threads" = "主題串"; + +////////////////////////////////////////////////////////////////////////////// +// NAVIGATION // +// Settings → Navigation tab // +////////////////////////////////////////////////////////////////////////////// + +"Also hide the bottom tab bar — only the inbox is visible" = "同時隱藏底部分頁列 — 僅顯示收件匣"; +"Hide create tab" = "隱藏建立分頁"; +"Hide explore tab" = "隱藏探索分頁"; +"Hide feed tab" = "隱藏動態分頁"; +"Hide messages tab" = "隱藏訊息分頁"; +"Hide reels tab" = "隱藏短片分頁"; +"Hide tab bar" = "隱藏分頁列"; +"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 direct messages 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 reels tab on the bottom navigation bar" = "隱藏底部導覽列的短片分頁"; +"Hiding tabs" = "隱藏分頁"; +"Icon order" = "圖示排序"; +"Launch tab" = "啟動分頁"; +"Lets you swipe to switch between navigation bar tabs" = "允許滑動切換導覽列分頁"; +"Messages only" = "僅限訊息"; +"Messages-only mode" = "僅限訊息模式"; +"Navigation" = "導覽"; +"Swipe between tabs" = "在分頁間滑動"; +"Tab the app opens to. Ignored when Messages-only is on" = "應用程式開啟時的分頁。啟用僅限訊息時忽略此設定"; +"The order of the icons on the bottom navigation bar" = "底部導覽列圖示的順序"; +"Turn IG into a DM-only client" = "將 IG 變成僅限私訊的客戶端"; + +////////////////////////////////////////////////////////////////////////////// +// CONFIRM ACTIONS // +// Settings → Confirm actions tab // +////////////////////////////////////////////////////////////////////////////// + +"Confirm actions" = "確認操作"; +"Confirm video call" = "確認視訊通話"; +"Confirm voice call" = "確認語音通話"; +"Confirm changing theme" = "確認更換主題"; +"Confirm follow" = "確認追蹤"; +"Confirm follow requests" = "確認追蹤請求"; +"Confirm like: Posts" = "確認按讚:貼文"; +"Confirm like: Reels" = "確認按讚:Reels"; +"Confirm posting comment" = "確認發佈留言"; +"Confirm repost" = "確認轉貼"; +"Confirm vanish mode" = "確認消失模式"; +"Confirm sticker interaction (stories)" = "確認貼圖互動(限時動態)"; +"Confirm sticker interaction (highlights)" = "確認貼圖互動(精選)"; +"Confirm story emoji reaction" = "確認限時動態表情反應"; +"Confirm story like" = "確認限時動態按讚"; +"Confirm unfollow" = "確認取消追蹤"; +"Confirm voice messages" = "確認語音訊息"; +"Shows an alert before sending an emoji reaction on a story" = "發送限時動態表情反應前顯示警告"; +"Shows an alert to confirm before sending a voice message" = "發送語音訊息前顯示確認提示"; +"Shows an alert to confirm before toggling vanish mode" = "切換消失模式前顯示確認提示"; +"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 tap a sticker on someone's story" = "點擊他人限時動態貼圖時顯示確認提示"; +"Shows an alert when you tap a sticker inside a highlight" = "點擊精選中的貼圖時顯示確認提示"; +"Shows an alert when you click the video call button to confirm before calling" = "點擊視訊通話按鈕時顯示確認警告"; +"Shows an alert when you click the voice 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 like button on posts to confirm the like" = "點擊貼文按讚按鈕時顯示確認警告"; +"Shows an alert when you click the like button on reels to confirm the like" = "點擊 Reels 的按讚按鈕時顯示確認提示"; +"Shows an alert when you click the like button on stories to confirm the like" = "點擊限時動態按讚按鈕時顯示確認警告"; +"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 unfollow button to confirm" = "點擊取消追蹤按鈕時顯示確認提示"; + +////////////////////////////////////////////////////////////////////////////// +// BACKUP & RESTORE // +// Settings → Backup & Restore tab // +////////////////////////////////////////////////////////////////////////////// + +"Backup & Restore" = "備份與還原"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED // +// Settings → Advanced tab // +////////////////////////////////////////////////////////////////////////////// + +"Advanced" = "進階"; +"Auto-clear cache" = "自動清除快取"; +"Automatically opens settings when the app launches" = "啟動應用程式時自動開啟設定"; +"Cache" = "快取"; +"Cache cleared" = "快取已清除"; +"Calculating cache size…" = "計算快取大小中…"; +"Clear" = "清除"; +"Clear cache" = "清除快取"; +"Clear cache (%@)" = "清除快取 (%@)"; +"Clear cache?" = "要清除快取嗎?"; +"Clearing cache…" = "清除快取中…"; +"Clearing still scans on demand." = "關閉時仍會按需掃描。"; +"Daily" = "每日"; +"Disable safe mode" = "停用安全模式"; +"Enable tweak settings quick-access" = "啟用調整設定快速存取"; +"Free %@ of Instagram cache. A restart is recommended." = "釋放 %@ 的 Instagram 快取。建議重新啟動。"; +"Freed %@. Restart to apply." = "已釋放 %@。重新啟動以套用。"; +"Hold on the home tab to open RyukGram settings" = "長按主頁標籤以開啟 RyukGram 設定"; +"Instagram" = "Instagram"; +"Monthly" = "每月"; +"Nothing to clear" = "無可清除內容"; +"Off skips the size scan when Advanced opens." = "關閉時,開啟進階頁不會掃描大小。"; +"Pause playback when opening settings" = "開啟設定時暫停播放"; +"Pauses any playing video/audio when settings opens" = "設定開啟時暫停任何正在播放的影片/音訊"; +"Prevents Instagram from resetting settings after crashes (at your own risk)" = "防止 Instagram 崩潰後重置設定(風險自負)"; +"Remove Instagram's cached images, videos, and temporary files." = "移除 Instagram 的快取圖片、影片及暫存檔案。"; +"Reset onboarding state" = "重設引導狀態"; +"Run a silent cache clear on launch when the interval has elapsed." = "啟動時在達到間隔後靜默清除快取。"; +"Show cache size" = "顯示快取大小"; +"Show tweak settings on app launch" = "啟動應用程式時顯示調整設定"; +"Weekly" = "每週"; + +////////////////////////////////////////////////////////////////////////////// +// ADVANCED EXPERIMENTAL // +// Settings → Advanced → Advanced experimental features // +////////////////////////////////////////////////////////////////////////////// + +"Actions" = "操作"; +"Advanced experimental features" = "進階實驗功能"; +"All experimental toggles will be turned off. Instagram will restart." = "所有實驗開關都會關閉,Instagram 將重新啟動。"; +"Direct Notes — Audio reply" = "Direct 記事 — 語音回覆"; +"Direct Notes — Avatar reply" = "Direct 記事 — Avatar 回覆"; +"Direct Notes — Friend Map" = "Direct 記事 — 朋友地圖"; +"Direct Notes — GIFs & stickers reply" = "Direct 記事 — GIF 與貼圖回覆"; +"Direct Notes — Photo reply" = "Direct 記事 — 照片回覆"; +"Disabled after repeated crashes." = "因多次閃退已停用。"; +"Enables GIF/sticker replies" = "啟用 GIF/貼圖回覆"; +"Enables photo replies" = "啟用照片回覆"; +"Enables the audio-note reply type" = "啟用語音記事回覆"; +"Enables the avatar reply type" = "啟用 Avatar 回覆"; +"Experimental flags reset" = "實驗旗標已重置"; +"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times." = "選擇要啟用的項目後點「套用」以重新啟動。部分功能可能無法在所有帳號或 IG 版本上運作。若 IG 連續 3 次啟動閃退,旗標會自動重置。"; +"Forces Prism-gated experiments on" = "強制啟用 Prism 相關實驗"; +"Forces the Homecoming home surface / nav on" = "強制啟用 Homecoming 首頁/導覽"; +"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray" = "強制在動態、收件匣、限時動態與記事列顯示 QuickSnap/Instants"; +"Got it" = "了解"; +"Heads up" = "注意"; +"Hidden Instagram experiments" = "隱藏的 Instagram 實驗"; +"Hidden Instagram experiments (in Advanced)" = "隱藏的 Instagram 實驗(位於進階設定)"; +"Homecoming" = "Homecoming"; +"Notes & QuickSnap" = "記事與 QuickSnap"; +"Prism design system" = "Prism 設計系統"; +"QuickSnap (Instants)" = "QuickSnap(Instants)"; +"Reset all experimental flags" = "重置所有實驗旗標"; +"Reset experimental flags?" = "要重置實驗旗標嗎?"; +"Restart Instagram to apply changes" = "重新啟動 Instagram 以套用變更"; +"Shows the friend map entry in Direct Notes" = "在 Direct 記事顯示朋友地圖入口"; +"Surfaces" = "介面"; +"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts." = "這些開關會啟用 Instagram 的隱藏實驗。部分功能可能無法在所有帳號或 IG 版本上運作。若 IG 連續 3 次啟動閃退,旗標會自動重置。"; +"Toggle hidden Instagram experiments. Some may not work on every account or IG version." = "啟用 Instagram 的隱藏實驗。部分可能無法在所有帳號或 IG 版本上運作。"; +"Turn every experimental toggle off" = "關閉所有實驗開關"; + +////////////////////////////////////////////////////////////////////////////// +// DEBUG // +// Settings → Debug tab // +////////////////////////////////////////////////////////////////////////////// + +"Button Cell" = "按鈕欄位"; +"Change the value on the right" = "更改右側數值"; +"Could not delete: %@" = "無法刪除:%@"; +"Debug" = "除錯"; +"Delete an imported override and fall back to the shipped strings" = "刪除匯入的覆寫檔並回退到內建字串"; +"Deleted %@ override. Restart to apply." = "已刪除 %@ 覆寫檔。重新啟動以套用。"; +"Enable FLEX gesture" = "啟用 FLEX 手勢"; +"Export English strings" = "匯出英文字串"; +"Hold 5 fingers on the screen to open FLEX" = "用五指按住螢幕以開啟 FLEX"; +"I have %@%@" = "我有 %@%@"; +"Import a .strings file for a language" = "為語言匯入 .strings 檔"; +"Import a .strings file to update a translation. Pick a language, select the file, restart." = "匯入 .strings 檔以更新翻譯。選擇語言、挑選檔案,然後重新啟動。"; +"Link Cell" = "連結欄位"; +"Localization" = "本地化"; +"Menu Cell" = "選單欄位"; +"Navigation Cell" = "導覽單元"; +"No imported localization files to reset." = "無可重設的匯入本地化檔案。"; +"No overrides" = "無覆寫"; +"Open FLEX on app focus" = "應用程式聚焦時開啟 FLEX"; +"Open FLEX on app launch" = "應用程式啟動時開啟 FLEX"; +"Opens FLEX when the app is focused" = "應用程式聚焦時開啟 FLEX"; +"Opens FLEX when the app launches" = "應用程式啟動時開啟 FLEX"; +"Pick a language to delete the imported file" = "選擇要刪除匯入檔的語言"; +"Reset localization" = "重設本地化"; +"Share the base English .strings file for translating" = "分享英文基準 .strings 檔以供翻譯"; +"Static Cell" = "靜態欄位"; +"Stepper cell" = "步進器欄位"; +"Switch Cell" = "切換欄位"; +"Switch Cell (Restart)" = "切換欄位(重新啟動)"; +"Tap the switch" = "點擊切換開關"; +"These features rely on hidden Instagram flags and may not work on all accounts or versions." = "這些功能依賴隱藏的 Instagram 標誌,可能不適用於所有帳號或版本。"; +"Update localization file" = "更新本地化檔案"; +"Using icon" = "使用圖示"; +"Using image" = "使用圖片"; + +////////////////////////////////////////////////////////////////////////////// +// DOWNLOADS & MEDIA ACTIONS // +// Action button menus, download/share/copy toasts, quality picker pills. // +////////////////////////////////////////////////////////////////////////////// + +"%@ settings" = "%@ 設定"; +"Cancelled" = "已取消"; +"Copied %lu URLs" = "已複製 %lu 個網址"; +"Copied caption" = "已複製說明文字"; +"Copied download URL" = "已複製下載網址"; +"Copy all URLs" = "複製所有網址"; +"Copy caption" = "複製說明文字"; +"Copy download URL" = "複製下載網址"; +"Could not extract any URLs" = "無法擷取任何網址"; +"Could not extract media URL" = "無法擷取媒體網址"; +"Could not extract photo URL" = "無法擷取照片網址"; +"Could not extract video URL" = "無法擷取影片網址"; +"Done" = "完成"; +"Download all (%lu)" = "全部下載 (%lu)"; +"Download all stories and share?" = "下載所有限時動態並分享?"; +"Download all to Photos" = "全部下載到「照片」"; +"Download and share all" = "全部下載並分享"; +"Download and share?" = "下載並分享?"; +"Download failed" = "下載失敗"; +"Downloaded %lu items" = "已下載 %lu 項目"; +"Downloading %@..." = "正在下載 %@..."; +"Downloading..." = "正在下載..."; +"Failed to save" = "儲存失敗"; +"HD download complete" = "高清下載完成"; +"Mute audio" = "靜音音訊"; +"No caption on this post" = "此貼文無說明文字"; +"No carousel children" = "無輪播子項目"; +"No cover image" = "無封面圖片"; +"No files downloaded" = "無檔案下載"; +"No media" = "無媒體"; +"No media to expand" = "無媒體可展開"; +"No media to show" = "無媒體可顯示"; +"No media URL" = "無媒體網址"; +"No URLs" = "無網址"; +"No URLs found" = "找不到網址"; +"No video URL" = "無影片網址"; +"Not a carousel" = "非輪播貼文"; +"Nothing to save" = "無可儲存內容"; +"Nothing to share" = "無可分享內容"; +"Opening creator..." = "正在開啟創作者..."; +"Photo library access denied" = "照片庫存取被拒"; +"Photos access denied" = "照片存取被拒"; +"Preparing repost..." = "準備重新發布..."; +"Repost" = "重新發布"; +"Repost unavailable" = "無法重新發布"; +"Save all stories to Photos?" = "要將所有限時動態儲存到「照片」嗎?"; +"Save failed" = "儲存失敗"; +"Save to Photos?" = "要儲存到「照片」嗎?"; +"Saved %lu items" = "已儲存 %lu 項目"; +"Saved to Photos" = "已儲存至照片"; +"Saved to RyukGram" = "已儲存至 RyukGram"; +"Tap to cancel" = "點擊以取消"; +"Unmute audio" = "取消靜音"; +"View cover" = "查看封面"; +"View mentions" = "查看提及"; + +////////////////////////////////////////////////////////////////////////////// +// STORIES & MESSAGES (FEATURES) // +// Buttons, menu entries, toasts and alerts shown while watching stories or // +// inside DM threads. // +////////////////////////////////////////////////////////////////////////////// + +"A message was unsent" = "訊息已撤回"; +"Add" = "新增"; +"Add to block list" = "加入封鎖名單"; +"Add to block list?" = "要加入封鎖名單嗎?"; +"Added to block list" = "已加入封鎖名單"; +"Added to exclude list" = "已加入排除清單"; +"Audio not loaded yet. Play the message first and try again." = "音訊尚未載入。請先播放訊息後再試。"; +"Audio sent" = "音訊已傳送"; +"Audio/Video from Files" = "來自檔案的音訊/影片"; +"Blocked" = "已封鎖"; +"Cancel" = "取消"; +"Clear preserved messages?" = "要清除保留的訊息嗎?"; +"Converting..." = "轉換中..."; +"Copy text" = "複製文字"; +"Could not find media" = "找不到媒體"; +"Could not find story media" = "找不到故事媒體"; +"Could not get audio data. Try again after refreshing the chat." = "無法取得音訊資料。請刷新聊天後再試。"; +"Could not get video URL" = "無法取得影片網址"; +"Disable read receipts" = "停用已讀回條"; +"Disappearing media" = "消失訊息媒體"; +"Done!" = "完成!"; +"Download audio" = "下載音訊"; +"Downloading audio..." = "音訊下載中..."; +"Enable read receipts" = "啟用已讀回條"; +"Error: %@" = "錯誤:%@"; +"Exclude chat" = "排除聊天"; +"Exclude from seen" = "排除已讀"; +"Exclude story seen" = "排除故事已讀"; +"Excluded" = "已排除"; +"Extracting audio..." = "擷取音訊中..."; +"Failed to encode GIF" = "GIF 編碼失敗"; +"File sending not supported" = "不支援檔案傳送"; +"Follow" = "追蹤"; +"Following" = "已追蹤"; +"Inserts a button on disappearing media overlays" = "在消失訊息媒體覆蓋層上插入按鈕"; +"Inserts a speaker button to mute/unmute disappearing media" = "插入喇叭按鈕以靜音/取消靜音消失訊息媒體"; +"Inserts an eye button to mark the current disappearing media as viewed" = "插入眼睛按鈕,將當前消失訊息媒體標記為已檢視"; +"Mark as viewed" = "標記為已檢視"; +"Mark messages as seen" = "標記訊息為已讀"; +"Mark seen" = "標記已讀"; +"Marked as seen" = "標記為已查看"; +"Marked as viewed" = "標記為已閱覽"; +"Marked messages as seen" = "標記訊息為已查看"; +"Mentions" = "提及"; +"Message sender not found" = "找不到訊息發送者"; +"Messages settings" = "訊息設定"; +"Audio URL not available" = "音訊網址無法取得"; +"Mute story audio" = "靜音故事音訊"; +"No audio URL found. Try again after refreshing the chat." = "找不到音訊 URL。請刷新聊天後再試。"; +"No mentions in this story" = "此故事中無提及"; +"No thread key" = "找不到討論串金鑰"; +"No voice send method found" = "找不到語音發送方式"; +"Note not found" = "找不到筆記"; +"Note text copied" = "筆記文字已複製"; +"Open GitHub" = "開啟 GitHub"; +"Read receipts disabled" = "已停用已讀回條"; +"Read receipts enabled" = "已啟用已讀回條"; +"Read receipts will be blocked for this chat." = "此聊天將封鎖已讀回條。"; +"Read receipts will no longer be blocked for this chat." = "此聊天將不再封鎖已讀回條。"; +"Remove" = "移除"; +"Remove from block list" = "從封鎖清單移除"; +"Remove from block list?" = "確定要從封鎖清單移除?"; +"Remove from exclude list" = "從排除清單移除"; +"Removed" = "已移除"; +"Removed from list" = "已從清單移除"; +"Save GIF" = "儲存 GIF"; +"Selection too short (min 0.5s)" = "選取太短(最短 0.5 秒)"; +"Send anyway" = "仍要傳送"; +"Send Audio" = "傳送音訊"; +"Send failed: %@" = "傳送失敗:%@"; +"Send service not found" = "找不到傳送服務"; +"Show audio toggle" = "顯示音訊切換"; +"Show mark-as-viewed button" = "顯示標記已檢視按鈕"; +"Story read receipts disabled" = "故事已讀回條已停用"; +"Story read receipts enabled" = "故事已讀回條已啟用"; +"This chat will resume normal read-receipt behavior." = "此聊天將恢復正常的已讀回條行為。"; +"Total: %@" = "總計:%@"; +"Un-exclude chat" = "取消排除聊天"; +"Un-exclude chat?" = "確定要取消排除聊天?"; +"Un-exclude story seen" = "取消排除故事已讀"; +"Un-excluded" = "已取消排除"; +"Unblocked" = "已解除封鎖"; +"Unlimited replay enabled" = "已啟用無限重播"; +"Unmute story audio" = "取消靜音故事音訊"; +"Unsent" = "未發送"; +"Upload Audio" = "上傳音訊"; +"VC not found" = "找不到 VC"; +"Video from Library" = "從資料庫選擇影片"; +"Visual messages will expire" = "視覺訊息將會過期"; +"Visual messages: expiring" = "視覺訊息:即將過期"; +"Visual messages: unlimited replay" = "視覺訊息:無限重播"; +"Will sync when leaving stories" = "離開故事時會同步"; + +////////////////////////////////////////////////////////////////////////////// +// GENERAL FEATURES // +// Strings inside per-feature overlays: fake location, color picker, notes // +// customization, profile copy, etc. // +////////////////////////////////////////////////////////////////////////////// + +"Add location" = "新增位置"; +"Add preset" = "新增預設"; +"Change location" = "更改位置"; +"Click the Apply button after this to see the emoji" = "點擊套用按鈕後即可看到表情符號"; +"Clipboard is not an Instagram URL" = "剪貼簿內容不是 Instagram 網址"; +"Comments hidden" = "留言已隱藏"; +"Comments shown" = "留言已顯示"; +"Copied text to clipboard" = "文字已複製到剪貼簿"; +"Copy" = "複製"; +"Copy all" = "全部複製"; +"Copy bio" = "複製個人簡介"; +"Copy from profile" = "從個人檔案複製"; +"Copy name" = "複製名稱"; +"Could not find cover image" = "找不到封面圖片"; +"Current: %@" = "目前:%@"; +"Disable" = "停用"; +"Download GIF" = "下載 GIF"; +"Dropped pin" = "釘選位置"; +"Enable" = "啟用"; +"Enable Location Services for Instagram in Settings to use your current location." = "請在設定中為 Instagram 啟用定位服務以使用目前位置。"; +"Enter Emoji Text" = "輸入表情符號文字"; +"Fake location" = "假位置"; +"Location access denied" = "定位存取被拒"; +"Location Services off" = "定位服務已關閉"; +"Name" = "名稱"; +"Nothing to copy" = "無內容可複製"; +"Open Settings" = "開啟設定"; +"Pick location" = "選擇位置"; +"Save" = "儲存"; +"Save preset" = "儲存預設"; +"Saved locations" = "已儲存位置"; +"Select color" = "選擇顏色"; +"Set location" = "設定位置"; +"Settings…" = "設定…"; +"Turn Location Services on in Settings → Privacy to use your current location." = "請在設定 → 隱私中開啟定位服務以使用目前位置。"; +"Type emoji..." = "輸入表情符號…"; + +"Theme" = "主題"; +"Appearance" = "外觀"; +"Keyboard" = "鍵盤"; +"Force dark mode" = "強制深色模式"; +"Keep Instagram in dark appearance regardless of iOS system setting" = "無論 iOS 系統設定為何,皆讓 Instagram 保持深色外觀"; +"Full OLED" = "完整 OLED"; +"Replace Instagram's dark grays with pure black across the entire app" = "將 Instagram 整個應用程式的深灰色替換為純黑"; +"OLED chat theme" = "OLED 聊天主題"; +"Pure black DM thread background and incoming message bubbles" = "純黑色私訊背景及收到訊息氣泡"; +"Keyboard theme" = "鍵盤主題"; +"Override the keyboard appearance when typing inside Instagram" = "在 Instagram 內打字時覆寫鍵盤外觀"; +"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black." = "深色使用系統深色鍵盤。OLED 強制鍵盤背景為純黑。"; +"Dark" = "深色"; +"OLED" = "OLED"; +"Apply & restart" = "套用並重新啟動"; +"Restart Instagram to apply your theme changes" = "重新啟動 Instagram 以套用主題變更"; +"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing." = "主題變更僅在應用程式重新啟動後才會生效。選擇完成後點擊下方的「套用」。"; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE ANALYZER // +// Settings → Profile Analyzer // +////////////////////////////////////////////////////////////////////////////// + +"%lu followers · %lu following" = "%lu 粉絲 · %lu 追蹤中"; +"%lu of %lu" = "%lu / %lu"; +"Analysis complete" = "分析完成"; +"Analysis failed" = "分析失敗"; +"Another analysis is already running" = "已經有另一個分析正在執行"; +"Available after your next scan" = "將於下次掃描後提供"; +"Cancelled" = "已取消"; +"Couldn't fetch profile information" = "無法取得個人資料資訊"; +"Fetching followers (%lu/%ld)…" = "正在取得粉絲 (%lu/%ld)…"; +"Fetching following (%lu/%ld)…" = "正在取得追蹤中 (%lu/%ld)…"; +"Fetching profile info…" = "正在取得個人資料資訊…"; +"Categories" = "類別"; +"First scan: %@" = "首次掃描:%@"; +"Follower count exceeds %ld — analysis disabled to avoid rate limits." = "粉絲數超過 %ld — 已停用分析以避免速率限制。"; +"Gained since last scan" = "自上次掃描後增加"; +"Last scan: %@" = "上次掃描:%@"; +"Lost followers" = "失去的粉絲"; +"Mutual followers" = "互相追蹤"; +"Name: %@ → %@" = "名稱:%@ → %@"; +"New followers" = "新粉絲"; +"No results" = "無結果"; +"No active Instagram session found" = "找不到已登入的 Instagram 工作階段"; +"No scan yet" = "尚未掃描"; +"Not following you back" = "未回追你"; +"OK" = "OK"; +"Private account" = "私人帳號"; +"Profile Analyzer" = "個人檔案分析器"; +"Profile picture changed" = "大頭貼已變更"; +"Profile updates" = "個人檔案更新"; +"Removes cached snapshots for this account. You'll lose since-last-scan diffs." = "移除此帳號的快取快照。你將失去「自上次掃描後」的差異資料。"; +"Request failed" = "請求失敗"; +"Reset analyzer data?" = "要重設分析器資料嗎?"; +"Run analysis" = "執行分析"; +"Run your first analysis" = "執行首次分析"; +"Search username or name" = "搜尋使用者名稱或名稱"; +"Since last scan" = "自上次掃描後"; +"Starting…" = "開始中…"; +"They follow you, you don't follow back" = "對方追蹤你,你未回追"; +"Too many followers" = "粉絲過多"; +"Too many followers to analyze" = "粉絲數量過多,無法分析"; +"Unfollow" = "取消追蹤"; +"Unfollow @%@?" = "取消追蹤 @%@?"; +"Unfollowed you since last scan" = "自上次掃描後取消追蹤你"; +"Username, name or picture changes" = "使用者名稱、名稱或大頭貼變更"; +"Username: @%@ → @%@" = "使用者名稱:@%@ → @%@"; +"We refuse to run when the follower count exceeds %ld to avoid Instagram rate limits." = "粉絲數超過 %ld 時我們拒絕執行,以避免 Instagram 速率限制。"; +"You both follow each other" = "你們互相追蹤"; +"You don't follow back" = "你未回追"; +"You follow them, they don't follow back" = "你追蹤對方,對方未回追"; +"You started following" = "你開始追蹤"; +"You unfollowed" = "你已取消追蹤"; + +"%@ %lu accounts? The first %ld will be processed to avoid rate limits." = "%@ %lu 個帳號?為避免速率限制,僅處理前 %ld 個。"; +"%@ %lu accounts? This runs sequentially with a short pause between each." = "%@ %lu 個帳號?此操作會依序執行,每個之間有短暫停頓。"; +"%lu account(s) · %lu snapshot(s) · tap to inspect" = "%lu 個帳號 · %lu 個快照 · 點擊以檢視"; +"%lu accounts followed" = "已追蹤 %lu 個帳號"; +"%lu accounts unfollowed" = "已取消追蹤 %lu 個帳號"; +"%lu entries across %lu lists · tap to inspect" = "%lu 筆項目,分佈於 %lu 個清單 · 點擊以檢視"; +"%lu preferences · tap to inspect" = "%lu 項偏好設定 · 點擊以檢視"; +"(empty)" = "(空)"; +"(no analyzer data)" = "(無分析器資料)"; +"(no lists)" = "(無清單)"; +"About Profile Analyzer" = "關於個人檔案分析器"; +"All preferences (%lu)" = "所有偏好設定 (%lu)"; +"Apply imported data?" = "要套用匯入的資料嗎?"; +"Batch follow" = "批次追蹤"; +"Batch follow finished" = "批次追蹤完成"; +"Batch unfollow" = "批次取消追蹤"; +"Batch unfollow finished" = "批次取消追蹤完成"; +"Continue" = "繼續"; +"Current snapshot" = "目前快照"; +"Embed domains" = "嵌入網域"; +"Excluded lists" = "排除清單"; +"Excluded story users" = "排除的限時動態用戶"; +"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect." = "所選範圍的既有值將被覆寫。部分變更可能需重新啟動應用程式後才會生效。"; +"Export" = "匯出"; +"File has no importable sections." = "檔案中沒有可匯入的區段。"; +"File is not a valid RyukGram export." = "檔案不是有效的 RyukGram 匯出檔。"; +"Filter" = "篩選"; +"First scan: we collect your followers and following lists and save them locally." = "首次掃描:我們會收集你的粉絲及追蹤清單並儲存在本機。"; +"Follow %lu" = "追蹤 %lu"; +"Followers" = "粉絲"; +"Following… %lu / %lu" = "追蹤中… %lu / %lu"; +"Full name" = "全名"; +"Heads up: this feature is in beta and hits Instagram's private API. Running it back-to-back or right after heavy follow/unfollow activity can trigger a short rate-limit. Use it sparingly and at your own risk." = "注意:此功能為 Beta 版本,會呼叫 Instagram 的私有 API。連續執行或在大量追蹤/取消追蹤活動之後立即執行可能觸發短暫速率限制。請謹慎使用,風險自負。"; +"Import complete" = "匯入完成"; +"Include" = "包含"; +"Included story users" = "包含的限時動態用戶"; +"Inspect the full payload" = "檢視完整負載資料"; +"Keep scan history" = "保留掃描歷史"; +"Large accounts are blocked: analysis is disabled above 13,000 followers to avoid Instagram rate-limiting the whole app." = "大型帳號已被封鎖:粉絲數超過 13,000 時停用分析,以避免 Instagram 對整個應用程式施加速率限制。"; +"Nothing is uploaded — everything stays on this device and can be wiped from the trash icon." = "不會上傳任何資料 — 所有資料僅保留在此裝置,可透過垃圾桶圖示清除。"; +"Not verified only" = "僅限未認證"; +"Nothing was applied." = "未套用任何變更。"; +"Posts" = "貼文"; +"Preferences" = "偏好設定"; +"Previous snapshot" = "上一個快照"; +"Private only" = "僅限私人"; +"Profile Analyzer data" = "個人檔案分析器資料"; +"Raw" = "原始"; +"Raw JSON" = "原始 JSON"; +"Reset analyzer data" = "重設分析器資料"; +"Reset complete" = "重設完成"; +"Reset selected data?" = "要重設選取的資料嗎?"; +"Second scan onward: each scan compares against the last, so we can show gained/lost followers, your own follow/unfollow moves, and profile updates." = "第二次掃描起:每次掃描都會與上一次比較,以顯示增加/失去的粉絲、你自己的追蹤/取消追蹤動作,以及個人檔案更新。"; +"Select all" = "全選"; +"Selected data will be cleared. Tap any row to see what's stored." = "選取的資料將被清除。點擊任一列以檢視已儲存內容。"; +"Settings" = "設定"; +"Sort" = "排序"; +"This can't be undone." = "此操作無法復原。"; +"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled." = "勾選要套用的項目。點擊任一列以檢視。檔案中未包含的區段將停用。"; +"Tick what to include. Tap any row to inspect its contents." = "勾選要包含的項目。點擊任一列以檢視其內容。"; +"Unfollow %lu" = "取消追蹤 %lu"; +"Unfollowing… %lu / %lu" = "取消追蹤中… %lu / %lu"; +"Username A → Z" = "使用者名稱 A → Z"; +"Username Z → A" = "使用者名稱 Z → A"; +"Verified only" = "僅限已認證"; +"When on, scans compare against your first scan so new/lost followers and profile updates don't disappear between scans." = "啟用時,掃描會與首次掃描比較,讓新增/失去的粉絲及個人檔案更新不會在掃描之間消失。"; + +////////////////////////////////////////////////////////////////////////////// +// SETTINGS VIEWS & DIALOGS // +// Excluded-lists managers, backup/restore flows, in-picker labels. // +////////////////////////////////////////////////////////////////////////////// + +"Add chat" = "新增聊天"; +"Add custom domain" = "新增自訂網域"; +"Add preset…" = "新增預設…"; +"Add to list?" = "要加入清單嗎?"; +"Add user" = "新增使用者"; +"Apply" = "套用"; +"Apply to" = "套用至"; +"Chats" = "聊天"; +"Could not read file." = "無法讀取檔案。"; +"Could not resolve user ID" = "無法解析使用者 ID"; +"Could not write temporary file." = "無法寫入暫存檔案。"; +"Current location" = "目前位置"; +"Custom" = "自訂"; +"Date Format" = "日期格式"; +"Delete" = "刪除"; +"Enable fake location" = "啟用假位置"; +"Enter username" = "輸入使用者名稱"; +"Enter username of the DM thread" = "輸入私訊串的使用者名稱"; +"Excluded chats" = "排除的聊天"; +"Excluded users" = "排除的使用者"; +"Follow default" = "遵循預設"; +"Force OFF (allow unsends)" = "強制關閉(允許撤回)"; +"Force ON (preserve unsends)" = "強制開啟(保留撤回)"; +"Format" = "格式"; +"Import failed" = "匯入失敗"; +"Included chats" = "包含的聊天"; +"Included users" = "包含的使用者"; +"KD: default" = "KD:預設"; +"KD: ON" = "KD:開啟"; +"Keep-deleted" = "保留已刪除"; +"Keep-deleted override" = "保留已刪除覆寫"; +"Name (A–Z)" = "名稱 (A–Z)"; +"No DM thread found with @%@" = "找不到與 @%@ 的私訊串"; +"Off" = "關閉"; +"Presets" = "預設組合"; +"Recently added" = "最近新增"; +"Remove from list" = "從清單移除"; +"Remove Selected" = "移除選取項目"; +"Reset" = "重設"; +"Saved presets are reusable. Tap a preset to make it the active location." = "已儲存的預設可重複使用。點選預設以設為目前位置。"; +"Search" = "搜尋"; +"Search address or place" = "搜尋地址或地點"; +"Search by name or username" = "依名稱或使用者名稱搜尋"; +"Search by username or name" = "依使用者名稱或名稱搜尋"; +"Select" = "選擇"; +"Select location on map" = "在地圖上選擇位置"; +"Set current location" = "設定目前位置"; +"Set keep-deleted override" = "設定保留已刪除覆寫"; +"Settings exported" = "設定已匯出"; +"Show map button" = "顯示地圖按鈕"; +"Show seconds" = "顯示秒數"; +"Sort by" = "排序依據"; +"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." = "切換 IG 使用的每個 NSDate 格式化器。不同介面(動態消息、留言、限時動態、私訊)使用不同方法 — 啟用您想套用自訂格式的項目。"; +"Use this location" = "使用此位置"; +"User '%@' not found" = "找不到使用者 '%@'"; +"Username (A–Z)" = "使用者名稱 (A–Z)"; +"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." = "啟用時,Instagram 內所有 CoreLocation 請求皆回傳以下位置。切換地圖按鈕可在朋友地圖檢視中顯示或隱藏快速切換。"; + +////////////////////////////////////////////////////////////////////////////// +// REELS (FEATURES) // +// Strings from Reels. // +////////////////////////////////////////////////////////////////////////////// + +"Copied!" = "已複製!"; +"No password found" = "找不到密碼"; +"No text field found" = "找不到文字欄位"; +"Password" = "密碼"; +"Refresh Reels?" = "重新整理 Reels?"; + +////////////////////////////////////////////////////////////////////////////// +// PROFILE (FEATURES) // +// Strings from Profile. // +////////////////////////////////////////////////////////////////////////////// + +"Doesn't follow you" = "未追蹤你"; +"Follows you" = "追蹤你"; +"Note copied" = "備註已複製"; + +////////////////////////////////////////////////////////////////////////////// +// CONFIRM DIALOGS (IN-FEATURE) // +// Strings from Confirm dialogs. // +////////////////////////////////////////////////////////////////////////////// + +"Unfollow?" = "確定要取消追蹤?"; + +////////////////////////////////////////////////////////////////////////////// +// MISC // +// Anything that didn't fit a named section. Usually short labels. // +////////////////////////////////////////////////////////////////////////////// + +"720p • progressive • fastest" = "720p • 漸進式 • 最快"; +"Are you sure?" = "你確定嗎?"; +"Bundle" = "Bundle"; +"Copy audio URL" = "複製音訊網址"; +"Copy quality info" = "複製品質資訊"; +"Copy video URL" = "複製影片網址"; +"Could not access reel media" = "無法存取 Reels 媒體"; +"Could not access reel photo" = "無法存取 Reels 照片"; +"Could not extract photo url from post" = "無法從貼文擷取照片網址"; +"Could not extract photo url from reel" = "無法從 Reels 擷取照片網址"; +"Could not extract photo url from story" = "無法從限時動態擷取照片網址"; +"Could not extract video url from post" = "無法從貼文擷取影片網址"; +"Could not extract video url from reel" = "無法從 Reels 擷取影片網址"; +"Could not extract video url from story" = "無法從限時動態擷取影片網址"; +"Download Quality" = "下載品質"; +"Extras" = "附加項目"; +"FFmpegKit Debug" = "FFmpegKit 除錯"; +"Later" = "稍後"; +"No!" = "不要!"; +"OK" = "OK"; +"Restart" = "重新啟動"; +"Restart required" = "需要重新啟動"; +"username" = "使用者名稱"; +"Yes" = "是"; +"You must restart the app to apply this change" = "您必須重新啟動應用程式以套用此變更"; + +////////////////////////////////////////////////////////////////////////////// +// ABOUT / CREDITS // +// Strings from the About / Credits footer of Settings. // +////////////////////////////////////////////////////////////////////////////// + +"%@ — GitHub & Telegram" = "%@ — GitHub 與 Telegram"; +"About" = "關於"; +"Arabic translation" = "阿拉伯文翻譯"; +"Chinese (Traditional) translation" = "繁體中文翻譯"; +"Chinese (Traditional) translation" = "繁體中文翻譯"; +"Credits" = "製作團隊"; +"Developers" = "開發者"; +"Donate to SoCuul" = "捐款給 SoCuul"; +"installed" = "已安裝"; +"Korean translation" = "韓文翻譯"; +"latest" = "最新"; +"Links" = "連結"; +"No releases" = "無版本發行"; +"Original SCInsta developer" = "原 SCInsta 開發者"; +"Release notes" = "版本說明"; +"Releases" = "版本發行"; +"Report an issue" = "回報問題"; +"Russian translation" = "俄文翻譯"; +"RyukGram developer" = "RyukGram 開發者"; +"Join Telegram channel" = "加入 Telegram 頻道"; +"View on GitHub" = "在 GitHub 查看"; +"Source code" = "原始碼"; +"Spanish translation" = "西班牙文翻譯"; +"Support the original developer" = "支持原開發者"; +"Telegram channel" = "Telegram 頻道"; +"Testing and feature suggestions" = "測試與功能建議"; +"Tweak settings" = "Tweak 設定"; +"Version" = "版本"; +"Version, credits, and links" = "版本、製作團隊與連結"; +"What's new in RyukGram" = "RyukGram 有什麼新功能"; + +////////////////////////////////////////////////////////////////////////////// +// HD DOWNLOADS // +// Enhanced / HD downloads settings (DASH + FFmpegKit encoding). // +////////////////////////////////////////////////////////////////////////////// + +"720p • progressive • silent" = "720p • 漸進式 • 靜音"; +"Audio extract failed" = "音訊擷取失敗"; +"Audio only" = "僅音訊"; +"Audio ready" = "音訊就緒"; +"Download video at the highest available quality" = "下載最高可用品質的影片"; +"Downloads HD video via DASH streams and encodes to H.264. Requires FFmpegKit." = "透過 DASH 串流下載 HD 影片並編碼為 H.264。需要 FFmpegKit。"; +"Encoding speed" = "編碼速度"; +"Enhanced downloads" = "增強下載"; +"Faster = lower quality" = "速度越快 = 品質越低"; +"FFmpeg not available" = "FFmpeg 不可用"; +"FFmpegKit is not available. Install the sideloaded IPA or the _ffmpeg .deb variant to enable." = "FFmpegKit 不可用。請安裝側載的 IPA 或 _ffmpeg .deb 版本以啟用。"; +"No audio stream available" = "無可用音訊串流"; +"No audio track found" = "找不到音訊軌"; +"Photo" = "照片"; +"Photo quality" = "照片品質"; +"Raw image (no audio, no video)" = "原始圖片(無音訊、無影片)"; +"silent" = "靜音"; +"Use highest resolution available" = "使用最高可用解析度"; +"Video quality" = "影片品質"; +"Which quality to download" = "選擇下載的品質"; diff --git a/src/Networking/SCIInstagramAPI.x b/src/Networking/SCIInstagramAPI.x index a274264..067a053 100644 --- a/src/Networking/SCIInstagramAPI.x +++ b/src/Networking/SCIInstagramAPI.x @@ -1,6 +1,7 @@ // Reusable IG private API helper. See SCIInstagramAPI.h. #import "SCIInstagramAPI.h" +#import "../Utils.h" #import #import #import @@ -32,43 +33,8 @@ static NSString *sciUserAgent(void) { // ============ 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; -} +static id sciCurrentUserSession(void) { return [SCIUtils activeUserSession]; } +static NSString *sciCurrentUserPK(void) { return [SCIUtils currentUserPK]; } // Bearer token for the active account, read fresh from // -[IGUserSession authHeaderManager] -> -[IGUserAuthHeaderManager authHeader]. diff --git a/src/SCIChrome.h b/src/SCIChrome.h new file mode 100644 index 0000000..da1fd4d --- /dev/null +++ b/src/SCIChrome.h @@ -0,0 +1,66 @@ +// Capture-aware chrome primitives. SCIChromeCanvas handles redaction via +// the UITextField secure-canvas technique; SCIChromeButton / SCIChromeLabel +// own the full visible hierarchy so IG's liquid glass can't wrap them. + +#import + +NS_ASSUME_NONNULL_BEGIN + +// MARK: - SCIChromeCanvas + +@interface SCIChromeCanvas : UIView +@property (nonatomic, readonly) UIView *contentContainer; +@end + +// MARK: - SCIChromeButton + +@interface SCIChromeButton : UIButton +- (instancetype)initWithSymbol:(NSString *)symbol + pointSize:(CGFloat)pointSize + diameter:(CGFloat)diameter NS_DESIGNATED_INITIALIZER; + +@property (nonatomic, assign, readonly) CGFloat diameter; +@property (nonatomic, copy) NSString *symbolName; +@property (nonatomic, assign) CGFloat symbolPointSize; +@property (nonatomic, copy) UIColor *iconTint; +@property (nonatomic, copy) UIColor *bubbleColor; +// Set `.image` for custom/baked images that the symbol API can't produce. +@property (nonatomic, strong, readonly) UIImageView *iconView; + +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; +@end + +// MARK: - SCIChromeLabel + +@interface SCIChromeLabel : UIView +- (instancetype)initWithText:(NSString *)text NS_DESIGNATED_INITIALIZER; +@property (nonatomic, copy) NSString *text; +@property (nonatomic, strong) UIFont *font; +@property (nonatomic, strong) UIColor *textColor; +@property (nonatomic, assign) NSTextAlignment textAlignment; +- (instancetype)init NS_UNAVAILABLE; +- (instancetype)initWithFrame:(CGRect)frame NS_UNAVAILABLE; +- (instancetype)initWithCoder:(NSCoder *)coder NS_UNAVAILABLE; +@end + +#ifdef __cplusplus +extern "C" { +#endif + +// Bar button item whose customView is an SCIChromeButton. `outButton` yields +// the inner button for menu/tint/etc. +UIBarButtonItem *SCIChromeBarButtonItem(NSString *symbol, + CGFloat pointSize, + id _Nullable target, + SEL _Nullable action, + SCIChromeButton * _Nullable * _Nullable outButton); + +SCIChromeButton * _Nullable SCIChromeButtonForBarItem(UIBarButtonItem *item); + +#ifdef __cplusplus +} +#endif + +NS_ASSUME_NONNULL_END diff --git a/src/SCIChrome.m b/src/SCIChrome.m new file mode 100644 index 0000000..5f682f7 --- /dev/null +++ b/src/SCIChrome.m @@ -0,0 +1,279 @@ +#import "SCIChrome.h" +#import "Utils.h" +#import "SCIPrefObserver.h" + +// MARK: - Canvas discovery + +static UIView *sciFindCanvasDeep(UIView *root, int depth) { + if (depth > 4) return nil; + for (UIView *sub in root.subviews) { + if ([NSStringFromClass([sub class]) containsString:@"CanvasView"]) return sub; + UIView *found = sciFindCanvasDeep(sub, depth + 1); + if (found) return found; + } + return nil; +} + +// MARK: - SCIChromeCanvas + +@interface SCIChromeCanvas () +@property (nonatomic, strong) UITextField *secureField; +@property (nonatomic, strong, nullable) UIView *canvas; +@end + +@implementation SCIChromeCanvas + ++ (NSHashTable *)instances { + static NSHashTable *t; + static dispatch_once_t once; + dispatch_once(&once, ^{ t = [NSHashTable weakObjectsHashTable]; }); + return t; +} + ++ (void)ensureObserverInstalled { + static dispatch_once_t once; + dispatch_once(&once, ^{ + [SCIPrefObserver observeKey:@"hide_ui_on_capture" handler:^{ + for (SCIChromeCanvas *v in [SCIChromeCanvas instances]) [v applyPref]; + }]; + }); +} + +- (instancetype)initWithFrame:(CGRect)frame { + self = [super initWithFrame:frame]; + if (self) { + [SCIChromeCanvas ensureObserverInstalled]; + self.translatesAutoresizingMaskIntoConstraints = NO; + _secureField = [UITextField new]; + _secureField.userInteractionEnabled = NO; + _secureField.autocorrectionType = UITextAutocorrectionTypeNo; + _secureField.spellCheckingType = UITextSpellCheckingTypeNo; + _secureField.smartDashesType = UITextSmartDashesTypeNo; + _secureField.smartQuotesType = UITextSmartQuotesTypeNo; + _secureField.smartInsertDeleteType = UITextSmartInsertDeleteTypeNo; + _secureField.autocapitalizationType = UITextAutocapitalizationTypeNone; + [self applyPref]; + [[SCIChromeCanvas instances] addObject:self]; + [self attachCanvasIfPossible]; + } + return self; +} + +- (UIView *)contentContainer { return self.canvas ?: self; } + +- (void)applyPref { + BOOL on = [SCIUtils getBoolPref:@"hide_ui_on_capture"]; + if (self.secureField.secureTextEntry != on) self.secureField.secureTextEntry = on; +} + +- (void)didMoveToWindow { [super didMoveToWindow]; [self attachCanvasIfPossible]; } +- (void)layoutSubviews { [super layoutSubviews]; [self attachCanvasIfPossible]; } + +- (void)attachCanvasIfPossible { + if (self.canvas && self.canvas.superview == self) return; + + [self.secureField layoutIfNeeded]; + UIView *c = sciFindCanvasDeep(self.secureField, 0); + if (!c) return; + + // Migrate anything that landed on self (contentContainer fallback) into + // the canvas so redaction covers it. + NSMutableArray *stashed = [NSMutableArray array]; + for (UIView *sub in self.subviews) { + if (sub != c) [stashed addObject:sub]; + } + + [c removeFromSuperview]; + [self insertSubview:c atIndex:0]; + c.translatesAutoresizingMaskIntoConstraints = NO; + [NSLayoutConstraint activateConstraints:@[ + [c.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [c.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [c.topAnchor constraintEqualToAnchor:self.topAnchor], + [c.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + ]]; + self.canvas = c; + + for (UIView *v in stashed) { + [v removeFromSuperview]; + [c addSubview:v]; + } +} + +@end + +// MARK: - SCIChromeButton + +@interface SCIChromeButton () +@property (nonatomic, strong) SCIChromeCanvas *chromeCanvas; +@property (nonatomic, strong) UIView *bubbleView; +@property (nonatomic, strong, readwrite) UIImageView *iconView; +@end + +@implementation SCIChromeButton + +- (instancetype)initWithSymbol:(NSString *)symbol + pointSize:(CGFloat)pointSize + diameter:(CGFloat)diameter { + self = [super initWithFrame:CGRectMake(0, 0, diameter, diameter)]; + if (self) { + _diameter = diameter; + _symbolName = [symbol copy]; + _symbolPointSize = pointSize; + _iconTint = [UIColor whiteColor]; + _bubbleColor = [UIColor colorWithWhite:0.0 alpha:0.4]; + [self buildChrome]; + } + return self; +} + +- (void)buildChrome { + self.adjustsImageWhenHighlighted = NO; + self.translatesAutoresizingMaskIntoConstraints = NO; + + _chromeCanvas = [SCIChromeCanvas new]; + _chromeCanvas.userInteractionEnabled = NO; + [self addSubview:_chromeCanvas]; + [NSLayoutConstraint activateConstraints:@[ + [_chromeCanvas.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_chromeCanvas.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [_chromeCanvas.topAnchor constraintEqualToAnchor:self.topAnchor], + [_chromeCanvas.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + ]]; + + _bubbleView = [UIView new]; + _bubbleView.userInteractionEnabled = NO; + _bubbleView.translatesAutoresizingMaskIntoConstraints = NO; + _bubbleView.backgroundColor = _bubbleColor; + _bubbleView.layer.cornerRadius = _diameter / 2; + _bubbleView.clipsToBounds = YES; + + _iconView = [UIImageView new]; + _iconView.userInteractionEnabled = NO; + _iconView.contentMode = UIViewContentModeCenter; + _iconView.translatesAutoresizingMaskIntoConstraints = NO; + _iconView.tintColor = _iconTint; + [self reloadIcon]; + + UIView *host = _chromeCanvas.contentContainer; + [host addSubview:_bubbleView]; + [host addSubview:_iconView]; + [NSLayoutConstraint activateConstraints:@[ + [_bubbleView.leadingAnchor constraintEqualToAnchor:host.leadingAnchor], + [_bubbleView.trailingAnchor constraintEqualToAnchor:host.trailingAnchor], + [_bubbleView.topAnchor constraintEqualToAnchor:host.topAnchor], + [_bubbleView.bottomAnchor constraintEqualToAnchor:host.bottomAnchor], + [_iconView.centerXAnchor constraintEqualToAnchor:host.centerXAnchor], + [_iconView.centerYAnchor constraintEqualToAnchor:host.centerYAnchor], + ]]; +} + +- (CGSize)intrinsicContentSize { return CGSizeMake(_diameter, _diameter); } + +- (void)setSymbolName:(NSString *)symbolName { + _symbolName = [symbolName copy]; + [self reloadIcon]; +} + +- (void)setSymbolPointSize:(CGFloat)symbolPointSize { + _symbolPointSize = symbolPointSize; + [self reloadIcon]; +} + +- (void)setIconTint:(UIColor *)iconTint { + _iconTint = [iconTint copy]; + _iconView.tintColor = iconTint; +} + +- (void)setBubbleColor:(UIColor *)bubbleColor { + _bubbleColor = [bubbleColor copy]; + _bubbleView.backgroundColor = bubbleColor; +} + +- (void)reloadIcon { + if (!_symbolName.length) { _iconView.image = nil; return; } + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:_symbolPointSize + weight:UIImageSymbolWeightSemibold]; + _iconView.image = [UIImage systemImageNamed:_symbolName withConfiguration:cfg]; +} + +- (void)layoutSubviews { + [super layoutSubviews]; + // Keep the bubble circular when the caller resizes via constraints. + CGFloat r = MIN(self.bounds.size.width, self.bounds.size.height) / 2; + _bubbleView.layer.cornerRadius = r; +} + +@end + +// MARK: - SCIChromeLabel + +@interface SCIChromeLabel () +@property (nonatomic, strong) SCIChromeCanvas *chromeCanvas; +@property (nonatomic, strong) UILabel *label; +@end + +@implementation SCIChromeLabel + +- (instancetype)initWithText:(NSString *)text { + self = [super initWithFrame:CGRectZero]; + if (self) { + self.translatesAutoresizingMaskIntoConstraints = NO; + + _chromeCanvas = [SCIChromeCanvas new]; + _chromeCanvas.userInteractionEnabled = NO; + [self addSubview:_chromeCanvas]; + [NSLayoutConstraint activateConstraints:@[ + [_chromeCanvas.leadingAnchor constraintEqualToAnchor:self.leadingAnchor], + [_chromeCanvas.trailingAnchor constraintEqualToAnchor:self.trailingAnchor], + [_chromeCanvas.topAnchor constraintEqualToAnchor:self.topAnchor], + [_chromeCanvas.bottomAnchor constraintEqualToAnchor:self.bottomAnchor], + ]]; + + _label = [UILabel new]; + _label.translatesAutoresizingMaskIntoConstraints = NO; + _label.text = text; + + UIView *host = _chromeCanvas.contentContainer; + [host addSubview:_label]; + [NSLayoutConstraint activateConstraints:@[ + [_label.leadingAnchor constraintEqualToAnchor:host.leadingAnchor], + [_label.trailingAnchor constraintEqualToAnchor:host.trailingAnchor], + [_label.topAnchor constraintEqualToAnchor:host.topAnchor], + [_label.bottomAnchor constraintEqualToAnchor:host.bottomAnchor], + ]]; + } + return self; +} + +- (NSString *)text { return _label.text; } +- (void)setText:(NSString *)t { _label.text = t; } +- (UIFont *)font { return _label.font; } +- (void)setFont:(UIFont *)f { _label.font = f; } +- (UIColor *)textColor { return _label.textColor; } +- (void)setTextColor:(UIColor *)c { _label.textColor = c; } +- (NSTextAlignment)textAlignment { return _label.textAlignment; } +- (void)setTextAlignment:(NSTextAlignment)a { _label.textAlignment = a; } + +@end + +// MARK: - Bar button helpers + +UIBarButtonItem *SCIChromeBarButtonItem(NSString *symbol, + CGFloat pointSize, + id target, + SEL action, + SCIChromeButton **outButton) { + SCIChromeButton *btn = [[SCIChromeButton alloc] initWithSymbol:symbol + pointSize:pointSize + diameter:28]; + btn.bubbleColor = [UIColor clearColor]; + if (target && action) [btn addTarget:target action:action forControlEvents:UIControlEventTouchUpInside]; + if (outButton) *outButton = btn; + return [[UIBarButtonItem alloc] initWithCustomView:btn]; +} + +SCIChromeButton *SCIChromeButtonForBarItem(UIBarButtonItem *item) { + UIView *v = item.customView; + return [v isKindOfClass:[SCIChromeButton class]] ? (SCIChromeButton *)v : nil; +} diff --git a/src/SCIDashParser.m b/src/SCIDashParser.m index b13e868..dfd2022 100644 --- a/src/SCIDashParser.m +++ b/src/SCIDashParser.m @@ -5,14 +5,17 @@ @implementation SCIDashRepresentation @end +// Resolve _fieldCache per class (walking the hierarchy). Caching the ivar +// against IGAPIStorableObject and then reading that offset from an unrelated +// class like IGVideo segfaults — ivar offsets aren't shared. 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 (!obj || !key.length) return nil; + Ivar fcIvar = NULL; + @try { + for (Class c = [obj class]; c && !fcIvar; c = class_getSuperclass(c)) { + fcIvar = class_getInstanceVariable(c, "_fieldCache"); + } + } @catch (__unused id e) { return nil; } if (!fcIvar) return nil; id fc = nil; @try { fc = object_getIvar(obj, fcIvar); } @catch (__unused id e) { return nil; } @@ -24,30 +27,170 @@ static id sciDashFieldCache(id obj, NSString *key) { @implementation SCIDashParser +// Looks like XML DASH manifest or a URL to one. +static BOOL sciLooksLikeManifest(id val) { + if (![val isKindOfClass:[NSString class]]) return NO; + NSString *s = (NSString *)val; + if (s.length < 10) return NO; + NSString *head = [s substringToIndex:MIN((NSUInteger)16, s.length)]; + return [head containsString:@" 3 || ![dict isKindOfClass:[NSDictionary class]]) return nil; + for (NSString *k in dict) { + id v = dict[k]; + NSString *lk = k.lowercaseString; + if (([lk containsString:@"dash"] || [lk containsString:@"manifest"]) && sciLooksLikeManifest(v)) { + NSLog(@"[SCInsta][Dash] hit %@/%@ (len=%lu)", path, k, (unsigned long)[(NSString *)v length]); + return v; + } + if ([v isKindOfClass:[NSDictionary class]]) { + NSString *found = sciScanDictForManifest(v, [NSString stringWithFormat:@"%@/%@", path, k], depth + 1); + if (found) return found; + } else if ([v isKindOfClass:[NSArray class]]) { + for (id item in (NSArray *)v) { + if ([item isKindOfClass:[NSDictionary class]]) { + NSString *found = sciScanDictForManifest(item, [NSString stringWithFormat:@"%@/%@[]", path, k], depth + 1); + if (found) return found; + } + } + } + } + return nil; +} + +static NSDictionary *sciFieldCacheDict(id obj) { + if (!obj) return nil; + Ivar fcIvar = NULL; + @try { + for (Class c = [obj class]; c && !fcIvar; c = class_getSuperclass(c)) { + fcIvar = class_getInstanceVariable(c, "_fieldCache"); + } + } @catch (__unused id e) { return nil; } + if (!fcIvar) return nil; + id fc = nil; + @try { fc = object_getIvar(obj, fcIvar); } @catch (__unused id e) { return nil; } + return [fc isKindOfClass:[NSDictionary class]] ? fc : nil; +} + +// Coerce an arbitrary object (NSString or NSData) into a manifest string. +static NSString *sciToManifestString(id val) { + if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10) return val; + if ([val isKindOfClass:[NSData class]] && [(NSData *)val length] > 10) { + NSString *s = [[NSString alloc] initWithData:(NSData *)val encoding:NSUTF8StringEncoding]; + if (s.length > 10) return s; + } + return nil; +} + + (NSString *)dashManifestForMedia:(id)media { if (!media) return nil; NSArray *keys = @[@"video_dash_manifest", @"dash_manifest", @"video_dash_manifest_url", @"dash_manifest_url"]; + // Direct hits on the media's fieldCache (older builds). for (NSString *key in keys) { id val = sciDashFieldCache(media, key); - if ([val isKindOfClass:[NSString class]] && [(NSString *)val length] > 10) - return val; + if (sciLooksLikeManifest(val)) return val; } + // IGBaseMedia -videoDashManifest (used through IG v440ish). + @try { + if ([media respondsToSelector:@selector(videoDashManifest)]) { + id val = ((id(*)(id, SEL))objc_msgSend)(media, @selector(videoDashManifest)); + NSString *str = sciToManifestString(val); + if (sciLooksLikeManifest(str)) return str; + } + } @catch (__unused id e) {} + + // Nested IGVideo — both fieldCache + the new -dashManifestData NSData getter. 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; - } + @try { + if ([media respondsToSelector:@selector(video)]) { + video = ((id(*)(id, SEL))objc_msgSend)(media, @selector(video)); + } + } @catch (__unused id e) { 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; + if (sciLooksLikeManifest(val)) return val; } + @try { + if ([video respondsToSelector:@selector(dashManifestData)]) { + id val = ((id(*)(id, SEL))objc_msgSend)(video, @selector(dashManifestData)); + NSString *str = sciToManifestString(val); + if (sciLooksLikeManifest(str)) return str; + } + } @catch (__unused id e) {} + // Direct ivar read as last resort (handles future property removals). + @try { + Ivar iv = NULL; + for (Class c = [video class]; c && !iv; c = class_getSuperclass(c)) + iv = class_getInstanceVariable(c, "_dashManifestData"); + if (iv) { + id val = object_getIvar(video, iv); + NSString *str = sciToManifestString(val); + if (sciLooksLikeManifest(str)) return str; + } + } @catch (__unused id e) {} + } + + // Wider scan: walk the fieldCache dict recursively for any key containing + // "dash" or "manifest". + NSDictionary *fc = sciFieldCacheDict(media); + if (fc) { + NSString *found = sciScanDictForManifest(fc, @"fieldCache", 0); + if (found) return found; + + // Last-ditch manifest hunt + dump via iterative stack (no recursion, + // no block self-capture). + NSMutableArray *stack = [NSMutableArray arrayWithObject:@[fc, @"fieldCache", @(0)]]; + NSString *bigManifest = nil; + NSString *bigManifestPath = nil; + NSMutableArray *longStrings = [NSMutableArray array]; + while (stack.count) { + NSArray *frame = stack.lastObject; [stack removeLastObject]; + id obj = frame[0]; + NSString *path = frame[1]; + int depth = [frame[2] intValue]; + if (depth > 4) continue; + if ([obj isKindOfClass:[NSDictionary class]]) { + for (NSString *k in obj) { + [stack addObject:@[obj[k], [NSString stringWithFormat:@"%@/%@", path, k], @(depth + 1)]]; + } + } else if ([obj isKindOfClass:[NSArray class]]) { + NSUInteger i = 0; + for (id item in obj) { + [stack addObject:@[item, [NSString stringWithFormat:@"%@[%lu]", path, (unsigned long)i++], @(depth + 1)]]; + } + } else if ([obj isKindOfClass:[NSString class]]) { + NSString *s = obj; + if (s.length > 300) { + NSString *head = [s substringToIndex:MIN((NSUInteger)32, s.length)]; + if (!bigManifest && ([head containsString:@" 200) [longStrings addObject:@[path, @(s.length), [s substringToIndex:MIN((NSUInteger)120, s.length)]]]; + } + } + } + if (bigManifest) { + NSLog(@"[SCInsta][Dash] found manifest at %@ (len=%lu)", bigManifestPath, (unsigned long)bigManifest.length); + return bigManifest; + } + + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSLog(@"[SCInsta][Dash] no manifest found; top-level keys=%@", [[fc allKeys] componentsJoinedByString:@","]); + for (NSArray *row in longStrings) { + NSLog(@"[SCInsta][Dash] long-str %@ (len=%@) head=%@", row[0], row[1], row[2]); + } + }); } return nil; diff --git a/src/SCIFFmpeg.m b/src/SCIFFmpeg.m index 6a8c73d..ececd67 100644 --- a/src/SCIFFmpeg.m +++ b/src/SCIFFmpeg.m @@ -1,4 +1,5 @@ #import "SCIFFmpeg.h" +#import "ActionButton/SCIMediaActions.h" #import #import #import @@ -396,12 +397,15 @@ static void sciLoadFFmpegKit(void) { dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^{ NSString *tmpDir = NSTemporaryDirectory(); + // Intermediates stay UUID-named; the muxed output uses the stem. 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 *outStem = [SCIMediaActions currentFilenameStem] + ?: [NSString stringWithFormat:@"sci_muxed_%@", [[NSUUID UUID] UUIDString]]; NSString *outputPath = [tmpDir stringByAppendingPathComponent: - [NSString stringWithFormat:@"sci_muxed_%@.mp4", [[NSUUID UUID] UUIDString]]]; + [NSString stringWithFormat:@"%@.mp4", outStem]]; NSError *(^cancelledError)(void) = ^NSError *{ return [NSError errorWithDomain:@"SCIFFmpeg" code:NSUserCancelledError diff --git a/src/SCIImageCache.h b/src/SCIImageCache.h new file mode 100644 index 0000000..054c363 --- /dev/null +++ b/src/SCIImageCache.h @@ -0,0 +1,10 @@ +#import + +// Memory + disk image cache for remote URLs. Completion runs on main queue. +// Disk cache lives under Library/Caches/RyukGramImages and survives reinstall +// so long as Caches isn't wiped. +@interface SCIImageCache : NSObject + ++ (void)loadImageFromURL:(NSURL *)url completion:(void (^)(UIImage *_Nullable image))completion; + +@end diff --git a/src/SCIImageCache.m b/src/SCIImageCache.m new file mode 100644 index 0000000..ec46db7 --- /dev/null +++ b/src/SCIImageCache.m @@ -0,0 +1,70 @@ +#import "SCIImageCache.h" +#import + +static NSCache *memCache(void) { + static NSCache *c; + static dispatch_once_t once; + dispatch_once(&once, ^{ + c = [NSCache new]; + c.countLimit = 64; + }); + return c; +} + +static NSString *diskDir(void) { + static NSString *dir; + static dispatch_once_t once; + dispatch_once(&once, ^{ + NSString *base = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).firstObject; + dir = [base stringByAppendingPathComponent:@"RyukGramImages"]; + [[NSFileManager defaultManager] createDirectoryAtPath:dir withIntermediateDirectories:YES attributes:nil error:nil]; + }); + return dir; +} + +static NSString *hashKey(NSString *urlString) { + const char *cstr = urlString.UTF8String; + unsigned char hash[CC_SHA1_DIGEST_LENGTH]; + CC_SHA1(cstr, (CC_LONG)strlen(cstr), hash); + NSMutableString *hex = [NSMutableString stringWithCapacity:CC_SHA1_DIGEST_LENGTH * 2]; + for (int i = 0; i < CC_SHA1_DIGEST_LENGTH; i++) [hex appendFormat:@"%02x", hash[i]]; + return hex; +} + +@implementation SCIImageCache + ++ (void)loadImageFromURL:(NSURL *)url completion:(void (^)(UIImage *))completion { + if (!url || !completion) return; + NSString *key = url.absoluteString; + + void (^deliver)(UIImage *) = ^(UIImage *image) { + dispatch_async(dispatch_get_main_queue(), ^{ completion(image); }); + }; + + UIImage *hit = [memCache() objectForKey:key]; + if (hit) { deliver(hit); return; } + + NSString *path = [diskDir() stringByAppendingPathComponent:hashKey(key)]; + NSFileManager *fm = [NSFileManager defaultManager]; + if ([fm fileExistsAtPath:path]) { + dispatch_async(dispatch_get_global_queue(QOS_CLASS_UTILITY, 0), ^{ + NSData *data = [NSData dataWithContentsOfFile:path]; + UIImage *image = data ? [UIImage imageWithData:data] : nil; + if (image) [memCache() setObject:image forKey:key]; + deliver(image); + }); + return; + } + + [[[NSURLSession sharedSession] dataTaskWithURL:url + completionHandler:^(NSData *data, NSURLResponse *_r, NSError *_e) { + UIImage *image = data ? [UIImage imageWithData:data] : nil; + if (image) { + [memCache() setObject:image forKey:key]; + [data writeToFile:path atomically:YES]; + } + deliver(image); + }] resume]; +} + +@end diff --git a/src/SCIPrefObserver.h b/src/SCIPrefObserver.h new file mode 100644 index 0000000..02d2723 --- /dev/null +++ b/src/SCIPrefObserver.h @@ -0,0 +1,14 @@ +#import + +// KVO on a single NSUserDefaults key. Handler runs on main queue. +// App-lifetime observer — no teardown. +// +// Usage: +// [SCIPrefObserver observeKey:@"my_pref_key" handler:^{ +// // main queue — do the reflect work here +// }]; +@interface SCIPrefObserver : NSObject + ++ (void)observeKey:(NSString *)key handler:(void (^)(void))handler; + +@end diff --git a/src/SCIPrefObserver.m b/src/SCIPrefObserver.m new file mode 100644 index 0000000..b9e8fe6 --- /dev/null +++ b/src/SCIPrefObserver.m @@ -0,0 +1,50 @@ +#import "SCIPrefObserver.h" + +@interface SCIPrefObserver () +@property (nonatomic, strong) NSMutableDictionary *handlers; +@end + +@implementation SCIPrefObserver + ++ (instancetype)shared { + static SCIPrefObserver *s; + static dispatch_once_t once; + dispatch_once(&once, ^{ + s = [SCIPrefObserver new]; + s.handlers = [NSMutableDictionary dictionary]; + }); + return s; +} + ++ (void)observeKey:(NSString *)key handler:(void (^)(void))handler { + if (!key.length || !handler) return; + SCIPrefObserver *s = [self shared]; + @synchronized (s) { + NSMutableArray *arr = s.handlers[key]; + if (!arr) { + arr = [NSMutableArray array]; + s.handlers[key] = arr; + [[NSUserDefaults standardUserDefaults] addObserver:s + forKeyPath:key + options:0 + context:NULL]; + } + [arr addObject:[handler copy]]; + } +} + +- (void)observeValueForKeyPath:(NSString *)keyPath + ofObject:(id)object + change:(NSDictionary *)change + context:(void *)context { + NSArray *snapshot; + @synchronized (self) { snapshot = [self.handlers[keyPath] copy]; } + if (!snapshot.count) return; + dispatch_block_t run = ^{ + for (void (^h)(void) in snapshot) h(); + }; + if ([NSThread isMainThread]) run(); + else dispatch_async(dispatch_get_main_queue(), run); +} + +@end diff --git a/src/SCIQualityPicker.h b/src/SCIQualityPicker.h index b9496eb..7b9421c 100644 --- a/src/SCIQualityPicker.h +++ b/src/SCIQualityPicker.h @@ -2,13 +2,16 @@ #import #import "SCIDashParser.h" +#import "Downloader/Download.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). +/// Show quality picker or auto-pick based on prefs. Returns NO if enhanced +/// downloads are off or no DASH manifest is found (calls fallback). +/// `action` is passed through to the Audio / Photo rows inside the sheet. + (BOOL)pickQualityForMedia:(id)media fromView:(UIView *)sourceView + action:(DownloadAction)action picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked fallback:(void(^)(void))fallback; diff --git a/src/SCIQualityPicker.m b/src/SCIQualityPicker.m index a822c40..ad626ae 100644 --- a/src/SCIQualityPicker.m +++ b/src/SCIQualityPicker.m @@ -2,6 +2,7 @@ #import "SCIFFmpeg.h" #import "Utils.h" #import "InstagramHeaders.h" +#import "ActionButton/SCIMediaActions.h" #import #import #import @@ -105,7 +106,11 @@ @property (nonatomic, strong) UIButton *closeButton; @property (nonatomic, strong) NSArray *videoReps; @property (nonatomic, strong) SCIDashRepresentation *audioRep; -@property (nonatomic, strong) NSURL *standardURL; // progressive 720p +@property (nonatomic, strong) NSURL *standardURL; +@property (nonatomic, strong) id mediaRef; +@property (nonatomic, assign) DownloadAction saveAction; +@property (nonatomic, assign) BOOL hasAudio; +@property (nonatomic, strong) NSURL *photoURL; @property (nonatomic, copy) void (^onPickStandard)(void); @property (nonatomic, copy) void (^onPickHD)(SCIDashRepresentation *video, SCIDashRepresentation *audio); @end @@ -168,26 +173,52 @@ - (void)dismiss { [self dismissViewControllerAnimated:YES completion:nil]; } // MARK: - Table +// Sections: Standard, HD, optional Audio, optional Extras (photo). Audio +// appears before Extras when both are present. -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; } +- (BOOL)_hasExtrasSection { return self.photoURL != nil; } +- (BOOL)_hasAudioSection { return self.audioRep.url != nil; } + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { + NSInteger n = 2; + if ([self _hasExtrasSection]) n++; + if ([self _hasAudioSection]) n++; + return n; +} - (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { - return section == 0 ? 1 : (NSInteger)self.videoReps.count; + if (section == 0) return 1; + if (section == 1) return (NSInteger)self.videoReps.count; + return 1; } - (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section { - return section == 0 ? @"Standard" : @"HD"; + if (section == 0) return @"Standard"; + if (section == 1) return @"HD"; + if (section == 2 && [self _hasAudioSection]) return SCILocalized(@"Audio"); + return SCILocalized(@"Extras"); +} + +- (UIImage *)_playIconSilent:(BOOL)silent { + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium]; + NSString *name = silent ? @"play.slash.fill" : @"play.fill"; + return [UIImage systemImageNamed:name withConfiguration:cfg]; } - (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip { _SCIQualityCell *cell = [tv dequeueReusableCellWithIdentifier:@"q" forIndexPath:ip]; [cell setLoading:NO]; + BOOL silent = !self.hasAudio; + 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.subtitleLabel.text = silent + ? SCILocalized(@"720p • progressive • silent") + : SCILocalized(@"720p • progressive • fastest"); cell.playButton.hidden = (self.standardURL == nil); cell.menuButton.hidden = (self.standardURL == nil); + [cell.playButton setImage:[self _playIconSilent:silent] forState:UIControlStateNormal]; cell.accessoryType = UITableViewCellAccessoryNone; cell.playButton.tag = -1; @@ -195,11 +226,12 @@ [cell.playButton addTarget:self action:@selector(playStandardPreview:) forControlEvents:UIControlEventTouchUpInside]; cell.menuButton.menu = [self menuForStandard]; - } else { + } else if (ip.section == 1) { SCIDashRepresentation *rep = self.videoReps[ip.row]; cell.accessoryType = UITableViewCellAccessoryNone; cell.playButton.hidden = NO; cell.menuButton.hidden = NO; + [cell.playButton setImage:[self _playIconSilent:silent] forState:UIControlStateNormal]; NSString *label = rep.qualityLabel ?: @""; if (rep.height > 0) { @@ -222,6 +254,7 @@ NSString *codec = [[rep.codecs componentsSeparatedByString:@"."] firstObject] ?: rep.codecs; [parts addObject:codec]; } + if (silent) [parts addObject:SCILocalized(@"silent")]; cell.subtitleLabel.text = [parts componentsJoinedByString:@" • "]; cell.playButton.tag = ip.row; @@ -229,6 +262,35 @@ [cell.playButton addTarget:self action:@selector(playPreview:) forControlEvents:UIControlEventTouchUpInside]; cell.menuButton.menu = [self menuForRow:ip.row videoRep:rep]; + } else { + BOOL isAudio = (ip.section == 2 && [self _hasAudioSection]); + BOOL isPhoto = !isAudio; + + cell.accessoryType = UITableViewCellAccessoryNone; + cell.playButton.hidden = NO; + cell.menuButton.hidden = YES; + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:18 weight:UIImageSymbolWeightMedium]; + + if (isPhoto) { + cell.titleLabel.text = SCILocalized(@"Photo"); + cell.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + cell.subtitleLabel.text = SCILocalized(@"Raw image (no audio, no video)"); + [cell.playButton setImage:[UIImage systemImageNamed:@"photo" withConfiguration:cfg] forState:UIControlStateNormal]; + } else if (isAudio) { + cell.titleLabel.text = SCILocalized(@"Audio only"); + cell.titleLabel.font = [UIFont systemFontOfSize:15 weight:UIFontWeightSemibold]; + NSString *codec = self.audioRep.codecs.length + ? [[self.audioRep.codecs componentsSeparatedByString:@"."] firstObject] + : @"m4a"; + NSString *bw = self.audioRep.bandwidth > 0 + ? [NSString stringWithFormat:@"%ld Kbps", (long)(self.audioRep.bandwidth / 1000)] + : @""; + cell.subtitleLabel.text = [@[codec, bw] componentsJoinedByString:@" • "]; + [cell.playButton setImage:[UIImage systemImageNamed:@"music.note" withConfiguration:cfg] forState:UIControlStateNormal]; + } + + cell.playButton.tag = -2; + [cell.playButton removeTarget:self action:NULL forControlEvents:UIControlEventTouchUpInside]; } return cell; } @@ -298,9 +360,16 @@ [self dismissViewControllerAnimated:YES completion:^{ if (ip.section == 0) { if (self.onPickStandard) self.onPickStandard(); - } else { + } else if (ip.section == 1) { SCIDashRepresentation *rep = self.videoReps[ip.row]; if (self.onPickHD) self.onPickHD(rep, self.audioRep); + } else { + BOOL isAudio = (ip.section == 2 && [self _hasAudioSection]); + if (isAudio) { + [SCIMediaActions downloadAudioOnlyForMedia:self.mediaRef action:self.saveAction]; + } else if (self.photoURL) { + [SCIMediaActions downloadPhotoOnlyForMedia:self.mediaRef action:self.saveAction]; + } } }]; } @@ -400,6 +469,7 @@ + (BOOL)pickQualityForMedia:(id)media fromView:(UIView *)sourceView + action:(DownloadAction)action picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked fallback:(void(^)(void))fallback { if (!media) { if (fallback) fallback(); return NO; } @@ -427,6 +497,8 @@ [self showSheetWithVideoReps:videoReps audioRep:audioRep standardURL:standardURL + media:media + action:action picked:picked fallback:fallback]; } else { @@ -443,6 +515,8 @@ + (void)showSheetWithVideoReps:(NSArray *)videoReps audioRep:(SCIDashRepresentation *)audioRep standardURL:(NSURL *)standardURL + media:(id)media + action:(DownloadAction)action picked:(void(^)(SCIDashRepresentation *video, SCIDashRepresentation *audio))picked fallback:(void(^)(void))fallback { dispatch_async(dispatch_get_main_queue(), ^{ @@ -450,6 +524,11 @@ vc.videoReps = videoReps; vc.audioRep = audioRep; vc.standardURL = standardURL; + vc.mediaRef = media; + vc.saveAction = action; + // DASH truth: audio exists iff the manifest parsed an audio rep. + vc.hasAudio = (audioRep.url != nil); + vc.photoURL = [SCIUtils getPhotoUrlForMedia:(IGMedia *)media]; vc.onPickStandard = fallback; vc.onPickHD = picked; diff --git a/src/Settings/SCIBackupDetailVC.h b/src/Settings/SCIBackupDetailVC.h new file mode 100644 index 0000000..5ba6292 --- /dev/null +++ b/src/Settings/SCIBackupDetailVC.h @@ -0,0 +1,11 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Read-only searchable key/value list. +// Sections: [ { "title": ..., "rows": [ { "title": ..., "value": ... }, ... ] } ] +@interface SCIBackupDetailVC : UIViewController +- (instancetype)initWithTitle:(NSString *)title sections:(NSArray *)sections; +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Settings/SCIBackupDetailVC.m b/src/Settings/SCIBackupDetailVC.m new file mode 100644 index 0000000..dff9b31 --- /dev/null +++ b/src/Settings/SCIBackupDetailVC.m @@ -0,0 +1,109 @@ +#import "SCIBackupDetailVC.h" +#import "SCISearchBarStyler.h" +#import "../Utils.h" +#import "../Localization/SCILocalization.h" + +@interface SCIBackupDetailVC () +@property (nonatomic, copy) NSArray *allSections; +@property (nonatomic, copy) NSArray *visibleSections; +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UISearchController *searchController; +@end + +@implementation SCIBackupDetailVC + +- (instancetype)initWithTitle:(NSString *)title sections:(NSArray *)sections { + self = [super init]; + if (!self) return self; + self.title = title; + self.allSections = sections ?: @[]; + self.visibleSections = self.allSections; + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + 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.tableView.estimatedRowHeight = 44; + self.tableView.rowHeight = UITableViewAutomaticDimension; + [self.view addSubview:self.tableView]; + + self.searchController = [[UISearchController alloc] initWithSearchResultsController:nil]; + self.searchController.searchResultsUpdater = self; + self.searchController.delegate = self; + self.searchController.obscuresBackgroundDuringPresentation = NO; + self.searchController.searchBar.placeholder = SCILocalized(@"Search"); + self.navigationItem.searchController = self.searchController; + self.navigationItem.hidesSearchBarWhenScrolling = NO; + self.definesPresentationContext = YES; +} + +- (void)viewWillAppear:(BOOL)animated { + [super viewWillAppear:animated]; + [self sciStyleSearchBar]; +} + +- (void)sciStyleSearchBar { [SCISearchBarStyler styleSearchBar:self.searchController.searchBar]; } +- (void)willPresentSearchController:(UISearchController *)sc { [self sciStyleSearchBar]; } +- (void)didPresentSearchController:(UISearchController *)sc { + [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 *)sc { + NSString *q = [sc.searchBar.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (!q.length) { self.visibleSections = self.allSections; [self.tableView reloadData]; return; } + NSMutableArray *out = [NSMutableArray array]; + for (NSDictionary *section in self.allSections) { + NSMutableArray *matched = [NSMutableArray array]; + for (NSDictionary *r in section[@"rows"]) { + NSString *t = r[@"title"] ?: @""; + NSString *v = r[@"value"] ?: @""; + if ([t localizedCaseInsensitiveContainsString:q] || [v localizedCaseInsensitiveContainsString:q]) { + [matched addObject:r]; + } + } + if (matched.count) [out addObject:@{ @"title": section[@"title"] ?: @"", @"rows": matched }]; + } + self.visibleSections = out; + [self.tableView reloadData]; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return self.visibleSections.count; } +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { + return [self.visibleSections[section][@"rows"] count]; +} +- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section { + NSString *t = self.visibleSections[section][@"title"]; + return t.length ? t : nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath { + static NSString *rid = @"row"; + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:rid]; + NSDictionary *r = self.visibleSections[indexPath.section][@"rows"][indexPath.row]; + cell.textLabel.text = r[@"title"]; + cell.detailTextLabel.text = r[@"value"]; + cell.textLabel.numberOfLines = 0; + cell.selectionStyle = UITableViewCellSelectionStyleNone; + // Color on/off for quick visual scan + NSString *v = r[@"value"] ?: @""; + if ([v isEqualToString:@"on"]) cell.detailTextLabel.textColor = [UIColor systemGreenColor]; + else if ([v isEqualToString:@"off"]) cell.detailTextLabel.textColor = [UIColor tertiaryLabelColor]; + else cell.detailTextLabel.textColor = [UIColor secondaryLabelColor]; + return cell; +} + +@end diff --git a/src/Settings/SCIBackupScopePickerVC.h b/src/Settings/SCIBackupScopePickerVC.h new file mode 100644 index 0000000..f06605d --- /dev/null +++ b/src/Settings/SCIBackupScopePickerVC.h @@ -0,0 +1,29 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +// Bitmask must match the one in SCISettingsBackup.m — kept as plain +// NSInteger here so consumers don't have to drag the enum around. +typedef NS_OPTIONS(NSInteger, SCIBackupScopePickerMask) { + SCIBackupScopePickerSettings = 1 << 0, + SCIBackupScopePickerLists = 1 << 1, + SCIBackupScopePickerAnalyzer = 1 << 2, +}; + +// Scope picker + live preview. Rows combine a leading checkbox toggle with a +// tappable body that pushes a read-only drill-down; a "Raw JSON" row pushes +// the full payload viewer; a CTA commits. +@interface SCIBackupScopePickerVC : UIViewController + +@property (nonatomic, copy) NSString *continueTitle; +@property (nonatomic, copy, nullable) NSString *headerMessage; +// Scopes present in the payload. Rows outside the mask are disabled. +@property (nonatomic, assign) SCIBackupScopePickerMask availableScopes; +@property (nonatomic, assign) SCIBackupScopePickerMask initialSelection; +// v2 envelope: {"settings": {...}, "lists": {...}, "analyzer": {...}}. +@property (nonatomic, copy, nullable) NSDictionary *payload; +@property (nonatomic, copy) void (^onContinue)(SCIBackupScopePickerMask chosen); + +@end + +NS_ASSUME_NONNULL_END diff --git a/src/Settings/SCIBackupScopePickerVC.m b/src/Settings/SCIBackupScopePickerVC.m new file mode 100644 index 0000000..edb428e --- /dev/null +++ b/src/Settings/SCIBackupScopePickerVC.m @@ -0,0 +1,496 @@ +#import "SCIBackupScopePickerVC.h" +#import "SCIBackupDetailVC.h" +#import "../Utils.h" +#import "../Localization/SCILocalization.h" + +#pragma mark - Row model + +typedef NS_ENUM(NSInteger, SCIPickerRowKind) { + SCIPickerRowKindScope, + SCIPickerRowKindJSON, +}; + +@interface SCIPickerRow : NSObject +@property (nonatomic, assign) SCIPickerRowKind kind; +@property (nonatomic, assign) SCIBackupScopePickerMask scope; // only for Scope +@property (nonatomic, copy) NSString *title; +@property (nonatomic, copy) NSString *subtitle; +@property (nonatomic, copy) NSString *symbol; +@property (nonatomic, strong) UIColor *iconColor; +@end +@implementation SCIPickerRow @end + +#pragma mark - Cell + +@interface SCIPickerCell : UITableViewCell +@property (nonatomic, strong) UIButton *checkboxButton; +@property (nonatomic, strong) UIImageView *iconView; +@property (nonatomic, strong) UILabel *titleLabel; +@property (nonatomic, strong) UILabel *subtitleLabel; +@property (nonatomic, copy) void(^onToggle)(void); +@end + +@implementation SCIPickerCell +- (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)rid { + self = [super initWithStyle:style reuseIdentifier:rid]; + if (!self) return self; + self.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + + _checkboxButton = [UIButton buttonWithType:UIButtonTypeSystem]; + _checkboxButton.translatesAutoresizingMaskIntoConstraints = NO; + [_checkboxButton addTarget:self action:@selector(toggleTapped) forControlEvents:UIControlEventTouchUpInside]; + [self.contentView addSubview:_checkboxButton]; + + _iconView = [UIImageView new]; + _iconView.translatesAutoresizingMaskIntoConstraints = NO; + _iconView.contentMode = UIViewContentModeScaleAspectFit; + [self.contentView addSubview:_iconView]; + + _titleLabel = [UILabel new]; + _titleLabel.translatesAutoresizingMaskIntoConstraints = NO; + _titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + _titleLabel.textColor = [UIColor labelColor]; + [self.contentView addSubview:_titleLabel]; + + _subtitleLabel = [UILabel new]; + _subtitleLabel.translatesAutoresizingMaskIntoConstraints = NO; + _subtitleLabel.font = [UIFont systemFontOfSize:12]; + _subtitleLabel.textColor = [UIColor secondaryLabelColor]; + _subtitleLabel.numberOfLines = 2; + [self.contentView addSubview:_subtitleLabel]; + + [NSLayoutConstraint activateConstraints:@[ + [_checkboxButton.leadingAnchor constraintEqualToAnchor:self.contentView.layoutMarginsGuide.leadingAnchor], + [_checkboxButton.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + [_checkboxButton.widthAnchor constraintEqualToConstant:30], + [_checkboxButton.heightAnchor constraintEqualToConstant:30], + + [_iconView.leadingAnchor constraintEqualToAnchor:_checkboxButton.trailingAnchor constant:12], + [_iconView.centerYAnchor constraintEqualToAnchor:self.contentView.centerYAnchor], + [_iconView.widthAnchor constraintEqualToConstant:22], + [_iconView.heightAnchor constraintEqualToConstant:22], + + [_titleLabel.leadingAnchor constraintEqualToAnchor:_iconView.trailingAnchor constant:10], + [_titleLabel.topAnchor constraintEqualToAnchor:self.contentView.topAnchor constant:10], + [_titleLabel.trailingAnchor constraintEqualToAnchor:self.contentView.trailingAnchor constant:-4], + + [_subtitleLabel.leadingAnchor constraintEqualToAnchor:_titleLabel.leadingAnchor], + [_subtitleLabel.topAnchor constraintEqualToAnchor:_titleLabel.bottomAnchor constant:2], + [_subtitleLabel.trailingAnchor constraintEqualToAnchor:_titleLabel.trailingAnchor], + [_subtitleLabel.bottomAnchor constraintLessThanOrEqualToAnchor:self.contentView.bottomAnchor constant:-10], + ]]; + return self; +} + +- (void)toggleTapped { if (self.onToggle) self.onToggle(); } + +- (void)setChecked:(BOOL)checked enabled:(BOOL)enabled { + UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:22 weight:UIImageSymbolWeightRegular]; + NSString *name = checked ? @"checkmark.circle.fill" : @"circle"; + UIImage *img = [[UIImage systemImageNamed:name] imageByApplyingSymbolConfiguration:cfg]; + [self.checkboxButton setImage:img forState:UIControlStateNormal]; + self.checkboxButton.tintColor = checked ? ([SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]) + : [UIColor systemGray3Color]; + self.checkboxButton.enabled = enabled; + self.contentView.alpha = enabled ? 1.0 : 0.45; + self.selectionStyle = enabled ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone; + self.userInteractionEnabled = enabled; +} +@end + +#pragma mark - VC + +@interface SCIBackupScopePickerVC () +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UIButton *continueButton; +@property (nonatomic, assign) SCIBackupScopePickerMask selection; +@property (nonatomic, copy) NSArray *rows; // section 1 +@end + +@implementation SCIBackupScopePickerVC + +- (instancetype)init { + self = [super init]; + if (!self) return self; + _availableScopes = SCIBackupScopePickerSettings | SCIBackupScopePickerLists | SCIBackupScopePickerAnalyzer; + _continueTitle = SCILocalized(@"Continue"); + return self; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; + self.selection = self.initialSelection & self.availableScopes; + + self.navigationItem.leftBarButtonItem = [[UIBarButtonItem alloc] + initWithBarButtonSystemItem:UIBarButtonSystemItemCancel + target:self action:@selector(cancelTapped)]; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] + initWithTitle:SCILocalized(@"Select all") style:UIBarButtonItemStylePlain + target:self action:@selector(selectAllTapped)]; + + [self buildRows]; + [self buildTable]; + [self buildCommitBar]; + [self refreshContinue]; +} + +- (void)buildRows { + NSMutableArray *rows = [NSMutableArray array]; + if (self.availableScopes & SCIBackupScopePickerSettings) { + SCIPickerRow *r = [SCIPickerRow new]; + r.kind = SCIPickerRowKindScope; + r.scope = SCIBackupScopePickerSettings; + r.title = SCILocalized(@"Settings"); + r.subtitle = [self summaryForSettings]; + r.symbol = @"slider.horizontal.3"; + r.iconColor = [UIColor systemBlueColor]; + [rows addObject:r]; + } + if (self.availableScopes & SCIBackupScopePickerLists) { + SCIPickerRow *r = [SCIPickerRow new]; + r.kind = SCIPickerRowKindScope; + r.scope = SCIBackupScopePickerLists; + r.title = SCILocalized(@"Excluded lists"); + r.subtitle = [self summaryForLists]; + r.symbol = @"person.crop.circle.badge.xmark"; + r.iconColor = [UIColor systemOrangeColor]; + [rows addObject:r]; + } + if (self.availableScopes & SCIBackupScopePickerAnalyzer) { + SCIPickerRow *r = [SCIPickerRow new]; + r.kind = SCIPickerRowKindScope; + r.scope = SCIBackupScopePickerAnalyzer; + r.title = SCILocalized(@"Profile Analyzer data"); + r.subtitle = [self summaryForAnalyzer]; + r.symbol = @"person.fill.viewfinder"; + r.iconColor = [UIColor systemPurpleColor]; + [rows addObject:r]; + } + self.rows = rows; +} + +#pragma mark - Summaries + +- (NSDictionary *)settingsPayload { + NSDictionary *p = self.payload; + id s = p[@"settings"]; + if ([s isKindOfClass:[NSDictionary class]]) return s; + if (p && !p[@"ryukgram_export"] && !p[@"settings"] && !p[@"lists"] && !p[@"analyzer"]) return p; + return @{}; +} +- (NSDictionary *)listsPayload { id v = self.payload[@"lists"]; return [v isKindOfClass:[NSDictionary class]] ? v : @{}; } +- (NSDictionary *)analyzerPayload { id v = self.payload[@"analyzer"]; return [v isKindOfClass:[NSDictionary class]] ? v : @{}; } + +- (NSString *)summaryForSettings { + NSUInteger n = [self settingsPayload].count; + return [NSString stringWithFormat:SCILocalized(@"%lu preferences · tap to inspect"), (unsigned long)n]; +} +- (NSString *)summaryForLists { + NSDictionary *lists = [self listsPayload]; + NSUInteger total = 0; + for (NSString *k in lists) { + id v = lists[k]; + if ([v isKindOfClass:[NSArray class]]) total += [(NSArray *)v count]; + } + return [NSString stringWithFormat:SCILocalized(@"%lu entries across %lu lists · tap to inspect"), + (unsigned long)total, (unsigned long)lists.count]; +} +- (NSString *)summaryForAnalyzer { + NSDictionary *a = [self analyzerPayload]; + NSMutableSet *pks = [NSMutableSet set]; + NSUInteger snaps = 0; + for (NSString *f in a) { + NSArray *parts = [f componentsSeparatedByString:@"."]; + if (parts.count >= 2) [pks addObject:parts[0]]; + if ([f hasSuffix:@".current.json"] || [f hasSuffix:@".previous.json"]) snaps++; + } + return [NSString stringWithFormat:SCILocalized(@"%lu account(s) · %lu snapshot(s) · tap to inspect"), + (unsigned long)pks.count, (unsigned long)snaps]; +} + +#pragma mark - UI + +- (void)buildTable { + self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStyleInsetGrouped]; + self.tableView.translatesAutoresizingMaskIntoConstraints = NO; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.estimatedRowHeight = 64; + self.tableView.rowHeight = UITableViewAutomaticDimension; + [self.tableView registerClass:[SCIPickerCell class] forCellReuseIdentifier:@"scope"]; + [self.view addSubview:self.tableView]; + [NSLayoutConstraint activateConstraints:@[ + [self.tableView.topAnchor constraintEqualToAnchor:self.view.topAnchor], + [self.tableView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [self.tableView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + ]]; +} + +- (void)buildCommitBar { + UIView *bar = [UIView new]; + bar.translatesAutoresizingMaskIntoConstraints = NO; + bar.backgroundColor = [UIColor systemGroupedBackgroundColor]; + [self.view addSubview:bar]; + + self.continueButton = [UIButton buttonWithType:UIButtonTypeSystem]; + self.continueButton.translatesAutoresizingMaskIntoConstraints = NO; + self.continueButton.titleLabel.font = [UIFont systemFontOfSize:16 weight:UIFontWeightSemibold]; + self.continueButton.backgroundColor = [SCIUtils SCIColor_Primary] ?: [UIColor systemBlueColor]; + [self.continueButton setTitleColor:[UIColor whiteColor] forState:UIControlStateNormal]; + self.continueButton.layer.cornerRadius = 14; + [self.continueButton addTarget:self action:@selector(continueTapped) forControlEvents:UIControlEventTouchUpInside]; + [bar addSubview:self.continueButton]; + + [NSLayoutConstraint activateConstraints:@[ + [bar.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor], + [bar.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor], + [bar.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor], + [bar.topAnchor constraintEqualToAnchor:self.tableView.bottomAnchor], + + [self.continueButton.leadingAnchor constraintEqualToAnchor:bar.leadingAnchor constant:16], + [self.continueButton.trailingAnchor constraintEqualToAnchor:bar.trailingAnchor constant:-16], + [self.continueButton.topAnchor constraintEqualToAnchor:bar.topAnchor constant:10], + [self.continueButton.bottomAnchor constraintEqualToAnchor:self.view.safeAreaLayoutGuide.bottomAnchor constant:-10], + [self.continueButton.heightAnchor constraintEqualToConstant:48], + ]]; +} + +#pragma mark - Actions + +- (void)cancelTapped { [self dismissOrPopWithCompletion:nil]; } + +- (void)selectAllTapped { + BOOL all = (self.selection & self.availableScopes) == self.availableScopes && self.availableScopes != 0; + self.selection = all ? 0 : self.availableScopes; + [self.tableView reloadData]; + [self refreshContinue]; +} + +- (void)continueTapped { + SCIBackupScopePickerMask chosen = self.selection; + void (^block)(SCIBackupScopePickerMask) = self.onContinue; + [self dismissOrPopWithCompletion:^{ + if (block && chosen) block(chosen); + }]; +} + +- (void)dismissOrPopWithCompletion:(void(^)(void))completion { + if (self.navigationController.viewControllers.firstObject == self) { + [self dismissViewControllerAnimated:YES completion:completion]; + } else { + [self.navigationController popViewControllerAnimated:YES]; + if (completion) dispatch_async(dispatch_get_main_queue(), completion); + } +} + +- (void)refreshContinue { + BOOL any = self.selection != 0; + self.continueButton.enabled = any; + self.continueButton.alpha = any ? 1.0 : 0.4; + NSInteger n = __builtin_popcountll((unsigned long long)self.selection); + [self.continueButton setTitle:any + ? [NSString stringWithFormat:@"%@ (%ld)", self.continueTitle, (long)n] + : self.continueTitle + forState:UIControlStateNormal]; +} + +- (void)toggleScope:(SCIBackupScopePickerMask)scope { + if (!(self.availableScopes & scope)) return; + if (self.selection & scope) self.selection &= ~scope; + else self.selection |= scope; + [self.tableView reloadData]; + [self refreshContinue]; +} + +#pragma mark - Table + +- (NSInteger)numberOfSectionsInTableView:(UITableView *)tv { return 2; } +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { + return section == 0 ? (NSInteger)self.rows.count : 1; +} +- (NSString *)tableView:(UITableView *)tv titleForHeaderInSection:(NSInteger)section { + return section == 0 ? SCILocalized(@"Include") : SCILocalized(@"Raw"); +} +- (NSString *)tableView:(UITableView *)tv titleForFooterInSection:(NSInteger)section { + return section == 0 ? self.headerMessage : nil; +} + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)indexPath { + if (indexPath.section == 1) { + static NSString *rid = @"json"; + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:rid]; + if (!cell) cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:rid]; + cell.textLabel.text = SCILocalized(@"Raw JSON"); + cell.detailTextLabel.text = SCILocalized(@"Inspect the full payload"); + cell.imageView.image = [UIImage systemImageNamed:@"curlybraces"]; + cell.imageView.tintColor = [UIColor systemGrayColor]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + return cell; + } + + SCIPickerCell *cell = [tv dequeueReusableCellWithIdentifier:@"scope" forIndexPath:indexPath]; + SCIPickerRow *r = self.rows[indexPath.row]; + cell.titleLabel.text = r.title; + cell.subtitleLabel.text = r.subtitle; + cell.iconView.image = [UIImage systemImageNamed:r.symbol]; + cell.iconView.tintColor = r.iconColor; + BOOL enabled = (self.availableScopes & r.scope) != 0; + BOOL checked = (self.selection & r.scope) != 0; + [cell setChecked:checked enabled:enabled]; + __weak typeof(self) weakSelf = self; + cell.onToggle = ^{ [weakSelf toggleScope:r.scope]; }; + return cell; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)indexPath { + [tv deselectRowAtIndexPath:indexPath animated:YES]; + if (indexPath.section == 1) { + [self pushRawJSON]; + return; + } + SCIPickerRow *r = self.rows[indexPath.row]; + [self pushDetailForScope:r.scope]; +} + +#pragma mark - Detail pushes + +- (void)pushRawJSON { + NSData *data = [NSJSONSerialization dataWithJSONObject:(self.payload ?: @{}) + options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys + error:nil]; + NSString *json = [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}"; + + UIViewController *vc = [UIViewController new]; + vc.title = SCILocalized(@"Raw JSON"); + UITextView *tv = [UITextView new]; + tv.translatesAutoresizingMaskIntoConstraints = NO; + tv.editable = NO; + tv.font = [UIFont monospacedSystemFontOfSize:11 weight:UIFontWeightRegular]; + tv.text = json; + tv.textContainerInset = UIEdgeInsetsMake(12, 12, 12, 12); + tv.backgroundColor = [UIColor secondarySystemBackgroundColor]; + [vc.view addSubview:tv]; + [NSLayoutConstraint activateConstraints:@[ + [tv.topAnchor constraintEqualToAnchor:vc.view.topAnchor], + [tv.leadingAnchor constraintEqualToAnchor:vc.view.leadingAnchor], + [tv.trailingAnchor constraintEqualToAnchor:vc.view.trailingAnchor], + [tv.bottomAnchor constraintEqualToAnchor:vc.view.bottomAnchor], + ]]; + [self.navigationController pushViewController:vc animated:YES]; +} + +- (void)pushDetailForScope:(SCIBackupScopePickerMask)scope { + NSArray *sections = nil; + NSString *title = nil; + if (scope == SCIBackupScopePickerSettings) { + title = SCILocalized(@"Settings"); + sections = [self detailSectionsForSettings:[self settingsPayload]]; + } else if (scope == SCIBackupScopePickerLists) { + title = SCILocalized(@"Excluded lists"); + sections = [self detailSectionsForLists:[self listsPayload]]; + } else if (scope == SCIBackupScopePickerAnalyzer) { + title = SCILocalized(@"Profile Analyzer data"); + sections = [self detailSectionsForAnalyzer:[self analyzerPayload]]; + } else return; + SCIBackupDetailVC *vc = [[SCIBackupDetailVC alloc] initWithTitle:title sections:sections]; + [self.navigationController pushViewController:vc animated:YES]; +} + +- (NSString *)displayValue:(id)v { + if ([v isKindOfClass:[NSNumber class]]) { + NSNumber *n = v; + const char *t = n.objCType; + if (t && strcmp(t, "c") == 0) return n.boolValue ? @"on" : @"off"; + return n.stringValue; + } + if ([v isKindOfClass:[NSString class]]) return v; + if ([v isKindOfClass:[NSArray class]]) return [NSString stringWithFormat:@"[%lu]", (unsigned long)[(NSArray *)v count]]; + if ([v isKindOfClass:[NSDictionary class]]) return [NSString stringWithFormat:@"{%lu}", (unsigned long)[(NSDictionary *)v count]]; + return @"—"; +} + +- (NSString *)prettyKeyForList:(NSString *)k { + if ([k isEqualToString:@"excluded_threads"]) return SCILocalized(@"Excluded chats"); + if ([k isEqualToString:@"included_threads"]) return SCILocalized(@"Included chats"); + if ([k isEqualToString:@"excluded_story_users"]) return SCILocalized(@"Excluded story users"); + if ([k isEqualToString:@"included_story_users"]) return SCILocalized(@"Included story users"); + if ([k isEqualToString:@"embed_custom_domains"]) return SCILocalized(@"Embed domains"); + return k; +} + +- (NSArray *)detailSectionsForSettings:(NSDictionary *)settings { + NSArray *keys = [[settings allKeys] sortedArrayUsingSelector:@selector(compare:)]; + NSMutableArray *rows = [NSMutableArray array]; + for (NSString *k in keys) { + [rows addObject:@{ @"title": k, @"value": [self displayValue:settings[k]] }]; + } + return @[@{ @"title": [NSString stringWithFormat:SCILocalized(@"All preferences (%lu)"), (unsigned long)rows.count], + @"rows": rows }]; +} + +- (NSArray *)detailSectionsForLists:(NSDictionary *)lists { + NSMutableArray *sections = [NSMutableArray array]; + NSArray *keys = [[lists allKeys] sortedArrayUsingSelector:@selector(compare:)]; + for (NSString *k in keys) { + id v = lists[k]; + NSArray *items = [v isKindOfClass:[NSArray class]] ? v : @[]; + NSMutableArray *rows = [NSMutableArray array]; + for (id item in items) { + NSString *display = [item isKindOfClass:[NSString class]] ? item : [NSString stringWithFormat:@"%@", item]; + [rows addObject:@{ @"title": display, @"value": @"" }]; + } + if (!rows.count) [rows addObject:@{ @"title": SCILocalized(@"(empty)"), @"value": @"" }]; + [sections addObject:@{ @"title": [self prettyKeyForList:k], @"rows": rows }]; + } + if (!sections.count) sections = [@[@{ @"title": @"", @"rows": @[@{@"title": SCILocalized(@"(no lists)"), @"value": @""}] }] mutableCopy]; + return sections; +} + +- (NSArray *)detailSectionsForAnalyzer:(NSDictionary *)analyzer { + NSMutableDictionary *byPK = [NSMutableDictionary dictionary]; + for (NSString *file in analyzer) { + NSArray *parts = [file componentsSeparatedByString:@"."]; + if (parts.count < 2) continue; + NSMutableDictionary *slot = byPK[parts[0]] ?: [NSMutableDictionary dictionary]; + slot[parts[1]] = analyzer[file]; + byPK[parts[0]] = slot; + } + NSMutableArray *sections = [NSMutableArray array]; + for (NSString *pk in [[byPK allKeys] sortedArrayUsingSelector:@selector(compare:)]) { + NSDictionary *slot = byPK[pk]; + NSDictionary *hdr = slot[@"header"]; + NSString *username = [hdr[@"username"] isKindOfClass:[NSString class]] ? hdr[@"username"] : nil; + NSString *header = username.length ? [NSString stringWithFormat:@"@%@", username] : [NSString stringWithFormat:@"PK %@", pk]; + + NSMutableArray *rows = [NSMutableArray array]; + if (hdr) { + [rows addObject:@{ @"title": SCILocalized(@"Full name"), @"value": hdr[@"full_name"] ?: @"—" }]; + [rows addObject:@{ @"title": SCILocalized(@"Followers"), @"value": [NSString stringWithFormat:@"%ld", (long)[hdr[@"follower_count"] integerValue]] }]; + [rows addObject:@{ @"title": SCILocalized(@"Following"), @"value": [NSString stringWithFormat:@"%ld", (long)[hdr[@"following_count"] integerValue]] }]; + [rows addObject:@{ @"title": SCILocalized(@"Posts"), @"value": [NSString stringWithFormat:@"%ld", (long)[hdr[@"media_count"] integerValue]] }]; + } + [rows addObject:@{ @"title": SCILocalized(@"Current snapshot"), @"value": [self snapshotSummary:slot[@"current"]] }]; + [rows addObject:@{ @"title": SCILocalized(@"Previous snapshot"), @"value": [self snapshotSummary:slot[@"previous"]] }]; + [sections addObject:@{ @"title": header, @"rows": rows }]; + } + if (!sections.count) sections = [@[@{ @"title": @"", @"rows": @[@{@"title": SCILocalized(@"(no analyzer data)"), @"value": @""}] }] mutableCopy]; + return sections; +} + +- (NSString *)snapshotSummary:(NSDictionary *)snap { + if (![snap isKindOfClass:[NSDictionary class]]) return @"—"; + NSArray *followers = snap[@"followers"]; + NSArray *following = snap[@"following"]; + NSTimeInterval ts = [snap[@"scan_date"] doubleValue]; + NSString *when = ts > 0 ? [NSDateFormatter localizedStringFromDate:[NSDate dateWithTimeIntervalSince1970:ts] + dateStyle:NSDateFormatterShortStyle + timeStyle:NSDateFormatterShortStyle] + : @""; + return [NSString stringWithFormat:@"%lu / %lu — %@", + (unsigned long)([followers isKindOfClass:[NSArray class]] ? followers.count : 0), + (unsigned long)([following isKindOfClass:[NSArray class]] ? following.count : 0), + when]; +} + +@end diff --git a/src/Settings/SCIDateFormatPickerVC.m b/src/Settings/SCIDateFormatPickerVC.m index 42260a7..b2d8f68 100644 --- a/src/Settings/SCIDateFormatPickerVC.m +++ b/src/Settings/SCIDateFormatPickerVC.m @@ -124,7 +124,7 @@ static NSString *sciExampleForKey(NSString *key) { 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.text = SCILocalized(entry[1]); cell.textLabel.numberOfLines = 0; cell.textLabel.font = [UIFont systemFontOfSize:15]; UISwitch *sw = [UISwitch new]; diff --git a/src/Settings/SCIEmbedDomainViewController.m b/src/Settings/SCIEmbedDomainViewController.m index eded9ee..ae10705 100644 --- a/src/Settings/SCIEmbedDomainViewController.m +++ b/src/Settings/SCIEmbedDomainViewController.m @@ -4,7 +4,7 @@ #define SCI_CUSTOM_DOMAINS_KEY @"embed_custom_domains" static NSArray *sciPresetDomains(void) { - return @[@"kkinstagram.com", @"ddinstagram.com", @"d.ddinstagram.com", @"g.ddinstagram.com"]; + return @[@"eeinstagram.com", @"vxinstagram.com", @"kkinstagram.com", @"ddinstagram.com", @"d.ddinstagram.com", @"g.ddinstagram.com"]; } @interface SCIEmbedDomainViewController () diff --git a/src/Settings/SCIExcludedChatsViewController.m b/src/Settings/SCIExcludedChatsViewController.m index 8cb24dc..d9caf4c 100644 --- a/src/Settings/SCIExcludedChatsViewController.m +++ b/src/Settings/SCIExcludedChatsViewController.m @@ -119,7 +119,7 @@ UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Add chat") message:SCILocalized(@"Enter username of the DM thread") preferredStyle:UIAlertControllerStyleAlert]; - [alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = @"username"; tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }]; + [alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"username"); tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }]; [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Search") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) { NSString *q = [alert.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; @@ -178,7 +178,7 @@ UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by") message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - NSArray *titles = @[@"Recently added", @"Name (A–Z)"]; + NSArray *titles = @[SCILocalized(@"Recently added"), SCILocalized(@"Name (A–Z)")]; for (NSInteger i = 0; i < (NSInteger)titles.count; i++) { UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i] style:UIAlertActionStyleDefault diff --git a/src/Settings/SCIExcludedStoryUsersViewController.m b/src/Settings/SCIExcludedStoryUsersViewController.m index 134277f..4691b4a 100644 --- a/src/Settings/SCIExcludedStoryUsersViewController.m +++ b/src/Settings/SCIExcludedStoryUsersViewController.m @@ -90,7 +90,7 @@ UIAlertController *alert = [UIAlertController alertControllerWithTitle:SCILocalized(@"Add user") message:SCILocalized(@"Enter username") preferredStyle:UIAlertControllerStyleAlert]; - [alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = @"username"; tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }]; + [alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { tf.placeholder = SCILocalized(@"username"); tf.autocapitalizationType = UITextAutocapitalizationTypeNone; }]; [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Search") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) { NSString *q = [alert.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; @@ -131,7 +131,7 @@ UIAlertController *sheet = [UIAlertController alertControllerWithTitle:SCILocalized(@"Sort by") message:nil preferredStyle:UIAlertControllerStyleActionSheet]; - NSArray *titles = @[@"Recently added", @"Username (A–Z)"]; + NSArray *titles = @[SCILocalized(@"Recently added"), SCILocalized(@"Username (A–Z)")]; for (NSInteger i = 0; i < (NSInteger)titles.count; i++) { UIAlertAction *a = [UIAlertAction actionWithTitle:titles[i] style:UIAlertActionStyleDefault diff --git a/src/Settings/SCIExpFlagsViewController.h b/src/Settings/SCIExpFlagsViewController.h new file mode 100644 index 0000000..36f4e73 --- /dev/null +++ b/src/Settings/SCIExpFlagsViewController.h @@ -0,0 +1,4 @@ +#import + +@interface SCIExpFlagsViewController : UIViewController +@end diff --git a/src/Settings/SCIExpFlagsViewController.m_ b/src/Settings/SCIExpFlagsViewController.m_ new file mode 100644 index 0000000..196c5b4 --- /dev/null +++ b/src/Settings/SCIExpFlagsViewController.m_ @@ -0,0 +1,420 @@ +// Exp flag browser + override editor. +// Tabs: Browser(native) | Meta(override) | MC(view) | Scanned(view) | Overrides + +#import "SCIExpFlagsViewController.h" +#import "../Features/ExpFlags/SCIExpFlags.h" +#import "../Utils.h" +#import +#import + +typedef NS_ENUM(NSInteger, SCIExpTab) { + SCIExpTabBrowser = 0, + SCIExpTabMeta, + SCIExpTabMC, + SCIExpTabScanned, + SCIExpTabOverrides, +}; + +@interface SCIExpFlagsViewController () +@property (nonatomic, strong) UISegmentedControl *seg; +@property (nonatomic, strong) UISearchBar *searchBar; +@property (nonatomic, strong) UITableView *tableView; +@property (nonatomic, strong) UIActivityIndicatorView *spinner; +@property (nonatomic, strong) UILabel *empty; + +@property (nonatomic, assign) SCIExpTab tab; +@property (nonatomic, copy) NSString *query; + +// Tab data. +@property (nonatomic, strong) NSArray *metaObs; +@property (nonatomic, strong) NSArray *mcObs; +@property (nonatomic, strong) NSArray *scannedNames; // lazy-loaded +@property (nonatomic, assign) BOOL scannedLoading; +@property (nonatomic, strong) NSArray *overriddenNames; +@end + +@implementation SCIExpFlagsViewController + +- (void)viewDidLoad { + [super viewDidLoad]; + self.title = @"Experimental flags"; + self.view.backgroundColor = UIColor.systemBackgroundColor; + self.navigationItem.rightBarButtonItem = [[UIBarButtonItem alloc] + initWithImage:[UIImage systemImageNamed:@"xmark.circle"] + style:UIBarButtonItemStylePlain target:self action:@selector(confirmResetAll)]; + + self.seg = [[UISegmentedControl alloc] initWithItems:@[@"Browser", @"Meta", @"MC IDs", @"Scanned", @"Overrides"]]; + self.seg.selectedSegmentIndex = SCIExpTabMeta; + self.tab = SCIExpTabMeta; + [self.seg addTarget:self action:@selector(segChanged) forControlEvents:UIControlEventValueChanged]; + self.seg.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.seg]; + + self.searchBar = [UISearchBar new]; + self.searchBar.searchBarStyle = UISearchBarStyleMinimal; + self.searchBar.placeholder = @"Search"; + self.searchBar.autocapitalizationType = UITextAutocapitalizationTypeNone; + self.searchBar.autocorrectionType = UITextAutocorrectionTypeNo; + self.searchBar.delegate = self; + self.searchBar.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:self.searchBar]; + + self.tableView = [[UITableView alloc] initWithFrame:CGRectZero style:UITableViewStylePlain]; + self.tableView.dataSource = self; + self.tableView.delegate = self; + self.tableView.translatesAutoresizingMaskIntoConstraints = NO; + self.tableView.keyboardDismissMode = UIScrollViewKeyboardDismissModeOnDrag; + [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"cell"]; + [self.view addSubview:self.tableView]; + + self.spinner = [[UIActivityIndicatorView alloc] initWithActivityIndicatorStyle:UIActivityIndicatorViewStyleLarge]; + self.spinner.translatesAutoresizingMaskIntoConstraints = NO; + self.spinner.hidesWhenStopped = YES; + [self.view addSubview:self.spinner]; + + self.empty = [UILabel new]; + self.empty.translatesAutoresizingMaskIntoConstraints = NO; + self.empty.textColor = UIColor.secondaryLabelColor; + self.empty.textAlignment = NSTextAlignmentCenter; + self.empty.numberOfLines = 0; + self.empty.font = [UIFont systemFontOfSize:14]; + [self.view addSubview:self.empty]; + + UILayoutGuide *g = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [self.seg.topAnchor constraintEqualToAnchor:g.topAnchor constant:8], + [self.seg.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:12], + [self.seg.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-12], + + [self.searchBar.topAnchor constraintEqualToAnchor:self.seg.bottomAnchor constant:4], + [self.searchBar.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:8], + [self.searchBar.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-8], + + [self.tableView.topAnchor constraintEqualToAnchor:self.searchBar.bottomAnchor], + [self.tableView.leadingAnchor constraintEqualToAnchor:g.leadingAnchor], + [self.tableView.trailingAnchor constraintEqualToAnchor:g.trailingAnchor], + [self.tableView.bottomAnchor constraintEqualToAnchor:g.bottomAnchor], + + [self.spinner.centerXAnchor constraintEqualToAnchor:self.tableView.centerXAnchor], + [self.spinner.centerYAnchor constraintEqualToAnchor:self.tableView.centerYAnchor], + + [self.empty.centerXAnchor constraintEqualToAnchor:self.tableView.centerXAnchor], + [self.empty.centerYAnchor constraintEqualToAnchor:self.tableView.centerYAnchor], + [self.empty.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:24], + [self.empty.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-24], + ]]; +} + +- (void)viewWillAppear:(BOOL)animated { [super viewWillAppear:animated]; [self refresh]; } + +// tab state + +- (void)segChanged { + self.tab = (SCIExpTab)self.seg.selectedSegmentIndex; + if (self.tab == SCIExpTabScanned && !self.scannedNames && !self.scannedLoading) [self loadScanned]; + [self refresh]; +} + +- (void)loadScanned { + self.scannedLoading = YES; + [self.spinner startAnimating]; + [self updateEmpty]; + [SCIExpFlags scanExecutableNamesWithCompletion:^(NSArray *names) { + self.scannedNames = names; + self.scannedLoading = NO; + [self.spinner stopAnimating]; + [self refresh]; + }]; +} + +- (void)refresh { + self.metaObs = [SCIExpFlags allObservations]; + self.mcObs = [SCIExpFlags allMCObservations]; + self.overriddenNames = [[SCIExpFlags allOverriddenNames] sortedArrayUsingSelector:@selector(compare:)]; + [self.tableView reloadData]; + [self updateEmpty]; +} + +- (void)updateEmpty { + NSInteger rows = [self tableView:self.tableView numberOfRowsInSection:0]; + if (self.tab == SCIExpTabScanned && self.scannedLoading) { + self.empty.text = @"Scanning…"; + self.empty.hidden = NO; + return; + } + if (rows == 0) { + switch (self.tab) { + case SCIExpTabBrowser: self.empty.text = @""; break; + case SCIExpTabMeta: self.empty.text = @"Browse IG to populate."; break; + case SCIExpTabMC: self.empty.text = @"Browse IG to populate."; break; + case SCIExpTabScanned: self.empty.text = self.query.length ? @"No match" : @"Empty."; break; + case SCIExpTabOverrides: self.empty.text = @"None."; break; + } + self.empty.hidden = NO; + return; + } + self.empty.hidden = YES; +} + +// filter + +- (NSArray *)filteredRows { + switch (self.tab) { + case SCIExpTabBrowser: return @[@"Open native list", @"Add override"]; + case SCIExpTabMeta: return [self filtered:self.metaObs keyPath:@"experimentName"]; + case SCIExpTabMC: return [self filterMC:self.mcObs]; + case SCIExpTabScanned: return [self filterStrings:self.scannedNames]; + case SCIExpTabOverrides: return [self filterStrings:self.overriddenNames]; + } +} + +- (NSArray *)filtered:(NSArray *)items keyPath:(NSString *)kp { + if (!self.query.length) return items ?: @[]; + NSString *q = self.query.lowercaseString; + NSMutableArray *out = [NSMutableArray array]; + for (id o in items) { + NSString *s = [[o valueForKey:kp] lowercaseString]; + if ([s containsString:q]) [out addObject:o]; + } + return out; +} + +- (NSArray *)filterMC:(NSArray *)items { + if (!self.query.length) return items ?: @[]; + NSString *q = self.query.lowercaseString; + NSMutableArray *out = [NSMutableArray array]; + for (SCIExpMCObservation *o in items) { + NSString *s = [NSString stringWithFormat:@"%llu", o.paramID]; + if ([s containsString:q]) [out addObject:o]; + } + return out; +} + +- (NSArray *)filterStrings:(NSArray *)items { + if (!self.query.length) return items ?: @[]; + NSString *q = self.query.lowercaseString; + NSMutableArray *out = [NSMutableArray array]; + for (NSString *s in items) if ([s.lowercaseString containsString:q]) [out addObject:s]; + return out; +} + +// table + +- (NSInteger)tableView:(UITableView *)tv numberOfRowsInSection:(NSInteger)section { return [self filteredRows].count; } + +- (UITableViewCell *)tableView:(UITableView *)tv cellForRowAtIndexPath:(NSIndexPath *)ip { + UITableViewCell *cell = [tv dequeueReusableCellWithIdentifier:@"cell" forIndexPath:ip]; + cell.accessoryType = UITableViewCellAccessoryNone; + cell.selectionStyle = UITableViewCellSelectionStyleDefault; + cell.textLabel.textColor = UIColor.labelColor; + cell.textLabel.font = [UIFont systemFontOfSize:15]; + cell.textLabel.numberOfLines = 0; + cell.detailTextLabel.text = nil; + + id row = [self filteredRows][ip.row]; + + switch (self.tab) { + case SCIExpTabBrowser: { + cell.textLabel.text = (NSString *)row; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + break; + } + case SCIExpTabMeta: { + SCIExpObservation *o = row; + [self fillCell:cell withName:o.experimentName subtitle:[NSString stringWithFormat:@"group=%@ · ×%lu", o.lastGroup ?: @"nil", (unsigned long)o.hitCount]]; + break; + } + case SCIExpTabMC: { + SCIExpMCObservation *o = row; + NSString *tname = @"?"; + switch (o.type) { + case SCIExpMCTypeBool: tname = @"bool"; break; + case SCIExpMCTypeInt: tname = @"int64"; break; + case SCIExpMCTypeDouble: tname = @"double"; break; + case SCIExpMCTypeString: tname = @"string"; break; + } + cell.textLabel.text = [NSString stringWithFormat:@"%llu", o.paramID]; + cell.textLabel.font = [UIFont monospacedSystemFontOfSize:13 weight:UIFontWeightRegular]; + cell.detailTextLabel.text = [NSString stringWithFormat:@"%@ · default=%@ · ×%lu", tname, o.lastDefault ?: @"?", (unsigned long)o.hitCount]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + break; + } + case SCIExpTabScanned: { + cell.textLabel.text = (NSString *)row; + cell.textLabel.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; + break; + } + case SCIExpTabOverrides: { + NSString *name = (NSString *)row; + [self fillCell:cell withName:name subtitle:nil]; + break; + } + } + return cell; +} + +- (void)fillCell:(UITableViewCell *)cell withName:(NSString *)name subtitle:(NSString *)sub { + SCIExpFlagOverride o = [SCIExpFlags overrideForName:name]; + NSString *prefix = o == SCIExpFlagOverrideTrue ? @"● " : o == SCIExpFlagOverrideFalse ? @"○ " : @""; + cell.textLabel.text = [prefix stringByAppendingString:name]; + cell.textLabel.font = [UIFont monospacedSystemFontOfSize:13 weight:UIFontWeightRegular]; + cell.textLabel.textColor = o == SCIExpFlagOverrideOff ? UIColor.labelColor : UIColor.systemOrangeColor; + + NSMutableArray *parts = [NSMutableArray array]; + if (sub.length) [parts addObject:sub]; + if (o == SCIExpFlagOverrideTrue) [parts addObject:@"FORCED ON"]; + if (o == SCIExpFlagOverrideFalse) [parts addObject:@"FORCED OFF"]; + cell.detailTextLabel.text = [parts componentsJoinedByString:@" · "]; + cell.accessoryType = UITableViewCellAccessoryDisclosureIndicator; +} + +- (void)tableView:(UITableView *)tv didSelectRowAtIndexPath:(NSIndexPath *)ip { + [tv deselectRowAtIndexPath:ip animated:YES]; + UITableViewCell *cell = [tv cellForRowAtIndexPath:ip]; + id row = [self filteredRows][ip.row]; + switch (self.tab) { + case SCIExpTabBrowser: + if (ip.row == 0) [self openNativeBrowser]; + else [self promptAddByName]; + break; + case SCIExpTabMeta: + [self presentOverrideSheetForName:((SCIExpObservation *)row).experimentName fromCell:cell]; + break; + case SCIExpTabMC: { + // View-only; offer Copy ID for user convenience. + SCIExpMCObservation *o = row; + [self presentCopySheetWithText:[NSString stringWithFormat:@"%llu", o.paramID] title:@"MobileConfig param" fromCell:cell]; + break; + } + case SCIExpTabScanned: + [self presentCopySheetWithText:(NSString *)row title:@"Scanned name" fromCell:cell]; + break; + case SCIExpTabOverrides: + [self presentOverrideSheetForName:(NSString *)row fromCell:cell]; + break; + } +} + +// actions + +- (void)openNativeBrowser { + Class cls = NSClassFromString(@"MetaLocalExperimentListViewController"); + if (!cls) { [SCIUtils showErrorHUDWithDescription:@"Native browser missing"]; return; } + SEL initSel = NSSelectorFromString(@"initWithExperimentConfigs:experimentGenerator:"); + UIViewController *vc = nil; + @try { + if ([cls instancesRespondToSelector:initSel]) { + id (*send)(id, SEL, id, id) = (id (*)(id, SEL, id, id))objc_msgSend; + vc = send([cls alloc], initSel, [self nativeBrowserConfigs], [self nativeBrowserGenerator]); + } else { + vc = [[cls alloc] init]; + } + } @catch (__unused id e) {} + if (!vc) { [SCIUtils showErrorHUDWithDescription:@"Init failed"]; return; } + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + nav.modalPresentationStyle = UIModalPresentationFullScreen; + [self presentViewController:nav animated:YES completion:nil]; +} + +- (NSArray *)nativeBrowserConfigs { + Protocol *p = objc_getProtocol("MetaLocalExperimentConfigProtocol"); + if (!p) return @[]; + unsigned int n = 0; + Class *all = objc_copyClassList(&n); + NSMutableArray *out = [NSMutableArray array]; + for (unsigned int i = 0; i < n; i++) { + if (class_conformsToProtocol(all[i], p)) { + @try { id x = [[all[i] alloc] init]; if (x) [out addObject:x]; } @catch (__unused id e) {} + } + } + if (all) free(all); + return out; +} + +- (id)nativeBrowserGenerator { + Class c = NSClassFromString(@"LIDExperimentGenerator"); + if (!c) return nil; + SEL s = NSSelectorFromString(@"initWithDeviceID:logger:"); + if (![c instancesRespondToSelector:s]) return nil; + id (*send)(id, SEL, id, id) = (id (*)(id, SEL, id, id))objc_msgSend; + return send([c alloc], s, nil, nil); +} + +- (void)promptAddByName { + UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Add override" message:@"Substring match, case-insensitive." preferredStyle:UIAlertControllerStyleAlert]; + [a addTextFieldWithConfigurationHandler:^(UITextField *tf) { + tf.placeholder = @"name (e.g. liquidglass)"; + tf.autocapitalizationType = UITextAutocapitalizationTypeNone; + tf.autocorrectionType = UITextAutocorrectionTypeNo; + }]; + [a addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [a addAction:[UIAlertAction actionWithTitle:@"Force ON" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + NSString *n = [a.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (n.length) { [SCIExpFlags setOverride:SCIExpFlagOverrideTrue forName:n]; [self refresh]; } + }]]; + [a addAction:[UIAlertAction actionWithTitle:@"Force OFF" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + NSString *n = [a.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (n.length) { [SCIExpFlags setOverride:SCIExpFlagOverrideFalse forName:n]; [self refresh]; } + }]]; + [self presentViewController:a animated:YES completion:nil]; +} + +- (void)presentOverrideSheetForName:(NSString *)name fromCell:(UITableViewCell *)cell { + SCIExpFlagOverride cur = [SCIExpFlags overrideForName:name]; + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:name message:nil preferredStyle:UIAlertControllerStyleActionSheet]; + NSArray *opts = @[@{@"t": @"No override", @"v": @(SCIExpFlagOverrideOff)}, + @{@"t": @"Force ON", @"v": @(SCIExpFlagOverrideTrue)}, + @{@"t": @"Force OFF", @"v": @(SCIExpFlagOverrideFalse)}]; + for (NSDictionary *o in opts) { + NSString *t = o[@"t"]; + if (((NSNumber *)o[@"v"]).integerValue == cur) t = [t stringByAppendingString:@" ✓"]; + [sheet addAction:[UIAlertAction actionWithTitle:t style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [SCIExpFlags setOverride:((NSNumber *)o[@"v"]).integerValue forName:name]; + [self refresh]; + }]]; + } + [sheet addAction:[UIAlertAction actionWithTitle:@"Copy name" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [UIPasteboard generalPasteboard].string = name; + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + if (sheet.popoverPresentationController) { + sheet.popoverPresentationController.sourceView = cell; + sheet.popoverPresentationController.sourceRect = cell.bounds; + } + [self presentViewController:sheet animated:YES completion:nil]; +} + +- (void)presentCopySheetWithText:(NSString *)text title:(NSString *)title fromCell:(UITableViewCell *)cell { + UIAlertController *sheet = [UIAlertController alertControllerWithTitle:title message:text preferredStyle:UIAlertControllerStyleActionSheet]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Copy" style:UIAlertActionStyleDefault handler:^(UIAlertAction *_) { + [UIPasteboard generalPasteboard].string = text; + }]]; + [sheet addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + if (sheet.popoverPresentationController) { + sheet.popoverPresentationController.sourceView = cell; + sheet.popoverPresentationController.sourceRect = cell.bounds; + } + [self presentViewController:sheet animated:YES completion:nil]; +} + +- (void)confirmResetAll { + UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Reset all?" message:nil preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [a addAction:[UIAlertAction actionWithTitle:@"Reset" style:UIAlertActionStyleDestructive handler:^(UIAlertAction *_) { + [SCIExpFlags resetAllOverrides]; + [self refresh]; + }]]; + [self presentViewController:a animated:YES completion:nil]; +} + +// search + +- (void)searchBar:(UISearchBar *)searchBar textDidChange:(NSString *)text { + self.query = text; + [self.tableView reloadData]; + [self updateEmpty]; +} +- (void)searchBarSearchButtonClicked:(UISearchBar *)searchBar { [searchBar resignFirstResponder]; } + +@end diff --git a/src/Settings/SCILinksSheet.h b/src/Settings/SCILinksSheet.h new file mode 100644 index 0000000..a0618b9 --- /dev/null +++ b/src/Settings/SCILinksSheet.h @@ -0,0 +1,7 @@ +#import + +@interface SCILinksSheet : UIViewController + ++ (void)presentFrom:(UIViewController *)source; + +@end diff --git a/src/Settings/SCILinksSheet.m b/src/Settings/SCILinksSheet.m new file mode 100644 index 0000000..b59d36b --- /dev/null +++ b/src/Settings/SCILinksSheet.m @@ -0,0 +1,123 @@ +#import "SCILinksSheet.h" +#import "../Localization/SCILocalization.h" +#import "../Utils.h" + +@implementation SCILinksSheet + ++ (void)presentFrom:(UIViewController *)source { + SCILinksSheet *vc = [[SCILinksSheet alloc] init]; + vc.modalPresentationStyle = UIModalPresentationPageSheet; + UISheetPresentationController *sheet = vc.sheetPresentationController; + if (sheet) { + sheet.detents = @[[UISheetPresentationControllerDetent mediumDetent]]; + sheet.prefersGrabberVisible = YES; + sheet.preferredCornerRadius = 28; + } + [source presentViewController:vc animated:YES completion:nil]; +} + +- (void)viewDidLoad { + [super viewDidLoad]; + self.view.backgroundColor = [UIColor colorWithDynamicProvider:^UIColor *(UITraitCollection *tc) { + return tc.userInterfaceStyle == UIUserInterfaceStyleDark + ? [UIColor colorWithWhite:0.11 alpha:1.0] + : [UIColor systemBackgroundColor]; + }]; + + UIImageView *logo = [[UIImageView alloc] initWithImage: + [UIImage imageNamed:@"ryukgram" + inBundle:SCILocalizationBundle() + compatibleWithTraitCollection:nil]]; + logo.contentMode = UIViewContentModeScaleAspectFill; + logo.clipsToBounds = YES; + logo.layer.cornerRadius = 18; + logo.layer.cornerCurve = kCACornerCurveContinuous; + [logo.widthAnchor constraintEqualToConstant:78].active = YES; + [logo.heightAnchor constraintEqualToConstant:78].active = YES; + + UILabel *title = [[UILabel alloc] init]; + title.text = @"RyukGram"; + title.font = [UIFont systemFontOfSize:22 weight:UIFontWeightBold]; + title.textAlignment = NSTextAlignmentCenter; + + UILabel *version = [[UILabel alloc] init]; + version.text = SCIVersionString; + version.font = [UIFont systemFontOfSize:14 weight:UIFontWeightRegular]; + version.textColor = [UIColor secondaryLabelColor]; + version.textAlignment = NSTextAlignmentCenter; + + UIButton *github = [self makeButtonWithTitle:SCILocalized(@"View on GitHub") + sfSymbol:@"chevron.left.forwardslash.chevron.right" + tint:[UIColor labelColor] + background:[UIColor tertiarySystemFillColor]]; + [github addTarget:self action:@selector(openGitHub) forControlEvents:UIControlEventTouchUpInside]; + + UIButton *telegram = [self makeButtonWithTitle:SCILocalized(@"Join Telegram channel") + sfSymbol:@"paperplane.fill" + tint:[UIColor whiteColor] + background:[UIColor colorWithRed:0.15 green:0.56 blue:0.93 alpha:1.0]]; + [telegram addTarget:self action:@selector(openTelegram) forControlEvents:UIControlEventTouchUpInside]; + + UIStackView *buttons = [[UIStackView alloc] initWithArrangedSubviews:@[github, telegram]]; + buttons.axis = UILayoutConstraintAxisVertical; + buttons.spacing = 10; + buttons.distribution = UIStackViewDistributionFillEqually; + + UIStackView *stack = [[UIStackView alloc] initWithArrangedSubviews:@[logo, title, version, buttons]]; + stack.axis = UILayoutConstraintAxisVertical; + stack.alignment = UIStackViewAlignmentCenter; + stack.spacing = 14; + [stack setCustomSpacing:2 afterView:title]; + [stack setCustomSpacing:22 afterView:version]; + stack.translatesAutoresizingMaskIntoConstraints = NO; + [self.view addSubview:stack]; + + UILayoutGuide *g = self.view.safeAreaLayoutGuide; + [NSLayoutConstraint activateConstraints:@[ + [stack.centerYAnchor constraintEqualToAnchor:g.centerYAnchor], + [stack.leadingAnchor constraintEqualToAnchor:g.leadingAnchor constant:20], + [stack.trailingAnchor constraintEqualToAnchor:g.trailingAnchor constant:-20], + [buttons.widthAnchor constraintEqualToAnchor:stack.widthAnchor], + ]]; +} + +- (UIButton *)makeButtonWithTitle:(NSString *)title + sfSymbol:(NSString *)symbol + tint:(UIColor *)tint + background:(UIColor *)bg { + UIButtonConfiguration *cfg = [UIButtonConfiguration filledButtonConfiguration]; + cfg.title = title; + cfg.image = [UIImage systemImageNamed:symbol]; + cfg.imagePadding = 10; + cfg.imagePlacement = NSDirectionalRectEdgeLeading; + cfg.baseForegroundColor = tint; + cfg.baseBackgroundColor = bg; + cfg.cornerStyle = UIButtonConfigurationCornerStyleLarge; + cfg.contentInsets = NSDirectionalEdgeInsetsMake(14, 16, 14, 16); + + UIButton *b = [UIButton buttonWithConfiguration:cfg primaryAction:nil]; + b.translatesAutoresizingMaskIntoConstraints = NO; + return b; +} + +- (void)openGitHub { + NSURL *url = [NSURL URLWithString:@"https://github.com/faroukbmiled/RyukGram"]; + [self dismissViewControllerAnimated:YES completion:^{ + if (url) [[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil]; + }]; +} + +- (void)openTelegram { + UIApplication *app = [UIApplication sharedApplication]; + NSURL *scheme = [NSURL URLWithString:@"tg://resolve?domain=ryukgram"]; + NSURL *web = [NSURL URLWithString:@"https://t.me/ryukgram"]; + // IG's Info.plist doesn't whitelist `tg` for canOpenURL — skip the check + // and fall through to the web link if the scheme isn't handled. + [self dismissViewControllerAnimated:YES completion:^{ + [app openURL:scheme options:@{} completionHandler:^(BOOL ok) { + if (!ok && web) [app openURL:web options:@{} completionHandler:nil]; + }]; + }]; +} + +@end diff --git a/src/Settings/SCISetting.h b/src/Settings/SCISetting.h index 29f2539..163fbc6 100644 --- a/src/Settings/SCISetting.h +++ b/src/Settings/SCISetting.h @@ -14,8 +14,6 @@ typedef NS_ENUM(NSInteger, SCITableCell) { SCITableCellNavigation, }; -/// - @interface SCISetting : NSObject @property (nonatomic, readonly) SCITableCell type; @@ -28,6 +26,7 @@ typedef NS_ENUM(NSInteger, SCITableCell) { @property (nonatomic, strong) NSURL *url; @property (nonatomic, strong) NSURL *imageUrl; +@property (nonatomic, copy, nullable) NSString *bundleImageName; @property (nonatomic) BOOL requiresRestart; @property (nonatomic) BOOL disabled; @@ -44,6 +43,14 @@ typedef NS_ENUM(NSInteger, SCITableCell) { @property (nonatomic, copy, nullable) NSString *(^dynamicTitle)(void); +/// Optional trailing label for a static cell. Rendered right-aligned; pairs +/// with `subtitle` (which still renders beneath the title) when both are set. +@property (nonatomic, copy, nullable) NSString *valueText; + +/// Optional override for the title text color. Primarily useful for giving +/// action-style button cells the same tint as link cells. +@property (nonatomic, strong, nullable) UIColor *titleColor; + @property (nonatomic, strong) NSArray *navSections; @property (nonatomic, strong) UIViewController *navViewController; diff --git a/src/Settings/SCISettingsBackup.m b/src/Settings/SCISettingsBackup.m index 62fc8c0..752e402 100644 --- a/src/Settings/SCISettingsBackup.m +++ b/src/Settings/SCISettingsBackup.m @@ -3,382 +3,31 @@ #import "SCISetting.h" #import "../Utils.h" #import "../Tweak.h" +#import "../Features/ProfileAnalyzer/SCIProfileAnalyzerStorage.h" +#import "SCIBackupScopePickerVC.h" #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. - -#pragma mark - Preview view controller - -typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { - SCIBackupPreviewRowKindReadOnly, - SCIBackupPreviewRowKindSwitch, - SCIBackupPreviewRowKindMenu, +typedef NS_OPTIONS(NSInteger, SCIBackupScope) { + SCIBackupScopeSettings = 1 << 0, // preferences only (no lists, no analyzer) + SCIBackupScopeLists = 1 << 1, // excluded chats / story users / embed domains + SCIBackupScopeAnalyzer = 1 << 2, // Profile Analyzer snapshots + header cache }; +static const SCIBackupScope SCIBackupScopeAll = + SCIBackupScopeSettings | SCIBackupScopeLists | SCIBackupScopeAnalyzer; -@interface SCIBackupPreviewRow : NSObject -@property (nonatomic, copy) NSString *title; -@property (nonatomic, copy) NSString *value; -@property (nonatomic, copy, nullable) NSString *defaultsKey; -@property (nonatomic) SCIBackupPreviewRowKind kind; -@property (nonatomic, strong, nullable) NSArray *menuOptions; -@end -@implementation SCIBackupPreviewRow -@end +// Export / import / reset for Settings, excluded lists, and analyzer data — +// scoped via SCIBackupScopePickerVC, written as v2 JSON with a v1 flat-file +// import path for back-compat. -@interface SCIBackupPreviewGroup : NSObject -@property (nonatomic, copy) NSString *title; -@property (nonatomic, strong) NSMutableArray *rows; -@property (nonatomic) BOOL collapsed; -@end -@implementation SCIBackupPreviewGroup -@end -@class SCIBackupPreviewVC, SCIBackupPreviewGroup; -@interface SCISettingsBackup (PreviewBuilder) -+ (NSArray *)buildPreviewGroupsForSettings:(NSDictionary *)values; -+ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out; -+ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw; -@end - -@interface SCIBackupPreviewVC : UIViewController -@property (nonatomic, strong) NSMutableDictionary *mutableSettings; -@property (nonatomic, copy) NSString *primaryActionTitle; -@property (nonatomic, copy) void (^primaryAction)(SCIBackupPreviewVC *vc); - -@property (nonatomic, strong) NSArray *allGroups; -@property (nonatomic, strong) NSArray *visibleGroups; -@property (nonatomic, copy) NSString *searchText; -@property (nonatomic, strong) UITableView *tableView; -@property (nonatomic, strong) UITextView *jsonTextView; -@property (nonatomic, strong) UISearchController *searchController; -@property (nonatomic, strong) UIBarButtonItem *moreItem; -@property (nonatomic) BOOL editMode; -@property (nonatomic) BOOL jsonMode; -@end - -@implementation SCIBackupPreviewVC - -- (void)viewDidLoad { - [super viewDidLoad]; - self.view.backgroundColor = [UIColor systemGroupedBackgroundColor]; - - self.navigationItem.leftBarButtonItem = - [[UIBarButtonItem alloc] initWithBarButtonSystemItem:UIBarButtonSystemItemCancel - target:self - action:@selector(cancel)]; - - NSMutableArray *rightItems = [NSMutableArray array]; - if (self.primaryActionTitle.length && self.primaryAction) { - [rightItems addObject:[[UIBarButtonItem alloc] initWithTitle:self.primaryActionTitle - style:UIBarButtonItemStyleDone - target:self - action:@selector(runPrimary)]]; - } - // Edit and JSON view live inside a single "More" menu so the title has room. - self.moreItem = [[UIBarButtonItem alloc] - initWithImage:[UIImage systemImageNamed:@"ellipsis.circle"] - style:UIBarButtonItemStylePlain - target:nil action:nil]; - self.moreItem.menu = [self buildMoreMenu]; - [rightItems addObject:self.moreItem]; - self.navigationItem.rightBarButtonItems = rightItems; - - UITableView *table = [[UITableView alloc] initWithFrame:self.view.bounds - style:UITableViewStyleInsetGrouped]; - table.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - table.dataSource = self; - table.delegate = self; - table.rowHeight = UITableViewAutomaticDimension; - table.estimatedRowHeight = 50; - table.sectionHeaderHeight = UITableViewAutomaticDimension; - table.estimatedSectionHeaderHeight = 44; - [self.view addSubview:table]; - self.tableView = table; - - UISearchController *sc = [[UISearchController alloc] initWithSearchResultsController:nil]; - sc.searchResultsUpdater = self; - sc.delegate = self; - sc.obscuresBackgroundDuringPresentation = NO; - 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 { - NSString *q = searchController.searchBar.text ?: @""; - self.searchText = q; - if (q.length == 0) { - self.visibleGroups = self.allGroups; - } else { - NSMutableArray *out = [NSMutableArray array]; - for (SCIBackupPreviewGroup *g in self.allGroups) { - NSMutableArray *matches = [NSMutableArray array]; - for (SCIBackupPreviewRow *r in g.rows) { - if ([r.title rangeOfString:q options:NSCaseInsensitiveSearch].location != NSNotFound) { - [matches addObject:r]; - } - } - if (matches.count) { - SCIBackupPreviewGroup *clone = [SCIBackupPreviewGroup new]; - clone.title = g.title; - clone.rows = matches; - clone.collapsed = NO; // force-expand while searching - [out addObject:clone]; - } - } - self.visibleGroups = out; - } - [self.tableView reloadData]; -} - -#pragma mark Table data source - -- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView { - return self.visibleGroups.count; -} - -- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section { - SCIBackupPreviewGroup *g = self.visibleGroups[section]; - return g.collapsed ? 0 : g.rows.count; -} - -- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { - SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section]; - SCIBackupPreviewRow *row = g.rows[indexPath.row]; - - UITableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:@"row"]; - if (!cell) { - cell = [[UITableViewCell alloc] initWithStyle:UITableViewCellStyleValue1 reuseIdentifier:@"row"]; - } - cell.textLabel.text = row.title; - cell.textLabel.numberOfLines = 0; - cell.textLabel.font = [UIFont preferredFontForTextStyle:UIFontTextStyleBody]; - cell.selectionStyle = UITableViewCellSelectionStyleNone; - - if (row.kind == SCIBackupPreviewRowKindSwitch && row.defaultsKey.length) { - UISwitch *sw = [[UISwitch alloc] init]; - id raw = self.mutableSettings[row.defaultsKey]; - sw.on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO; - sw.enabled = self.editMode; - objc_setAssociatedObject(sw, "sci_key", row.defaultsKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); - [sw addTarget:self action:@selector(switchToggled:) forControlEvents:UIControlEventValueChanged]; - cell.accessoryView = sw; - cell.detailTextLabel.text = nil; - cell.accessoryType = UITableViewCellAccessoryNone; - } else if (row.kind == SCIBackupPreviewRowKindMenu && row.defaultsKey.length) { - cell.accessoryView = nil; - cell.detailTextLabel.text = row.value; - cell.accessoryType = self.editMode ? UITableViewCellAccessoryDisclosureIndicator : UITableViewCellAccessoryNone; - cell.selectionStyle = self.editMode ? UITableViewCellSelectionStyleDefault : UITableViewCellSelectionStyleNone; - } else { - cell.accessoryView = nil; - cell.accessoryType = UITableViewCellAccessoryNone; - cell.detailTextLabel.text = row.value; - } - return cell; -} - -- (void)switchToggled:(UISwitch *)sender { - NSString *key = objc_getAssociatedObject(sender, "sci_key"); - if (!key.length) return; - self.mutableSettings[key] = @(sender.isOn); -} - -- (UIMenu *)buildMoreMenu { - __weak typeof(self) weakSelf = self; - 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 ? SCILocalized(@"Form view") : SCILocalized(@"Raw JSON view")) - image:[UIImage systemImageNamed:(self.jsonMode ? @"list.bullet" : @"curlybraces")] - identifier:nil - handler:^(__kindof UIAction *_) { - [weakSelf toggleJsonMode]; - }]; - return [UIMenu menuWithChildren:@[editAction, jsonAction]]; -} - -- (void)refreshMoreMenu { self.moreItem.menu = [self buildMoreMenu]; } - -- (void)toggleEditMode { - self.editMode = !self.editMode; - [self.tableView reloadData]; - [self refreshMoreMenu]; -} - -- (void)toggleJsonMode { - self.jsonMode = !self.jsonMode; - if (self.jsonMode) { - if (!self.jsonTextView) { - self.jsonTextView = [[UITextView alloc] initWithFrame:self.view.bounds]; - self.jsonTextView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight; - self.jsonTextView.editable = NO; - self.jsonTextView.font = [UIFont monospacedSystemFontOfSize:12 weight:UIFontWeightRegular]; - self.jsonTextView.backgroundColor = [UIColor systemGroupedBackgroundColor]; - self.jsonTextView.textContainerInset = UIEdgeInsetsMake(16, 12, 16, 12); - self.jsonTextView.alwaysBounceVertical = YES; - } - NSData *data = [NSJSONSerialization dataWithJSONObject:self.mutableSettings ?: @{} - options:NSJSONWritingPrettyPrinted | NSJSONWritingSortedKeys - error:nil]; - self.jsonTextView.text = data ? [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] : @"{}"; - [self.view addSubview:self.jsonTextView]; - self.tableView.hidden = YES; - self.navigationItem.searchController = nil; - } else { - [self.jsonTextView removeFromSuperview]; - self.tableView.hidden = NO; - self.navigationItem.searchController = self.searchController; - } - [self refreshMoreMenu]; -} - -- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { - [tableView deselectRowAtIndexPath:indexPath animated:YES]; - if (!self.editMode) return; - - SCIBackupPreviewGroup *g = self.visibleGroups[indexPath.section]; - SCIBackupPreviewRow *row = g.rows[indexPath.row]; - if (row.kind != SCIBackupPreviewRowKindMenu || !row.menuOptions.count || !row.defaultsKey.length) return; - - UIAlertController *sheet = [UIAlertController alertControllerWithTitle:row.title - message:nil - preferredStyle:UIAlertControllerStyleActionSheet]; - NSString *currentValue = [self.mutableSettings[row.defaultsKey] description]; - for (NSDictionary *opt in row.menuOptions) { - NSString *optTitle = opt[@"title"]; - NSString *optValue = opt[@"value"]; - if (!optTitle.length || !optValue.length) continue; - NSString *display = [optValue isEqualToString:currentValue] - ? [NSString stringWithFormat:@"%@ ✓", optTitle] - : optTitle; - [sheet addAction:[UIAlertAction actionWithTitle:display - style:UIAlertActionStyleDefault - handler:^(UIAlertAction *_) { - self.mutableSettings[row.defaultsKey] = optValue; - row.value = optTitle; - [self.tableView reloadRowsAtIndexPaths:@[indexPath] - withRowAnimation:UITableViewRowAnimationFade]; - }]]; - } - [sheet addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; - UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; - sheet.popoverPresentationController.sourceView = cell; - sheet.popoverPresentationController.sourceRect = cell.bounds; - [self presentViewController:sheet animated:YES completion:nil]; -} - -#pragma mark Section headers (collapsible) - -- (UIView *)tableView:(UITableView *)tableView viewForHeaderInSection:(NSInteger)section { - SCIBackupPreviewGroup *g = self.visibleGroups[section]; - UIView *header = [[UIView alloc] init]; - header.backgroundColor = [UIColor clearColor]; - - UILabel *label = [[UILabel alloc] init]; - label.text = g.title; - label.font = [UIFont preferredFontForTextStyle:UIFontTextStyleFootnote]; - label.textColor = [UIColor secondaryLabelColor]; - label.translatesAutoresizingMaskIntoConstraints = NO; - - UIImageView *chev = [[UIImageView alloc] init]; - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold]; - chev.image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")] - imageByApplyingSymbolConfiguration:cfg]; - chev.tintColor = [UIColor secondaryLabelColor]; - chev.translatesAutoresizingMaskIntoConstraints = NO; - - [header addSubview:label]; - [header addSubview:chev]; - - [NSLayoutConstraint activateConstraints:@[ - [label.leadingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.leadingAnchor], - [label.centerYAnchor constraintEqualToAnchor:header.centerYAnchor], - [label.trailingAnchor constraintLessThanOrEqualToAnchor:chev.leadingAnchor constant:-8], - [chev.trailingAnchor constraintEqualToAnchor:header.layoutMarginsGuide.trailingAnchor], - [chev.centerYAnchor constraintEqualToAnchor:header.centerYAnchor], - [header.heightAnchor constraintGreaterThanOrEqualToConstant:36], - ]]; - - UITapGestureRecognizer *tap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(sectionHeaderTapped:)]; - header.tag = section; - [header addGestureRecognizer:tap]; - return header; -} - -- (void)sectionHeaderTapped:(UITapGestureRecognizer *)tap { - NSInteger section = tap.view.tag; - if (section < 0 || section >= (NSInteger)self.visibleGroups.count) return; - SCIBackupPreviewGroup *g = self.visibleGroups[section]; - g.collapsed = !g.collapsed; - [self.tableView reloadSections:[NSIndexSet indexSetWithIndex:section] - withRowAnimation:UITableViewRowAnimationFade]; - UIView *header = [self.tableView headerViewForSection:section] ?: [self tableView:self.tableView viewForHeaderInSection:section]; - for (UIView *sub in header.subviews) { - if ([sub isKindOfClass:[UIImageView class]]) { - UIImageSymbolConfiguration *cfg = [UIImageSymbolConfiguration configurationWithPointSize:13 weight:UIImageSymbolWeightSemibold]; - ((UIImageView *)sub).image = [[UIImage systemImageNamed:(g.collapsed ? @"chevron.right" : @"chevron.down")] - imageByApplyingSymbolConfiguration:cfg]; - } - } -} - -- (void)cancel { - [self dismissViewControllerAnimated:YES completion:nil]; -} - -- (void)runPrimary { - if (self.primaryAction) self.primaryAction(self); -} - -@end - -@class SCIBackupPreviewGroup; @interface SCISettingsBackup () + (void)showError:(NSString *)message; + (void)showSuccessHUD:(NSString *)message; + (void)presentApplyConfirmationForData:(NSData *)data; + (void)pickFromFiles; -+ (NSArray *)buildPreviewGroupsForSettings:(NSDictionary *)values; @end #pragma mark - Helper singleton (document picker delegate) @@ -449,6 +98,14 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { return keys; } +// Settings-scope keys = allPrefKeys minus the list keys. Used when the user +// picks "Settings only" in the scope sheet — lists stay put on import/reset. ++ (NSSet *)settingsOnlyKeys { + NSMutableSet *keys = [[self allPrefKeys] mutableCopy]; + [keys minusSet:[NSSet setWithArray:[self extraDataKeys]]]; + return keys; +} + + (void)collectKeysFromSections:(NSArray *)sections into:(NSMutableSet *)keys { for (id section in sections) { if (![section isKindOfClass:[NSDictionary class]]) continue; @@ -536,186 +193,6 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { return [[NSString alloc] initWithData:d encoding:NSUTF8StringEncoding] ?: @""; } -#pragma mark Human-readable preview groups - -+ (NSArray *)buildPreviewGroupsForSettings:(NSDictionary *)values { - NSMutableArray *groups = [NSMutableArray array]; - [self collectGroupsFromSections:[SCITweakSettings sections] - breadcrumb:@"" - values:values - out:groups]; - - NSSet *known = [self allPrefKeys]; - NSMutableArray *unknown = [NSMutableArray array]; - for (NSString *k in values) { - if (![known containsObject:k]) [unknown addObject:k]; - } - if (unknown.count) { - [unknown sortUsingSelector:@selector(compare:)]; - SCIBackupPreviewGroup *g = [SCIBackupPreviewGroup new]; - g.title = @"OTHER"; - g.rows = [NSMutableArray array]; - for (NSString *k in unknown) { - SCIBackupPreviewRow *r = [SCIBackupPreviewRow new]; - r.title = k; - r.value = [self displayStringForValue:values[k]]; - r.kind = SCIBackupPreviewRowKindReadOnly; - [g.rows addObject:r]; - } - [groups addObject:g]; - } - return groups; -} - -+ (void)collectGroupsFromSections:(NSArray *)sections - breadcrumb:(NSString *)breadcrumb - values:(NSDictionary *)values - out:(NSMutableArray *)out { - for (id sectionObj in sections) { - if (![sectionObj isKindOfClass:[NSDictionary class]]) continue; - NSDictionary *section = sectionObj; - NSString *sectionHeader = section[@"header"] ?: @""; - NSArray *rows = section[@"rows"]; - - SCIBackupPreviewGroup *currentGroup = nil; - - for (id rowObj in rows) { - if (![rowObj isKindOfClass:[SCISetting class]]) continue; - SCISetting *s = rowObj; - - if (s.navSections) { - NSString *childBreadcrumb = breadcrumb.length - ? [NSString stringWithFormat:@"%@ › %@", breadcrumb, s.title] - : s.title; - [self collectGroupsFromSections:s.navSections - breadcrumb:childBreadcrumb - values:values - out:out]; - continue; - } - - BOOL isMenu = (s.type == SCITableCellMenu); - if (!s.defaultsKey.length && !isMenu) continue; - - SCIBackupPreviewRow *r = [SCIBackupPreviewRow new]; - r.title = s.title.length ? s.title : (s.defaultsKey ?: @"?"); - r.defaultsKey = s.defaultsKey; - - if (s.type == SCITableCellSwitch) { - r.kind = SCIBackupPreviewRowKindSwitch; - id raw = values[s.defaultsKey]; - BOOL on = [raw respondsToSelector:@selector(boolValue)] ? [raw boolValue] : NO; - r.value = on ? SCILocalized(@"On") : SCILocalized(@"Off"); - } else if (s.type == SCITableCellStepper) { - r.kind = SCIBackupPreviewRowKindReadOnly; - id raw = values[s.defaultsKey]; - NSString *display = @"—"; - if (raw) { - double d = [raw doubleValue]; - if (fmod(d, 1.0) == 0.0) display = [NSString stringWithFormat:@"%lld", (long long)d]; - else display = [NSString stringWithFormat:@"%g", d]; - if (s.label.length) display = [display stringByAppendingFormat:@" %@", s.label]; - } - r.value = display; - } else if (isMenu) { - r.kind = SCIBackupPreviewRowKindMenu; - NSMutableArray *opts = [NSMutableArray array]; - NSString *defKey = nil; - [self collectOptionsFromMenu:s.baseMenu defaultsKeyOut:&defKey into:opts]; - r.menuOptions = opts; - r.defaultsKey = defKey ?: s.defaultsKey; - NSString *menuTitle = [self menuTitleForBaseMenu:s.baseMenu values:values resolvedKey:NULL]; - r.value = menuTitle ?: @"—"; - } else { - r.kind = SCIBackupPreviewRowKindReadOnly; - r.value = [self displayStringForValue:values[s.defaultsKey]]; - } - - if (!currentGroup) { - currentGroup = [SCIBackupPreviewGroup new]; - NSMutableString *hdr = [NSMutableString string]; - if (breadcrumb.length) [hdr appendString:breadcrumb]; - if (sectionHeader.length) { - if (hdr.length) [hdr appendString:@" — "]; - [hdr appendString:sectionHeader]; - } - if (!hdr.length) hdr = [NSMutableString stringWithString:@"General"]; - currentGroup.title = [hdr uppercaseString]; - currentGroup.rows = [NSMutableArray array]; - [out addObject:currentGroup]; - } - [currentGroup.rows addObject:r]; - } - } -} - -+ (NSString *)displayStringForValue:(id)raw { - if (!raw || raw == [NSNull null]) return @"—"; - if ([raw isKindOfClass:[NSNumber class]]) { - NSNumber *n = raw; - const char *t = n.objCType; - if (t && strcmp(t, "c") == 0) return n.boolValue ? SCILocalized(@"On") : SCILocalized(@"Off"); - return n.stringValue; - } - if ([raw isKindOfClass:[NSString class]]) return raw; - return [NSString stringWithFormat:@"%@", raw]; -} - -+ (NSString *)menuTitleForBaseMenu:(UIMenu *)menu values:(NSDictionary *)values resolvedKey:(id *)outRaw { - if (!menu) return nil; - NSString *defaultsKey = nil; - UICommand *match = [self findMatchingCommandInMenu:menu values:values defaultsKeyOut:&defaultsKey]; - if (defaultsKey && outRaw) *outRaw = values[defaultsKey]; - if (match) return match.title; - if (defaultsKey) return [self displayStringForValue:values[defaultsKey]]; - return nil; -} - -+ (void)collectOptionsFromMenu:(UIMenu *)menu defaultsKeyOut:(NSString **)outKey into:(NSMutableArray *)out { - if (!menu) return; - for (id child in menu.children) { - if ([child isKindOfClass:[UIMenu class]]) { - [self collectOptionsFromMenu:child defaultsKeyOut:outKey into:out]; - } else if ([child isKindOfClass:[UICommand class]]) { - UICommand *cmd = child; - id pl = cmd.propertyList; - if ([pl isKindOfClass:[NSDictionary class]]) { - NSString *k = ((NSDictionary *)pl)[@"defaultsKey"]; - NSString *v = ((NSDictionary *)pl)[@"value"]; - if ([k isKindOfClass:[NSString class]] && k.length && - [v isKindOfClass:[NSString class]] && v.length) { - if (outKey && !*outKey) *outKey = k; - [out addObject:@{ @"value": v, @"title": cmd.title ?: v }]; - } - } - } - } -} - -+ (UICommand *)findMatchingCommandInMenu:(UIMenu *)menu values:(NSDictionary *)values defaultsKeyOut:(NSString **)outKey { - for (id child in menu.children) { - if ([child isKindOfClass:[UIMenu class]]) { - UICommand *m = [self findMatchingCommandInMenu:child values:values defaultsKeyOut:outKey]; - if (m) return m; - } else if ([child isKindOfClass:[UICommand class]]) { - UICommand *cmd = child; - id pl = cmd.propertyList; - if ([pl isKindOfClass:[NSDictionary class]]) { - NSString *k = ((NSDictionary *)pl)[@"defaultsKey"]; - NSString *v = ((NSDictionary *)pl)[@"value"]; - if ([k isKindOfClass:[NSString class]] && k.length) { - if (outKey && !*outKey) *outKey = k; - id current = values[k]; - if (current && v && [[NSString stringWithFormat:@"%@", current] isEqualToString:v]) { - return cmd; - } - } - } - } - } - return nil; -} - + (void)showSuccessHUD:(NSString *)message { UINotificationFeedbackGenerator *fb = [[UINotificationFeedbackGenerator alloc] init]; [fb prepare]; @@ -743,56 +220,164 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { [topMostController() presentViewController:a animated:YES completion:nil]; } -#pragma mark Export +#pragma mark Scope picker -+ (void)presentExport { - NSDictionary *snap = [self snapshotCurrentSettings]; - - SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init]; - vc.title = SCILocalized(@"Export settings"); - vc.mutableSettings = [snap mutableCopy]; - vc.primaryActionTitle = @"Save"; - vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) { - NSData *data = [self serializeSettings:previewVC.mutableSettings]; - NSString *fname = [NSString stringWithFormat:@"ryukgram-settings-%@.json", [self timestampString]]; - NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname]; - NSError *err = nil; - [data writeToURL:tmp options:NSDataWritingAtomic error:&err]; - if (err) { [self showError:SCILocalized(@"Could not write temporary file.")]; return; } - UIDocumentPickerViewController *p = - [[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]]; - SCIBackupHelper *helper = [SCIBackupHelper shared]; - helper.expectingExportPick = YES; - p.delegate = helper; - [previewVC presentViewController:p animated:YES completion:nil]; - }; +// Scope enum bits match SCIBackupScopePickerMask one-to-one, so the mask can +// be cast back and forth. ++ (void)presentScopePickerWithContinueTitle:(NSString *)continueTitle + message:(NSString *)message + availableMask:(SCIBackupScope)available + initialSelection:(SCIBackupScope)initial + payload:(NSDictionary *)payload + handler:(void(^)(SCIBackupScope scope))handler { + SCIBackupScopePickerVC *vc = [SCIBackupScopePickerVC new]; + vc.title = continueTitle; + vc.continueTitle = continueTitle; + vc.headerMessage = message; + vc.availableScopes = (SCIBackupScopePickerMask)available; + vc.initialSelection = (SCIBackupScopePickerMask)initial; + vc.payload = payload; + vc.onContinue = ^(SCIBackupScopePickerMask chosen) { handler((SCIBackupScope)chosen); }; UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; nav.modalPresentationStyle = UIModalPresentationFormSheet; [topMostController() presentViewController:nav animated:YES completion:nil]; } +#pragma mark Scoped payload build / apply + ++ (NSDictionary *)snapshotForScope:(SCIBackupScope)scope { + NSMutableDictionary *root = [NSMutableDictionary dictionary]; + root[@"ryukgram_export"] = @(YES); + root[@"version"] = @(2); + root[@"exported_at"] = @([[NSDate date] timeIntervalSince1970]); + + NSDictionary *full = [self snapshotCurrentSettings]; + if (scope & SCIBackupScopeSettings) { + NSMutableDictionary *s = [NSMutableDictionary dictionary]; + NSSet *listKeys = [NSSet setWithArray:[self extraDataKeys]]; + for (NSString *k in full) if (![listKeys containsObject:k]) s[k] = full[k]; + root[@"settings"] = s; + } + if (scope & SCIBackupScopeLists) { + NSMutableDictionary *l = [NSMutableDictionary dictionary]; + for (NSString *k in [self extraDataKeys]) if (full[k]) l[k] = full[k]; + root[@"lists"] = l; + } + if (scope & SCIBackupScopeAnalyzer) { + root[@"analyzer"] = [SCIProfileAnalyzerStorage exportedDict] ?: @{}; + } + return root; +} + +// Applies the intersection of payload sections and the chosen scope. ++ (BOOL)applyImport:(NSDictionary *)root scope:(SCIBackupScope)scope { + if (![root isKindOfClass:[NSDictionary class]]) return NO; + BOOL anyApplied = NO; + + NSDictionary *settings = [root[@"settings"] isKindOfClass:[NSDictionary class]] ? root[@"settings"] : nil; + // v1 back-compat: file is a flat map of pref keys → value. + if (!settings && !root[@"ryukgram_export"]) settings = root; + + if ((scope & SCIBackupScopeSettings) && settings) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + NSSet *keys = [self settingsOnlyKeys]; + for (NSString *k in keys) [d removeObjectForKey:k]; + for (NSString *k in settings) if ([keys containsObject:k]) [d setObject:settings[k] forKey:k]; + [d synchronize]; + anyApplied = YES; + } + if ((scope & SCIBackupScopeLists) && [root[@"lists"] isKindOfClass:[NSDictionary class]]) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + for (NSString *k in [self extraDataKeys]) [d removeObjectForKey:k]; + NSDictionary *lists = root[@"lists"]; + for (NSString *k in lists) if ([[self extraDataKeys] containsObject:k]) [d setObject:lists[k] forKey:k]; + [d synchronize]; + anyApplied = YES; + } + if ((scope & SCIBackupScopeAnalyzer) && [root[@"analyzer"] isKindOfClass:[NSDictionary class]]) { + [SCIProfileAnalyzerStorage importFromDict:root[@"analyzer"]]; + anyApplied = YES; + } + return anyApplied; +} + ++ (void)resetForScope:(SCIBackupScope)scope { + if (scope & SCIBackupScopeSettings) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + for (NSString *k in [self settingsOnlyKeys]) [d removeObjectForKey:k]; + [d synchronize]; + } + if (scope & SCIBackupScopeLists) { + NSUserDefaults *d = [NSUserDefaults standardUserDefaults]; + for (NSString *k in [self extraDataKeys]) [d removeObjectForKey:k]; + [d synchronize]; + } + if (scope & SCIBackupScopeAnalyzer) { + [SCIProfileAnalyzerStorage resetAll]; + } +} + +#pragma mark Export + ++ (void)presentExport { + NSDictionary *preview = [self snapshotForScope:SCIBackupScopeAll]; + [self presentScopePickerWithContinueTitle:SCILocalized(@"Export") + message:SCILocalized(@"Tick what to include. Tap any row to inspect its contents.") + availableMask:SCIBackupScopeAll + initialSelection:SCIBackupScopeAll + payload:preview + handler:^(SCIBackupScope scope) { + // Rebuild payload against the final selection. + NSDictionary *payload = [self snapshotForScope:scope]; + [self writeExportToFilePicker:payload host:topMostController()]; + }]; +} + ++ (void)writeExportToFilePicker:(NSDictionary *)payload host:(UIViewController *)host { + NSData *data = [self serializeSettings:payload]; + NSString *fname = [NSString stringWithFormat:@"ryukgram-export-%@.json", [self timestampString]]; + NSURL *tmp = [[NSFileManager defaultManager].temporaryDirectory URLByAppendingPathComponent:fname]; + NSError *err = nil; + [data writeToURL:tmp options:NSDataWritingAtomic error:&err]; + if (err) { [self showError:SCILocalized(@"Could not write temporary file.")]; return; } + UIDocumentPickerViewController *p = + [[UIDocumentPickerViewController alloc] initForExportingURLs:@[tmp]]; + SCIBackupHelper *helper = [SCIBackupHelper shared]; + helper.expectingExportPick = YES; + p.delegate = helper; + [host presentViewController:p animated:YES completion:nil]; +} + #pragma mark Import + (void)presentImport { + // File first, then scope picker against its contents. [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]; + NSDictionary *preview = [self snapshotForScope:SCIBackupScopeAll]; + [self presentScopePickerWithContinueTitle:SCILocalized(@"Reset") + message:SCILocalized(@"Selected data will be cleared. Tap any row to see what's stored.") + availableMask:SCIBackupScopeAll + initialSelection:0 + payload:preview + handler:^(SCIBackupScope scope) { + UIAlertController *alert = [UIAlertController + alertControllerWithTitle:SCILocalized(@"Reset selected data?") + message:SCILocalized(@"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) { + [self resetForScope:scope]; + if (scope & SCIBackupScopeSettings) [SCIUtils showRestartConfirmation]; + else [self showSuccessHUD:SCILocalized(@"Reset complete")]; + }]]; + [topMostController() presentViewController:alert animated:YES completion:nil]; + }]; } + (void)pickFromFiles { @@ -805,35 +390,45 @@ typedef NS_ENUM(NSInteger, SCIBackupPreviewRowKind) { } + (void)presentApplyConfirmationForData:(NSData *)data { - NSDictionary *settings = [self parseSettingsFromData:data]; - if (!settings) { - [self showError:SCILocalized(@"File is not a valid RyukGram settings export.")]; + NSError *parseErr = nil; + id parsed = [NSJSONSerialization JSONObjectWithData:data options:0 error:&parseErr]; + if (![parsed isKindOfClass:[NSDictionary class]]) { + [self showError:SCILocalized(@"File is not a valid RyukGram export.")]; return; } + NSDictionary *root = parsed; - SCIBackupPreviewVC *vc = [[SCIBackupPreviewVC alloc] init]; - vc.title = SCILocalized(@"Import preview"); - vc.mutableSettings = [settings mutableCopy]; - vc.primaryActionTitle = @"Apply"; - vc.primaryAction = ^(SCIBackupPreviewVC *previewVC) { - UIAlertController *confirm = - [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]; + // Offer only the sections actually present in the file. + SCIBackupScope available = 0; + if ([root[@"settings"] isKindOfClass:[NSDictionary class]]) available |= SCIBackupScopeSettings; + if ([root[@"lists"] isKindOfClass:[NSDictionary class]]) available |= SCIBackupScopeLists; + if ([root[@"analyzer"] isKindOfClass:[NSDictionary class]]) available |= SCIBackupScopeAnalyzer; + // v1 back-compat: flat pref map → treat as settings-only. + if (!available && !root[@"ryukgram_export"]) available = SCIBackupScopeSettings; + if (!available) { [self showError:SCILocalized(@"File has no importable sections.")]; return; } + + // Wrap v1 flat files into the v2 envelope for the picker. + NSDictionary *normalized = root[@"ryukgram_export"] ? root : @{ @"settings": root }; + + [self presentScopePickerWithContinueTitle:SCILocalized(@"Apply") + message:SCILocalized(@"Tick what to apply. Tap any row to inspect. Sections not in the file are disabled.") + availableMask:available + initialSelection:available + payload:normalized + handler:^(SCIBackupScope scope) { + UIAlertController *confirm = [UIAlertController + alertControllerWithTitle:SCILocalized(@"Apply imported data?") + message:SCILocalized(@"Existing values for the selected scope will be replaced. The app may need to restart for some changes to take effect.") + preferredStyle:UIAlertControllerStyleAlert]; [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:SCILocalized(@"Settings imported")]; - [SCIUtils showRestartConfirmation]; - }]; + BOOL applied = [self applyImport:root scope:scope]; + if (!applied) { [self showError:SCILocalized(@"Nothing was applied.")]; return; } + [self showSuccessHUD:SCILocalized(@"Import complete")]; + if (scope & SCIBackupScopeSettings) [SCIUtils showRestartConfirmation]; }]]; - [previewVC presentViewController:confirm animated:YES completion:nil]; - }; - - UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; - nav.modalPresentationStyle = UIModalPresentationFormSheet; - [topMostController() presentViewController:nav animated:YES completion:nil]; + [topMostController() presentViewController:confirm animated:YES completion:nil]; + }]; } @end diff --git a/src/Settings/SCISettingsViewController.m b/src/Settings/SCISettingsViewController.m index fbaae45..b368368 100644 --- a/src/Settings/SCISettingsViewController.m +++ b/src/Settings/SCISettingsViewController.m @@ -1,5 +1,7 @@ #import "SCISettingsViewController.h" #import "SCISearchBarStyler.h" +#import "../Features/General/SCICacheManager.h" +#import "../SCIImageCache.h" static char rowStaticRef[] = "row"; @@ -15,8 +17,6 @@ static char rowStaticRef[] = "row"; @end -/// - @implementation SCISettingsViewController - (instancetype)initWithTitle:(NSString *)title sections:(NSArray *)sections reduceMargin:(BOOL)reduceMargin { @@ -25,9 +25,9 @@ static char rowStaticRef[] = "row"; if (self) { self.title = title; self.reduceMargin = reduceMargin; - self.isRoot = reduceMargin; // root call uses reduceMargin=YES - - // Exclude development cells from release builds + self.isRoot = reduceMargin; + + // Hide dev-only sections in non-dev builds. NSMutableArray *mutableSections = [sections mutableCopy]; [mutableSections enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(NSDictionary *section, NSUInteger index, BOOL *stop) { @@ -96,6 +96,20 @@ static char rowStaticRef[] = "row"; langItem.menu = [self sciBuildLanguageMenu]; self.navigationItem.rightBarButtonItem = langItem; } + + // Pushed Advanced VC reloads the Clear cache row when size lands. + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(sciCacheSizeDidUpdate) + name:SCICacheSizeDidUpdateNotification + object:nil]; +} + +- (void)sciCacheSizeDidUpdate { + [self.tableView reloadData]; +} + +- (void)dealloc { + [[NSNotificationCenter defaultCenter] removeObserver:self]; } - (void)sciShowLanguageInfo { @@ -288,7 +302,16 @@ static char rowStaticRef[] = "row"; cellContentConfig.text = row.dynamicTitle ? row.dynamicTitle() : row.title; - // While searching, show the breadcrumb path instead of the row subtitle. + // Value1-style static row: trailing label on the right. Subtitle still + // renders below the title when both are set. + if (row.valueText.length && ![self isSearching]) { + UILabel *value = [UILabel new]; + value.text = row.valueText; + value.font = [UIFont systemFontOfSize:16]; + value.textColor = [UIColor secondaryLabelColor]; + [value sizeToFit]; + cell.accessoryView = value; + } NSString *displaySubtitle = [self isSearching] && searchBreadcrumb.length ? searchBreadcrumb : row.subtitle; if (displaySubtitle.length) { cellContentConfig.secondaryText = displaySubtitle; @@ -304,9 +327,21 @@ static char rowStaticRef[] = "row"; // Image url if (row.imageUrl != nil) { [self loadImageFromURL:row.imageUrl atIndexPath:indexPath forTableView:tableView]; - + cellContentConfig.imageToTextPadding = 14; } + + if (row.bundleImageName) { + UIImage *img = [UIImage imageNamed:row.bundleImageName + inBundle:SCILocalizationBundle() + compatibleWithTraitCollection:nil]; + if (img) { + cellContentConfig.image = img; + cellContentConfig.imageProperties.maximumSize = CGSizeMake(45, 45); + cellContentConfig.imageProperties.cornerRadius = 10; + cellContentConfig.imageToTextPadding = 14; + } + } switch (row.type) { case SCITableCellStatic: { @@ -402,7 +437,11 @@ static char rowStaticRef[] = "row"; break; } } - + + if (row.titleColor) { + cellContentConfig.textProperties.color = row.titleColor; + } + cell.contentConfiguration = cellContentConfig; return cell; @@ -544,27 +583,16 @@ static char rowStaticRef[] = "row"; - (void)loadImageFromURL:(NSURL *)url atIndexPath:(NSIndexPath *)indexPath forTableView:(UITableView *)tableView { if (!url) return; - - NSURLSessionDataTask *task = [[NSURLSession sharedSession] dataTaskWithURL:url - completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) - { - if (!data || error) return; - - UIImage *image = [UIImage imageWithData:data]; + [SCIImageCache loadImageFromURL:url completion:^(UIImage *image) { if (!image) return; - - dispatch_async(dispatch_get_main_queue(), ^{ - UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; - if (!cell) return; - - UIListContentConfiguration *config = (UIListContentConfiguration *)cell.contentConfiguration; - config.image = image; - config.imageProperties.maximumSize = CGSizeMake(45, 45); - cell.contentConfiguration = config; - }); + UITableViewCell *cell = [tableView cellForRowAtIndexPath:indexPath]; + if (!cell) return; + UIListContentConfiguration *config = (UIListContentConfiguration *)cell.contentConfiguration; + config.image = image; + config.imageProperties.maximumSize = CGSizeMake(45, 45); + config.imageProperties.cornerRadius = 22.5; + cell.contentConfiguration = config; }]; - - [task resume]; } @end diff --git a/src/Settings/SCISymbol.m b/src/Settings/SCISymbol.m index f050beb..269adaa 100644 --- a/src/Settings/SCISymbol.m +++ b/src/Settings/SCISymbol.m @@ -1,4 +1,5 @@ #import "SCISymbol.h" +#import "../Localization/SCILocalization.h" @interface SCISymbol () @@ -32,15 +33,26 @@ - (UIImage *)image { UIImage *symbol = [UIImage systemImageNamed:self.name]; - + + // Fallback to PNGs in RyukGram.bundle, template-rendered so the cell's + // tintColor recolors them the same way SF Symbols get tinted. + if (!symbol) { + NSBundle *resource = SCILocalizationBundle(); + UIImage *bundled = resource ? [UIImage imageNamed:self.name + inBundle:resource + compatibleWithTraitCollection:nil] + : nil; + return [bundled imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + } + if (self.size || (self.size && self.weight)) { UIImageSymbolConfiguration *symbolConfig = [UIImageSymbolConfiguration configurationWithTextStyle:UIFontTextStyleTitle1]; symbolConfig = [symbolConfig configurationByApplyingConfiguration: [UIImageSymbolConfiguration configurationWithPointSize:self.size weight:self.weight]]; - + return [symbol imageWithConfiguration:symbolConfig]; } - + return symbol; } diff --git a/src/Settings/TweakSettings.m b/src/Settings/TweakSettings.m index 03ce7d3..3150316 100644 --- a/src/Settings/TweakSettings.m +++ b/src/Settings/TweakSettings.m @@ -1,13 +1,21 @@ #import "TweakSettings.h" +#import +#import "SCILinksSheet.h" #import "SCISettingsBackup.h" #import "SCIFakeLocationSettingsVC.h" +#import "../Features/ProfileAnalyzer/SCIProfileAnalyzerViewController.h" #import "SCIExcludedChatsViewController.h" #import "../Features/StoriesAndMessages/SCIExcludedThreads.h" #import "../Features/StoriesAndMessages/SCIExcludedStoryUsers.h" #import "SCIExcludedStoryUsersViewController.h" #import "SCIEmbedDomainViewController.h" #import "SCIDateFormatPickerVC.h" +#import "../Features/General/SCICacheManager.h" +#import "../Features/General/SCIChangelog.h" #import "../SCIFFmpeg.h" +#import "../Features/Experimental/SCIExperimentalGuard.h" +#import "SCISettingsViewController.h" +#import "../../modules/JGProgressHUD/JGProgressHUD.h" #import // Copies imported .strings into the writable override dir. @@ -20,7 +28,6 @@ NSString *code = objc_getAssociatedObject(controller, "sci_lang"); if (!code.length) return; - // Validate it parses NSDictionary *test = [NSDictionary dictionaryWithContentsOfURL:src]; if (!test.count) { UIAlertController *a = [UIAlertController alertControllerWithTitle:@"Error" @@ -31,7 +38,6 @@ return; } - // Write to the writable override dir (Library/RyukGram.bundle/.lproj/). NSString *lproj = [NSString stringWithFormat:@"%@.lproj", code]; NSString *dir = [SCILocalizationOverridePath() stringByAppendingPathComponent:lproj]; NSFileManager *fm = [NSFileManager defaultManager]; @@ -60,21 +66,26 @@ // MARK: - Sections -/// -/// This returns an array of sections, with each section consisting of a dictionary -/// -/// `"title"`: The section title (leave blank for no title) -/// -/// `"rows"`: An array of **SCISetting** classes, potentially containing a "navigationCellWithTitle" initializer to allow for nested setting pages. -/// -/// `"footer`: The section footer (leave blank for no footer) - + (NSArray *)sections { return @[ @{ @"header": @"", @"rows": @[ - [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"] + ({ + SCISetting *s = [SCISetting buttonCellWithTitle:@"RyukGram" + subtitle:[NSString stringWithFormat:SCILocalized(@"%@ — GitHub & Telegram"), SCIVersionString] + icon:nil + action:^{ + UIWindow *win = nil; + for (UIWindow *w in [UIApplication sharedApplication].windows) if (w.isKeyWindow) { win = w; break; } + UIViewController *top = win.rootViewController; + while (top.presentedViewController) top = top.presentedViewController; + [SCILinksSheet presentFrom:top]; + }]; + s.bundleImageName = @"ryukgram"; + s.titleColor = [UIColor labelColor]; + s; + }) ] }, @{ @@ -90,6 +101,7 @@ [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(@"Open link from clipboard") subtitle:SCILocalized(@"Long-press the search tab to open a copied Instagram link") defaultsKey:@"paste_link_from_search"], [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"], ] @@ -152,18 +164,36 @@ @"rows": @[ [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"], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide explore posts grid") subtitle:SCILocalized(@"Hides the grid of suggested posts on the explore/search tab") defaultsKey:@"hide_explore_grid" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide trending searches") subtitle:SCILocalized(@"Hides the trending searches under the explore search bar") defaultsKey:@"hide_trending_searches" requiresRestart:YES], + ] + }, + @{ + @"header": SCILocalized(@"Live"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Anonymous live viewing") subtitle:SCILocalized(@"Blocks the viewer-count heartbeat so the broadcaster doesn't see you — you also won't see the viewer count") defaultsKey:@"live_anonymous_view"], + [SCISetting switchCellWithTitle:SCILocalized(@"Toggle live comments") subtitle:SCILocalized(@"Long-press the heart button in a live to hide or show the comments") defaultsKey:@"live_hide_comments"], + ] + }, + @{ + @"header": SCILocalized(@"Privacy"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Hide UI on capture") subtitle:SCILocalized(@"Redacts RyukGram buttons from screenshots, screen recordings, and mirroring") defaultsKey:@"hide_ui_on_capture"], ] }, @{ @"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)."), + @"footer": SCILocalized(@"These features rely on hidden Instagram flags and may not work on all accounts or versions."), @"rows": @[ [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 switchCellWithTitle:SCILocalized(@"Disable app haptics") subtitle:SCILocalized(@"Disables haptics/vibrations within the app") defaultsKey:@"disable_haptics"], + [SCISetting buttonCellWithTitle:SCILocalized(@"Open app icon picker") + subtitle:@"" + icon:nil + action:^{ sciPresentTeenIconPicker(); }], + [self advancedExperimentalShortcutCell], ] }] ], @@ -221,14 +251,14 @@ @"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:SCILocalized(@"Show action button") subtitle:SCILocalized(@"Inserts a button next to the seen/eye button on story overlays") defaultsKey:@"stories_action_button"], + [SCISetting switchCellWithTitle:SCILocalized(@"Show action button") subtitle:SCILocalized(@"Inserts a button next to the seen/eye button on story overlays") defaultsKey:@"stories_action_button" requiresRestart:YES], [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": SCILocalized(@"Seen receipts"), @"rows": @[ - [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(@"Disable story seen receipt") subtitle:SCILocalized(@"Hides the notification for others when you view their story") defaultsKey:@"no_seen_receipt" requiresRestart:YES], [SCISetting switchCellWithTitle:SCILocalized(@"Keep stories visually seen locally") subtitle:SCILocalized(@"Marks stories as seen locally (grey ring) while still blocking the seen receipt on the server") defaultsKey:@"keep_seen_visual_local"], [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"], @@ -276,7 +306,16 @@ @{ @"header": SCILocalized(@"Audio"), @"rows": @[ - [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"], + [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" requiresRestart:YES], + ] + }, + @{ + @"header": SCILocalized(@"Stickers"), + @"footer": SCILocalized(@"Peek at poll/quiz/slider results before interacting — you can still tap to vote normally. Force Quiz brings the legacy Quiz sticker back into the story composer tray."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Force Quiz sticker in tray") subtitle:SCILocalized(@"Adds Quiz back to the story sticker picker") defaultsKey:@"force_enable_quiz_sticker" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Show quiz answer") subtitle:SCILocalized(@"Circle the correct option on quiz stickers, or the leading option on polls") defaultsKey:@"stories_show_quiz_answer"], + [SCISetting switchCellWithTitle:SCILocalized(@"Show poll vote counts") subtitle:SCILocalized(@"Show vote tallies on poll options and slider count/average before you vote") defaultsKey:@"stories_show_poll_votes_count"], ] }, @{ @@ -324,6 +363,20 @@ [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"] ] + }, + @{ + @"header": SCILocalized(@"Stickers"), + @"footer": SCILocalized(@"Peek at poll/quiz/slider results on reels before interacting — you can still tap to vote normally."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Show quiz answer") subtitle:SCILocalized(@"Circle the correct option on quiz stickers, or the leading option on polls") defaultsKey:@"reels_show_quiz_answer"], + [SCISetting switchCellWithTitle:SCILocalized(@"Show poll vote counts") subtitle:SCILocalized(@"Show vote tallies on poll options and slider count/average before you vote") defaultsKey:@"reels_show_poll_votes_count"], + ] + }, + @{ + @"header": SCILocalized(@"Advanced"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Tap to mute on photo reels") subtitle:SCILocalized(@"When pause mode is on, tap on photo reels toggles audio instead of the native pause gesture") defaultsKey:@"reels_photo_tap_mute"], + ] }] ], [SCISetting navigationCellWithTitle:SCILocalized(@"Messages") @@ -360,9 +413,9 @@ }] ], [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(@"Disable vanish mode swipe") subtitle:SCILocalized(@"Prevents accidental swipe-up activation of vanish 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" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Hide video call button") subtitle:SCILocalized(@"Removes the video call button from DM thread header") defaultsKey:@"hide_video_call_button" requiresRestart:YES], [SCISetting switchCellWithTitle:SCILocalized(@"Hide reels blend button") subtitle:SCILocalized(@"Hides the blend button in DMs") defaultsKey:@"hide_reels_blend"], ] }, @@ -434,8 +487,12 @@ ] }, @{ - @"header": SCILocalized(@"Visual messages"), + @"header": SCILocalized(@"Disappearing media"), @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Show action button") subtitle:SCILocalized(@"Inserts a button on disappearing media overlays") defaultsKey:@"dm_visual_action_button" requiresRestart:YES], + [SCISetting menuCellWithTitle:SCILocalized(@"Default tap action") subtitle:SCILocalized(@"What happens on a single tap. Long-press always opens the full menu") menu:[self menus][@"dm_visual_action_default"]], + [SCISetting switchCellWithTitle:SCILocalized(@"Show mark-as-viewed button") subtitle:SCILocalized(@"Inserts an eye button to mark the current disappearing media as viewed") defaultsKey:@"dm_visual_seen_button" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Show audio toggle") subtitle:SCILocalized(@"Inserts a speaker button to mute/unmute disappearing media") defaultsKey:@"dm_visual_audio_toggle" requiresRestart:YES], [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"], @@ -456,6 +513,28 @@ [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"], ] + }, + @{ + @"header": SCILocalized(@"Fake profile stats"), + @"footer": SCILocalized(@"Only affects your own profile header. Other users see the real numbers."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Fake verified badge") subtitle:SCILocalized(@"Show a checkmark next to your name on your own profile") defaultsKey:@"fake_verified" requiresRestart:YES], + [SCISetting switchCellWithTitle:SCILocalized(@"Fake follower count") subtitle:@"" defaultsKey:@"fake_follower_count" requiresRestart:YES], + [SCISetting buttonCellWithTitle:SCILocalized(@"Follower count") + subtitle:[[NSUserDefaults standardUserDefaults] stringForKey:@"fake_follower_count_value"] ?: SCILocalized(@"Tap to set") + icon:nil + action:^{ [self promptFakeCountForKey:@"fake_follower_count_value" title:SCILocalized(@"Follower count")]; }], + [SCISetting switchCellWithTitle:SCILocalized(@"Fake following count") subtitle:@"" defaultsKey:@"fake_following_count" requiresRestart:YES], + [SCISetting buttonCellWithTitle:SCILocalized(@"Following count") + subtitle:[[NSUserDefaults standardUserDefaults] stringForKey:@"fake_following_count_value"] ?: SCILocalized(@"Tap to set") + icon:nil + action:^{ [self promptFakeCountForKey:@"fake_following_count_value" title:SCILocalized(@"Following count")]; }], + [SCISetting switchCellWithTitle:SCILocalized(@"Fake post count") subtitle:@"" defaultsKey:@"fake_post_count" requiresRestart:YES], + [SCISetting buttonCellWithTitle:SCILocalized(@"Post count") + subtitle:[[NSUserDefaults standardUserDefaults] stringForKey:@"fake_post_count_value"] ?: SCILocalized(@"Tap to set") + icon:nil + action:^{ [self promptFakeCountForKey:@"fake_post_count_value" title:SCILocalized(@"Post count")]; }], + ] }] ], [SCISetting navigationCellWithTitle:SCILocalized(@"Navigation") @@ -484,6 +563,7 @@ @"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 switchCellWithTitle:SCILocalized(@"Hide tab bar") subtitle:SCILocalized(@"Also hide the bottom tab bar — only the inbox is visible") defaultsKey:@"messages_only_hide_tabbar" requiresRestart:YES], ] }] ], @@ -528,13 +608,15 @@ [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 call") subtitle:SCILocalized(@"Shows an alert when you click the voice call button to confirm before calling") defaultsKey:@"voice_call_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm video call") subtitle:SCILocalized(@"Shows an alert when you click the video call button to confirm before calling") defaultsKey:@"video_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 vanish mode") subtitle:SCILocalized(@"Shows an alert to confirm before toggling vanish mode") 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"] + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm sticker interaction (stories)") subtitle:SCILocalized(@"Shows an alert when you tap a sticker on someone's story") defaultsKey:@"sticker_interact_confirm"], + [SCISetting switchCellWithTitle:SCILocalized(@"Confirm sticker interaction (highlights)") subtitle:SCILocalized(@"Shows an alert when you tap a sticker inside a highlight") defaultsKey:@"sticker_interact_confirm_highlights"], ] }] ] @@ -543,10 +625,44 @@ @{ @"header": @"", @"rows": @[ + [SCISetting navigationCellWithTitle:[NSString stringWithFormat:@"%@ - BETA", SCILocalized(@"Profile Analyzer")] + subtitle:@"" + icon:[SCISymbol symbolWithName:@"person.fill.viewfinder"] + viewController:[[SCIProfileAnalyzerViewController alloc] init]], [SCISetting navigationCellWithTitle:SCILocalized(@"Fake location") subtitle:@"" icon:[SCISymbol symbolWithName:@"location.fill.viewfinder"] viewController:[[SCIFakeLocationSettingsVC alloc] init]], + [SCISetting navigationCellWithTitle:SCILocalized(@"Theme") + subtitle:@"" + icon:[SCISymbol symbolWithName:@"moon"] + navSections:@[@{ + @"header": SCILocalized(@"Appearance"), + @"footer": SCILocalized(@"Theme changes only take effect after an app restart. Tap Apply below when you're done choosing."), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Force dark mode") subtitle:SCILocalized(@"Keep Instagram in dark appearance regardless of iOS system setting") defaultsKey:@"theme_force_dark"], + [SCISetting switchCellWithTitle:SCILocalized(@"Full OLED") subtitle:SCILocalized(@"Replace Instagram's dark grays with pure black across the entire app") defaultsKey:@"theme_full_oled"], + [SCISetting switchCellWithTitle:SCILocalized(@"OLED chat theme") subtitle:SCILocalized(@"Pure black DM thread background and incoming message bubbles") defaultsKey:@"theme_oled_chat"], + ] + }, + @{ + @"header": SCILocalized(@"Keyboard"), + @"footer": SCILocalized(@"Dark uses the system dark keyboard. OLED forces the keyboard backdrop to pure black."), + @"rows": @[ + [SCISetting menuCellWithTitle:SCILocalized(@"Keyboard theme") subtitle:SCILocalized(@"Override the keyboard appearance when typing inside Instagram") menu:[self menus][@"theme_keyboard"]], + ] + }, + @{ + @"header": @"", + @"rows": @[ + [SCISetting buttonCellWithTitle:SCILocalized(@"Apply & restart") + subtitle:SCILocalized(@"Restart Instagram to apply your theme changes") + icon:[SCISymbol symbolWithName:@"arrow.clockwise.circle.fill"] + action:^(void) { [SCIUtils showRestartConfirmation]; } + ] + ] + }] + ], ] }, @{ @@ -557,52 +673,46 @@ icon:[SCISymbol symbolWithName:@"arrow.up.arrow.down.square"] navSections:@[@{ @"header": @"", - @"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."), + @"footer": SCILocalized(@"Export or import RyukGram settings, excluded lists and Profile Analyzer data. Pick any combination on each page."), @"rows": @[ - [SCISetting buttonCellWithTitle:SCILocalized(@"Export settings") - subtitle:SCILocalized(@"Save settings as a JSON file") + [SCISetting buttonCellWithTitle:SCILocalized(@"Export") + subtitle:SCILocalized(@"Save to a JSON file") icon:[SCISymbol symbolWithName:@"square.and.arrow.up"] action:^(void) { [SCISettingsBackup presentExport]; } ], - [SCISetting buttonCellWithTitle:SCILocalized(@"Import settings") - subtitle:SCILocalized(@"Load settings from a JSON file") + [SCISetting buttonCellWithTitle:SCILocalized(@"Import") + subtitle:SCILocalized(@"Load 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") + [SCISetting buttonCellWithTitle:SCILocalized(@"Reset") + subtitle:SCILocalized(@"Clear selected data") icon:[SCISymbol symbolWithName:@"arrow.counterclockwise.circle"] action:^(void) { [SCISettingsBackup presentReset]; } ] ] }] ], - // [SCISetting navigationCellWithTitle:SCILocalized(@"Experimental") - // subtitle:@"" - // icon:[SCISymbol symbolWithName:@"testtube.2"] - // navSections:@[@{ - // @"header": SCILocalized(@"Warning"), - // @"footer": SCILocalized(@"These features are unstable and cause the Instagram app to crash unexpectedly.\n\nUse at your own risk!") - // }, - // @{ - // @"header": @"", - // @"rows": @[ - - // ] - // } - // ] - // ], [SCISetting navigationCellWithTitle:SCILocalized(@"Advanced") subtitle:@"" icon:[SCISymbol symbolWithName:@"gearshape.2"] navSections:@[@{ - @"header": SCILocalized(@"Settings"), + @"header": SCILocalized(@"Tweak settings"), @"rows": @[ [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": SCILocalized(@"Cache"), + @"footer": SCILocalized(@"Clearing still scans on demand."), + @"rows": @[ + [self clearCacheButtonCell], + [self autoClearCacheMenuCell], + [self autoCheckCacheSizeCell], + ] + }, @{ @"header": SCILocalized(@"Instagram"), @"rows": @[ @@ -613,6 +723,13 @@ action:^(void) { [[NSUserDefaults standardUserDefaults] removeObjectForKey:@"SCInstaFirstRun"]; [SCIUtils showRestartConfirmation];} ], ] + }, + @{ + @"header": SCILocalized(@"Advanced experimental features"), + @"footer": SCILocalized(@"Toggle hidden Instagram experiments. Some may not work on every account or IG version."), + @"rows": @[ + [self experimentalEntryCell], + ] }] ], [SCISetting navigationCellWithTitle:SCILocalized(@"Debug") @@ -632,6 +749,11 @@ icon:[SCISymbol symbolWithName:@"square.and.arrow.up"] action:^(void) { [self exportEnglishStrings]; } ], + [SCISetting buttonCellWithTitle:SCILocalized(@"Reset localization") + subtitle:SCILocalized(@"Delete an imported override and fall back to the shipped strings") + icon:[SCISymbol symbolWithName:@"trash"] + action:^(void) { [self presentLocalizationReset]; } + ], ] }, @{ @@ -643,7 +765,7 @@ ] }, @{ - @"header": SCILocalized(@"_ Example"), + @"header": @"_ Example", @"rows": @[ [SCISetting staticCellWithTitle:SCILocalized(@"Static Cell") subtitle:@"" icon:[SCISymbol symbolWithName:@"tablecells"]], [SCISetting switchCellWithTitle:SCILocalized(@"Switch Cell") subtitle:SCILocalized(@"Tap the switch") defaultsKey:@"test_switch_cell"], @@ -666,22 +788,219 @@ }] ] ], - @"footer": SCILocalized(@"_ Example") + @"footer": @"_ Example" } ] ] ] }, + @{ + @"header": @"", + @"rows": @[ + [SCISetting navigationCellWithTitle:SCILocalized(@"About") + subtitle:SCILocalized(@"Version, credits, and links") + icon:[SCISymbol symbolWithName:@"info.circle"] + navSections:[self aboutNavSections]] + ] + }, + ]; +} + +// MARK: - Advanced experimental features + ++ (void)pushExperimentalMenu { + SCISettingsViewController *vc = [[SCISettingsViewController alloc] + initWithTitle:SCILocalized(@"Advanced experimental features") + sections:[self experimentalNavSections] + reduceMargin:NO]; + UIViewController *top = sciTopVC(); + if ([top isKindOfClass:[UINavigationController class]]) { + [(UINavigationController *)top pushViewController:vc animated:YES]; + } else if (top.navigationController) { + [top.navigationController pushViewController:vc animated:YES]; + } else { + UINavigationController *nav = [[UINavigationController alloc] initWithRootViewController:vc]; + [top presentViewController:nav animated:YES completion:nil]; + } +} + ++ (SCISetting *)experimentalEntryCell { + return [SCISetting buttonCellWithTitle:SCILocalized(@"Advanced experimental features") + subtitle:SCILocalized(@"Hidden Instagram experiments") + icon:[SCISymbol symbolWithName:@"flask"] + action:^{ + if ([[NSUserDefaults standardUserDefaults] boolForKey:@"sci_exp_warning_seen"]) { + [self pushExperimentalMenu]; + return; + } + UIAlertController *a = [UIAlertController + alertControllerWithTitle:SCILocalized(@"Heads up") + message:SCILocalized(@"These toggles flip hidden Instagram experiments on. Some features may not work on every account or IG version. If IG keeps crashing on launch, the flags auto-reset after 3 failed starts.") + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Got it") style:UIAlertActionStyleDefault + handler:^(UIAlertAction *_) { + [[NSUserDefaults standardUserDefaults] setBool:YES forKey:@"sci_exp_warning_seen"]; + [self pushExperimentalMenu]; + }]]; + [sciTopVC() presentViewController:a animated:YES completion:nil]; + }]; +} + ++ (SCISetting *)advancedExperimentalShortcutCell { + return [SCISetting buttonCellWithTitle:SCILocalized(@"Advanced experimental features") + subtitle:SCILocalized(@"Hidden Instagram experiments (in Advanced)") + icon:[SCISymbol symbolWithName:@"flask"] + action:^{ [self pushExperimentalMenu]; }]; +} + ++ (SCISetting *)applyRestartCell { + SCISetting *cell = [SCISetting buttonCellWithTitle:SCILocalized(@"Apply & restart") + subtitle:SCILocalized(@"Restart Instagram to apply changes") + icon:[SCISymbol symbolWithName:@"arrow.clockwise.circle.fill"] + action:^{ [SCIUtils showRestartConfirmation]; }]; + cell.titleColor = [UIColor systemBlueColor]; + return cell; +} + ++ (SCISetting *)resetAllExperimentalCell { + SCISetting *cell = [SCISetting buttonCellWithTitle:SCILocalized(@"Reset all experimental flags") + subtitle:SCILocalized(@"Turn every experimental toggle off") + icon:[SCISymbol symbolWithName:@"arrow.counterclockwise.circle"] + action:^{ + UIAlertController *a = [UIAlertController + alertControllerWithTitle:SCILocalized(@"Reset experimental flags?") + message:SCILocalized(@"All experimental toggles will be turned off. Instagram will restart.") + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Reset") style:UIAlertActionStyleDestructive + handler:^(UIAlertAction *_) { + [SCIExperimentalGuard resetAll]; + [SCIUtils showRestartConfirmation]; + }]]; + [sciTopVC() presentViewController:a animated:YES completion:nil]; + }]; + cell.titleColor = [UIColor systemRedColor]; + return cell; +} + ++ (NSArray *)experimentalNavSections { + return @[ + @{ + @"header": @"", + @"footer": SCILocalized(@"Flip what you want on, then tap Apply to restart. Some flags may not work on every account or IG version. Flags auto-reset if IG crashes on launch 3 times."), + @"rows": @[] + }, + @{ + @"header": SCILocalized(@"Notes & QuickSnap"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"QuickSnap (Instants)") + subtitle:SCILocalized(@"Forces the QuickSnap / Instants surface on in feed, inbox, stories, and notes tray") + defaultsKey:@"igt_quicksnap"], + [SCISetting switchCellWithTitle:SCILocalized(@"Direct Notes — Friend Map") + subtitle:SCILocalized(@"Shows the friend map entry in Direct Notes") + defaultsKey:@"igt_directnotes_friendmap"], + [SCISetting switchCellWithTitle:SCILocalized(@"Direct Notes — Audio reply") + subtitle:SCILocalized(@"Enables the audio-note reply type") + defaultsKey:@"igt_directnotes_audio_reply"], + [SCISetting switchCellWithTitle:SCILocalized(@"Direct Notes — Avatar reply") + subtitle:SCILocalized(@"Enables the avatar reply type") + defaultsKey:@"igt_directnotes_avatar_reply"], + [SCISetting switchCellWithTitle:SCILocalized(@"Direct Notes — GIFs & stickers reply") + subtitle:SCILocalized(@"Enables GIF/sticker replies") + defaultsKey:@"igt_directnotes_gifs_reply"], + [SCISetting switchCellWithTitle:SCILocalized(@"Direct Notes — Photo reply") + subtitle:SCILocalized(@"Enables photo replies") + defaultsKey:@"igt_directnotes_photo_reply"], + ] + }, + @{ + @"header": SCILocalized(@"Surfaces"), + @"rows": @[ + [SCISetting switchCellWithTitle:SCILocalized(@"Homecoming") + subtitle:SCILocalized(@"Forces the Homecoming home surface / nav on") + defaultsKey:@"igt_homecoming"], + [SCISetting switchCellWithTitle:SCILocalized(@"Prism design system") + subtitle:SCILocalized(@"Forces Prism-gated experiments on") + defaultsKey:@"igt_prism"], + ] + }, + @{ + @"header": SCILocalized(@"Actions"), + @"rows": @[ + [self applyRestartCell], + [self resetAllExperimentalCell], + ] + }, + ]; +} + +// MARK: - About + ++ (SCISetting *)releaseNotesButtonCell { + SCISetting *cell = [SCISetting buttonCellWithTitle:SCILocalized(@"Release notes") + subtitle:@"" + icon:nil + action:^{ [SCIChangelog presentAllFromViewController:sciTopVC()]; }]; + cell.titleColor = [UIColor systemBlueColor]; + return cell; +} + ++ (SCISetting *)aboutVersionRowTitle:(NSString *)title value:(NSString *)value icon:(SCISymbol *)icon { + SCISetting *cell = [SCISetting staticCellWithTitle:title subtitle:@"" icon:icon]; + cell.valueText = value; + return cell; +} + ++ (NSArray *)aboutNavSections { + return @[ + @{ + @"header": SCILocalized(@"Version"), + @"rows": @[ + [self aboutVersionRowTitle:@"RyukGram" value:SCIVersionString icon:[SCISymbol symbolWithName:@"wrench.and.screwdriver.fill" color:[UIColor systemGrayColor] size:14.0]], + [self aboutVersionRowTitle:@"Instagram" value:[SCIUtils IGVersionString] icon:[SCISymbol symbolWithName:@"camera.fill" color:[UIColor systemGrayColor] size:14.0]], + [self aboutVersionRowTitle:SCILocalized(@"Bundle") value:[[NSBundle mainBundle] bundleIdentifier] icon:[SCISymbol symbolWithName:@"number" color:[UIColor systemGrayColor] size:14.0]], + ] + }, + @{ + @"header": SCILocalized(@"Developers"), + @"rows": @[ + [SCISetting linkCellWithTitle:@"Ryuk" subtitle:SCILocalized(@"RyukGram developer") imageUrl:@"https://github.com/faroukbmiled.png" url:@"https://github.com/faroukbmiled"], + [SCISetting linkCellWithTitle:@"darthplagueiswise (Radan)" subtitle:SCILocalized(@"Experimental features") imageUrl:@"https://github.com/darthplagueiswise.png" url:@"https://github.com/darthplagueiswise"], + [SCISetting linkCellWithTitle:@"SoCuul" subtitle:SCILocalized(@"Original SCInsta developer") imageUrl:@"https://i.imgur.com/c9CbytZ.png" url:@"https://github.com/SoCuul/SCInsta"], + ] + }, + @{ + @"header": @"", + @"rows": @[ + [self releaseNotesButtonCell], + ] + }, @{ @"header": SCILocalized(@"Credits"), @"rows": @[ - [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:SCILocalized(@"RyukGram %@\n\nInstagram v%@\n\nBased on SCInsta by SoCuul"), SCIVersionString, [SCIUtils IGVersionString]] - } + [SCISetting staticCellWithTitle:@"ZomkaDEV" subtitle:SCILocalized(@"Russian translation") icon:nil], + [SCISetting staticCellWithTitle:@"Furamako" subtitle:SCILocalized(@"Spanish translation") icon:nil], + [SCISetting staticCellWithTitle:@"N4C (@ch1tmdgus)" subtitle:SCILocalized(@"Korean translation") icon:nil], + [SCISetting staticCellWithTitle:@"bruuhim" subtitle:SCILocalized(@"Arabic translation") icon:nil], + [SCISetting staticCellWithTitle:@"jaydenjcpy" subtitle:SCILocalized(@"Chinese (Traditional) translation") icon:nil], + [SCISetting staticCellWithTitle:@"John (@erupts0)" subtitle:SCILocalized(@"Testing and feature suggestions") icon:nil], + ] + }, + @{ + @"header": SCILocalized(@"Links"), + @"rows": @[ + [SCISetting linkCellWithTitle:SCILocalized(@"Source code") subtitle:@"" icon:nil url:@"https://github.com/faroukbmiled/RyukGram"], + [SCISetting linkCellWithTitle:SCILocalized(@"Report an issue") subtitle:@"" icon:nil url:@"https://github.com/faroukbmiled/RyukGram/issues"], + [SCISetting linkCellWithTitle:SCILocalized(@"Releases") subtitle:@"" icon:nil url:@"https://github.com/faroukbmiled/RyukGram/releases"], + [SCISetting linkCellWithTitle:SCILocalized(@"Telegram channel") subtitle:@"" icon:nil url:@"https://t.me/ryukgram"], + ] + }, + @{ + @"header": @"", + @"rows": @[ + [SCISetting linkCellWithTitle:SCILocalized(@"Donate to SoCuul") subtitle:SCILocalized(@"Support the original developer") icon:[SCISymbol symbolWithName:@"heart.fill" color:[UIColor systemPinkColor] size:20.0] url:@"https://ko-fi.com/SoCuul"], + ] + }, ]; } @@ -724,6 +1043,82 @@ } +// MARK: - Cache clear + ++ (SCISetting *)autoClearCacheMenuCell { + SCISetting *cell = [SCISetting menuCellWithTitle:SCILocalized(@"Auto-clear cache") + subtitle:SCILocalized(@"Run a silent cache clear on launch when the interval has elapsed.") + menu:[self menus][@"cache_auto_clear_mode"]]; + cell.icon = [SCISymbol symbolWithName:@"clock.arrow.circlepath"]; + return cell; +} + ++ (SCISetting *)autoCheckCacheSizeCell { + SCISetting *cell = [SCISetting switchCellWithTitle:SCILocalized(@"Show cache size") + subtitle:SCILocalized(@"Off skips the size scan when Advanced opens.") + defaultsKey:@"cache_auto_check_size"]; + cell.icon = [SCISymbol symbolWithName:@"magnifyingglass"]; + return cell; +} + ++ (SCISetting *)clearCacheButtonCell { + [SCICacheManager refreshSizeInBackgroundIfEnabled]; + SCISetting *cell = [SCISetting buttonCellWithTitle:SCILocalized(@"Clear cache") + subtitle:SCILocalized(@"Remove Instagram's cached images, videos, and temporary files.") + icon:[SCISymbol symbolWithName:@"trash"] + action:^{ [self presentClearCacheConfirmation]; }]; + cell.dynamicTitle = ^{ + if (![SCIUtils getBoolPref:@"cache_auto_check_size"]) return SCILocalized(@"Clear cache"); + uint64_t cached = [SCICacheManager cachedSize]; + if (cached == 0) return SCILocalized(@"Clear cache"); + return [NSString stringWithFormat:SCILocalized(@"Clear cache (%@)"), [SCICacheManager formattedSize:cached]]; + }; + return cell; +} + ++ (void)presentClearCacheConfirmation { + void (^showResult)(uint64_t) = ^(uint64_t bytes) { + if (bytes == 0) { + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Nothing to clear") message:nil preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"OK") style:UIAlertActionStyleCancel handler:nil]]; + [sciTopVC() presentViewController:a animated:YES completion:nil]; + return; + } + NSString *msg = [NSString stringWithFormat:SCILocalized(@"Free %@ of Instagram cache. A restart is recommended."), [SCICacheManager formattedSize:bytes]]; + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"Clear cache?") message:msg preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Clear") style:UIAlertActionStyleDestructive handler:^(__unused UIAlertAction *x) { + JGProgressHUD *hud = [[JGProgressHUD alloc] init]; + hud.textLabel.text = SCILocalized(@"Clearing cache…"); + UIView *host = sciTopVC().view ?: UIApplication.sharedApplication.keyWindow; + if (host) [hud showInView:host]; + [SCICacheManager clearCacheWithCompletion:^(uint64_t cleared) { + [hud dismissAnimated:YES]; + NSString *done = [NSString stringWithFormat:SCILocalized(@"Freed %@. Restart to apply."), [SCICacheManager formattedSize:cleared]]; + UIAlertController *r = [UIAlertController alertControllerWithTitle:SCILocalized(@"Cache cleared") message:done preferredStyle:UIAlertControllerStyleAlert]; + [r addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Restart") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *y) { exit(0); }]]; + [r addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Later") style:UIAlertActionStyleCancel handler:nil]]; + [sciTopVC() presentViewController:r animated:YES completion:nil]; + }]; + }]]; + [a addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [sciTopVC() presentViewController:a animated:YES completion:nil]; + }; + + BOOL autoCheck = [SCIUtils getBoolPref:@"cache_auto_check_size"]; + uint64_t cached = [SCICacheManager cachedSize]; + if (autoCheck && cached > 0) { showResult(cached); return; } + + UIAlertController *calc = [UIAlertController alertControllerWithTitle:SCILocalized(@"Calculating cache size…") + message:nil + preferredStyle:UIAlertControllerStyleAlert]; + [sciTopVC() presentViewController:calc animated:YES completion:nil]; + void (^onScan)(uint64_t) = ^(uint64_t bytes) { + [calc dismissViewControllerAnimated:YES completion:^{ showResult(bytes); }]; + }; + if (autoCheck) [SCICacheManager getCacheSizeWithCompletion:onScan]; + else [SCICacheManager getCacheSizeTransientWithCompletion:onScan]; +} + // MARK: - Date format + (SCISetting *)dateFormatNavCell { @@ -760,6 +1155,47 @@ static UIViewController *sciTopVC(void) { return top; } +// Find any mounted IGHomeFeedHeaderViewController across scenes. +static UIViewController *sciFindHomeFeedHeader(void) { + Class cls = NSClassFromString(@"IGHomeFeedHeaderViewController"); + if (!cls) return nil; + for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) continue; + for (UIWindow *w in ((UIWindowScene *)scene).windows) { + NSMutableArray *stack = [NSMutableArray array]; + if (w.rootViewController) [stack addObject:w.rootViewController]; + while (stack.count) { + UIViewController *cur = stack.lastObject; + [stack removeLastObject]; + if ([cur isKindOfClass:cls]) return cur; + for (UIViewController *c in cur.childViewControllers) [stack addObject:c]; + if (cur.presentedViewController) [stack addObject:cur.presentedViewController]; + } + } + } + return nil; +} + +// Dismiss the settings modal then trigger the native teen icon picker. +static void sciPresentTeenIconPicker(void) { + UIViewController *top = sciTopVC(); + void (^invoke)(void) = ^{ + id vc = sciFindHomeFeedHeader(); + SEL sel = @selector(headerDidLongPressLogo:); + if ([vc respondsToSelector:sel]) { + ((void(*)(id, SEL, id))objc_msgSend)(vc, sel, nil); + } + }; + if (top.presentingViewController) { + [top dismissViewControllerAnimated:YES completion:^{ + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(0.1 * NSEC_PER_SEC)), + dispatch_get_main_queue(), invoke); + }]; + } else { + invoke(); + } +} + + (void)exportEnglishStrings { NSBundle *res = SCILocalizationBundle(); @@ -806,6 +1242,118 @@ static UIViewController *sciTopVC(void) { [sciTopVC() presentViewController:picker animated:YES completion:nil]; } ++ (void)presentLocalizationReset { + NSString *overrides = SCILocalizationOverridePath(); + NSFileManager *fm = [NSFileManager defaultManager]; + NSArray *contents = [fm contentsOfDirectoryAtPath:overrides error:nil]; + NSMutableArray *codes = [NSMutableArray array]; + for (NSString *name in [contents sortedArrayUsingSelector:@selector(compare:)]) { + if (![name hasSuffix:@".lproj"]) continue; + NSString *stringsPath = [[overrides stringByAppendingPathComponent:name] + stringByAppendingPathComponent:@"Localizable.strings"]; + if (![fm fileExistsAtPath:stringsPath]) continue; + [codes addObject:[name stringByDeletingPathExtension]]; + } + + if (!codes.count) { + UIAlertController *a = [UIAlertController alertControllerWithTitle:SCILocalized(@"No overrides") + message:SCILocalized(@"No imported localization files to reset.") + preferredStyle:UIAlertControllerStyleAlert]; + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]]; + [sciTopVC() presentViewController:a animated:YES completion:nil]; + return; + } + + UIAlertController *picker = [UIAlertController alertControllerWithTitle:SCILocalized(@"Reset localization") + message:SCILocalized(@"Pick a language to delete the imported file") + preferredStyle:UIAlertControllerStyleActionSheet]; + for (NSString *code in codes) { + NSLocale *loc = [NSLocale localeWithLocaleIdentifier:code]; + NSString *native = [loc localizedStringForLanguageCode:code] ?: code; + if (native.length) native = [[[native substringToIndex:1] uppercaseString] + stringByAppendingString:[native substringFromIndex:1]]; + NSString *title = [NSString stringWithFormat:@"%@ (%@)", native, code]; + [picker addAction:[UIAlertAction actionWithTitle:title + style:UIAlertActionStyleDestructive + handler:^(__unused UIAlertAction *a) { + [self resetLocalizationForCode:code]; + }]]; + } + [picker addAction:[UIAlertAction actionWithTitle:@"Cancel" style:UIAlertActionStyleCancel handler:nil]]; + [sciTopVC() presentViewController:picker animated:YES completion:nil]; +} + ++ (void)resetLocalizationForCode:(NSString *)code { + NSString *overrides = SCILocalizationOverridePath(); + NSString *lproj = [overrides stringByAppendingPathComponent: + [NSString stringWithFormat:@"%@.lproj", code]]; + NSError *err = nil; + [[NSFileManager defaultManager] removeItemAtPath:lproj error:&err]; + + NSString *msg = err + ? [NSString stringWithFormat:SCILocalized(@"Could not delete: %@"), err.localizedDescription] + : [NSString stringWithFormat:SCILocalized(@"Deleted %@ override. Restart to apply."), code]; + UIAlertController *a = [UIAlertController alertControllerWithTitle:err ? @"Error" : @"Done" + message:msg + preferredStyle:UIAlertControllerStyleAlert]; + if (!err) { + SCILocalizationReset(); + [a addAction:[UIAlertAction actionWithTitle:@"Restart now" style:UIAlertActionStyleDefault + handler:^(__unused UIAlertAction *x) { [SCIUtils showRestartConfirmation]; }]]; + } + [a addAction:[UIAlertAction actionWithTitle:@"OK" style:UIAlertActionStyleCancel handler:nil]]; + [sciTopVC() presentViewController:a animated:YES completion:nil]; +} + ++ (void)refreshFakeCountSubtitlesInVC:(UIViewController *)vc { + if ([vc isKindOfClass:[UINavigationController class]]) + vc = [(UINavigationController *)vc topViewController]; + if (![vc respondsToSelector:@selector(sections)]) return; + NSArray *sections = [vc valueForKey:@"sections"]; + NSDictionary *map = @{ + SCILocalized(@"Follower count"): @"fake_follower_count_value", + SCILocalized(@"Following count"): @"fake_following_count_value", + SCILocalized(@"Post count"): @"fake_post_count_value", + }; + for (NSDictionary *section in sections) { + for (SCISetting *row in section[@"rows"]) { + NSString *k = map[row.title]; + if (!k) continue; + NSString *v = [[NSUserDefaults standardUserDefaults] stringForKey:k]; + row.subtitle = v.length ? v : SCILocalized(@"Tap to set"); + } + } + if ([vc respondsToSelector:@selector(tableView)]) { + id tv = [vc performSelector:@selector(tableView)]; + if ([tv respondsToSelector:@selector(reloadData)]) + [tv performSelector:@selector(reloadData)]; + } +} + ++ (void)promptFakeCountForKey:(NSString *)key title:(NSString *)title { + NSString *current = [[NSUserDefaults standardUserDefaults] stringForKey:key]; + UIViewController *presenter = sciTopVC(); + UIAlertController *alert = [UIAlertController alertControllerWithTitle:title + message:nil + preferredStyle:UIAlertControllerStyleAlert]; + [alert addTextFieldWithConfigurationHandler:^(UITextField *tf) { + tf.placeholder = @"e.g. 1000000"; + tf.text = current ?: @""; + tf.keyboardType = UIKeyboardTypeNumberPad; + }]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Cancel") style:UIAlertActionStyleCancel handler:nil]]; + [alert addAction:[UIAlertAction actionWithTitle:SCILocalized(@"Save") style:UIAlertActionStyleDefault handler:^(__unused UIAlertAction *a) { + NSString *v = [alert.textFields.firstObject.text stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; + if (v.length == 0) { + [[NSUserDefaults standardUserDefaults] removeObjectForKey:key]; + } else { + [[NSUserDefaults standardUserDefaults] setObject:v forKey:key]; + } + [self refreshFakeCountSubtitlesInVC:presenter]; + }]]; + [presenter presentViewController:alert animated:YES completion:nil]; +} + + (void)promptNewLanguageCode { UIAlertController *alert = [UIAlertController alertControllerWithTitle:@"Add language" message:@"Enter the language code (e.g. fr, de, ja)" @@ -847,30 +1395,19 @@ static UIViewController *sciTopVC(void) { // MARK: - Menus -/// -/// This returns a dictionary where each key corresponds to a certain menu that can be displayed. -/// Each "propertyList" item is an NSDictionary containing the following items: -/// -/// `"defaultsKey"`: The key to save the selected value under in NSUserDefaults -/// -/// `"value"`: A unique string corresponding to the menu item which is selected -/// -/// `"requiresRestart"`: (optional) Causes a popup to appear detailing you have to restart to use these features -/// - #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wundeclared-selector" // Builds the default-tap-action picker menu for a given action button context. // Adding a new tap action = one entry here. Order: actions first, downloads last. + (UIMenu *)defaultTapMenuForKey:(NSString *)key context:(NSString *)ctx { - // { value, title, contexts ("all" or csv of feed,reels,stories) } + // { value, title, contexts (csv of feed,reels,stories,dm_visual,all) } NSArray *entries = @[ @[@"menu", SCILocalized(@"Open menu"), @"all"], @[@"expand", SCILocalized(@"Expand"), @"all"], - @[@"repost", SCILocalized(@"Repost"), @"all"], + @[@"repost", SCILocalized(@"Repost"), @"feed,reels,stories"], @[@"view_mentions", SCILocalized(@"View mentions"), @"stories"], - @[@"copy_link", SCILocalized(@"Copy download URL"), @"all"], + @[@"copy_link", SCILocalized(@"Copy download URL"), @"feed,reels,stories"], @[@"download_share", SCILocalized(@"Download and share"), @"all"], @[@"download_photos",SCILocalized(@"Download to Photos"), @"all"], ]; @@ -887,6 +1424,24 @@ static UIViewController *sciTopVC(void) { + (NSDictionary *)menus { return @{ + @"theme_keyboard": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Off") + image:nil + action:@selector(menuChanged:) + propertyList:@{ @"defaultsKey": @"theme_keyboard", @"value": @"off" } + ], + [UICommand commandWithTitle:SCILocalized(@"Dark") + image:nil + action:@selector(menuChanged:) + propertyList:@{ @"defaultsKey": @"theme_keyboard", @"value": @"dark" } + ], + [UICommand commandWithTitle:SCILocalized(@"OLED") + image:nil + action:@selector(menuChanged:) + propertyList:@{ @"defaultsKey": @"theme_keyboard", @"value": @"oled" } + ] + ]], + @"chat_blocking_mode": [UIMenu menuWithChildren:@[ [UICommand commandWithTitle:SCILocalized(@"Block all") image:nil @@ -973,6 +1528,7 @@ static UIViewController *sciTopVC(void) { @"feed_action_default": [self defaultTapMenuForKey:@"feed_action_default" context:@"feed"], @"reels_action_default": [self defaultTapMenuForKey:@"reels_action_default" context:@"reels"], @"stories_action_default": [self defaultTapMenuForKey:@"stories_action_default" context:@"stories"], + @"dm_visual_action_default": [self defaultTapMenuForKey:@"dm_visual_action_default" context:@"dm_visual"], @"default_video_quality": [UIMenu menuWithChildren:@[ [UICommand commandWithTitle:SCILocalized(@"Always ask") image:nil action:@selector(menuChanged:) @@ -1176,6 +1732,29 @@ static UIViewController *sciTopVC(void) { @"requiresRestart": @YES } ], + ]], + + @"cache_auto_clear_mode": [UIMenu menuWithChildren:@[ + [UICommand commandWithTitle:SCILocalized(@"Off") + image:nil + action:@selector(menuChanged:) + propertyList:@{ @"defaultsKey": @"cache_auto_clear_mode", @"value": @"off" } + ], + [UICommand commandWithTitle:SCILocalized(@"Daily") + image:nil + action:@selector(menuChanged:) + propertyList:@{ @"defaultsKey": @"cache_auto_clear_mode", @"value": @"daily" } + ], + [UICommand commandWithTitle:SCILocalized(@"Weekly") + image:nil + action:@selector(menuChanged:) + propertyList:@{ @"defaultsKey": @"cache_auto_clear_mode", @"value": @"weekly" } + ], + [UICommand commandWithTitle:SCILocalized(@"Monthly") + image:nil + action:@selector(menuChanged:) + propertyList:@{ @"defaultsKey": @"cache_auto_clear_mode", @"value": @"monthly" } + ], ]] }; } diff --git a/src/SideloadPatch/SideloadPatch.xm b/src/SideloadPatch/SideloadPatch.xm deleted file mode 100644 index f916be4..0000000 --- a/src/SideloadPatch/SideloadPatch.xm +++ /dev/null @@ -1,216 +0,0 @@ -// Sideload compatibility patch for Instagram. -// Fixes keychain, app groups, CloudKit, and container access when sideloaded. - -#import -#import -#import -#import -#import "../../modules/fishhook/fishhook.h" - -static NSString *bundleId = nil; -static NSString *accessGroupId = nil; - -static OSStatus (*orig_SecItemAdd)(CFDictionaryRef, CFTypeRef *) = NULL; -static OSStatus (*orig_SecItemCopyMatching)(CFDictionaryRef, CFTypeRef *) = NULL; -static OSStatus (*orig_SecItemUpdate)(CFDictionaryRef, CFDictionaryRef) = NULL; -static OSStatus (*orig_SecItemDelete)(CFDictionaryRef) = NULL; - -static IMP orig_CKEntitlements_initWithEntitlementsDict __attribute__((unused)) = NULL; -static IMP orig_CKContainer_setupWithContainerID __attribute__((unused)) = NULL; -static IMP orig_CKContainer_initWithContainerIdentifier __attribute__((unused)) = NULL; -static IMP orig_NSFileManager_containerURL __attribute__((unused)) = NULL; - -// -- app group path -- - -static NSString *_appGroupPath = nil; -static dispatch_once_t _appGroupOnce = 0; - -static NSString *getAppGroupPathIfExists(void) { - dispatch_once(&_appGroupOnce, ^{ - Class LSBundleProxy = objc_getClass("LSBundleProxy"); - if (!LSBundleProxy) return; - - id proxy = ((id(*)(id, SEL))objc_msgSend)( - (id)LSBundleProxy, sel_registerName("bundleProxyForCurrentProcess")); - if (!proxy) return; - - NSDictionary *ents = ((NSDictionary *(*)(id, SEL))objc_msgSend)( - proxy, sel_registerName("entitlements")); - if (!ents || ![ents isKindOfClass:[NSDictionary class]]) return; - - NSArray *groups = ents[@"com.apple.security.application-groups"]; - if (!groups || groups.count == 0) return; - - NSDictionary *urls = ((NSDictionary *(*)(id, SEL))objc_msgSend)( - proxy, sel_registerName("groupContainerURLs")); - if (!urls || ![urls isKindOfClass:[NSDictionary class]]) return; - - NSURL *url = urls[groups.firstObject]; - if (url) _appGroupPath = [url path]; - }); - return _appGroupPath; -} - -static BOOL createDirectoryIfNotExists(NSString *path) { - NSFileManager *fm = [NSFileManager defaultManager]; - if ([fm fileExistsAtPath:path]) return YES; - return [fm createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:nil]; -} - -// -- SecItem replacements: set the correct access group on every call -- - -static OSStatus replaced_SecItemAdd(CFDictionaryRef attributes, CFTypeRef *result) { - if (attributes && accessGroupId) { - NSMutableDictionary *q = [(__bridge NSDictionary *)attributes mutableCopy]; - q[(__bridge id)kSecAttrAccessGroup] = accessGroupId; - return orig_SecItemAdd((__bridge CFDictionaryRef)q, result); - } - return orig_SecItemAdd(attributes, result); -} - -static OSStatus replaced_SecItemCopyMatching(CFDictionaryRef query, CFTypeRef *result) { - if (query && accessGroupId) { - NSMutableDictionary *q = [(__bridge NSDictionary *)query mutableCopy]; - q[(__bridge id)kSecAttrAccessGroup] = accessGroupId; - return orig_SecItemCopyMatching((__bridge CFDictionaryRef)q, result); - } - return orig_SecItemCopyMatching(query, result); -} - -static OSStatus replaced_SecItemUpdate(CFDictionaryRef query, CFDictionaryRef attrs) { - if (query && accessGroupId) { - NSMutableDictionary *q = [(__bridge NSDictionary *)query mutableCopy]; - q[(__bridge id)kSecAttrAccessGroup] = accessGroupId; - return orig_SecItemUpdate((__bridge CFDictionaryRef)q, attrs); - } - return orig_SecItemUpdate(query, attrs); -} - -static OSStatus replaced_SecItemDelete(CFDictionaryRef query) { - if (query && accessGroupId) { - NSMutableDictionary *q = [(__bridge NSDictionary *)query mutableCopy]; - q[(__bridge id)kSecAttrAccessGroup] = accessGroupId; - return orig_SecItemDelete((__bridge CFDictionaryRef)q); - } - return orig_SecItemDelete(query); -} - -// -- CloudKit patches: strip iCloud entitlements, disable container init -- - -static id replaced_CKEntitlements_init(id self, SEL _cmd, NSDictionary *dict) { - NSMutableDictionary *d = [dict mutableCopy]; - [d removeObjectForKey:@"com.apple.developer.icloud-container-environment"]; - [d removeObjectForKey:@"com.apple.developer.icloud-services"]; - return ((id(*)(id, SEL, NSDictionary *))orig_CKEntitlements_initWithEntitlementsDict)(self, _cmd, [d copy]); -} - -static id replaced_CKContainer_setup(id self, SEL _cmd, id containerID, id options) { - return nil; -} - -static id replaced_CKContainer_init(id self, SEL _cmd, id identifier) { - return nil; -} - -// -- NSFileManager: redirect app group container to a local fallback -- - -static NSURL *replaced_containerURL(id self, SEL _cmd, NSString *groupId) { - NSString *groupPath = getAppGroupPathIfExists(); - if (!groupPath) { - NSString *docs = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES).lastObject; - NSString *fallback = [docs stringByAppendingPathComponent:groupId]; - createDirectoryIfNotExists(fallback); - return [NSURL fileURLWithPath:fallback]; - } - NSURL *url = [[NSURL fileURLWithPath:groupPath] URLByAppendingPathComponent:groupId]; - createDirectoryIfNotExists([url path]); - return url; -} - -// -- swizzle helper: walks class hierarchy, handles inherited methods -- - -static void swizzleMethod(Class cls, SEL sel, IMP newIMP, IMP *outOrig) { - if (!cls) return; - Class cur = cls; - while (cur) { - unsigned int count = 0; - Method *list = class_copyMethodList(cur, &count); - for (unsigned int i = 0; i < count; i++) { - if (method_getName(list[i]) == sel) { - if (cur == cls) { - *outOrig = method_setImplementation(list[i], newIMP); - } else { - *outOrig = method_getImplementation(list[i]); - class_addMethod(cls, sel, newIMP, method_getTypeEncoding(list[i])); - } - free(list); - return; - } - } - free(list); - cur = class_getSuperclass(cur); - } -} - -// -- keychain bootstrap: discover the access group assigned to this app -- - -static void bootstrapKeychainAccessGroup(void) { - NSDictionary *query = @{ - (__bridge id)kSecClass: (__bridge id)kSecClassGenericPassword, - (__bridge id)kSecAttrAccount: @"RyukGramSideloadPatch", - (__bridge id)kSecAttrService: @"", - (__bridge id)kSecReturnAttributes: @YES, - }; - - CFTypeRef result = NULL; - OSStatus status = SecItemCopyMatching((__bridge CFDictionaryRef)query, &result); - if (status == errSecItemNotFound) - status = SecItemAdd((__bridge CFDictionaryRef)query, &result); - - if (status == errSecSuccess && result) { - bundleId = [[NSBundle mainBundle] bundleIdentifier]; - NSDictionary *attrs = (__bridge NSDictionary *)result; - NSString *group = attrs[(__bridge id)kSecAttrAccessGroup]; - if (group) accessGroupId = [group copy]; - CFRelease(result); - } -} - -// -- init -- - -%ctor { - @autoreleasepool { - bootstrapKeychainAccessGroup(); - - // rebind SecItem functions so keychain calls use the right access group - struct rebinding rebindings[] = { - {"SecItemAdd", (void *)replaced_SecItemAdd, (void **)&orig_SecItemAdd}, - {"SecItemCopyMatching", (void *)replaced_SecItemCopyMatching, (void **)&orig_SecItemCopyMatching}, - {"SecItemUpdate", (void *)replaced_SecItemUpdate, (void **)&orig_SecItemUpdate}, - {"SecItemDelete", (void *)replaced_SecItemDelete, (void **)&orig_SecItemDelete}, - }; - rebind_symbols(rebindings, 4); - - // patch NSFileManager for app group container fallback - Class fm = objc_getClass("NSFileManager"); - if (fm) swizzleMethod(fm, sel_registerName("containerURLForSecurityApplicationGroupIdentifier:"), - (IMP)replaced_containerURL, &orig_NSFileManager_containerURL); - - // patch CloudKit to prevent crashes from missing entitlements - Class ckEnt = objc_getClass("CKEntitlements"); - if (ckEnt) swizzleMethod(ckEnt, sel_registerName("initWithEntitlementsDict:"), - (IMP)replaced_CKEntitlements_init, &orig_CKEntitlements_initWithEntitlementsDict); - - Class ckCon = objc_getClass("CKContainer"); - if (ckCon) { - swizzleMethod(ckCon, sel_registerName("_setupWithContainerID:options:"), - (IMP)replaced_CKContainer_setup, &orig_CKContainer_setupWithContainerID); - swizzleMethod(ckCon, sel_registerName("_initWithContainerIdentifier:"), - (IMP)replaced_CKContainer_init, &orig_CKContainer_initWithContainerIdentifier); - } - - // NSUserDefaults _initWithSuiteName:container: intentionally not patched — - // crashes on current IG versions. the NSFileManager patch covers the - // group container redirect which is what actually matters. - } -} diff --git a/src/Tweak.x b/src/Tweak.x index 93bdffd..8c9bc4c 100644 --- a/src/Tweak.x +++ b/src/Tweak.x @@ -2,6 +2,8 @@ #import "InstagramHeaders.h" #import "Tweak.h" #import "Utils.h" +#import "Features/General/SCICacheManager.h" +#import "Features/General/SCIChangelog.h" #include "../modules/fishhook/fishhook.h" /////////////////////////////////////////////////////////// @@ -14,7 +16,7 @@ /////////////////////////////////////////////////////////// // * Tweak version * -NSString *SCIVersionString = @"v1.2.1"; +NSString *SCIVersionString = @"v1.2.2"; // Variables that work across features BOOL dmVisualMsgsViewedButtonEnabled = false; @@ -29,9 +31,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"profile_copy_button": @(YES), @"detailed_color_picker": @(YES), @"remove_screenshot_alert": @(YES), - @"call_confirm": @(YES), + @"voice_call_confirm": @(NO), + @"video_call_confirm": @(NO), @"keep_deleted_message": @(NO), @"hide_suggested_stories": @(NO), + @"profile_analyzer_accumulate": @(NO), @"story_tray_actions": @(NO), @"zoom_profile_photo": @(NO), @"follow_indicator": @(NO), @@ -46,6 +50,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"fake_location_name": @"Eiffel Tower", @"fake_location_presets": @[], @"messages_only": @(NO), + @"messages_only_hide_tabbar": @(NO), + @"fake_follower_count": @(NO), + @"fake_following_count": @(NO), + @"fake_post_count": @(NO), + @"fake_verified": @(NO), @"launch_tab": @"default", @"save_profile": @(YES), // Per-context action buttons (new in 1.1.6) @@ -69,6 +78,10 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"reels_action_default": @"menu", @"stories_action_button": @(YES), @"stories_action_default": @"menu", + @"dm_visual_action_button": @(YES), + @"dm_visual_action_default": @"menu", + @"dm_visual_seen_button": @(YES), + @"dm_visual_audio_toggle": @(NO), // Legacy long-press gesture (off by default — kept for users who prefer it) @"dw_legacy_gesture": @(NO), @"dw_confirm": @(NO), @@ -77,10 +90,13 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"default_photo_quality": @"high", @"ffmpeg_encoding_speed": @"ultrafast", @"unfollow_confirm": @(NO), + @"sticker_interact_confirm": @(NO), + @"sticker_interact_confirm_highlights": @(NO), @"dw_save_action": @"share", @"dw_finger_count": @(3), @"dw_finger_duration": @(0.5), @"reels_tap_control": @"default", + @"reels_photo_tap_mute": @(NO), @"nav_icon_ordering": @"default", @"swipe_nav_tabs": @"default", @"enable_notes_customization": @(YES), @@ -115,6 +131,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"story_seen_mode": @"button", @"story_audio_toggle": @(NO), @"view_story_mentions": @(YES), + @"stories_show_quiz_answer": @(NO), + @"stories_show_poll_votes_count": @(NO), + @"reels_show_quiz_answer": @(NO), + @"reels_show_poll_votes_count": @(NO), + @"force_enable_quiz_sticker": @(NO), @"settings_pause_playback": @(YES), @"embed_links": @(NO), @"embed_link_domain": @"kkinstagram.com", @@ -125,7 +146,28 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; @"hide_feed_repost": @(NO), @"copy_comment": @(YES), @"download_gif_comment": @(YES), - @"sci_language": @"system" + @"cache_auto_clear_mode": @"off", + @"cache_auto_check_size": @(YES), + @"sci_changelog_force_show": @(NO), + @"live_anonymous_view": @(NO), + @"live_hide_comments": @(NO), + @"hide_ui_on_capture": @(NO), + @"paste_link_from_search": @(NO), + @"sci_language": @"system", + @"theme_force_dark": @(NO), + @"theme_full_oled": @(NO), + @"theme_oled_chat": @(NO), + @"theme_keyboard": @"off", + // Experimental IG features (credits: Radan). See SCIExperimentalGuard. + @"igt_homecoming": @(NO), + @"igt_quicksnap": @(NO), + @"igt_prism": @(NO), + @"igt_directnotes_friendmap": @(NO), + @"igt_directnotes_audio_reply": @(NO), + @"igt_directnotes_avatar_reply": @(NO), + @"igt_directnotes_gifs_reply": @(NO), + @"igt_directnotes_photo_reply": @(NO), + @"sci_exp_warning_seen": @(NO) }; [[NSUserDefaults standardUserDefaults] registerDefaults:sciDefaults]; [SCIUtils setSciRegisteredDefaults:sciDefaults]; @@ -151,17 +193,11 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; ![[[NSUserDefaults standardUserDefaults] objectForKey:@"SCInstaFirstRun"] isEqualToString:SCIVersionString] || [SCIUtils getBoolPref:@"tweak_settings_app_launch"] ) { - NSLog(@"[SCInsta] First run, initializing"); - - // Display settings modal on screen - NSLog(@"[SCInsta] Displaying RyukGram first-time settings modal"); + NSLog(@"[SCInsta] First run — showing settings modal"); [SCIUtils showSettingsVC:[self window]]; } }); - NSLog(@"[SCInsta] Cleaning cache..."); - [SCIUtils cleanCache]; - if ([SCIUtils getBoolPref:@"flex_app_launch"]) { [[objc_getClass("FLEXManager") sharedManager] showExplorer]; } @@ -177,6 +213,24 @@ BOOL dmVisualMsgsViewedButtonEnabled = false; } } + +- (void)applicationDidEnterBackground:(id)arg1 { + %orig; + // Cache housekeeping while backgrounded — never competes with IG's foreground I/O. + [SCICacheManager runAutoClearIfDue]; +} +%end + +// Tab bar only exists in the logged-in state — fire the changelog popup here +// rather than at app launch (which runs pre-login). +%hook IGTabBarController +- (void)viewDidAppear:(BOOL)animated { + %orig; + static dispatch_once_t once; + dispatch_once(&once, ^{ + [SCIChangelog presentIfNewFromWindow:self.view.window]; + }); +} %end %hook IGDSLauncherConfig diff --git a/src/Utils.h b/src/Utils.h index db2b4a0..b1da799 100644 --- a/src/Utils.h +++ b/src/Utils.h @@ -33,8 +33,6 @@ + (_Bool)liquidGlassEnabledBool:(_Bool)fallback; -+ (void)cleanCache; - // Displaying View Controllers + (void)showQuickLookVC:(NSArray *)items; + (void)showShareVC:(id)item; @@ -87,4 +85,12 @@ + (id)getIvarForObj:(id)obj name:(const char *)name; + (void)setIvarForObj:(id)obj name:(const char *)name value:(id)value; +// Active IG user session (walks all connected scenes for the first window +// with a non-nil `userSession`). ++ (id)activeUserSession; +// PK string read from an IGUser object's `_pk` ivar (walks superclass chain). ++ (NSString *)pkFromIGUser:(id)user; +// Current logged-in user's PK via the active session. ++ (NSString *)currentUserPK; + @end \ No newline at end of file diff --git a/src/Utils.m b/src/Utils.m index d08d548..48b2536 100644 --- a/src/Utils.m +++ b/src/Utils.m @@ -32,55 +32,6 @@ static NSDictionary *sciRegisteredDefaultsRef = nil; return setting ? true : fallback; } -+ (void)cleanCache { - NSFileManager *fileManager = [NSFileManager defaultManager]; - NSMutableArray *deletionErrors = [NSMutableArray array]; - - // Temp folder - // * disabled bc app crashed trying to delete certain files inside it - // todo: remove the above disclaimer if this new code doesn't cause crashing - NSArray *tempFolderContents = [fileManager contentsOfDirectoryAtURL:[NSURL fileURLWithPath:NSTemporaryDirectory()] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]; - - for (NSURL *fileURL in tempFolderContents) { - NSError *cacheItemDeletionError; - [fileManager removeItemAtURL:fileURL error:&cacheItemDeletionError]; - - if (cacheItemDeletionError) [deletionErrors addObject:cacheItemDeletionError]; - } - - // Analytics folder - NSString *analyticsFolder = [[NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES) objectAtIndex:0] stringByAppendingPathComponent:@"Application Support/com.burbn.instagram/analytics"]; - NSArray *analyticsFolderContents = [fileManager contentsOfDirectoryAtURL:[[NSURL alloc] initFileURLWithPath:analyticsFolder] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]; - - for (NSURL *fileURL in analyticsFolderContents) { - NSError *cacheItemDeletionError; - [fileManager removeItemAtURL:fileURL error:&cacheItemDeletionError]; - - if (cacheItemDeletionError) [deletionErrors addObject:cacheItemDeletionError]; - } - - // Caches folder - NSString *cachesFolder = [[NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES) objectAtIndex:0] stringByAppendingPathComponent:@"Caches"]; - NSArray *cachesFolderContents = [fileManager contentsOfDirectoryAtURL:[[NSURL alloc] initFileURLWithPath:cachesFolder] includingPropertiesForKeys:nil options:NSDirectoryEnumerationSkipsHiddenFiles error:nil]; - - for (NSURL *fileURL in cachesFolderContents) { - NSError *cacheItemDeletionError; - [fileManager removeItemAtURL:fileURL error:&cacheItemDeletionError]; - - if (cacheItemDeletionError) [deletionErrors addObject:cacheItemDeletionError]; - } - - // Log errors - if (deletionErrors.count > 1) { - - for (NSError *error in deletionErrors) { - NSLog(@"[SCInsta] File Deletion Error: %@", error); - } - - } - -} - // Displaying View Controllers + (void)showQuickLookVC:(NSArray *)items { UIViewController *topVC = topMostController(); @@ -192,45 +143,122 @@ static NSDictionary *sciRegisteredDefaultsRef = nil; } // Media + +// fieldCache fallback — reads the Pando-backed dict directly for when +// IG's exposed property accessors break between versions. +static id sciFieldCacheValue(id obj, NSString *key) { + if (!obj || !key.length) return nil; + Ivar iv = NULL; + for (Class c = [obj class]; c && !iv; c = class_getSuperclass(c)) + iv = class_getInstanceVariable(c, "_fieldCache"); + if (!iv) return nil; + @try { + NSDictionary *dict = object_getIvar(obj, iv); + if (![dict isKindOfClass:[NSDictionary class]]) return nil; + return dict[key]; + } @catch (__unused id e) { return nil; } +} + + (NSURL *)getPhotoUrl:(IGPhoto *)photo { if (!photo) return nil; - - // Get highest quality photo link - NSURL *photoUrl = [photo imageURLForWidth:100000.00]; - - return photoUrl; + @try { + if ([photo respondsToSelector:@selector(imageURLForWidth:)]) { + NSURL *url = [photo imageURLForWidth:100000.00]; + if (url) return url; + } + } @catch (__unused NSException *e) {} + return nil; } + + (NSURL *)getPhotoUrlForMedia:(IGMedia *)media { if (!media) return nil; - IGPhoto *photo = media.photo; + // fieldCache first — IGPhoto selectors crash on newer IG builds. + @try { + NSDictionary *imageVersions = sciFieldCacheValue(media, @"image_versions2"); + NSArray *candidates = [imageVersions isKindOfClass:[NSDictionary class]] ? imageVersions[@"candidates"] : nil; + if ([candidates isKindOfClass:[NSArray class]] && candidates.count) { + NSDictionary *best = nil; + NSInteger bestW = -1; + for (id c in candidates) { + if (![c isKindOfClass:[NSDictionary class]]) continue; + NSInteger w = [[c objectForKey:@"width"] integerValue]; + if (w > bestW) { bestW = w; best = c; } + } + NSString *urlStr = best[@"url"] ?: [[candidates firstObject] objectForKey:@"url"]; + if ([urlStr isKindOfClass:[NSString class]] && urlStr.length) { + return [NSURL URLWithString:urlStr]; + } + } + } @catch (__unused NSException *e) {} - return [SCIUtils getPhotoUrl:photo]; + IGPhoto *photo = nil; + @try { + if ([media respondsToSelector:@selector(photo)]) photo = media.photo; + } @catch (__unused NSException *e) {} + if (photo) return [SCIUtils getPhotoUrl:photo]; + return nil; } + + (NSURL *)getVideoUrl:(IGVideo *)video { if (!video) return nil; - // The past (pre v398) - if ([video respondsToSelector:@selector(sortedVideoURLsBySize)]) { - NSArray *sorted = [video sortedVideoURLsBySize]; - NSString *urlString = sorted.firstObject[@"url"]; - return urlString.length ? [NSURL URLWithString:urlString] : nil; - } - - // The present (post v398) - if ([video respondsToSelector:@selector(allVideoURLs)]) { - return [[video allVideoURLs] anyObject]; - } + @try { + if ([video respondsToSelector:@selector(sortedVideoURLsBySize)]) { + NSArray *sorted = [video sortedVideoURLsBySize]; + NSString *urlString = [sorted.firstObject isKindOfClass:[NSDictionary class]] ? sorted.firstObject[@"url"] : nil; + if ([urlString isKindOfClass:[NSString class]] && urlString.length) return [NSURL URLWithString:urlString]; + } + } @catch (__unused NSException *e) {} + @try { + if ([video respondsToSelector:@selector(allVideoURLs)]) { + id set = [video allVideoURLs]; + if ([set respondsToSelector:@selector(anyObject)]) { + id obj = [set anyObject]; + if ([obj isKindOfClass:[NSURL class]]) { + NSString *abs = nil; + @try { abs = [(NSURL *)obj absoluteString]; } @catch (__unused NSException *e) {} + if (abs.length && ([abs hasPrefix:@"http"] || [abs hasPrefix:@"file:"])) { + return [NSURL URLWithString:abs]; + } + } else if ([obj isKindOfClass:[NSString class]]) { + NSString *s = (NSString *)obj; + if (s.length && ([s hasPrefix:@"http"] || [s hasPrefix:@"file:"])) return [NSURL URLWithString:s]; + } + } + } + } @catch (__unused NSException *e) {} return nil; } + + (NSURL *)getVideoUrlForMedia:(IGMedia *)media { if (!media) return nil; - IGVideo *video = media.video; - if (!video) return nil; + // fieldCache first — IGVideo selectors crash on newer IG builds. + @try { + NSArray *versions = sciFieldCacheValue(media, @"video_versions"); + if ([versions isKindOfClass:[NSArray class]] && versions.count) { + NSDictionary *best = nil; + NSInteger bestType = -1; + for (id v in versions) { + if (![v isKindOfClass:[NSDictionary class]]) continue; + NSInteger type = [[v objectForKey:@"type"] integerValue]; + if (type > bestType) { bestType = type; best = v; } + } + NSString *urlStr = best[@"url"] ?: [[versions firstObject] objectForKey:@"url"]; + if ([urlStr isKindOfClass:[NSString class]] && urlStr.length) { + return [NSURL URLWithString:urlStr]; + } + } + } @catch (__unused NSException *e) {} - return [SCIUtils getVideoUrl:video]; + IGVideo *video = nil; + @try { + if ([media respondsToSelector:@selector(video)]) video = media.video; + } @catch (__unused NSException *e) {} + if (video) return [SCIUtils getVideoUrl:video]; + return nil; } // View Controllers @@ -378,9 +406,41 @@ static NSDictionary *sciRegisteredDefaultsRef = nil; + (void)setIvarForObj:(id)obj name:(const char *)name value:(id)value { Ivar ivar = class_getInstanceVariable(object_getClass(obj), name); if (!ivar) return; - + object_setIvarWithStrongDefault(obj, ivar, value); } ++ (id)activeUserSession { + for (UIScene *scene in [UIApplication sharedApplication].connectedScenes) { + if (![scene isKindOfClass:[UIWindowScene class]]) continue; + for (UIWindow *w in ((UIWindowScene *)scene).windows) { + @try { + id s = [w valueForKey:@"userSession"]; + if (s) return s; + } @catch (__unused id e) {} + } + } + return nil; +} + ++ (NSString *)pkFromIGUser:(id)user { + if (!user) return nil; + Ivar pkIvar = NULL; + for (Class c = [user class]; c && !pkIvar; c = class_getSuperclass(c)) { + pkIvar = class_getInstanceVariable(c, "_pk"); + } + if (!pkIvar) return nil; + id pk = object_getIvar(user, pkIvar); + return pk ? [pk description] : nil; +} + ++ (NSString *)currentUserPK { + id session = [self activeUserSession]; + if (!session) return nil; + @try { + id user = [session valueForKey:@"user"]; + return [self pkFromIGUser:user]; + } @catch (__unused id e) { return nil; } +} @end \ No newline at end of file